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')}
)}
); }