From 3e4c104e1d0ca111b0ecb0b746c9864a0e81e45f Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 14 May 2026 02:42:15 +0800 Subject: [PATCH] feat: added logging system --- .gitignore | 2 +- migrations/0001_init.sql | 4 + scripts/i18n-validate.cjs | 13 +- src/handlers/accounts.ts | 99 ++- src/handlers/admin.ts | 130 +++- src/handlers/attachments.ts | 27 + src/handlers/backup.ts | 35 +- src/handlers/ciphers.ts | 45 ++ src/handlers/devices.ts | 64 ++ src/handlers/folders.ts | 28 + src/handlers/identity.ts | 101 ++- src/handlers/sends-private.ts | 41 +- src/router-admin.ts | 18 + src/services/audit-events.ts | 209 +++++++ src/services/auth.ts | 42 +- src/services/storage-admin-repo.ts | 123 +++- src/services/storage-schema.ts | 8 +- src/services/storage.ts | 23 +- src/types/index.ts | 4 + webapp/src/App.tsx | 10 +- .../src/components/AppAuthenticatedShell.tsx | 10 +- webapp/src/components/AppMainRoutes.tsx | 33 +- webapp/src/components/LogCenterPage.tsx | 578 ++++++++++++++++++ webapp/src/lib/api/admin.ts | 65 +- webapp/src/lib/demo.ts | 9 + webapp/src/lib/i18n/locales/en.ts | 185 ++++++ webapp/src/lib/i18n/locales/es.ts | 185 ++++++ webapp/src/lib/i18n/locales/ru.ts | 185 ++++++ webapp/src/lib/i18n/locales/zh-CN.ts | 185 ++++++ webapp/src/lib/i18n/locales/zh-TW.ts | 185 ++++++ webapp/src/lib/types.ts | 36 ++ webapp/src/styles/dark.css | 30 + webapp/src/styles/management.css | 527 ++++++++++++++++ webapp/src/styles/shell.css | 6 + 34 files changed, 3179 insertions(+), 66 deletions(-) create mode 100644 src/services/audit-events.ts create mode 100644 webapp/src/components/LogCenterPage.tsx diff --git a/.gitignore b/.gitignore index e89c40c..50b5aa9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ Thumbs.db # Logs *.log npm-debug.log* - +.vite-tailwind.err # Environment .env .env.local diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 1e25231..92c61d2 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -154,6 +154,8 @@ CREATE TABLE IF NOT EXISTS audit_logs ( id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'system', + level TEXT NOT NULL DEFAULT 'info', target_type TEXT, target_id TEXT, metadata TEXT, @@ -162,6 +164,8 @@ CREATE TABLE IF NOT EXISTS audit_logs ( ); CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at); CREATE TABLE IF NOT EXISTS devices ( user_id TEXT NOT NULL, diff --git a/scripts/i18n-validate.cjs b/scripts/i18n-validate.cjs index 914ed7e..f2e3b19 100644 --- a/scripts/i18n-validate.cjs +++ b/scripts/i18n-validate.cjs @@ -22,6 +22,17 @@ const intentionallyEnglishKeys = new Set([ 'txt_dash', 'txt_text_3', ]); +const intentionallyEnglishPrefixes = [ + 'txt_log_action_', + 'txt_log_meta_', + 'txt_log_reason_', + 'txt_log_target_type_', + 'txt_log_trigger_', +]; + +function isIntentionallyEnglishKey(key) { + return intentionallyEnglishKeys.has(key) || intentionallyEnglishPrefixes.some((prefix) => key.startsWith(prefix)); +} for (const [locale, table] of Object.entries(locales)) { const keys = Object.keys(table).sort(); @@ -40,7 +51,7 @@ for (const [locale, table] of Object.entries(locales)) { } if (locale !== 'en') { - const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !intentionallyEnglishKeys.has(key)); + const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !isIntentionallyEnglishKey(key)); if (sameAsEnglish.length > 40) { errors.push({ locale, diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index d0822af..5635c5e 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -2,6 +2,7 @@ import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types'; import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; +import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../services/audit-events'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; @@ -227,14 +228,14 @@ export async function handleRegister(request: Request, env: Env): Promise | null + metadata: Record | null, + request?: Request ): Promise { - await storage.createAuditLog({ - id: generateUUID(), + await writeAuditEvent(storage, { actorUserId, action, targetType, targetId, - metadata: metadata ? JSON.stringify(metadata) : null, - createdAt: new Date().toISOString(), + category: action.startsWith('admin.user.') ? 'security' : 'system', + level: action.startsWith('admin.user.') ? 'security' : 'info', + metadata: { + ...(metadata || {}), + ...(request ? auditRequestMetadata(request) : {}), + }, }); } @@ -82,6 +86,106 @@ export async function handleAdminListUsers( }); } +// GET /api/admin/logs +export async function handleAdminListAuditLogs( + request: Request, + env: Env, + actorUser: User +): Promise { + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + + const url = new URL(request.url); + const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50))); + const offset = Math.max(0, Number(url.searchParams.get('offset') || 0)); + const category = String(url.searchParams.get('category') || '').trim() || null; + const level = String(url.searchParams.get('level') || '').trim() || null; + const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null; + const from = String(url.searchParams.get('from') || '').trim() || null; + const to = String(url.searchParams.get('to') || '').trim() || null; + + const storage = new StorageService(env.DB); + const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to }); + return jsonResponse({ + data: result.logs.map(log => ({ + id: log.id, + actorUserId: log.actorUserId, + actorEmail: log.actorEmail, + action: log.action, + category: log.category, + level: log.level, + targetType: log.targetType, + targetId: log.targetId, + targetUserEmail: log.targetUserEmail, + metadata: log.metadata, + createdAt: log.createdAt, + object: 'auditLog', + })), + total: result.total, + limit, + offset, + hasMore: result.hasMore, + object: 'list', + continuationToken: result.hasMore ? String(offset + result.logs.length) : null, + }); +} + +// GET /api/admin/logs/settings +export async function handleAdminGetAuditLogSettings( + request: Request, + env: Env, + actorUser: User +): Promise { + void request; + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + const storage = new StorageService(env.DB); + return jsonResponse({ + object: 'auditLogSettings', + ...await getAuditLogSettings(storage), + }); +} + +// PUT /api/admin/logs/settings +export async function handleAdminUpdateAuditLogSettings( + request: Request, + env: Env, + actorUser: User +): Promise { + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + const storage = new StorageService(env.DB); + const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body)); + await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request); + return jsonResponse({ + object: 'auditLogSettings', + ...settings, + }); +} + +// DELETE /api/admin/logs +export async function handleAdminClearAuditLogs( + request: Request, + env: Env, + actorUser: User +): Promise { + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + const storage = new StorageService(env.DB); + const deleted = await storage.clearAuditLogs(); + return jsonResponse({ object: 'auditLogClear', deleted }); +} + // POST /api/admin/invites export async function handleAdminCreateInvite( request: Request, @@ -116,9 +220,9 @@ export async function handleAdminCreateInvite( }; await storage.createInvite(invite); - await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, { + await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, { expiresInHours, - }); + }, request); return jsonResponse(toInviteResponse(request, invite), 201); } @@ -161,7 +265,7 @@ export async function handleAdminRevokeInvite( return errorResponse('Invite not found or already inactive', 404); } - await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null); + await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', null, null, request); return new Response(null, { status: 204 }); } @@ -180,7 +284,7 @@ export async function handleAdminDeleteAllInvites( const deleted = await storage.deleteAllInvites(); await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, { deleted, - }); + }, request); return jsonResponse({ deleted }, 200); } @@ -226,7 +330,7 @@ export async function handleAdminSetUserStatus( AuthService.invalidateUserCache(target.id); await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, { status: nextStatus, - }); + }, request); return jsonResponse({ id: target.id, @@ -284,8 +388,8 @@ export async function handleAdminDeleteUser( await storage.deleteUserById(target.id); AuthService.invalidateUserCache(target.id); await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, { - email: target.email, - }); + targetEmail: target.email, + }, request); return new Response(null, { status: 204 }); } diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index d022e78..eae0a26 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -20,6 +20,7 @@ import { getBlobStorageMaxBytes, putBlobObject, } from '../services/blob-store'; +import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; function notifyVaultSyncForRequest( request: Request, @@ -30,6 +31,27 @@ function notifyVaultSyncForRequest( notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } +async function writeAttachmentAudit( + storage: StorageService, + request: Request, + userId: string, + action: string, + metadata: Record +): Promise { + await writeAuditEvent(storage, { + actorUserId: userId, + action, + category: 'data', + level: action.includes('delete') ? 'security' : 'info', + targetType: 'attachment', + targetId: typeof metadata.id === 'string' ? metadata.id : null, + metadata: { + ...metadata, + ...auditRequestMetadata(request), + }, + }); +} + // Format file size to human readable function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} Bytes`; @@ -430,6 +452,11 @@ export async function handleDeleteAttachment( const revisionInfo = await storage.updateCipherRevisionDate(cipherId); if (revisionInfo) { notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); + await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', { + id: attachmentId, + cipherId, + size: attachment.size, + }); } // Get updated cipher for response diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index a221120..1fef3fe 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -40,6 +40,7 @@ import { uploadBackupArchive, } from '../services/backup-uploader'; import { StorageService } from '../services/storage'; +import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { getBlobObject } from '../services/blob-store'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; @@ -53,16 +54,20 @@ async function writeAuditLog( action: string, targetType: string | null, targetId: string | null, - metadata: Record | null + metadata: Record | null, + request?: Request ): Promise { - await storage.createAuditLog({ - id: generateUUID(), + await writeAuditEvent(storage, { actorUserId, action, targetType, targetId, - metadata: metadata ? JSON.stringify(metadata) : null, - createdAt: new Date().toISOString(), + category: 'data', + level: action.endsWith('.failed') ? 'error' : 'info', + metadata: { + ...(metadata || {}), + ...(request ? auditRequestMetadata(request) : {}), + }, }); } @@ -267,7 +272,8 @@ async function executeConfiguredBackup( done?: boolean; ok?: boolean; error?: string | null; - }) => Promise) | null + }) => Promise) | null, + auditMetadata?: Record | null ): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> { const maxArchiveUploadAttempts = 3; const touchLease = async () => { @@ -423,6 +429,7 @@ async function executeConfiguredBackup( uploadVerificationAttempts: maxArchiveUploadAttempts, prunedFileCount, pruneError: pruneErrorMessage, + ...(auditMetadata || {}), }); await progress?.({ @@ -451,6 +458,7 @@ async function executeConfiguredBackup( await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, { ...getBackupDestinationSummary(destination), error: destination.runtime.lastErrorMessage, + ...(auditMetadata || {}), }); await progress?.({ operation: 'backup-remote-run', @@ -513,7 +521,7 @@ async function runImportAndAudit( skippedReason: imported.result.skipped.reason, replaceExisting, ...metadata, - }); + }, request); return imported; } @@ -586,7 +594,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, { destinationCount: next.destinations.length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, - }); + }, request); return jsonResponse(next); } @@ -636,7 +644,7 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, { destinationCount: next.destinations.length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, - }); + }, request); return jsonResponse(next); } @@ -675,7 +683,8 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env, 'manual', body?.destinationId || null, keepAlive, - progress + progress, + auditRequestMetadata(request) ); const settings = await loadBackupSettings(storage, env, 'UTC'); return { result, settings }; @@ -777,7 +786,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, { ...getBackupDestinationSummary(destination), remotePath: path, - }); + }, request); return jsonResponse({ object: 'backup-remote-delete', deleted: true, path }); } catch (error) { return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409); @@ -860,7 +869,7 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, bytes: remoteFile.bytes.byteLength, trigger: 'remote', checksumMismatchAccepted: !checksumOk, - }); + }, request); return result; })(); return jsonResponse(imported.result); @@ -937,7 +946,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU attachments: archive.manifest.tableCounts.attachments, compressedBytes: archive.bytes.byteLength, includesAttachments: archive.manifest.includes.attachments, - }); + }, request); return new Response(archive.bytes, { status: 200, diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 8c2afa3..eb60b96 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -17,6 +17,7 @@ import { generateUUID } from '../utils/uuid'; import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments'; import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { readActingDeviceIdentifier } from '../utils/device'; +import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; // CONTRACT: // Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve @@ -83,6 +84,27 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher { return cipher; } +async function writeCipherAudit( + storage: StorageService, + request: Request, + userId: string, + action: string, + metadata: Record +): Promise { + await writeAuditEvent(storage, { + actorUserId: userId, + action, + category: 'data', + level: action.includes('delete') ? 'security' : 'info', + targetType: 'cipher', + targetId: typeof metadata.id === 'string' ? metadata.id : null, + metadata: { + ...metadata, + ...auditRequestMetadata(request), + }, + }); +} + function isValidEncString(value: unknown): value is string { if (typeof value !== 'string') return false; const trimmed = value.trim(); @@ -584,6 +606,11 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', { + id: cipher.id, + type: cipher.type, + folderId: cipher.folderId ?? null, + }); return jsonResponse( cipherToResponse(cipher, []) @@ -608,6 +635,12 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI await storage.deleteCipher(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', { + id, + type: cipher.type, + folderId: cipher.folderId ?? null, + compat: true, + }); return new Response(null, { status: 204 }); } @@ -629,6 +662,11 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us await storage.deleteCipher(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', { + id, + type: cipher.type, + folderId: cipher.folderId ?? null, + }); return new Response(null, { status: 204 }); } @@ -858,6 +896,9 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', { + count: body.ids.length, + }); } return new Response(null, { status: 204 }); @@ -917,6 +958,10 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', { + count: ownedIds.length, + requestedCount: ids.length, + }); } return new Response(null, { status: 204 }); diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index 1499b39..280657d 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -2,6 +2,7 @@ import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceR import { Env } from '../types'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { AuthService } from '../services/auth'; +import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { StorageService } from '../services/storage'; import { errorResponse, jsonResponse } from '../utils/response'; import { readKnownDeviceProbe } from '../utils/device'; @@ -268,6 +269,15 @@ export async function handleRevokeTrustedDevice( const storage = new StorageService(env.DB); const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); + await writeAuditEvent(storage, { + actorUserId: userId, + action: 'device.trust.revoke', + category: 'device', + level: 'security', + targetType: 'device', + targetId: normalized, + metadata: { removed, ...auditRequestMetadata(request) }, + }); return jsonResponse({ success: true, removed }); } @@ -286,6 +296,15 @@ export async function handleTrustDevicePermanently( const storage = new StorageService(env.DB); const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS); if (!updated) return errorResponse('Device is not currently trusted', 409); + await writeAuditEvent(storage, { + actorUserId: userId, + action: 'device.trust.permanent', + category: 'device', + level: 'security', + targetType: 'device', + targetId: normalized, + metadata: { updated, ...auditRequestMetadata(request) }, + }); return jsonResponse({ success: true, @@ -313,6 +332,15 @@ export async function handleDeleteDevice( AuthService.invalidateDeviceCache(userId, normalized); notifyUserLogout(env, userId, normalized); } + await writeAuditEvent(storage, { + actorUserId: userId, + action: 'device.delete', + category: 'device', + level: 'security', + targetType: 'device', + targetId: normalized, + metadata: { deleted, ...auditRequestMetadata(request) }, + }); return jsonResponse({ success: deleted }); } @@ -336,6 +364,15 @@ export async function handleUpdateDeviceName( const device = await storage.getDevice(userId, normalized); if (!device) return errorResponse('Device not found', 404); + await writeAuditEvent(storage, { + actorUserId: userId, + action: 'device.name.update', + category: 'device', + level: 'info', + targetType: 'device', + targetId: normalized, + metadata: { name, ...auditRequestMetadata(request) }, + }); return jsonResponse(buildDeviceResponse(device)); } @@ -356,6 +393,15 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId: await storage.saveUser(user); AuthService.invalidateUserCache(userId); notifyUserLogout(env, userId, null); + await writeAuditEvent(storage, { + actorUserId: userId, + action: 'device.delete_all', + category: 'device', + level: 'security', + targetType: 'user', + targetId: userId, + metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) }, + }); return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices }); } @@ -447,6 +493,15 @@ export async function handleUntrustDevices( if (!deviceIdentifier) continue; await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier); } + await writeAuditEvent(storage, { + actorUserId: userId, + action: 'device.trust.revoke_batch', + category: 'device', + level: 'security', + targetType: 'user', + targetId: userId, + metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) }, + }); return jsonResponse({ success: true, removed }); } @@ -489,6 +544,15 @@ export async function handleDeactivateDevice( AuthService.invalidateDeviceCache(userId, normalized); notifyUserLogout(env, userId, normalized); } + await writeAuditEvent(storage, { + actorUserId: userId, + action: 'device.deactivate', + category: 'device', + level: 'security', + targetType: 'device', + targetId: normalized, + metadata: { deleted, ...auditRequestMetadata(request) }, + }); return jsonResponse({ success: deleted }); } diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 411c0d1..08ce5a5 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -5,6 +5,7 @@ import { jsonResponse, errorResponse } from '../utils/response'; import { readActingDeviceIdentifier } from '../utils/device'; import { generateUUID } from '../utils/uuid'; import { parsePagination, encodeContinuationToken } from '../utils/pagination'; +import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; function notifyVaultSyncForRequest( request: Request, @@ -15,6 +16,27 @@ function notifyVaultSyncForRequest( notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } +async function writeFolderAudit( + storage: StorageService, + request: Request, + userId: string, + action: string, + metadata: Record +): Promise { + await writeAuditEvent(storage, { + actorUserId: userId, + action, + category: 'data', + level: action.includes('delete') ? 'security' : 'info', + targetType: 'folder', + targetId: typeof metadata.id === 'string' ? metadata.id : null, + metadata: { + ...metadata, + ...auditRequestMetadata(request), + }, + }); +} + // Convert internal folder to API response format function folderToResponse(folder: Folder): FolderResponse { return { @@ -134,6 +156,9 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str await storage.deleteFolder(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeFolderAudit(storage, request, userId, 'folder.delete', { + id, + }); return new Response(null, { status: 204 }); } @@ -157,6 +182,9 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId const revisionDate = await storage.bulkDeleteFolders(ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', { + count: ids.length, + }); } return new Response(null, { status: 204 }); diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index e4cdb4b..e8ce7cd 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -14,6 +14,7 @@ import { buildAccountKeys, buildUserDecryptionOptions, } from '../utils/user-decryption'; +import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; @@ -251,11 +252,37 @@ export async function handleToken(request: Request, env: Env): Promise } if (user.status !== 'active') { await rateLimit.recordFailedLogin(loginIdentifier); + await safeWriteAuditEvent(env, { + actorUserId: user.id, + action: 'auth.login.failed.user_inactive', + category: 'auth', + level: 'warn', + targetType: 'user', + targetId: user.id, + metadata: { + grantType, + deviceIdentifier: deviceInfo.deviceIdentifier, + ...auditRequestMetadata(request), + }, + }); return identityErrorResponse('Account is disabled', 'invalid_grant', 400); } const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); if (!valid) { + await safeWriteAuditEvent(env, { + actorUserId: user.id, + action: 'auth.login.failed.bad_password', + category: 'auth', + level: 'warn', + targetType: 'user', + targetId: user.id, + metadata: { + grantType, + deviceIdentifier: deviceInfo.deviceIdentifier, + ...auditRequestMetadata(request), + }, + }); return recordFailedLoginAndBuildResponse( rateLimit, loginIdentifier, @@ -349,6 +376,21 @@ export async function handleToken(request: Request, env: Env): Promise const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); + await safeWriteAuditEvent(env, { + actorUserId: user.id, + action: 'auth.login.success', + category: 'auth', + level: 'info', + targetType: 'user', + targetId: user.id, + metadata: { + grantType, + webSession: shouldUseWebSession(request), + deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier, + deviceType: deviceInfo.deviceType, + ...auditRequestMetadata(request), + }, + }); const response: TokenResponse = { access_token: accessToken, @@ -412,11 +454,37 @@ export async function handleToken(request: Request, env: Env): Promise } if (user.status !== 'active') { await rateLimit.recordFailedLogin(loginIdentifier); + await safeWriteAuditEvent(env, { + actorUserId: user.id, + action: 'auth.login.failed.user_inactive', + category: 'auth', + level: 'warn', + targetType: 'user', + targetId: user.id, + metadata: { + grantType, + deviceIdentifier: deviceInfo.deviceIdentifier, + ...auditRequestMetadata(request), + }, + }); return identityErrorResponse('Account is disabled', 'invalid_grant', 400); } if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { await rateLimit.recordFailedLogin(loginIdentifier); + await safeWriteAuditEvent(env, { + actorUserId: user.id, + action: 'auth.login.failed.bad_api_key', + category: 'auth', + level: 'warn', + targetType: 'user', + targetId: user.id, + metadata: { + grantType, + deviceIdentifier: deviceInfo.deviceIdentifier, + ...auditRequestMetadata(request), + }, + }); return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400); } @@ -439,6 +507,21 @@ export async function handleToken(request: Request, env: Env): Promise const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); + await safeWriteAuditEvent(env, { + actorUserId: user.id, + action: 'auth.login.success', + category: 'auth', + level: 'info', + targetType: 'user', + targetId: user.id, + metadata: { + grantType, + webSession: shouldUseWebSession(request), + deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier, + deviceType: deviceInfo.deviceType, + ...auditRequestMetadata(request), + }, + }); const response: TokenResponse = { access_token: accessToken, @@ -543,8 +626,22 @@ export async function handleToken(request: Request, env: Env): Promise return identityErrorResponse('Refresh token is required', 'invalid_request', 400); } - const result = await auth.refreshAccessToken(refreshToken); - if (!result) { + const result = await auth.refreshAccessTokenDetailed(refreshToken); + if (!result.ok) { + await safeWriteAuditEvent(env, { + actorUserId: result.userId ?? null, + action: `auth.refresh.failed.${result.reason}`, + category: 'auth', + level: 'warn', + targetType: result.deviceIdentifier ? 'device' : 'refreshToken', + targetId: result.deviceIdentifier ?? null, + metadata: { + grantType, + reason: result.reason, + webSession: shouldUseWebSession(request), + ...auditRequestMetadata(request), + }, + }); const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); return shouldUseWebSession(request) ? withWebRefreshCookie(request, invalidResponse, null) diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts index 8686acc..67daaf2 100644 --- a/src/handlers/sends-private.ts +++ b/src/handlers/sends-private.ts @@ -29,6 +29,28 @@ import { setSendPassword, validateDeletionDate, } from './sends-shared'; +import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; + +async function writeSendAudit( + storage: StorageService, + request: Request, + userId: string, + action: string, + metadata: Record +): Promise { + await writeAuditEvent(storage, { + actorUserId: userId, + action, + category: 'data', + level: action.includes('delete') ? 'security' : 'info', + targetType: 'send', + targetId: typeof metadata.id === 'string' ? metadata.id : null, + metadata: { + ...metadata, + ...auditRequestMetadata(request), + }, + }); +} async function processSendFileUpload( request: Request, @@ -602,7 +624,6 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin } export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise { - void request; const storage = new StorageService(env.DB); const send = await storage.getSend(sendId); if (!send || send.userId !== userId) { @@ -620,6 +641,10 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin await storage.deleteSend(sendId, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeSendAudit(storage, request, userId, 'send.delete', { + id: sendId, + type: send.type, + }); return new Response(null, { status: 200 }); } @@ -651,13 +676,16 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId: const revisionDate = await storage.bulkDeleteSends(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeSendAudit(storage, request, userId, 'send.delete.bulk', { + count: sends.length, + requestedCount: body.ids.length, + }); } return new Response(null, { status: 200 }); } export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise { - void request; const storage = new StorageService(env.DB); const send = await storage.getSend(sendId); if (!send || send.userId !== userId) { @@ -669,12 +697,15 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeSendAudit(storage, request, userId, 'send.password.remove', { + id: send.id, + type: send.type, + }); return jsonResponse(sendToResponse(send)); } export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise { - void request; const storage = new StorageService(env.DB); const send = await storage.getSend(sendId); if (!send || send.userId !== userId) { @@ -687,6 +718,10 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + await writeSendAudit(storage, request, userId, 'send.auth.remove', { + id: send.id, + type: send.type, + }); return jsonResponse(sendToResponse(send)); } diff --git a/src/router-admin.ts b/src/router-admin.ts index 2477274..71e9d8b 100644 --- a/src/router-admin.ts +++ b/src/router-admin.ts @@ -7,6 +7,10 @@ import { handleAdminRevokeInvite, handleAdminSetUserStatus, handleAdminDeleteUser, + handleAdminListAuditLogs, + handleAdminGetAuditLogSettings, + handleAdminUpdateAuditLogSettings, + handleAdminClearAuditLogs, } from './handlers/admin'; import { handleAdminBackupRoute } from './router-admin-backup'; @@ -21,6 +25,20 @@ export async function handleAdminRoute( return handleAdminListUsers(request, env, actorUser); } + if (path === '/api/admin/logs' && method === 'GET') { + return handleAdminListAuditLogs(request, env, actorUser); + } + + if (path === '/api/admin/logs' && method === 'DELETE') { + return handleAdminClearAuditLogs(request, env, actorUser); + } + + if (path === '/api/admin/logs/settings') { + if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser); + if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser); + return null; + } + const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method); if (adminBackupResponse) return adminBackupResponse; diff --git a/src/services/audit-events.ts b/src/services/audit-events.ts new file mode 100644 index 0000000..37573ac --- /dev/null +++ b/src/services/audit-events.ts @@ -0,0 +1,209 @@ +import type { Env } from '../types'; +import { generateUUID } from '../utils/uuid'; +import { StorageService } from './storage'; + +export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system'; +export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security'; + +export interface AuditEventInput { + actorUserId?: string | null; + action: string; + category: AuditLogCategory; + level?: AuditLogLevel; + targetType?: string | null; + targetId?: string | null; + metadata?: Record | null; +} + +const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i; +const MAX_METADATA_BYTES = 2048; +const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; +const AUDIT_CLEANUP_PROBABILITY = 0.02; +const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1'; +const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = { + retentionDays: 90, + maxEntries: null, +}; +let lastAuditCleanupAt = 0; + +export interface AuditLogSettings { + retentionDays: number | null; + maxEntries: number | null; +} + +const ALLOWED_METADATA_KEYS = new Set([ + 'method', + 'path', + 'ip', + 'userAgent', + 'email', + 'targetEmail', + 'grantType', + 'webSession', + 'deviceIdentifier', + 'deviceType', + 'reason', + 'status', + 'verifyDevices', + 'changed', + 'removed', + 'updated', + 'deleted', + 'removedTrusted', + 'removedSessions', + 'removedDevices', + 'requested', + 'count', + 'requestedCount', + 'type', + 'folderId', + 'cipherId', + 'size', + 'users', + 'ciphers', + 'attachments', + 'skippedAttachments', + 'skippedReason', + 'replaceExisting', + 'provider', + 'fileName', + 'fileBytes', + 'bytes', + 'compressedBytes', + 'includesAttachments', + 'destinationName', + 'destinationId', + 'destinationType', + 'destinationCount', + 'scheduledDestinationCount', + 'retentionDays', + 'maxEntries', + 'remotePath', + 'trigger', + 'prunedFileCount', + 'pruneError', + 'uploadVerificationAttempts', + 'error', + 'expiresInHours', + 'checksumMismatchAccepted', +]); + +function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null { + if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null; + const parsed = Math.floor(Number(value)); + return allowed.includes(parsed) ? parsed : null; +} + +export function normalizeAuditLogSettings(value: unknown): AuditLogSettings { + const input = value && typeof value === 'object' ? value as Record : {}; + const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]); + const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]); + + if (retentionDays) return { retentionDays, maxEntries: null }; + if (maxEntries) return { retentionDays: null, maxEntries }; + if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') { + return { retentionDays: null, maxEntries: null }; + } + if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') { + return { retentionDays: null, maxEntries: null }; + } + + return { + ...DEFAULT_AUDIT_LOG_SETTINGS, + }; +} + +export function auditRequestMetadata(request: Request): Record { + const url = new URL(request.url); + return { + method: request.method, + path: url.pathname, + ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null, + userAgent: request.headers.get('User-Agent') || null, + }; +} + +function sanitizeMetadata(metadata: Record): Record { + const clean: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (!ALLOWED_METADATA_KEYS.has(key)) continue; + if (value === undefined || value === null || value === '') continue; + if (SENSITIVE_KEY_RE.test(key)) continue; + if (Array.isArray(value)) { + clean[key] = value.length; + continue; + } + if (typeof value === 'object') continue; + clean[key] = value; + } + return clean; +} + +export async function getAuditLogSettings(storage: StorageService): Promise { + const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY); + if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS }; + try { + return normalizeAuditLogSettings(JSON.parse(raw)); + } catch { + return { ...DEFAULT_AUDIT_LOG_SETTINGS }; + } +} + +export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise { + const normalized = normalizeAuditLogSettings(settings); + await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized)); + await applyAuditLogRetention(storage, normalized); + return normalized; +} + +export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise { + const current = settings || await getAuditLogSettings(storage); + if (current.retentionDays) { + const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString(); + await storage.pruneAuditLogs(before); + } + if (current.maxEntries) { + await storage.pruneAuditLogsToMax(current.maxEntries); + } +} + +async function maybePruneAuditLogs(storage: StorageService): Promise { + const now = Date.now(); + if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return; + if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return; + lastAuditCleanupAt = now; + await applyAuditLogRetention(storage); +} + +async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise { + const metadata = sanitizeMetadata(event.metadata || {}); + let metadataJson = JSON.stringify(metadata); + if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) { + metadataJson = JSON.stringify({ truncated: true }); + } + + await storage.createAuditLog({ + id: generateUUID(), + actorUserId: event.actorUserId ?? null, + action: event.action, + category: event.category, + level: event.level || 'info', + targetType: event.targetType ?? null, + targetId: event.targetId ?? null, + metadata: metadataJson, + createdAt: new Date().toISOString(), + }); + await maybePruneAuditLogs(storage); +} + +export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise { + try { + await insertAuditEvent(storage, event); + } catch (error) { + console.error('audit log write failed', error); + } +} + +export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise { + await writeAuditEvent(new StorageService(env.DB), event); +} diff --git a/src/services/auth.ts b/src/services/auth.ts index 0ad7487..40a8a70 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -23,6 +23,22 @@ export interface VerifiedAccessContext { user: User; } +export type RefreshAccessTokenFailureReason = + | 'token_not_found_or_expired' + | 'user_missing' + | 'user_inactive' + | 'device_missing' + | 'device_session_mismatch'; + +export type RefreshAccessTokenResult = + | { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } + | { + ok: false; + reason: RefreshAccessTokenFailureReason; + userId?: string | null; + deviceIdentifier?: string | null; + }; + export class AuthService { private storage: StorageService; private static userCache = new Map(); @@ -223,17 +239,18 @@ export class AuthService { } // Refresh access token - async refreshAccessToken( - refreshToken: string - ): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> { + async refreshAccessTokenDetailed(refreshToken: string): Promise { const record = await this.storage.getRefreshTokenRecord(refreshToken); - if (!record?.userId) return null; + if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' }; const user = await this.storage.getUserById(record.userId); - if (!user) return null; + if (!user) { + await this.storage.deleteRefreshToken(refreshToken); + return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier }; + } if (user.status !== 'active') { await this.storage.deleteRefreshToken(refreshToken); - return null; + return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier }; } let device: { identifier: string; sessionStamp: string } | null = null; @@ -241,16 +258,23 @@ export class AuthService { const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); if (!boundDevice) { await this.storage.deleteRefreshToken(refreshToken); - return null; + return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; } if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) { await this.storage.deleteRefreshToken(refreshToken); - return null; + return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier }; } device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp }; } const accessToken = await this.generateAccessToken(user, device); - return { accessToken, user, device }; + return { ok: true, accessToken, user, device }; + } + + async refreshAccessToken( + refreshToken: string + ): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> { + const result = await this.refreshAccessTokenDetailed(refreshToken); + return result.ok ? result : null; } } diff --git a/src/services/storage-admin-repo.ts b/src/services/storage-admin-repo.ts index bd6b2e0..ddc62ff 100644 --- a/src/services/storage-admin-repo.ts +++ b/src/services/storage-admin-repo.ts @@ -1,5 +1,72 @@ import type { AuditLog, Invite } from '../types'; +export interface AuditLogListOptions { + limit: number; + offset: number; + category?: string | null; + level?: string | null; + q?: string | null; + from?: string | null; + to?: string | null; +} + +export interface AuditLogListResult { + logs: AuditLog[]; + total: number; + hasMore: boolean; +} + +function auditLogFromRow(row: any): AuditLog { + return { + id: row.id, + actorUserId: row.actor_user_id ?? null, + actorEmail: row.actor_email ?? null, + action: row.action, + category: row.category || 'system', + level: row.level || 'info', + targetType: row.target_type ?? null, + targetId: row.target_id ?? null, + targetUserEmail: row.target_user_email ?? null, + metadata: row.metadata ?? null, + createdAt: row.created_at, + }; +} + +function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } { + const conditions: string[] = []; + const params: unknown[] = []; + + if (options.from) { + conditions.push('l.created_at >= ?'); + params.push(options.from); + } + if (options.to) { + conditions.push('l.created_at <= ?'); + params.push(options.to); + } + if (options.category) { + conditions.push('l.category = ?'); + params.push(options.category); + } + if (options.level) { + conditions.push('l.level = ?'); + params.push(options.level); + } + if (options.q) { + const q = options.q.toLowerCase().slice(0, 48); + const like = `%${q}%`; + conditions.push( + '(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)' + ); + params.push(like, like, like, like, like, like); + } + + return { + where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '', + params, + }; +} + export async function createInvite(db: D1Database, invite: Invite): Promise { await db .prepare( @@ -77,8 +144,60 @@ export async function deleteAllInvites(db: D1Database): Promise { export async function createAuditLog(db: D1Database, log: AuditLog): Promise { await db .prepare( - 'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)' ) - .bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt) + .bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt) .run(); } + +export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise { + const result = await db + .prepare('DELETE FROM audit_logs WHERE created_at < ?') + .bind(beforeIso) + .run(); + return Number(result.meta.changes ?? 0); +} + +export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise { + const limit = Math.max(1, Math.floor(maxEntries)); + const result = await db + .prepare( + 'DELETE FROM audit_logs WHERE id IN (' + + 'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' + + ')' + ) + .bind(limit) + .run(); + return Number(result.meta.changes ?? 0); +} + +export async function clearAuditLogs(db: D1Database): Promise { + const result = await db.prepare('DELETE FROM audit_logs').run(); + return Number(result.meta.changes ?? 0); +} + +export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise { + const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50))); + const offset = Math.max(0, Math.floor(options.offset || 0)); + const { where, params } = buildAuditWhere(options); + + const rows = await db + .prepare( + 'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' + + 'FROM audit_logs l ' + + 'LEFT JOIN users actor ON actor.id = l.actor_user_id ' + + "LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " + + `${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?` + ) + .bind(...params, limit + 1, offset) + .all(); + const results = rows.results || []; + const logs = results.slice(0, limit).map(auditLogFromRow); + const hasMore = results.length > limit; + + return { + logs, + total: offset + logs.length + (hasMore ? 1 : 0), + hasMore, + }; +} diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index da996bb..6e801cb 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -82,10 +82,16 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)', 'CREATE TABLE IF NOT EXISTS audit_logs (' + - 'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' + + 'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' + 'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)', + 'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'', + 'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'', + 'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')', + 'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)', 'CREATE TABLE IF NOT EXISTS devices (' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' + diff --git a/src/services/storage.ts b/src/services/storage.ts index 51a2ec6..6b55743 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -18,12 +18,17 @@ import { saveUser as saveStoredUser, } from './storage-user-repo'; import { + type AuditLogListOptions, createAuditLog as createStoredAuditLog, + clearAuditLogs as clearStoredAuditLogs, createInvite as createStoredInvite, deleteAllInvites as deleteStoredInvites, getInvite as findStoredInvite, + listAuditLogs as listStoredAuditLogs, listInvites as listStoredInvites, markInviteUsed as markStoredInviteUsed, + pruneAuditLogs as pruneStoredAuditLogs, + pruneAuditLogsToMax as pruneStoredAuditLogsToMax, revokeInvite as revokeStoredInvite, } from './storage-admin-repo'; import { @@ -117,7 +122,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // changes. Existing D1 installs only rerun ensureStorageSchema() when this value // differs from config.schema.version. -const STORAGE_SCHEMA_VERSION = '2026-05-05-domain-rules-v2'; +const STORAGE_SCHEMA_VERSION = '2026-05-14-lightweight-audit-logs'; // D1-backed storage. // Contract: @@ -279,6 +284,22 @@ export class StorageService { await createStoredAuditLog(this.db, log); } + async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> { + return listStoredAuditLogs(this.db, options); + } + + async pruneAuditLogs(beforeIso: string): Promise { + return pruneStoredAuditLogs(this.db, beforeIso); + } + + async pruneAuditLogsToMax(maxEntries: number): Promise { + return pruneStoredAuditLogsToMax(this.db, maxEntries); + } + + async clearAuditLogs(): Promise { + return clearStoredAuditLogs(this.db); + } + // --- Domain rules --- async getUserDomainSettings(userId: string) { diff --git a/src/types/index.ts b/src/types/index.ts index 1ccb9ef..47d41d3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -96,9 +96,13 @@ export interface Invite { export interface AuditLog { id: string; actorUserId: string | null; + actorEmail?: string | null; action: string; + category: 'auth' | 'security' | 'device' | 'data' | 'system'; + level: 'info' | 'warn' | 'error' | 'security'; targetType: string | null; targetId: string | null; + targetUserEmail?: string | null; metadata: string | null; createdAt: string; } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index c125d74..746bec7 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -22,7 +22,7 @@ import { saveSession, stripProfileSecrets, } from '@/lib/api/auth'; -import { listAdminInvites, listAdminUsers } from '@/lib/api/admin'; +import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getSends } from '@/lib/api/send'; import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; @@ -96,6 +96,7 @@ const APP_ROUTE_PATHS = [ '/vault/totp', '/sends', '/admin', + '/logs', '/security/devices', '/backup', '/settings', @@ -1398,6 +1399,7 @@ export default function App() { if (location === '/vault/totp') return t('txt_verification_code'); if (location === '/sends') return t('nav_sends'); if (location === '/admin') return t('nav_admin_panel'); + if (location === '/logs') return t('nav_log_center'); if (location === '/security/devices') return t('nav_device_management'); if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules'); if (location === '/backup') return t('nav_backup_strategy'); @@ -1424,7 +1426,7 @@ export default function App() { }, [phase, isImportHashRoute, location, navigate]); useEffect(() => { - if (phase === 'app' && !isAdminProfile(profile) && location === '/backup' && !profileQuery.isFetching) { + if (phase === 'app' && !isAdminProfile(profile) && (location === '/backup' || location === '/logs') && !profileQuery.isFetching) { navigate('/vault'); } }, [phase, profile?.role, profileQuery.isFetching, location, navigate]); @@ -1527,6 +1529,10 @@ export default function App() { onToggleUserStatus: adminActions.toggleUserStatus, onDeleteUser: adminActions.deleteUser, onRevokeInvite: adminActions.revokeInvite, + onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters), + onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), + onSaveAuditLogSettings: (settings) => saveAuditLogSettings(authedFetch, settings), + onClearAuditLogs: () => clearAuditLogs(authedFetch), onExportBackup: backupActions.exportBackup, onImportBackup: backupActions.importBackup, onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, diff --git a/webapp/src/components/AppAuthenticatedShell.tsx b/webapp/src/components/AppAuthenticatedShell.tsx index 56ea058..f07aee3 100644 --- a/webapp/src/components/AppAuthenticatedShell.tsx +++ b/webapp/src/components/AppAuthenticatedShell.tsx @@ -1,4 +1,4 @@ -import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact'; +import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact'; import type { ComponentChildren } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; import { Link } from 'wouter'; @@ -48,11 +48,13 @@ function isAdminProfile(profile: Profile | null): boolean { export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) { const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location; + const isDomainRulesRoute = props.location === '/settings/domain-rules'; + const isLogRoute = props.location === '/logs'; const isAdmin = isAdminProfile(props.profile); const vaultActive = props.location === '/vault' || props.location === '/vault/totp'; const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules'; const dataActive = props.location === '/backup' || props.isImportRoute; - const managementActive = props.location === '/admin' || props.location === '/security/devices'; + const managementActive = props.location === '/admin' || props.location === '/security/devices' || props.location === '/logs'; const [navLayoutMode, setNavLayoutMode] = useState(readNavLayoutMode); const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false); const navLayoutPickerRef = useRef(null); @@ -173,6 +175,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {isAdmin && renderSideLink('/backup', props.location === '/backup', , t('nav_backup_strategy'))} {renderSideLink(props.importRoute, props.isImportRoute, , t('nav_import_export'))} {isAdmin && renderSideLink('/admin', props.location === '/admin', , t('nav_admin_panel'))} + {isAdmin && renderSideLink('/logs', props.location === '/logs', , t('nav_log_center'))} {renderSideLink('/security/devices', props.location === '/security/devices', , t('nav_device_management'))} ); @@ -217,6 +220,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) managementActive, <> {isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} + {isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))} {renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))} )} @@ -302,7 +306,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
-
+
diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index ac7a7b6..e951a52 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -1,13 +1,14 @@ import { lazy, Suspense } from 'preact/compat'; import { useEffect } from 'preact/hooks'; import { Link, Route, Switch } from 'wouter'; -import { ArrowUpDown, Cloud, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; +import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import LoadingState from '@/components/LoadingState'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; +import type { AuditLogFilters } from '@/lib/api/admin'; import type { CiphersImportPayload } from '@/lib/api/vault'; import { t } from '@/lib/i18n'; -import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; +import type { AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { ExportRequest } from '@/lib/export-formats'; const VaultPage = lazy(() => import('@/components/VaultPage')); @@ -17,6 +18,7 @@ const SettingsPage = lazy(() => import('@/components/SettingsPage')); const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage')); const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); const AdminPage = lazy(() => import('@/components/AdminPage')); +const LogCenterPage = lazy(() => import('@/components/LogCenterPage')); const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage')); const ImportPage = lazy(() => import('@/components/ImportPage')); @@ -126,6 +128,10 @@ export interface AppMainRoutesProps { onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise; onDeleteUser: (userId: string) => Promise; onRevokeInvite: (code: string) => Promise; + onLoadAuditLogs: (filters: AuditLogFilters) => Promise; + onLoadAuditLogSettings: () => Promise; + onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise; + onClearAuditLogs: () => Promise; onExportBackup: (includeAttachments?: boolean) => Promise; onImportBackup: (file: File, replaceExisting?: boolean) => Promise; onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise; @@ -289,6 +295,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { {t('nav_admin_panel')} )} + {isAdmin && ( + + + {t('nav_log_center')} + + )} {isAdmin && ( @@ -380,6 +392,23 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { + + {isAdmin ? ( +
+ }> + props.onNavigate(props.settingsHomeRoute)} + /> + +
+ ) : null} +
{importRoutePaths.map((path) => ( {renderImportPageRoute()} diff --git a/webapp/src/components/LogCenterPage.tsx b/webapp/src/components/LogCenterPage.tsx new file mode 100644 index 0000000..95ed56f --- /dev/null +++ b/webapp/src/components/LogCenterPage.tsx @@ -0,0 +1,578 @@ +import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; +import { ChevronLeft, ChevronRight, Database, RefreshCw, Save, Search, Server, Settings2, ShieldAlert, Smartphone, Trash2, UserRound } from 'lucide-preact'; +import LoadingState from '@/components/LoadingState'; +import type { AuditLogFilters } from '@/lib/api/admin'; +import { t } from '@/lib/i18n'; +import type { AuditLogCategory, AuditLogEntry, AuditLogLevel, AuditLogListResult, AuditLogSettings } from '@/lib/types'; + +interface LogCenterPageProps { + onLoadLogs: (filters: AuditLogFilters) => Promise; + onLoadSettings: () => Promise; + onSaveSettings: (settings: AuditLogSettings) => Promise; + onClearLogs: () => Promise; + onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; + mobileLayout?: boolean; + onMobileBack?: () => void; +} + +type TimeRange = '24h' | '7d' | '30d' | 'all'; +type FilterCategory = AuditLogCategory | 'all'; +type FilterLevel = AuditLogLevel | 'all'; +type RetentionMode = 'days' | 'entries'; + +const PAGE_SIZE = 50; +const CATEGORY_OPTIONS: Array<{ value: FilterCategory; labelKey: string }> = [ + { value: 'all', labelKey: 'txt_all_logs' }, + { value: 'auth', labelKey: 'txt_log_category_auth' }, + { value: 'security', labelKey: 'txt_log_category_security' }, + { value: 'device', labelKey: 'txt_log_category_device' }, + { value: 'data', labelKey: 'txt_log_category_data' }, + { value: 'system', labelKey: 'txt_log_category_system' }, +]; +const LEVEL_OPTIONS: Array<{ value: FilterLevel; labelKey: string }> = [ + { value: 'all', labelKey: 'txt_all_levels' }, + { value: 'info', labelKey: 'txt_log_level_info' }, + { value: 'warn', labelKey: 'txt_log_level_warn' }, + { value: 'error', labelKey: 'txt_log_level_error' }, + { value: 'security', labelKey: 'txt_log_level_security' }, +]; +const RANGE_OPTIONS: Array<{ value: TimeRange; labelKey: string }> = [ + { value: '24h', labelKey: 'txt_last_24_hours' }, + { value: '7d', labelKey: 'txt_last_7_days' }, + { value: '30d', labelKey: 'txt_last_30_days' }, + { value: 'all', labelKey: 'txt_all_time' }, +]; +const RETENTION_OPTIONS: Array<{ value: string; labelKey: string }> = [ + { value: '7', labelKey: 'txt_log_retention_7d' }, + { value: '30', labelKey: 'txt_log_retention_30d' }, + { value: '90', labelKey: 'txt_log_retention_90d' }, + { value: '180', labelKey: 'txt_log_retention_180d' }, + { value: '365', labelKey: 'txt_log_retention_365d' }, + { value: '0', labelKey: 'txt_log_retention_forever' }, +]; +const MAX_ENTRY_OPTIONS: Array<{ value: string; labelKey: string }> = [ + { value: '1000', labelKey: 'txt_log_max_1000' }, + { value: '5000', labelKey: 'txt_log_max_5000' }, + { value: '10000', labelKey: 'txt_log_max_10000' }, + { value: '50000', labelKey: 'txt_log_max_50000' }, + { value: '0', labelKey: 'txt_log_max_unlimited' }, +]; + +function parseMetadata(log: AuditLogEntry): Record { + if (!log.metadata) return {}; + try { + const parsed = JSON.parse(log.metadata); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return { raw: log.metadata }; + } +} + +function inferCategory(log: AuditLogEntry, metadata: Record): AuditLogCategory { + if (log.category === 'auth' || log.category === 'security' || log.category === 'device' || log.category === 'data' || log.category === 'system') { + return log.category; + } + const category = metadata.category; + if (category === 'auth' || category === 'security' || category === 'device' || category === 'data' || category === 'system') { + return category; + } + if (log.action.startsWith('auth.')) return 'auth'; + if (log.action.startsWith('device.')) return 'device'; + if (log.action.startsWith('admin.backup.')) return 'data'; + if (log.action.startsWith('account.') || log.action.startsWith('user.password.') || log.action.startsWith('user.register.') || log.action.startsWith('admin.user.')) return 'security'; + return 'system'; +} + +function inferLevel(log: AuditLogEntry, metadata: Record): AuditLogLevel { + if (log.level === 'info' || log.level === 'warn' || log.level === 'error' || log.level === 'security') { + return log.level; + } + const level = metadata.level; + if (level === 'info' || level === 'warn' || level === 'error' || level === 'security') return level; + if (log.action.includes('.failed') || log.action.includes('.error')) return 'error'; + if (log.action.includes('password') || log.action.includes('totp') || log.action.includes('delete') || log.action.includes('ban')) return 'security'; + return 'info'; +} + +function humanizeIdentifier(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split('.') + .flatMap((part) => part.split('_')) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' / '); +} + +function keyFor(prefix: string, value: string): string { + return `${prefix}${value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase()}`; +} + +function translatedOrHumanized(key: string, fallback: string): string { + const translated = t(key); + return translated === key ? humanizeIdentifier(fallback) : translated; +} + +function formatAction(action: string): string { + if (action.startsWith('auth.refresh.failed.')) { + const reason = formatReason(action.slice('auth.refresh.failed.'.length)); + return t('txt_log_action_auth_refresh_failed', { reason }); + } + return translatedOrHumanized(keyFor('txt_log_action_', action), action); +} + +function formatMetaKey(key: string): string { + return translatedOrHumanized(keyFor('txt_log_meta_', key), key); +} + +function formatReason(reason: string): string { + return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason); +} + +function formatTime(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +function formatMetaValue(value: unknown): string { + if (value === null || value === undefined || value === '') return t('txt_dash'); + if (typeof value === 'boolean') return value ? t('txt_yes') : t('txt_no'); + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return JSON.stringify(value); +} + +function formatMetaValueForKey(key: string, value: unknown): string { + if (key === 'reason' && typeof value === 'string') return formatReason(value); + if (key === 'trigger' && typeof value === 'string') { + return translatedOrHumanized(keyFor('txt_log_trigger_', value), value); + } + if (key === 'type' && typeof value === 'string') { + return translatedOrHumanized(keyFor('txt_log_target_type_', value), value); + } + return formatMetaValue(value); +} + +function iconForCategory(category: AuditLogCategory) { + if (category === 'auth') return ; + if (category === 'security') return ; + if (category === 'device') return ; + if (category === 'data') return ; + return ; +} + +function buildRange(range: TimeRange): { from?: string; to?: string } { + if (range === 'all') return {}; + const now = Date.now(); + const hours = range === '24h' ? 24 : range === '7d' ? 24 * 7 : 24 * 30; + return { + from: new Date(now - hours * 60 * 60 * 1000).toISOString(), + to: new Date(now).toISOString(), + }; +} + +function inferRetentionMode(settings: AuditLogSettings): RetentionMode { + return settings.retentionDays === null && settings.maxEntries !== null ? 'entries' : 'days'; +} + +export default function LogCenterPage(props: LogCenterPageProps) { + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + const [search, setSearch] = useState(''); + const [category, setCategory] = useState('all'); + const [level, setLevel] = useState('all'); + const [range, setRange] = useState('7d'); + const [loading, setLoading] = useState(false); + const [settingsLoading, setSettingsLoading] = useState(false); + const [settingsSaving, setSettingsSaving] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + const [retentionMode, setRetentionMode] = useState('days'); + const [settings, setSettings] = useState({ retentionDays: 90, maxEntries: null }); + const [error, setError] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + + const selectedLog = useMemo(() => logs.find((log) => log.id === selectedId) || logs[0] || null, [logs, selectedId]); + const selectedMetadata = useMemo(() => selectedLog ? parseMetadata(selectedLog) : {}, [selectedLog]); + const selectedCategory = selectedLog ? inferCategory(selectedLog, selectedMetadata) : 'system'; + const selectedLevel = selectedLog ? inferLevel(selectedLog, selectedMetadata) : 'info'; + const page = Math.floor(offset / PAGE_SIZE) + 1; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const load = useCallback(async (nextOffset = offset) => { + setLoading(true); + setError(''); + try { + const rangeFilter = buildRange(range); + const result = await props.onLoadLogs({ + limit: PAGE_SIZE, + offset: nextOffset, + category, + level, + q: search, + ...rangeFilter, + }); + setLogs(result.logs); + setTotal(result.total); + setHasMore(result.hasMore); + setOffset(result.offset); + setSelectedId((current) => current && result.logs.some((log) => log.id === current) ? current : result.logs[0]?.id || null); + setMobileDetailOpen(false); + } catch { + setError(t('txt_load_logs_failed')); + props.onNotify('error', t('txt_load_logs_failed')); + } finally { + setLoading(false); + } + }, [category, level, offset, props, range, search]); + + useEffect(() => { + void load(0); + }, [category, level, range]); + + useEffect(() => { + let cancelled = false; + setSettingsLoading(true); + props.onLoadSettings() + .then((next) => { + if (!cancelled) { + setSettings(next); + setRetentionMode(inferRetentionMode(next)); + } + }) + .catch(() => { + if (!cancelled) props.onNotify('error', t('txt_load_log_settings_failed')); + }) + .finally(() => { + if (!cancelled) setSettingsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + function submitFilters(event: Event): void { + event.preventDefault(); + void load(0); + } + + async function saveSettings(): Promise { + setSettingsSaving(true); + try { + const next = await props.onSaveSettings(settings); + setSettings(next); + setRetentionMode(inferRetentionMode(next)); + setSettingsOpen(false); + setClearConfirmOpen(false); + props.onNotify('success', t('txt_log_settings_saved')); + void load(0); + } catch { + props.onNotify('error', t('txt_log_settings_save_failed')); + } finally { + setSettingsSaving(false); + } + } + + async function clearLogs(): Promise { + setSettingsSaving(true); + try { + await props.onClearLogs(); + setLogs([]); + setTotal(0); + setHasMore(false); + setOffset(0); + setSelectedId(null); + setMobileDetailOpen(false); + setClearConfirmOpen(false); + setSettingsOpen(false); + props.onNotify('success', t('txt_logs_cleared')); + } catch { + props.onNotify('error', t('txt_clear_logs_failed')); + } finally { + setSettingsSaving(false); + } + } + + function selectRetentionMode(nextMode: RetentionMode): void { + setRetentionMode(nextMode); + setSettings((current) => nextMode === 'days' + ? { retentionDays: current.retentionDays ?? 90, maxEntries: null } + : { retentionDays: null, maxEntries: current.maxEntries ?? 10_000 }); + } + + const visibleMetaEntries = selectedLog + ? Object.entries(selectedMetadata).filter(([key]) => key !== 'category' && key !== 'level') + : []; + + function selectLog(logId: string): void { + setSelectedId(logId); + setSettingsOpen(false); + setClearConfirmOpen(false); + setMobileDetailOpen(true); + } + + function handleMobileBack(): void { + if (mobileDetailOpen) { + setMobileDetailOpen(false); + return; + } + props.onMobileBack?.(); + } + + return ( +
+ {props.mobileLayout && ( +
+ + +
+ )} +
+
+ + + + +
+ + +
+
+ + {settingsOpen && ( +
+
+

{t('txt_log_retention_settings')}

+
+
+ + +
+ {retentionMode === 'days' ? ( +
+ +
+ + +
+
+ ) : ( +
+ +
+ + +
+
+ )} +
+ {clearConfirmOpen ? ( + <> +

{t('txt_clear_logs_confirm')}

+
+ + +
+ + ) : ( + + )} +
+
+ )} +
+ +
+
+
+

{t('txt_audit_events')}

+ {page} / {totalPages} +
+
+ {logs.map((log) => { + const metadata = parseMetadata(log); + const logCategory = inferCategory(log, metadata); + const logLevel = inferLevel(log, metadata); + return ( + + ); + })} + {loading && !logs.length && } + {!loading && !logs.length &&
{t('txt_no_logs_found')}
} + {!!error &&
{error}
} +
+
+ + + {Math.min(offset + logs.length, total)} / {total} + + +
+
+ +
+ {selectedLog ? ( + <> +
+
+

{formatAction(selectedLog.action)}

+

{selectedLog.action}

+
+ {t(`txt_log_level_${selectedLevel}`)} +
+
+
{t('txt_time')}{formatTime(selectedLog.createdAt)}
+
{t('txt_log_category')}{t(`txt_log_category_${selectedCategory}`)}
+
{t('txt_actor')}{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}
+
{t('txt_target')}{selectedLog.targetUserEmail || String(selectedMetadata.targetEmail || '') || selectedLog.targetId || selectedLog.targetType || t('txt_dash')}
+
+
+

{t('txt_metadata')}

+ {visibleMetaEntries.length ? ( +
+ {visibleMetaEntries.map(([key, value]) => ( +
+
{formatMetaKey(key)}
+
{formatMetaValueForKey(key, value)}
+
+ ))} +
+ ) : ( +
{t('txt_no_metadata')}
+ )} +
+ + ) : ( +
{t('txt_no_logs_found')}
+ )} +
+
+
+ ); +} diff --git a/webapp/src/lib/api/admin.ts b/webapp/src/lib/api/admin.ts index b34038c..e7e4f61 100644 --- a/webapp/src/lib/api/admin.ts +++ b/webapp/src/lib/api/admin.ts @@ -1,4 +1,4 @@ -import type { AdminInvite, AdminUser, ListResponse } from '../types'; +import type { AdminInvite, AdminUser, AuditLogCategory, AuditLogEntry, AuditLogLevel, AuditLogListResult, AuditLogSettings, ListResponse } from '../types'; import { parseJson, type AuthedFetch } from './shared'; export async function listAdminUsers(authedFetch: AuthedFetch): Promise { @@ -51,3 +51,66 @@ export async function deleteUser(authedFetch: AuthedFetch, userId: string): Prom const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); if (!resp.ok) throw new Error('Delete user failed'); } + +export interface AuditLogFilters { + limit?: number; + offset?: number; + category?: AuditLogCategory | 'all'; + level?: AuditLogLevel | 'all'; + q?: string; + from?: string; + to?: string; +} + +export async function listAuditLogs(authedFetch: AuthedFetch, filters: AuditLogFilters = {}): Promise { + const params = new URLSearchParams(); + params.set('limit', String(filters.limit || 50)); + params.set('offset', String(filters.offset || 0)); + if (filters.category && filters.category !== 'all') params.set('category', filters.category); + if (filters.level && filters.level !== 'all') params.set('level', filters.level); + if (filters.q?.trim()) params.set('q', filters.q.trim()); + if (filters.from) params.set('from', filters.from); + if (filters.to) params.set('to', filters.to); + + const resp = await authedFetch(`/api/admin/logs?${params.toString()}`); + if (!resp.ok) throw new Error('Failed to load audit logs'); + const body = await parseJson>(resp); + return { + logs: body?.data || [], + total: body?.total || 0, + limit: body?.limit || filters.limit || 50, + offset: body?.offset || filters.offset || 0, + hasMore: !!body?.hasMore, + }; +} + +export async function getAuditLogSettings(authedFetch: AuthedFetch): Promise { + const resp = await authedFetch('/api/admin/logs/settings'); + if (!resp.ok) throw new Error('Failed to load audit log settings'); + const body = await parseJson(resp); + return { + retentionDays: body?.retentionDays ?? null, + maxEntries: body?.maxEntries ?? null, + }; +} + +export async function saveAuditLogSettings(authedFetch: AuthedFetch, settings: AuditLogSettings): Promise { + const resp = await authedFetch('/api/admin/logs/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + if (!resp.ok) throw new Error('Failed to save audit log settings'); + const body = await parseJson(resp); + return { + retentionDays: body?.retentionDays ?? null, + maxEntries: body?.maxEntries ?? null, + }; +} + +export async function clearAuditLogs(authedFetch: AuthedFetch): Promise { + const resp = await authedFetch('/api/admin/logs', { method: 'DELETE' }); + if (!resp.ok) throw new Error('Failed to clear audit logs'); + const body = await parseJson<{ deleted?: number }>(resp); + return Number(body?.deleted || 0); +} diff --git a/webapp/src/lib/demo.ts b/webapp/src/lib/demo.ts index e22af0c..dc98329 100644 --- a/webapp/src/lib/demo.ts +++ b/webapp/src/lib/demo.ts @@ -1137,6 +1137,15 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti ))); notify('success', t('txt_invite_revoked')); }, + onLoadAuditLogSettings: async () => ({ retentionDays: 90, maxEntries: null }), + onSaveAuditLogSettings: async (settings) => { + notify('success', t('txt_log_settings_saved')); + return settings; + }, + onClearAuditLogs: async () => { + notify('success', t('txt_logs_cleared')); + return 0; + }, onExportBackup: async () => { notify('success', t('txt_backup_export_success')); }, diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index f2fb03c..147b2ab 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -2,6 +2,7 @@ const en: Record = { "nav_account_settings": "Account Settings", "nav_admin_panel": "Admin Panel", + "nav_log_center": "Log Center", "nav_device_management": "Device Management", "nav_my_vault": "My Vault", "nav_vault_items": "Vault", @@ -941,6 +942,190 @@ const en: Record = { "txt_nav_layout_grouped_expanded_desc": "Keep all groups expanded", "txt_nav_layout_grouped_smart": "Smart groups", "txt_nav_layout_grouped_smart_desc": "Open active groups as needed", + "txt_actor": "Actor", + "txt_all_levels": "All levels", + "txt_all_logs": "All logs", + "txt_all_time": "All time", + "txt_audit_events": "Log list", + "txt_filter": "Filter", + "txt_last_24_hours": "Last 24 hours", + "txt_last_7_days": "Last 7 days", + "txt_last_30_days": "Last 30 days", + "txt_load_logs_failed": "Failed to load logs", + "txt_load_log_settings_failed": "Failed to load log settings", + "txt_log_category": "Category", + "txt_log_category_auth": "Auth & sessions", + "txt_log_category_data": "Data operations", + "txt_log_category_device": "Devices", + "txt_log_category_security": "Account security", + "txt_log_category_system": "System", + "txt_log_center_description": "Trace sign-ins, refresh failures, device events, security changes, backup actions, and admin operations.", + "txt_log_center_title": "Log Center", + "txt_log_level": "Level", + "txt_log_level_error": "Error", + "txt_log_level_info": "Info", + "txt_log_level_security": "Security", + "txt_log_level_warn": "Warn", + "txt_log_action_account_api_key_create": "Create API key", + "txt_log_action_account_api_key_rotate": "Rotate API key", + "txt_log_action_account_keys_update": "Update account keys", + "txt_log_action_account_profile_update": "Update account profile", + "txt_log_action_account_totp_disable": "Disable two-step login", + "txt_log_action_account_totp_enable": "Enable two-step login", + "txt_log_action_account_totp_recover": "Recover two-step login", + "txt_log_action_account_verify_devices_update": "Update device verification", + "txt_log_action_admin_audit_settings_update": "Update log retention settings", + "txt_log_action_admin_backup_export": "Export backup", + "txt_log_action_admin_backup_import": "Import backup", + "txt_log_action_admin_backup_remote_delete": "Delete remote backup", + "txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded", + "txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed", + "txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded", + "txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed", + "txt_log_action_admin_backup_settings_repair": "Repair backup settings", + "txt_log_action_admin_backup_settings_update": "Update backup settings", + "txt_log_action_admin_invite_create": "Create invite", + "txt_log_action_admin_invite_delete_all": "Clear invites", + "txt_log_action_admin_invite_revoke": "Revoke invite", + "txt_log_action_admin_user_delete": "Delete user", + "txt_log_action_admin_user_status": "Change user status", + "txt_log_action_attachment_delete": "Delete attachment", + "txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key", + "txt_log_action_auth_login_failed_bad_password": "Login failed: bad password", + "txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account", + "txt_log_action_auth_login_success": "Login succeeded", + "txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}", + "txt_log_action_cipher_delete_permanent": "Permanently delete vault item", + "txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items", + "txt_log_action_cipher_delete_soft": "Move vault item to trash", + "txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash", + "txt_log_action_device_deactivate": "Deactivate device", + "txt_log_action_device_delete": "Delete device", + "txt_log_action_device_delete_all": "Delete all devices", + "txt_log_action_device_name_update": "Update device name", + "txt_log_action_device_trust_permanent": "Trust device permanently", + "txt_log_action_device_trust_revoke": "Revoke device trust", + "txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk", + "txt_log_action_folder_delete": "Delete folder", + "txt_log_action_folder_delete_bulk": "Delete folders", + "txt_log_action_send_auth_remove": "Remove Send authentication", + "txt_log_action_send_delete": "Delete Send", + "txt_log_action_send_delete_bulk": "Delete Sends", + "txt_log_action_send_password_remove": "Remove Send password", + "txt_log_action_user_password_change": "Change master password", + "txt_log_action_user_register_first_admin": "Register first admin", + "txt_log_action_user_register_invite": "Register by invite", + "txt_log_meta_attachments": "Attachments", + "txt_log_meta_bytes": "Bytes", + "txt_log_meta_changed": "Changed fields", + "txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch", + "txt_log_meta_cipher_id": "Vault item ID", + "txt_log_meta_ciphers": "Vault items", + "txt_log_meta_compat": "Compatibility", + "txt_log_meta_compressed_bytes": "Compressed bytes", + "txt_log_meta_count": "Count", + "txt_log_meta_deleted": "Deleted count", + "txt_log_meta_destination_count": "Destination count", + "txt_log_meta_destination_id": "Destination ID", + "txt_log_meta_destination_name": "Destination name", + "txt_log_meta_destination_type": "Destination type", + "txt_log_meta_device_identifier": "Device ID", + "txt_log_meta_device_type": "Device type", + "txt_log_meta_email": "Email", + "txt_log_meta_error": "Error", + "txt_log_meta_expires_in_hours": "Expires in hours", + "txt_log_meta_file_bytes": "File bytes", + "txt_log_meta_file_name": "File name", + "txt_log_meta_folder_id": "Folder ID", + "txt_log_meta_grant_type": "Login method", + "txt_log_meta_includes_attachments": "Includes attachments", + "txt_log_meta_ip": "IP address", + "txt_log_meta_max_entries": "Entry limit", + "txt_log_meta_method": "Request method", + "txt_log_meta_path": "Request path", + "txt_log_meta_provider": "Provider", + "txt_log_meta_prune_error": "Cleanup error", + "txt_log_meta_pruned_file_count": "Cleaned files", + "txt_log_meta_raw": "Raw data", + "txt_log_meta_reason": "Reason", + "txt_log_meta_remote_path": "Remote path", + "txt_log_meta_removed": "Removed count", + "txt_log_meta_removed_devices": "Removed devices", + "txt_log_meta_removed_sessions": "Removed sessions", + "txt_log_meta_removed_trusted": "Trust removals", + "txt_log_meta_replace_existing": "Replace existing data", + "txt_log_meta_requested": "Requested count", + "txt_log_meta_requested_count": "Requested count", + "txt_log_meta_retention_days": "Retention days", + "txt_log_meta_scheduled_destination_count": "Scheduled destinations", + "txt_log_meta_size": "Size", + "txt_log_meta_skipped_attachments": "Skipped attachments", + "txt_log_meta_skipped_reason": "Skip reason", + "txt_log_meta_status": "Status", + "txt_log_meta_target_email": "Target email", + "txt_log_meta_trigger": "Trigger", + "txt_log_meta_type": "Type", + "txt_log_meta_updated": "Updated count", + "txt_log_meta_upload_verification_attempts": "Upload verification attempts", + "txt_log_meta_user_agent": "Browser/client", + "txt_log_meta_users": "Users", + "txt_log_meta_verify_devices": "Verify devices", + "txt_log_meta_web_session": "Web session", + "txt_log_reason_bad_api_key": "Bad API key", + "txt_log_reason_bad_password": "Bad password", + "txt_log_reason_device_missing": "Device missing", + "txt_log_reason_device_session_mismatch": "Device session mismatch", + "txt_log_reason_token_not_found_or_expired": "Token missing or expired", + "txt_log_reason_user_inactive": "User inactive", + "txt_log_reason_user_missing": "User missing", + "txt_log_target_type_attachment": "Attachment", + "txt_log_target_type_audit_log": "Log", + "txt_log_target_type_backup": "Backup", + "txt_log_target_type_cipher": "Vault item", + "txt_log_target_type_device": "Device", + "txt_log_target_type_folder": "Folder", + "txt_log_target_type_invite": "Invite", + "txt_log_target_type_refresh_token": "Refresh token", + "txt_log_target_type_send": "Send", + "txt_log_target_type_user": "User", + "txt_log_trigger_manual": "Manual", + "txt_log_trigger_remote": "Remote", + "txt_log_trigger_scheduled": "Scheduled", + "txt_log_max_1000": "Up to 1,000 entries", + "txt_log_max_5000": "Up to 5,000 entries", + "txt_log_max_10000": "Up to 10,000 entries", + "txt_log_max_50000": "Up to 50,000 entries", + "txt_log_max_entries": "Storage cap", + "txt_log_max_unlimited": "Unlimited entries", + "txt_log_retention_7d": "Keep 7 days", + "txt_log_retention_30d": "Keep 30 days", + "txt_log_retention_90d": "Keep 90 days", + "txt_log_retention_180d": "Keep 180 days", + "txt_log_retention_365d": "Keep 365 days", + "txt_log_retention_days": "Retention", + "txt_log_retention_forever": "Keep forever", + "txt_log_retention_hint": "Automatically trims by age and entry count to reduce D1 storage use.", + "txt_log_retention_mode": "Retention mode", + "txt_log_retention_mode_days": "By time", + "txt_log_retention_mode_entries": "By count", + "txt_log_retention_settings": "Log retention", + "txt_log_settings": "Settings", + "txt_log_settings_save_failed": "Failed to save log settings", + "txt_log_settings_saved": "Log settings saved", + "txt_log_search_placeholder": "Search action, actor, target, request path, or metadata", + "txt_log_total": " total", + "txt_log_visible": " visible", + "txt_metadata": "Metadata", + "txt_no_logs_found": "No logs found", + "txt_no_metadata": "No metadata", + "txt_clear_all_logs": "Clear logs", + "txt_clear_logs_confirm": "Clear all logs? This cannot be undone.", + "txt_clear_logs_failed": "Failed to clear logs", + "txt_logs_cleared": "Logs cleared", + "txt_search": "Search", + "txt_target": "Target", + "txt_time": "Time", + "txt_time_range": "Time range", "txt_remove_domain": "Remove domain" }; diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index 8bcd2d2..942bd6c 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -2,6 +2,7 @@ const es: Record = { "nav_account_settings": "Configuración de la cuenta", "nav_admin_panel": "Panel de administración", + "nav_log_center": "Centro de registros", "nav_device_management": "Gestión de dispositivos", "nav_my_vault": "Mi bóveda", "nav_vault_items": "Bóveda", @@ -941,6 +942,190 @@ const es: Record = { "txt_nav_layout_grouped_expanded_desc": "Mantener todos los grupos abiertos", "txt_nav_layout_grouped_smart": "Grupos inteligentes", "txt_nav_layout_grouped_smart_desc": "Abrir grupos activos cuando haga falta", + "txt_actor": "Actor", + "txt_all_levels": "Todos los niveles", + "txt_all_logs": "Todos los registros", + "txt_all_time": "Todo el tiempo", + "txt_audit_events": "Lista de registros", + "txt_filter": "Filtrar", + "txt_last_24_hours": "Últimas 24 horas", + "txt_last_7_days": "Últimos 7 días", + "txt_last_30_days": "Últimos 30 días", + "txt_load_logs_failed": "No se pudieron cargar los registros", + "txt_load_log_settings_failed": "No se pudo cargar la configuración de registros", + "txt_log_category": "Categoría", + "txt_log_category_auth": "Acceso y sesiones", + "txt_log_category_data": "Operaciones de datos", + "txt_log_category_device": "Dispositivos", + "txt_log_category_security": "Seguridad de cuenta", + "txt_log_category_system": "Sistema", + "txt_log_center_description": "Revisa inicios de sesión, fallos de renovación, eventos de dispositivos, cambios de seguridad, copias y acciones de administración.", + "txt_log_center_title": "Centro de registros", + "txt_log_level": "Nivel", + "txt_log_level_error": "Error", + "txt_log_level_info": "Info", + "txt_log_level_security": "Seguridad", + "txt_log_level_warn": "Aviso", + "txt_log_action_account_api_key_create": "Create API key", + "txt_log_action_account_api_key_rotate": "Rotate API key", + "txt_log_action_account_keys_update": "Update account keys", + "txt_log_action_account_profile_update": "Update account profile", + "txt_log_action_account_totp_disable": "Disable two-step login", + "txt_log_action_account_totp_enable": "Enable two-step login", + "txt_log_action_account_totp_recover": "Recover two-step login", + "txt_log_action_account_verify_devices_update": "Update device verification", + "txt_log_action_admin_audit_settings_update": "Update log retention settings", + "txt_log_action_admin_backup_export": "Export backup", + "txt_log_action_admin_backup_import": "Import backup", + "txt_log_action_admin_backup_remote_delete": "Delete remote backup", + "txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded", + "txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed", + "txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded", + "txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed", + "txt_log_action_admin_backup_settings_repair": "Repair backup settings", + "txt_log_action_admin_backup_settings_update": "Update backup settings", + "txt_log_action_admin_invite_create": "Create invite", + "txt_log_action_admin_invite_delete_all": "Clear invites", + "txt_log_action_admin_invite_revoke": "Revoke invite", + "txt_log_action_admin_user_delete": "Delete user", + "txt_log_action_admin_user_status": "Change user status", + "txt_log_action_attachment_delete": "Delete attachment", + "txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key", + "txt_log_action_auth_login_failed_bad_password": "Login failed: bad password", + "txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account", + "txt_log_action_auth_login_success": "Login succeeded", + "txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}", + "txt_log_action_cipher_delete_permanent": "Permanently delete vault item", + "txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items", + "txt_log_action_cipher_delete_soft": "Move vault item to trash", + "txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash", + "txt_log_action_device_deactivate": "Deactivate device", + "txt_log_action_device_delete": "Delete device", + "txt_log_action_device_delete_all": "Delete all devices", + "txt_log_action_device_name_update": "Update device name", + "txt_log_action_device_trust_permanent": "Trust device permanently", + "txt_log_action_device_trust_revoke": "Revoke device trust", + "txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk", + "txt_log_action_folder_delete": "Delete folder", + "txt_log_action_folder_delete_bulk": "Delete folders", + "txt_log_action_send_auth_remove": "Remove Send authentication", + "txt_log_action_send_delete": "Delete Send", + "txt_log_action_send_delete_bulk": "Delete Sends", + "txt_log_action_send_password_remove": "Remove Send password", + "txt_log_action_user_password_change": "Change master password", + "txt_log_action_user_register_first_admin": "Register first admin", + "txt_log_action_user_register_invite": "Register by invite", + "txt_log_meta_attachments": "Attachments", + "txt_log_meta_bytes": "Bytes", + "txt_log_meta_changed": "Changed fields", + "txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch", + "txt_log_meta_cipher_id": "Vault item ID", + "txt_log_meta_ciphers": "Vault items", + "txt_log_meta_compat": "Compatibility", + "txt_log_meta_compressed_bytes": "Compressed bytes", + "txt_log_meta_count": "Count", + "txt_log_meta_deleted": "Deleted count", + "txt_log_meta_destination_count": "Destination count", + "txt_log_meta_destination_id": "Destination ID", + "txt_log_meta_destination_name": "Destination name", + "txt_log_meta_destination_type": "Destination type", + "txt_log_meta_device_identifier": "Device ID", + "txt_log_meta_device_type": "Device type", + "txt_log_meta_email": "Email", + "txt_log_meta_error": "Error", + "txt_log_meta_expires_in_hours": "Expires in hours", + "txt_log_meta_file_bytes": "File bytes", + "txt_log_meta_file_name": "File name", + "txt_log_meta_folder_id": "Folder ID", + "txt_log_meta_grant_type": "Login method", + "txt_log_meta_includes_attachments": "Includes attachments", + "txt_log_meta_ip": "IP address", + "txt_log_meta_max_entries": "Entry limit", + "txt_log_meta_method": "Request method", + "txt_log_meta_path": "Request path", + "txt_log_meta_provider": "Provider", + "txt_log_meta_prune_error": "Cleanup error", + "txt_log_meta_pruned_file_count": "Cleaned files", + "txt_log_meta_raw": "Raw data", + "txt_log_meta_reason": "Reason", + "txt_log_meta_remote_path": "Remote path", + "txt_log_meta_removed": "Removed count", + "txt_log_meta_removed_devices": "Removed devices", + "txt_log_meta_removed_sessions": "Removed sessions", + "txt_log_meta_removed_trusted": "Trust removals", + "txt_log_meta_replace_existing": "Replace existing data", + "txt_log_meta_requested": "Requested count", + "txt_log_meta_requested_count": "Requested count", + "txt_log_meta_retention_days": "Retention days", + "txt_log_meta_scheduled_destination_count": "Scheduled destinations", + "txt_log_meta_size": "Size", + "txt_log_meta_skipped_attachments": "Skipped attachments", + "txt_log_meta_skipped_reason": "Skip reason", + "txt_log_meta_status": "Status", + "txt_log_meta_target_email": "Target email", + "txt_log_meta_trigger": "Trigger", + "txt_log_meta_type": "Type", + "txt_log_meta_updated": "Updated count", + "txt_log_meta_upload_verification_attempts": "Upload verification attempts", + "txt_log_meta_user_agent": "Browser/client", + "txt_log_meta_users": "Users", + "txt_log_meta_verify_devices": "Verify devices", + "txt_log_meta_web_session": "Web session", + "txt_log_reason_bad_api_key": "Bad API key", + "txt_log_reason_bad_password": "Bad password", + "txt_log_reason_device_missing": "Device missing", + "txt_log_reason_device_session_mismatch": "Device session mismatch", + "txt_log_reason_token_not_found_or_expired": "Token missing or expired", + "txt_log_reason_user_inactive": "User inactive", + "txt_log_reason_user_missing": "User missing", + "txt_log_target_type_attachment": "Attachment", + "txt_log_target_type_audit_log": "Log", + "txt_log_target_type_backup": "Backup", + "txt_log_target_type_cipher": "Vault item", + "txt_log_target_type_device": "Device", + "txt_log_target_type_folder": "Folder", + "txt_log_target_type_invite": "Invite", + "txt_log_target_type_refresh_token": "Refresh token", + "txt_log_target_type_send": "Send", + "txt_log_target_type_user": "User", + "txt_log_trigger_manual": "Manual", + "txt_log_trigger_remote": "Remote", + "txt_log_trigger_scheduled": "Scheduled", + "txt_log_max_1000": "Hasta 1000 entradas", + "txt_log_max_5000": "Hasta 5000 entradas", + "txt_log_max_10000": "Hasta 10 000 entradas", + "txt_log_max_50000": "Hasta 50 000 entradas", + "txt_log_max_entries": "Límite de almacenamiento", + "txt_log_max_unlimited": "Entradas ilimitadas", + "txt_log_retention_7d": "Conservar 7 días", + "txt_log_retention_30d": "Conservar 30 días", + "txt_log_retention_90d": "Conservar 90 días", + "txt_log_retention_180d": "Conservar 180 días", + "txt_log_retention_365d": "Conservar 365 días", + "txt_log_retention_days": "Retención", + "txt_log_retention_forever": "Conservar siempre", + "txt_log_retention_hint": "Recorta automáticamente por antigüedad y cantidad para reducir el uso de D1.", + "txt_log_retention_mode": "Modo de retención", + "txt_log_retention_mode_days": "Por tiempo", + "txt_log_retention_mode_entries": "Por cantidad", + "txt_log_retention_settings": "Retención de registros", + "txt_log_settings": "Configuración", + "txt_log_settings_save_failed": "No se pudo guardar la configuración de registros", + "txt_log_settings_saved": "Configuración de registros guardada", + "txt_log_search_placeholder": "Buscar acción, actor, destino, ruta o metadatos", + "txt_log_total": " total", + "txt_log_visible": " visibles", + "txt_metadata": "Metadatos", + "txt_no_logs_found": "No se encontraron registros", + "txt_no_metadata": "Sin metadatos", + "txt_clear_all_logs": "Borrar registros", + "txt_clear_logs_confirm": "¿Borrar todos los registros? Esta acción no se puede deshacer.", + "txt_clear_logs_failed": "No se pudieron borrar los registros", + "txt_logs_cleared": "Registros borrados", + "txt_search": "Buscar", + "txt_target": "Destino", + "txt_time": "Hora", + "txt_time_range": "Rango de tiempo", "txt_remove_domain": "Quitar dominio" }; diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index 5cf752e..ef1fca5 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -3,6 +3,7 @@ const ru: Record = { "txt_backup_destination_detail_note": "", "nav_account_settings": "Настройки учетной записи", "nav_admin_panel": "Панель администратора", + "nav_log_center": "Центр журналов", "nav_device_management": "Управление устройствами", "nav_my_vault": "Мое хранилище", "nav_vault_items": "Хранилище", @@ -941,6 +942,190 @@ const ru: Record = { "txt_nav_layout_grouped_expanded_desc": "Держать все группы открытыми", "txt_nav_layout_grouped_smart": "Умные группы", "txt_nav_layout_grouped_smart_desc": "Открывать активные группы по необходимости", + "txt_actor": "Инициатор", + "txt_all_levels": "Все уровни", + "txt_all_logs": "Все журналы", + "txt_all_time": "Все время", + "txt_audit_events": "Список журналов", + "txt_filter": "Фильтр", + "txt_last_24_hours": "Последние 24 часа", + "txt_last_7_days": "Последние 7 дней", + "txt_last_30_days": "Последние 30 дней", + "txt_load_logs_failed": "Не удалось загрузить журналы", + "txt_load_log_settings_failed": "Не удалось загрузить настройки журналов", + "txt_log_category": "Категория", + "txt_log_category_auth": "Вход и сессии", + "txt_log_category_data": "Операции с данными", + "txt_log_category_device": "Устройства", + "txt_log_category_security": "Безопасность учетной записи", + "txt_log_category_system": "Система", + "txt_log_center_description": "Просматривайте входы, сбои обновления, события устройств, изменения безопасности, резервные копии и действия администратора.", + "txt_log_center_title": "Центр журналов", + "txt_log_level": "Уровень", + "txt_log_level_error": "Ошибка", + "txt_log_level_info": "Инфо", + "txt_log_level_security": "Безопасность", + "txt_log_level_warn": "Предупреждение", + "txt_log_action_account_api_key_create": "Create API key", + "txt_log_action_account_api_key_rotate": "Rotate API key", + "txt_log_action_account_keys_update": "Update account keys", + "txt_log_action_account_profile_update": "Update account profile", + "txt_log_action_account_totp_disable": "Disable two-step login", + "txt_log_action_account_totp_enable": "Enable two-step login", + "txt_log_action_account_totp_recover": "Recover two-step login", + "txt_log_action_account_verify_devices_update": "Update device verification", + "txt_log_action_admin_audit_settings_update": "Update log retention settings", + "txt_log_action_admin_backup_export": "Export backup", + "txt_log_action_admin_backup_import": "Import backup", + "txt_log_action_admin_backup_remote_delete": "Delete remote backup", + "txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded", + "txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed", + "txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded", + "txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed", + "txt_log_action_admin_backup_settings_repair": "Repair backup settings", + "txt_log_action_admin_backup_settings_update": "Update backup settings", + "txt_log_action_admin_invite_create": "Create invite", + "txt_log_action_admin_invite_delete_all": "Clear invites", + "txt_log_action_admin_invite_revoke": "Revoke invite", + "txt_log_action_admin_user_delete": "Delete user", + "txt_log_action_admin_user_status": "Change user status", + "txt_log_action_attachment_delete": "Delete attachment", + "txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key", + "txt_log_action_auth_login_failed_bad_password": "Login failed: bad password", + "txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account", + "txt_log_action_auth_login_success": "Login succeeded", + "txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}", + "txt_log_action_cipher_delete_permanent": "Permanently delete vault item", + "txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items", + "txt_log_action_cipher_delete_soft": "Move vault item to trash", + "txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash", + "txt_log_action_device_deactivate": "Deactivate device", + "txt_log_action_device_delete": "Delete device", + "txt_log_action_device_delete_all": "Delete all devices", + "txt_log_action_device_name_update": "Update device name", + "txt_log_action_device_trust_permanent": "Trust device permanently", + "txt_log_action_device_trust_revoke": "Revoke device trust", + "txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk", + "txt_log_action_folder_delete": "Delete folder", + "txt_log_action_folder_delete_bulk": "Delete folders", + "txt_log_action_send_auth_remove": "Remove Send authentication", + "txt_log_action_send_delete": "Delete Send", + "txt_log_action_send_delete_bulk": "Delete Sends", + "txt_log_action_send_password_remove": "Remove Send password", + "txt_log_action_user_password_change": "Change master password", + "txt_log_action_user_register_first_admin": "Register first admin", + "txt_log_action_user_register_invite": "Register by invite", + "txt_log_meta_attachments": "Attachments", + "txt_log_meta_bytes": "Bytes", + "txt_log_meta_changed": "Changed fields", + "txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch", + "txt_log_meta_cipher_id": "Vault item ID", + "txt_log_meta_ciphers": "Vault items", + "txt_log_meta_compat": "Compatibility", + "txt_log_meta_compressed_bytes": "Compressed bytes", + "txt_log_meta_count": "Count", + "txt_log_meta_deleted": "Deleted count", + "txt_log_meta_destination_count": "Destination count", + "txt_log_meta_destination_id": "Destination ID", + "txt_log_meta_destination_name": "Destination name", + "txt_log_meta_destination_type": "Destination type", + "txt_log_meta_device_identifier": "Device ID", + "txt_log_meta_device_type": "Device type", + "txt_log_meta_email": "Email", + "txt_log_meta_error": "Error", + "txt_log_meta_expires_in_hours": "Expires in hours", + "txt_log_meta_file_bytes": "File bytes", + "txt_log_meta_file_name": "File name", + "txt_log_meta_folder_id": "Folder ID", + "txt_log_meta_grant_type": "Login method", + "txt_log_meta_includes_attachments": "Includes attachments", + "txt_log_meta_ip": "IP address", + "txt_log_meta_max_entries": "Entry limit", + "txt_log_meta_method": "Request method", + "txt_log_meta_path": "Request path", + "txt_log_meta_provider": "Provider", + "txt_log_meta_prune_error": "Cleanup error", + "txt_log_meta_pruned_file_count": "Cleaned files", + "txt_log_meta_raw": "Raw data", + "txt_log_meta_reason": "Reason", + "txt_log_meta_remote_path": "Remote path", + "txt_log_meta_removed": "Removed count", + "txt_log_meta_removed_devices": "Removed devices", + "txt_log_meta_removed_sessions": "Removed sessions", + "txt_log_meta_removed_trusted": "Trust removals", + "txt_log_meta_replace_existing": "Replace existing data", + "txt_log_meta_requested": "Requested count", + "txt_log_meta_requested_count": "Requested count", + "txt_log_meta_retention_days": "Retention days", + "txt_log_meta_scheduled_destination_count": "Scheduled destinations", + "txt_log_meta_size": "Size", + "txt_log_meta_skipped_attachments": "Skipped attachments", + "txt_log_meta_skipped_reason": "Skip reason", + "txt_log_meta_status": "Status", + "txt_log_meta_target_email": "Target email", + "txt_log_meta_trigger": "Trigger", + "txt_log_meta_type": "Type", + "txt_log_meta_updated": "Updated count", + "txt_log_meta_upload_verification_attempts": "Upload verification attempts", + "txt_log_meta_user_agent": "Browser/client", + "txt_log_meta_users": "Users", + "txt_log_meta_verify_devices": "Verify devices", + "txt_log_meta_web_session": "Web session", + "txt_log_reason_bad_api_key": "Bad API key", + "txt_log_reason_bad_password": "Bad password", + "txt_log_reason_device_missing": "Device missing", + "txt_log_reason_device_session_mismatch": "Device session mismatch", + "txt_log_reason_token_not_found_or_expired": "Token missing or expired", + "txt_log_reason_user_inactive": "User inactive", + "txt_log_reason_user_missing": "User missing", + "txt_log_target_type_attachment": "Attachment", + "txt_log_target_type_audit_log": "Log", + "txt_log_target_type_backup": "Backup", + "txt_log_target_type_cipher": "Vault item", + "txt_log_target_type_device": "Device", + "txt_log_target_type_folder": "Folder", + "txt_log_target_type_invite": "Invite", + "txt_log_target_type_refresh_token": "Refresh token", + "txt_log_target_type_send": "Send", + "txt_log_target_type_user": "User", + "txt_log_trigger_manual": "Manual", + "txt_log_trigger_remote": "Remote", + "txt_log_trigger_scheduled": "Scheduled", + "txt_log_max_1000": "До 1 000 записей", + "txt_log_max_5000": "До 5 000 записей", + "txt_log_max_10000": "До 10 000 записей", + "txt_log_max_50000": "До 50 000 записей", + "txt_log_max_entries": "Лимит хранения", + "txt_log_max_unlimited": "Без ограничения записей", + "txt_log_retention_7d": "Хранить 7 дней", + "txt_log_retention_30d": "Хранить 30 дней", + "txt_log_retention_90d": "Хранить 90 дней", + "txt_log_retention_180d": "Хранить 180 дней", + "txt_log_retention_365d": "Хранить 365 дней", + "txt_log_retention_days": "Срок хранения", + "txt_log_retention_forever": "Хранить всегда", + "txt_log_retention_hint": "Автоматически обрезает по возрасту и количеству, чтобы уменьшить использование D1.", + "txt_log_retention_mode": "Режим хранения", + "txt_log_retention_mode_days": "По времени", + "txt_log_retention_mode_entries": "По количеству", + "txt_log_retention_settings": "Хранение журналов", + "txt_log_settings": "Настройки", + "txt_log_settings_save_failed": "Не удалось сохранить настройки журналов", + "txt_log_settings_saved": "Настройки журналов сохранены", + "txt_log_search_placeholder": "Поиск действия, инициатора, цели, пути или метаданных", + "txt_log_total": " всего", + "txt_log_visible": " показано", + "txt_metadata": "Метаданные", + "txt_no_logs_found": "Журналы не найдены", + "txt_no_metadata": "Нет метаданных", + "txt_clear_all_logs": "Очистить журналы", + "txt_clear_logs_confirm": "Очистить все журналы? Это действие нельзя отменить.", + "txt_clear_logs_failed": "Не удалось очистить журналы", + "txt_logs_cleared": "Журналы очищены", + "txt_search": "Поиск", + "txt_target": "Цель", + "txt_time": "Время", + "txt_time_range": "Период", "txt_remove_domain": "Удалить домен" }; diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index a544aac..45fc7b1 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -2,6 +2,7 @@ const zhCN: Record = { "nav_account_settings": "账户设置", "nav_admin_panel": "用户管理", + "nav_log_center": "日志中心", "nav_device_management": "设备管理", "nav_my_vault": "我的密码库", "nav_vault_items": "密码库", @@ -941,6 +942,190 @@ const zhCN: Record = { "txt_nav_layout_grouped_expanded_desc": "父子菜单全部展开", "txt_nav_layout_grouped_smart": "智能分组", "txt_nav_layout_grouped_smart_desc": "当前相关分组自动展开", + "txt_actor": "操作者", + "txt_all_levels": "全部级别", + "txt_all_logs": "全部日志", + "txt_all_time": "全部时间", + "txt_audit_events": "日志列表", + "txt_filter": "筛选", + "txt_last_24_hours": "最近 24 小时", + "txt_last_7_days": "最近 7 天", + "txt_last_30_days": "最近 30 天", + "txt_load_logs_failed": "加载日志失败", + "txt_load_log_settings_failed": "加载日志设置失败", + "txt_log_category": "分类", + "txt_log_category_auth": "登录与会话", + "txt_log_category_data": "数据操作", + "txt_log_category_device": "设备", + "txt_log_category_security": "账户安全", + "txt_log_category_system": "系统", + "txt_log_center_description": "查看登录、刷新失败、设备事件、安全变更、备份操作和管理员操作。", + "txt_log_center_title": "日志中心", + "txt_log_level": "级别", + "txt_log_level_error": "错误", + "txt_log_level_info": "信息", + "txt_log_level_security": "安全", + "txt_log_level_warn": "警告", + "txt_log_action_account_api_key_create": "创建 API 密钥", + "txt_log_action_account_api_key_rotate": "轮换 API 密钥", + "txt_log_action_account_keys_update": "更新账户密钥", + "txt_log_action_account_profile_update": "更新账户资料", + "txt_log_action_account_totp_disable": "关闭两步验证", + "txt_log_action_account_totp_enable": "开启两步验证", + "txt_log_action_account_totp_recover": "恢复两步验证", + "txt_log_action_account_verify_devices_update": "更新设备验证设置", + "txt_log_action_admin_audit_settings_update": "更新日志保留设置", + "txt_log_action_admin_backup_export": "导出备份", + "txt_log_action_admin_backup_import": "导入备份", + "txt_log_action_admin_backup_remote_delete": "删除远程备份", + "txt_log_action_admin_backup_remote_manual": "手动远程备份成功", + "txt_log_action_admin_backup_remote_manual_failed": "手动远程备份失败", + "txt_log_action_admin_backup_remote_scheduled": "计划远程备份成功", + "txt_log_action_admin_backup_remote_scheduled_failed": "计划远程备份失败", + "txt_log_action_admin_backup_settings_repair": "修复备份设置", + "txt_log_action_admin_backup_settings_update": "更新备份设置", + "txt_log_action_admin_invite_create": "创建邀请", + "txt_log_action_admin_invite_delete_all": "清空邀请", + "txt_log_action_admin_invite_revoke": "撤销邀请", + "txt_log_action_admin_user_delete": "删除用户", + "txt_log_action_admin_user_status": "修改用户状态", + "txt_log_action_attachment_delete": "删除附件", + "txt_log_action_auth_login_failed_bad_api_key": "API 密钥错误登录失败", + "txt_log_action_auth_login_failed_bad_password": "密码错误登录失败", + "txt_log_action_auth_login_failed_user_inactive": "账号停用登录失败", + "txt_log_action_auth_login_success": "登录成功", + "txt_log_action_auth_refresh_failed": "刷新登录失败:{reason}", + "txt_log_action_cipher_delete_permanent": "永久删除密码项", + "txt_log_action_cipher_delete_permanent_bulk": "批量永久删除密码项", + "txt_log_action_cipher_delete_soft": "删除到回收站", + "txt_log_action_cipher_delete_soft_bulk": "批量删除到回收站", + "txt_log_action_device_deactivate": "停用设备", + "txt_log_action_device_delete": "删除设备", + "txt_log_action_device_delete_all": "删除全部设备", + "txt_log_action_device_name_update": "修改设备名称", + "txt_log_action_device_trust_permanent": "永久信任设备", + "txt_log_action_device_trust_revoke": "撤销设备信任", + "txt_log_action_device_trust_revoke_batch": "批量撤销设备信任", + "txt_log_action_folder_delete": "删除文件夹", + "txt_log_action_folder_delete_bulk": "批量删除文件夹", + "txt_log_action_send_auth_remove": "移除 Send 验证", + "txt_log_action_send_delete": "删除 Send", + "txt_log_action_send_delete_bulk": "批量删除 Send", + "txt_log_action_send_password_remove": "移除 Send 密码", + "txt_log_action_user_password_change": "修改主密码", + "txt_log_action_user_register_first_admin": "注册首个管理员", + "txt_log_action_user_register_invite": "通过邀请注册", + "txt_log_meta_attachments": "附件数", + "txt_log_meta_bytes": "字节数", + "txt_log_meta_changed": "变更项", + "txt_log_meta_checksum_mismatch_accepted": "已接受校验不一致", + "txt_log_meta_cipher_id": "密码项 ID", + "txt_log_meta_ciphers": "密码项数量", + "txt_log_meta_compat": "兼容信息", + "txt_log_meta_compressed_bytes": "压缩后字节数", + "txt_log_meta_count": "数量", + "txt_log_meta_deleted": "已删除数量", + "txt_log_meta_destination_count": "备份目标数量", + "txt_log_meta_destination_id": "备份目标 ID", + "txt_log_meta_destination_name": "备份目标名称", + "txt_log_meta_destination_type": "备份目标类型", + "txt_log_meta_device_identifier": "设备 ID", + "txt_log_meta_device_type": "设备类型", + "txt_log_meta_email": "邮箱", + "txt_log_meta_error": "错误", + "txt_log_meta_expires_in_hours": "过期小时数", + "txt_log_meta_file_bytes": "文件字节数", + "txt_log_meta_file_name": "文件名", + "txt_log_meta_folder_id": "文件夹 ID", + "txt_log_meta_grant_type": "登录方式", + "txt_log_meta_includes_attachments": "包含附件", + "txt_log_meta_ip": "IP 地址", + "txt_log_meta_max_entries": "条数上限", + "txt_log_meta_method": "请求方法", + "txt_log_meta_path": "请求路径", + "txt_log_meta_provider": "服务提供方", + "txt_log_meta_prune_error": "清理错误", + "txt_log_meta_pruned_file_count": "已清理文件数", + "txt_log_meta_raw": "原始数据", + "txt_log_meta_reason": "原因", + "txt_log_meta_remote_path": "远程路径", + "txt_log_meta_removed": "已移除数量", + "txt_log_meta_removed_devices": "已移除设备数", + "txt_log_meta_removed_sessions": "已移除会话数", + "txt_log_meta_removed_trusted": "已撤销信任数", + "txt_log_meta_replace_existing": "覆盖现有数据", + "txt_log_meta_requested": "请求数量", + "txt_log_meta_requested_count": "请求数量", + "txt_log_meta_retention_days": "保留天数", + "txt_log_meta_scheduled_destination_count": "已计划备份目标数", + "txt_log_meta_size": "大小", + "txt_log_meta_skipped_attachments": "跳过附件数", + "txt_log_meta_skipped_reason": "跳过原因", + "txt_log_meta_status": "状态", + "txt_log_meta_target_email": "目标邮箱", + "txt_log_meta_trigger": "触发方式", + "txt_log_meta_type": "类型", + "txt_log_meta_updated": "已更新数量", + "txt_log_meta_upload_verification_attempts": "上传校验次数", + "txt_log_meta_user_agent": "浏览器/客户端", + "txt_log_meta_users": "用户数量", + "txt_log_meta_verify_devices": "验证设备", + "txt_log_meta_web_session": "网页会话", + "txt_log_reason_bad_api_key": "API 密钥错误", + "txt_log_reason_bad_password": "密码错误", + "txt_log_reason_device_missing": "设备不存在", + "txt_log_reason_device_session_mismatch": "设备会话不匹配", + "txt_log_reason_token_not_found_or_expired": "令牌不存在或已过期", + "txt_log_reason_user_inactive": "用户未启用", + "txt_log_reason_user_missing": "用户不存在", + "txt_log_target_type_attachment": "附件", + "txt_log_target_type_audit_log": "日志", + "txt_log_target_type_backup": "备份", + "txt_log_target_type_cipher": "密码项", + "txt_log_target_type_device": "设备", + "txt_log_target_type_folder": "文件夹", + "txt_log_target_type_invite": "邀请", + "txt_log_target_type_refresh_token": "刷新令牌", + "txt_log_target_type_send": "Send", + "txt_log_target_type_user": "用户", + "txt_log_trigger_manual": "手动", + "txt_log_trigger_remote": "远程", + "txt_log_trigger_scheduled": "计划任务", + "txt_log_max_1000": "最多 1,000 条", + "txt_log_max_5000": "最多 5,000 条", + "txt_log_max_10000": "最多 10,000 条", + "txt_log_max_50000": "最多 50,000 条", + "txt_log_max_entries": "容量上限", + "txt_log_max_unlimited": "不限制条数", + "txt_log_retention_7d": "保留 7 天", + "txt_log_retention_30d": "保留 30 天", + "txt_log_retention_90d": "保留 90 天", + "txt_log_retention_180d": "保留 180 天", + "txt_log_retention_365d": "保留 365 天", + "txt_log_retention_days": "保留时间", + "txt_log_retention_forever": "永久保留", + "txt_log_retention_hint": "按时间和最大条数自动收缩,减少 D1 存储占用。", + "txt_log_retention_mode": "保留方式", + "txt_log_retention_mode_days": "按时间", + "txt_log_retention_mode_entries": "按条数", + "txt_log_retention_settings": "日志保留", + "txt_log_settings": "设置", + "txt_log_settings_save_failed": "保存日志设置失败", + "txt_log_settings_saved": "日志设置已保存", + "txt_log_search_placeholder": "搜索动作、操作者、目标、请求路径或元数据", + "txt_log_total": " 条总数", + "txt_log_visible": " 条显示", + "txt_metadata": "元数据", + "txt_no_logs_found": "没有找到日志", + "txt_no_metadata": "没有元数据", + "txt_clear_all_logs": "清空日志", + "txt_clear_logs_confirm": "确定清空全部日志吗?此操作无法撤销。", + "txt_clear_logs_failed": "清空日志失败", + "txt_logs_cleared": "日志已清空", + "txt_search": "搜索", + "txt_target": "目标", + "txt_time": "时间", + "txt_time_range": "时间范围", "txt_remove_domain": "移除域名" }; diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index 5b6872b..7a1f3d5 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -2,6 +2,7 @@ const zhTW: Record = { "nav_account_settings": "賬戶設置", "nav_admin_panel": "用戶管理", + "nav_log_center": "日誌中心", "nav_device_management": "設備管理", "nav_my_vault": "我的密碼庫", "nav_vault_items": "密碼庫", @@ -941,6 +942,190 @@ const zhTW: Record = { "txt_nav_layout_grouped_expanded_desc": "父子選單全部展開", "txt_nav_layout_grouped_smart": "智能分組", "txt_nav_layout_grouped_smart_desc": "目前相關分組自動展開", + "txt_actor": "操作者", + "txt_all_levels": "全部級別", + "txt_all_logs": "全部日誌", + "txt_all_time": "全部時間", + "txt_audit_events": "日誌列表", + "txt_filter": "篩選", + "txt_last_24_hours": "最近 24 小時", + "txt_last_7_days": "最近 7 天", + "txt_last_30_days": "最近 30 天", + "txt_load_logs_failed": "載入日誌失敗", + "txt_load_log_settings_failed": "載入日誌設定失敗", + "txt_log_category": "分類", + "txt_log_category_auth": "登入與會話", + "txt_log_category_data": "資料操作", + "txt_log_category_device": "設備", + "txt_log_category_security": "賬戶安全", + "txt_log_category_system": "系統", + "txt_log_center_description": "查看登入、刷新失敗、設備事件、安全變更、備份操作和管理員操作。", + "txt_log_center_title": "日誌中心", + "txt_log_level": "級別", + "txt_log_level_error": "錯誤", + "txt_log_level_info": "資訊", + "txt_log_level_security": "安全", + "txt_log_level_warn": "警告", + "txt_log_action_account_api_key_create": "建立 API 金鑰", + "txt_log_action_account_api_key_rotate": "輪換 API 金鑰", + "txt_log_action_account_keys_update": "更新帳戶金鑰", + "txt_log_action_account_profile_update": "更新帳戶資料", + "txt_log_action_account_totp_disable": "關閉兩步驟登入", + "txt_log_action_account_totp_enable": "開啟兩步驟登入", + "txt_log_action_account_totp_recover": "復原兩步驟登入", + "txt_log_action_account_verify_devices_update": "更新裝置驗證設定", + "txt_log_action_admin_audit_settings_update": "更新日誌保留設定", + "txt_log_action_admin_backup_export": "匯出備份", + "txt_log_action_admin_backup_import": "匯入備份", + "txt_log_action_admin_backup_remote_delete": "刪除遠端備份", + "txt_log_action_admin_backup_remote_manual": "手動遠端備份成功", + "txt_log_action_admin_backup_remote_manual_failed": "手動遠端備份失敗", + "txt_log_action_admin_backup_remote_scheduled": "排程遠端備份成功", + "txt_log_action_admin_backup_remote_scheduled_failed": "排程遠端備份失敗", + "txt_log_action_admin_backup_settings_repair": "修復備份設定", + "txt_log_action_admin_backup_settings_update": "更新備份設定", + "txt_log_action_admin_invite_create": "建立邀請", + "txt_log_action_admin_invite_delete_all": "清空邀請", + "txt_log_action_admin_invite_revoke": "撤銷邀請", + "txt_log_action_admin_user_delete": "刪除使用者", + "txt_log_action_admin_user_status": "修改使用者狀態", + "txt_log_action_attachment_delete": "刪除附件", + "txt_log_action_auth_login_failed_bad_api_key": "API 金鑰錯誤登入失敗", + "txt_log_action_auth_login_failed_bad_password": "密碼錯誤登入失敗", + "txt_log_action_auth_login_failed_user_inactive": "帳號停用登入失敗", + "txt_log_action_auth_login_success": "登入成功", + "txt_log_action_auth_refresh_failed": "刷新登入失敗:{reason}", + "txt_log_action_cipher_delete_permanent": "永久刪除密碼項", + "txt_log_action_cipher_delete_permanent_bulk": "批次永久刪除密碼項", + "txt_log_action_cipher_delete_soft": "刪除到回收桶", + "txt_log_action_cipher_delete_soft_bulk": "批次刪除到回收桶", + "txt_log_action_device_deactivate": "停用裝置", + "txt_log_action_device_delete": "刪除裝置", + "txt_log_action_device_delete_all": "刪除全部裝置", + "txt_log_action_device_name_update": "修改裝置名稱", + "txt_log_action_device_trust_permanent": "永久信任裝置", + "txt_log_action_device_trust_revoke": "撤銷裝置信任", + "txt_log_action_device_trust_revoke_batch": "批次撤銷裝置信任", + "txt_log_action_folder_delete": "刪除資料夾", + "txt_log_action_folder_delete_bulk": "批次刪除資料夾", + "txt_log_action_send_auth_remove": "移除 Send 驗證", + "txt_log_action_send_delete": "刪除 Send", + "txt_log_action_send_delete_bulk": "批次刪除 Send", + "txt_log_action_send_password_remove": "移除 Send 密碼", + "txt_log_action_user_password_change": "修改主密碼", + "txt_log_action_user_register_first_admin": "註冊首個管理員", + "txt_log_action_user_register_invite": "透過邀請註冊", + "txt_log_meta_attachments": "附件數", + "txt_log_meta_bytes": "位元組數", + "txt_log_meta_changed": "變更項", + "txt_log_meta_checksum_mismatch_accepted": "已接受校驗不一致", + "txt_log_meta_cipher_id": "密碼項 ID", + "txt_log_meta_ciphers": "密碼項數量", + "txt_log_meta_compat": "相容資訊", + "txt_log_meta_compressed_bytes": "壓縮後位元組數", + "txt_log_meta_count": "數量", + "txt_log_meta_deleted": "已刪除數量", + "txt_log_meta_destination_count": "備份目標數量", + "txt_log_meta_destination_id": "備份目標 ID", + "txt_log_meta_destination_name": "備份目標名稱", + "txt_log_meta_destination_type": "備份目標類型", + "txt_log_meta_device_identifier": "裝置 ID", + "txt_log_meta_device_type": "裝置類型", + "txt_log_meta_email": "信箱", + "txt_log_meta_error": "錯誤", + "txt_log_meta_expires_in_hours": "過期小時數", + "txt_log_meta_file_bytes": "檔案位元組數", + "txt_log_meta_file_name": "檔案名稱", + "txt_log_meta_folder_id": "資料夾 ID", + "txt_log_meta_grant_type": "登入方式", + "txt_log_meta_includes_attachments": "包含附件", + "txt_log_meta_ip": "IP 位址", + "txt_log_meta_max_entries": "筆數上限", + "txt_log_meta_method": "請求方法", + "txt_log_meta_path": "請求路徑", + "txt_log_meta_provider": "服務提供方", + "txt_log_meta_prune_error": "清理錯誤", + "txt_log_meta_pruned_file_count": "已清理檔案數", + "txt_log_meta_raw": "原始資料", + "txt_log_meta_reason": "原因", + "txt_log_meta_remote_path": "遠端路徑", + "txt_log_meta_removed": "已移除數量", + "txt_log_meta_removed_devices": "已移除裝置數", + "txt_log_meta_removed_sessions": "已移除工作階段數", + "txt_log_meta_removed_trusted": "已撤銷信任數", + "txt_log_meta_replace_existing": "覆蓋現有資料", + "txt_log_meta_requested": "請求數量", + "txt_log_meta_requested_count": "請求數量", + "txt_log_meta_retention_days": "保留天數", + "txt_log_meta_scheduled_destination_count": "已排程備份目標數", + "txt_log_meta_size": "大小", + "txt_log_meta_skipped_attachments": "略過附件數", + "txt_log_meta_skipped_reason": "略過原因", + "txt_log_meta_status": "狀態", + "txt_log_meta_target_email": "目標信箱", + "txt_log_meta_trigger": "觸發方式", + "txt_log_meta_type": "類型", + "txt_log_meta_updated": "已更新數量", + "txt_log_meta_upload_verification_attempts": "上傳校驗次數", + "txt_log_meta_user_agent": "瀏覽器/用戶端", + "txt_log_meta_users": "使用者數量", + "txt_log_meta_verify_devices": "驗證裝置", + "txt_log_meta_web_session": "網頁工作階段", + "txt_log_reason_bad_api_key": "API 金鑰錯誤", + "txt_log_reason_bad_password": "密碼錯誤", + "txt_log_reason_device_missing": "裝置不存在", + "txt_log_reason_device_session_mismatch": "裝置工作階段不相符", + "txt_log_reason_token_not_found_or_expired": "權杖不存在或已過期", + "txt_log_reason_user_inactive": "使用者未啟用", + "txt_log_reason_user_missing": "使用者不存在", + "txt_log_target_type_attachment": "附件", + "txt_log_target_type_audit_log": "日誌", + "txt_log_target_type_backup": "備份", + "txt_log_target_type_cipher": "密碼項", + "txt_log_target_type_device": "裝置", + "txt_log_target_type_folder": "資料夾", + "txt_log_target_type_invite": "邀請", + "txt_log_target_type_refresh_token": "刷新權杖", + "txt_log_target_type_send": "Send", + "txt_log_target_type_user": "使用者", + "txt_log_trigger_manual": "手動", + "txt_log_trigger_remote": "遠端", + "txt_log_trigger_scheduled": "排程工作", + "txt_log_max_1000": "最多 1,000 筆", + "txt_log_max_5000": "最多 5,000 筆", + "txt_log_max_10000": "最多 10,000 筆", + "txt_log_max_50000": "最多 50,000 筆", + "txt_log_max_entries": "容量上限", + "txt_log_max_unlimited": "不限制筆數", + "txt_log_retention_7d": "保留 7 天", + "txt_log_retention_30d": "保留 30 天", + "txt_log_retention_90d": "保留 90 天", + "txt_log_retention_180d": "保留 180 天", + "txt_log_retention_365d": "保留 365 天", + "txt_log_retention_days": "保留時間", + "txt_log_retention_forever": "永久保留", + "txt_log_retention_hint": "按時間和最大筆數自動收縮,減少 D1 儲存占用。", + "txt_log_retention_mode": "保留方式", + "txt_log_retention_mode_days": "按時間", + "txt_log_retention_mode_entries": "按筆數", + "txt_log_retention_settings": "日誌保留", + "txt_log_settings": "設定", + "txt_log_settings_save_failed": "儲存日誌設定失敗", + "txt_log_settings_saved": "日誌設定已儲存", + "txt_log_search_placeholder": "搜尋動作、操作者、目標、請求路徑或元資料", + "txt_log_total": " 條總數", + "txt_log_visible": " 條顯示", + "txt_metadata": "元資料", + "txt_no_logs_found": "沒有找到日誌", + "txt_no_metadata": "沒有元資料", + "txt_clear_all_logs": "清空日誌", + "txt_clear_logs_confirm": "確定清空全部日誌嗎?此操作無法復原。", + "txt_clear_logs_failed": "清空日誌失敗", + "txt_logs_cleared": "日誌已清空", + "txt_search": "搜尋", + "txt_target": "目標", + "txt_time": "時間", + "txt_time_range": "時間範圍", "txt_remove_domain": "移除域名" }; diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 5514f66..e465946 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -281,6 +281,11 @@ export interface VaultDraft { export interface ListResponse { object: 'list'; data: T[]; + total?: number; + limit?: number; + offset?: number; + hasMore?: boolean; + continuationToken?: string | null; } export interface WebBootstrapResponse { @@ -344,6 +349,37 @@ export interface AdminInvite { expiresAt?: string; } +export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system'; +export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security'; + +export interface AuditLogEntry { + id: string; + actorUserId: string | null; + actorEmail?: string | null; + action: string; + category: AuditLogCategory; + level: AuditLogLevel; + targetType: string | null; + targetId: string | null; + targetUserEmail?: string | null; + metadata: string | null; + createdAt: string; + object?: 'auditLog'; +} + +export interface AuditLogSettings { + retentionDays: number | null; + maxEntries: number | null; +} + +export interface AuditLogListResult { + logs: AuditLogEntry[]; + total: number; + limit: number; + offset: number; + hasMore: boolean; +} + export interface AuthorizedDevice { id: string; name: string; diff --git a/webapp/src/styles/dark.css b/webapp/src/styles/dark.css index 35ae007..6a8c443 100644 --- a/webapp/src/styles/dark.css +++ b/webapp/src/styles/dark.css @@ -336,3 +336,33 @@ background: color-mix(in srgb, var(--primary) 18%, var(--panel)); color: var(--primary-strong); } + +:root[data-theme='dark'] .log-detail-head h3, +:root[data-theme='dark'] .log-row-main strong, +:root[data-theme='dark'] .log-detail-meta strong, +:root[data-theme='dark'] .log-detail-json dd, +:root[data-theme='dark'] .log-detail-json h4, +:root[data-theme='dark'] .log-pagination-count { + color: var(--text); +} + +:root[data-theme='dark'] .log-row-main small, +:root[data-theme='dark'] .log-detail-meta span, +:root[data-theme='dark'] .log-detail-json dt { + color: var(--muted); +} + +:root[data-theme='dark'] .log-row, +:root[data-theme='dark'] .log-detail-meta > div, +:root[data-theme='dark'] .log-detail-json dl > div, +:root[data-theme='dark'] .log-pagination-count { + background: var(--panel-muted); + border-color: var(--line); + color: var(--text); +} + +:root[data-theme='dark'] .log-row:hover, +:root[data-theme='dark'] .log-row.active { + background: color-mix(in srgb, var(--primary) 12%, var(--panel)); + border-color: color-mix(in srgb, var(--primary) 34%, var(--line)); +} diff --git a/webapp/src/styles/management.css b/webapp/src/styles/management.css index b8e6392..3aef5e0 100644 --- a/webapp/src/styles/management.css +++ b/webapp/src/styles/management.css @@ -503,6 +503,533 @@ grid-template-columns: repeat(2, minmax(0, calc((100% - var(--settings-grid-gap)) / 2))); } +.log-center-page { + @apply grid h-full min-h-0 gap-3; + height: 100%; + max-height: 100%; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; +} + +.card.log-center-toolbar { + @apply relative; + margin-bottom: 0; +} + +.log-mobile-subhead { + display: none; +} + +.log-detail-head h3 { + @apply m-0 text-base font-extrabold; + color: #0f172a; +} + +.log-filter-form { + @apply grid items-end gap-3; + grid-template-columns: minmax(260px, 1.5fr) repeat(3, minmax(150px, 0.66fr)) auto; +} + +.log-filter-form .field { + @apply mb-0; +} + +.log-filter-form .input, +.log-filter-form .btn { + min-height: 42px; +} + +.log-search-field { + @apply min-w-0; +} + +.input-leading-icon { + @apply pointer-events-none absolute left-3 top-1/2 -translate-y-1/2; + color: #64748b; +} + +.log-search-input { + padding-left: 2.25rem; +} + +.log-filter-actions { + @apply flex-nowrap items-end; + align-self: end; +} + +.log-filter-actions .btn { + white-space: nowrap; +} + +.log-settings-popover { + @apply absolute right-3 z-30 grid gap-3 rounded-xl border p-3; + top: calc(100% + 8px); + width: min(390px, calc(100vw - 32px)); + border-color: var(--line); + background: #ffffff; + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.16); +} + +.log-settings-popover-head { + @apply mb-0; +} + +.log-settings-popover-head h3 { + @apply m-0 text-base font-extrabold; + color: #0f172a; +} + +.log-settings-mode { + @apply grid rounded-lg p-1; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: #f1f5f9; +} + +.log-mode-option { + @apply h-9 cursor-pointer rounded-md border-0 px-2 text-sm font-extrabold; + background: transparent; + color: #475569; + transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} + +.log-mode-option.active { + background: #ffffff; + color: #1d4ed8; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.12); +} + +.log-mode-option:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.log-settings-retention-block { + @apply grid gap-1.5; +} + +.log-settings-label { + @apply block text-[13px] font-bold; + color: var(--muted-strong); +} + +.log-settings-retention-row { + @apply grid items-center gap-2.5; + grid-template-columns: minmax(0, 1fr) 82px; +} + +.log-settings-retention-row .input { + width: 100%; + min-width: 0; + height: 42px; + min-height: 42px; +} + +.log-settings-save-btn.btn { + width: 82px; + height: 42px; + min-height: 42px; + align-self: center; + justify-content: center; + padding-inline: 10px; + white-space: nowrap; + transform: none; +} + +.log-settings-save-btn.btn:hover:not(:disabled), +.log-settings-save-btn.btn:active:not(:disabled) { + transform: none; +} + +.log-settings-danger { + @apply grid gap-2 border-t pt-3; + border-color: var(--line); +} + +.log-settings-danger p { + @apply m-0 text-sm font-semibold leading-5; + color: #7f1d1d; +} + +.ghost-danger { + @apply w-full justify-center; +} + +.log-clear-confirm-actions { + @apply grid grid-cols-2; +} + +.log-center-grid { + @apply grid min-h-0 items-stretch gap-3; + height: 100%; + max-height: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: hidden; +} + +.card.log-list-panel, +.card.log-detail-panel { + height: 100%; + max-height: 100%; + margin-bottom: 0; + min-height: 0; + min-width: 0; +} + +.card.log-list-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.card.log-detail-panel { + overflow: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; +} + +.log-list { + @apply grid content-start gap-2 overflow-auto pr-0.5; + min-height: 0; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; +} + +.log-list-panel > .section-head, +.log-pagination { + flex-shrink: 0; +} + +.log-row { + @apply grid w-full cursor-pointer items-center gap-3 rounded-xl p-3 text-left; + grid-template-columns: auto minmax(0, 1fr) auto; + border: 1px solid var(--line); + background: #ffffff; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} + +.log-row:hover, +.log-row.active { + border-color: #93c5fd; + background: #f8fbff; + box-shadow: 0 10px 22px rgba(37, 99, 235, 0.08); +} + +.log-row-icon { + @apply flex h-9 w-9 items-center justify-center rounded-xl; +} + +.log-category-auth { + background: #eff6ff; + color: #1d4ed8; +} + +.log-category-security { + background: #fff1f2; + color: #be123c; +} + +.log-category-device { + background: #ecfdf5; + color: #047857; +} + +.log-category-data { + background: #f5f3ff; + color: #6d28d9; +} + +.log-category-system { + background: #f8fafc; + color: #475467; +} + +.log-row-main { + @apply grid min-w-0 gap-1; +} + +.log-row-main strong { + @apply truncate text-sm; + color: #0f172a; +} + +.log-row-main small { + @apply text-xs; + color: #64748b; +} + +.log-level-pill { + @apply inline-flex whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-extrabold; +} + +.log-level-info { + background: #eef4ff; + color: #1d4ed8; +} + +.log-level-warn { + background: #fff7ed; + color: #c2410c; +} + +.log-level-error { + background: #fef2f2; + color: #b91c1c; +} + +.log-level-security { + background: #fff1f2; + color: #be123c; +} + +.log-pagination { + @apply mt-3 items-center justify-between; +} + +.log-pagination-count { + @apply inline-flex min-w-24 items-center justify-center rounded-full px-3 py-1.5 text-sm font-extrabold; + border: 1px solid var(--line); + background: #f8fafc; + color: #0f172a; +} + +.log-detail-meta { + @apply grid gap-2; +} + +.log-detail-meta > div, +.log-detail-json dl > div { + @apply grid gap-1 rounded-xl px-3 py-2.5; + border: 1px solid var(--line); + background: #f8fafc; +} + +.log-detail-meta span, +.log-detail-json dt { + @apply text-xs font-bold uppercase; + color: #64748b; +} + +.log-detail-meta strong, +.log-detail-json dd { + @apply m-0 min-w-0 text-sm font-semibold; + color: #0f172a; + overflow-wrap: anywhere; +} + +.log-detail-json { + @apply mt-3 grid gap-2; +} + +.log-detail-json h4 { + @apply m-0 text-sm font-extrabold; + color: #0f172a; +} + +.log-detail-json dl { + @apply m-0 grid gap-2; +} + +@media (max-width: 1120px) { + .log-filter-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .log-filter-actions { + @apply col-span-2; + } + + .log-center-grid { + grid-template-columns: 1fr; + grid-template-rows: repeat(2, minmax(220px, 1fr)); + } +} + +@media (max-width: 760px) { + .route-stage-log-fixed { + overflow: hidden; + } + + .log-center-page { + gap: 8px; + grid-template-rows: auto auto minmax(0, 1fr); + } + + .log-center-page.log-mobile-detail-open { + grid-template-rows: auto minmax(0, 1fr); + } + + .log-mobile-subhead { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + min-height: 38px; + flex-shrink: 0; + padding-top: 2px; + } + + .log-mobile-subhead .mobile-settings-back { + margin-right: auto; + } + + .log-mobile-settings-trigger { + width: 42px; + height: 38px; + justify-content: center; + padding: 0; + } + + .log-mobile-settings-trigger .btn-icon { + margin: 0; + } + + .log-mobile-detail-open .log-mobile-settings-trigger { + display: none; + } + + .card.log-center-toolbar { + padding: 10px 12px; + } + + .log-mobile-detail-open .card.log-center-toolbar { + display: none; + } + + .log-filter-form { + gap: 6px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .log-filter-actions { + display: none; + } + + .log-search-field > span { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .log-search-field { + grid-column: 1 / -1; + } + + .log-filter-form .field { + margin-bottom: 0; + min-width: 0; + } + + .log-filter-form > .field:not(.log-search-field) > span { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .log-filter-form .input { + min-height: 40px; + height: 40px; + width: 100%; + min-width: 0; + font-size: 13px; + padding-inline: 9px 26px; + } + + .log-search-input { + font-size: 14px; + padding-left: 2.15rem; + padding-right: 10px; + } + + .log-filter-form select.input { + text-overflow: ellipsis; + } + + .log-center-grid { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + } + + .card.log-list-panel { + grid-template-rows: minmax(0, 1fr); + padding: 8px; + } + + .log-list-panel > .section-head, + .log-pagination { + display: none; + } + + .card.log-detail-panel { + display: none; + } + + .log-mobile-detail-open .card.log-list-panel { + display: none; + } + + .log-mobile-detail-open .card.log-detail-panel { + display: block; + height: 100%; + max-height: 100%; + overflow: auto; + padding: 10px 12px 14px; + } + + .log-settings-popover { + @apply static mt-3 w-full; + } + + .log-settings-retention-row { + grid-template-columns: minmax(0, 1fr) 82px; + } + + .log-row { + min-height: 66px; + gap: 12px; + grid-template-columns: 38px minmax(0, 1fr) auto; + padding: 10px 12px; + } + + .log-row .log-level-pill { + grid-column: auto; + justify-self: end; + } + + .log-row-icon { + width: 38px; + height: 38px; + border-radius: 12px; + } + + .log-row-main { + justify-items: center; + text-align: center; + } + + .log-row-main strong { + max-width: 100%; + font-size: 14px; + } + + .log-row-main small { + font-size: 12px; + } +} + .settings-module { @apply min-w-0; width: 100%; diff --git a/webapp/src/styles/shell.css b/webapp/src/styles/shell.css index cd562b5..a3ac84f 100644 --- a/webapp/src/styles/shell.css +++ b/webapp/src/styles/shell.css @@ -316,6 +316,12 @@ overflow: hidden; } +.route-stage-log-fixed { + display: grid; + grid-template-rows: minmax(0, 1fr); + overflow: hidden; +} + .mobile-sidebar-mask { @apply pointer-events-none invisible fixed inset-0 opacity-0; background: rgba(15, 23, 42, 0.36);