feat: add verifiable audit evidence bundles

This commit is contained in:
2026-08-20 18:00:13 +02:00
parent f07d12fa52
commit 7b43816b22
19 changed files with 2164 additions and 10 deletions
+57 -1
View File
@@ -9,6 +9,7 @@ import {
Dialog,
DocumentationHelpLink,
formatAdminDateTime as formatDateTime,
hasScope,
i18nMessage,
mergeDeltaRows,
TableActionGroup,
@@ -18,7 +19,14 @@ import {
type DataGridColumn,
type DataGridQueryState
} from "@govoplan/core-webui";
import { fetchAdminAudit, fetchAdminAuditDelta, type AuditAdminItem, type AuditSortBy } from "../../api/audit";
import {
downloadAuditEvidenceBundle,
exportAuditEvidenceBundle,
fetchAdminAudit,
fetchAdminAuditDelta,
type AuditAdminItem,
type AuditSortBy
} from "../../api/audit";
type Props = {
settings: ApiSettings;
@@ -38,6 +46,8 @@ const I18N = {
close: "i18n:govoplan-audit.close.f1a20804",
details: "i18n:govoplan-audit.details.f1a20805",
eventDetails: "i18n:govoplan-audit.audit_event_details.f1a20806",
exportEvidence: "i18n:govoplan-audit.export_page_evidence.f1a20822",
exportingEvidence: "i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823",
inspect: "i18n:govoplan-audit.inspect_audit_event.f1a20807",
loading: "i18n:govoplan-audit.audit_evidence_is_loading.f1a20808",
noDetails: "i18n:govoplan-audit.no_additional_details_were_recorded.f1a20809",
@@ -74,7 +84,12 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [reloadToken, setReloadToken] = useState(0);
const [exporting, setExporting] = useState(false);
const tenantId = (auth.active_tenant ?? auth.tenant).id;
const canExport = hasScope(
auth,
systemMode ? "audit:system_evidence:export" : "audit:evidence:export"
);
const load = useCallback(async () => {
setLoading(true);
@@ -144,6 +159,29 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
});
}, []);
const exportPageEvidence = useCallback(async () => {
setExporting(true);
setError("");
try {
const created = await exportAuditEvidenceBundle(settings, {
scope: systemMode ? "system" : "tenant",
tenant_id: systemMode ? null : tenantId,
record_ids: items.map((item) => item.id),
max_records: Math.max(1, items.length),
sign: false
});
const downloaded = await downloadAuditEvidenceBundle(settings, created.id);
downloadJson(
downloaded.bundle,
`govoplan-audit-evidence-${created.id}.json`
);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setExporting(false);
}
}, [items, settings, systemMode, tenantId]);
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
{ id: "time", header: I18N.time, width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
{ id: "actor", header: I18N.actor, width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System", render: (row) => row.actor_email || I18N.system },
@@ -175,6 +213,14 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
error={error}
actions={(
<>
{canExport && (
<Button
onClick={() => { void exportPageEvidence(); }}
disabled={loading || exporting || items.length === 0}
disabledReason={exporting ? I18N.exportingEvidence : undefined}>
{I18N.exportEvidence}
</Button>
)}
<DocumentationHelpLink
reference={{
topicId: "audit.read-authorized-evidence",
@@ -243,6 +289,16 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
);
}
function downloadJson(value: unknown, filename: string): void {
const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function auditDetailRows(details: Record<string, unknown>): AuditDetailRow[] {
return Object.entries(details)
.sort(([left], [right]) => left.localeCompare(right))