diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 6bf4c32..c3b5d32 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -56,6 +56,11 @@ const LocalAiPage = lazyFeature( () => import('@/features/ai/LocalAiPage'), (m) => m.LocalAiPage, ); +// inbuxa: Management › Compliance › Audit Log (audit-hold-lock spec, AU-9). +const AuditLogPage = lazyFeature( + () => import('@/features/audit/AuditLogPage'), + (m) => m.AuditLogPage, +); const TenantLegacyProtocols = lazyFeature( () => import('@/features/hardening/TenantLegacyProtocols'), (m) => m.TenantLegacyProtocols, @@ -120,6 +125,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'LocalAi') { return ; } + if (componentName === 'AuditLog') { + return ; + } return (
Unknown component: {componentName} diff --git a/src/features/audit/AuditLogPage.tsx b/src/features/audit/AuditLogPage.tsx new file mode 100644 index 0000000..fecd07b --- /dev/null +++ b/src/features/audit/AuditLogPage.tsx @@ -0,0 +1,737 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Management › Compliance › Audit Log (audit-hold-lock spec, AU-9 to + * AU-11). Who changed what, who signed in as an administrator, and who + * reached another account's data; filtered, paged newest first, exported as + * a file the server builds, and checked against tampering. + * + * Records can't be edited or deleted from here or anywhere else: that is the + * point of them. A tenant administrator sees its own tenant's records; the + * server decides that, not this page. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronLeft, ChevronRight, Download, Loader2, RotateCcw, Search, ShieldCheck } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { PageHeader } from '@/components/common/PageHeader'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { useAccountStore } from '@/stores/accountStore'; +import { toast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { + AUDIT_ACTIONS, + AuditUnavailable, + EMPTY_FILTER, + PAGE_SIZE, + exportEvents, + fetchKeepForDays, + queryEvents, + updateKeepForDays, + verifyChains, + type AuditAction, + type AuditChange, + type AuditEvent, + type AuditFilterInput, + type AuditOutcome, + type Verification, +} from './auditLog'; +import { formatValue, targetKindLabel, targetName } from './format'; +import { actionLabel, viaLabel } from './labels'; + +/** What was last loaded, and for which filter and page. */ +type Load = + | { kind: 'loading' } + | { kind: 'ready'; key: string; events: AuditEvent[]; total: number } + | { kind: 'error'; key: string; message: string }; + +const ANY = '__any__'; + +export function AuditLogPage() { + const { t } = useTranslation(); + const canExport = useAccountStore((s) => s.hasPermission('sysAuditExport')); + // Retention and verification are the server's: tenant administrators hold neither + const canManage = useAccountStore((s) => s.hasPermission('sysAuditSettingsUpdate')); + + const [draft, setDraft] = useState(EMPTY_FILTER); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [position, setPosition] = useState(0); + // Bumped to fetch again: Show, Clear, and after this page's own actions, + // which are recorded too + const [fetches, setFetches] = useState(0); + const refetch = useCallback(() => setFetches((n) => n + 1), []); + const [load, setLoad] = useState({ kind: 'loading' }); + const [selected, setSelected] = useState(null); + const [exporting, setExporting] = useState(false); + const [verification, setVerification] = useState(null); + const [verifying, setVerifying] = useState(false); + const [keepForDays, setKeepForDays] = useState(null); + const [editingRetention, setEditingRetention] = useState(false); + + // A page that belongs to another filter or position shows as loading + const key = JSON.stringify([filter, position, fetches]); + const shown: Load = load.kind !== 'loading' && load.key === key ? load : { kind: 'loading' }; + + useEffect(() => { + const controller = new AbortController(); + queryEvents(filter, position, controller.signal) + .then((page) => { + if (!controller.signal.aborted) setLoad({ kind: 'ready', key, events: page.events, total: page.total }); + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return; + setLoad({ + kind: 'error', + key, + message: + e instanceof AuditUnavailable + ? t('audit.unavailable', 'This server does not keep an audit log.') + : e instanceof Error + ? e.message + : String(e), + }); + }); + return () => controller.abort(); + }, [filter, position, key, t]); + + useEffect(() => { + fetchKeepForDays() + .then(setKeepForDays) + .catch(() => setKeepForDays(null)); + }, []); + + const apply = useCallback(() => { + setPosition(0); + setFilter(draft); + refetch(); + }, [draft, refetch]); + + const clear = useCallback(() => { + setDraft(EMPTY_FILTER); + setPosition(0); + setFilter(EMPTY_FILTER); + refetch(); + }, [refetch]); + + const verify = useCallback(async () => { + setVerifying(true); + try { + setVerification(await verifyChains()); + refetch(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('audit.verifyFailed', 'The check could not run'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setVerifying(false); + } + }, [refetch, t]); + + const filtered = useMemo(() => JSON.stringify(filter) !== JSON.stringify(EMPTY_FILTER), [filter]); + + return ( +
+ + {t( + 'audit.subtitle', + 'Every change administrators and the server made, administrator sign-ins, and access to other accounts. Records can’t be edited or deleted.', + )}{' '} + {keepForDays !== null && ( + + {t('audit.keptFor', 'Kept for {{days}} days.', { days: keepForDays })}{' '} + {canManage && ( + + )} + + )} + + } + actions={ + <> + {canManage && ( + + )} + {canExport && ( + + )} + + } + /> + + + + {shown.kind === 'loading' && } + {shown.kind === 'error' && ( +
{shown.message}
+ )} + {shown.kind === 'ready' && ( + <> + + setPosition(Math.max(0, position - PAGE_SIZE))} + onNext={() => setPosition(position + PAGE_SIZE)} + /> + + )} + + setSelected(null)} /> + {exporting && ( + { + setExporting(false); + refetch(); + }} + /> + )} + setVerification(null)} /> + {editingRetention && keepForDays !== null && ( + setEditingRetention(false)} + onSaved={(days) => { + setKeepForDays(days); + setEditingRetention(false); + refetch(); + }} + /> + )} +
+ ); +} + +function OutcomeBadge({ outcome }: { outcome: AuditOutcome }) { + const { t } = useTranslation(); + switch (outcome.status) { + case 'success': + return {t('audit.outcome.success', 'Done')}; + case 'refused': + return ( + + {t('audit.outcome.refused', 'Refused')} + + ); + case 'pending': + return ( + + {t('audit.outcome.pending', 'Unfinished')} + + ); + } +} + +function Filters({ + draft, + setDraft, + onApply, + onClear, +}: { + draft: AuditFilterInput; + setDraft: (f: AuditFilterInput) => void; + onApply: () => void; + onClear: () => void; +}) { + const { t } = useTranslation(); + const set = (key: K, value: AuditFilterInput[K]) => + setDraft({ ...draft, [key]: value }); + return ( +
{ + e.preventDefault(); + onApply(); + }} + > + + + + + +
+ + +
+
+ ); +} + +function EventTable({ + events, + onSelect, + filtered, +}: { + events: AuditEvent[]; + onSelect: (e: AuditEvent) => void; + filtered: boolean; +}) { + const { t } = useTranslation(); + if (events.length === 0) { + return ( +
+ {filtered + ? t('audit.noneMatching', 'No records match these filters.') + : t('audit.none', 'Nothing has been recorded yet.')} +
+ ); + } + return ( +
+ + + + + + + + + + + + {events.map((event) => ( + onSelect(event)} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter') onSelect(event); + }} + > + + + + + + + ))} + +
{t('audit.col.when', 'When')}{t('audit.col.who', 'Who')}{t('audit.col.what', 'What')}{t('audit.col.target', 'To')}{t('audit.col.outcome', 'Outcome')}
+ {new Date(event.at).toLocaleString()} + +
+ {event.actor.name} +
+ {event.via &&
{viaLabel(t, event.via)}
} +
{actionLabel(t, event.action)} + {targetKindLabel(event.target.kind)}{' '} + {targetName(event.target)} + + +
+
+ ); +} + +function Pager({ + position, + count, + total, + onPrevious, + onNext, +}: { + position: number; + count: number; + total: number; + onPrevious: () => void; + onNext: () => void; +}) { + const { t } = useTranslation(); + if (total === 0) return null; + return ( +
+ + {t('audit.showing', 'Showing {{from}}–{{to}} of {{total}}', { + from: position + 1, + to: position + count, + total, + })} + +
+ + +
+
+ ); +} + +function EventDetail({ event, onClose }: { event: AuditEvent | null; onClose: () => void }) { + const { t } = useTranslation(); + return ( + !open && onClose()}> + + {event && ( + <> + + + {actionLabel(t, event.action)} · {targetKindLabel(event.target.kind)}{' '} + {targetName(event.target)} + + {new Date(event.at).toLocaleString()} + +
+
{t('audit.detail.who', 'Who')}
+
+ {event.actor.name} + {event.via && · {viaLabel(t, event.via)}} +
+ {event.remoteIp && ( + <> +
{t('audit.detail.from', 'From')}
+
{event.remoteIp}
+ + )} +
{t('audit.detail.outcome', 'Outcome')}
+
+ + {event.outcome.status === 'refused' && ( + + {event.outcome.description ?? event.outcome.error} + + )} +
+ {event.reason && ( + <> +
{t('audit.detail.reason', 'Reason given')}
+
{event.reason}
+ + )} + {event.details && ( + <> +
{t('audit.detail.details', 'Details')}
+
{event.details}
+ + )} +
{t('audit.detail.record', 'Record')}
+
+ {event.id} · {t('audit.detail.node', 'node {{node}}', { node: event.node })} +
+
+ {event.changes.length > 0 && } + + )} +
+
+ ); +} + +function ChangeTable({ changes }: { changes: AuditChange[] }) { + const { t } = useTranslation(); + return ( +
+ + + + + + + + + + {changes.map((change) => ( + + + {change.redacted ? ( + + ) : ( + <> + + + + )} + + ))} + +
{t('audit.change.field', 'Setting')}{t('audit.change.before', 'Before')}{t('audit.change.after', 'After')}
{change.field} + {t('audit.change.secret', 'Changed. Secrets are never recorded.')} + + {formatValue(change.before)} + {formatValue(change.after)}
+
+ ); +} + +function ExportDialog({ filter, onClose }: { filter: AuditFilterInput; onClose: () => void }) { + const { t } = useTranslation(); + const [format, setFormat] = useState<'csv' | 'jsonl'>('csv'); + const [reason, setReason] = useState(''); + const [busy, setBusy] = useState(false); + + const run = async () => { + setBusy(true); + try { + const result = await exportEvents(filter, format, reason); + toast({ + title: t('audit.exported', 'Exported {{count}} records', { count: result.count }), + description: ( + + {t('audit.exportedHash', 'SHA-256 {{hash}}', { hash: result.sha256 })} + + ), + }); + onClose(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('audit.exportFailed', 'Nothing was exported'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + return ( + !open && onClose()}> + + + {t('audit.exportTitle', 'Export the audit log')} + + {t( + 'audit.exportBody', + 'The records the filters above match, up to 100,000, newest first. Each line carries its chain hash, and the file ends with a manifest, so whoever receives it can check it. The export itself is recorded.', + )} + + +
+ +