Migrate Audit evidence interfaces
This commit is contained in:
@@ -12,6 +12,9 @@
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const panel = readFileSync(
|
||||
fileURLToPath(new URL("../src/features/audit/AdminAuditPanel.tsx", import.meta.url)),
|
||||
"utf8"
|
||||
);
|
||||
const moduleSource = readFileSync(
|
||||
fileURLToPath(new URL("../src/module.ts", import.meta.url)),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(panel, /AdminPageLayout,[\s\S]*DataGrid,[\s\S]*Dialog,[\s\S]*DocumentationHelpLink,[\s\S]*TableActionGroup/);
|
||||
assert.match(panel, /topicId: "audit\.read-authorized-evidence"/);
|
||||
assert.match(panel, /disabledReason=\{loading \? I18N\.loading : undefined\}/);
|
||||
assert.match(panel, /pagination=\{\{[\s\S]*mode: "server"/);
|
||||
assert.match(panel, /id="admin-audit-event-details-grid"/);
|
||||
assert.match(panel, /auditDetailRows\(selected\?\.details \?\? \{\}\)/);
|
||||
assert.match(panel, /emptyText=\{I18N\.noRecords\}/);
|
||||
assert.doesNotMatch(panel, /<pre|window\.(?:alert|confirm)\(/);
|
||||
assert.doesNotMatch(panel, /@govoplan\/(?:access|admin)-webui|govoplan_(?:access|admin)/);
|
||||
|
||||
assert.match(moduleSource, /generatedTranslations/);
|
||||
assert.match(moduleSource, /translations,/);
|
||||
assert.match(moduleSource, /surfaceId: "audit\.admin\.system"[\s\S]*allOf: \["system:audit:read"\]/);
|
||||
assert.match(moduleSource, /surfaceId: "audit\.admin\.tenant"[\s\S]*allOf: \["audit:read"\]/);
|
||||
|
||||
console.log("Audit administration surfaces satisfy the monitoring and evidence pattern contracts.");
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
Button,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
formatAdminDateTime as formatDateTime,
|
||||
i18nMessage,
|
||||
mergeDeltaRows,
|
||||
TableActionGroup,
|
||||
useDeltaWatermarks,
|
||||
@@ -22,6 +24,35 @@ type Props = {
|
||||
auth: AuthInfo;
|
||||
systemMode?: boolean;
|
||||
};
|
||||
type AuditDetailRow = {
|
||||
id: string;
|
||||
field: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const I18N = {
|
||||
actionLabel: "i18n:govoplan-audit.action.f1a20801",
|
||||
actions: "i18n:govoplan-audit.actions.f1a20802",
|
||||
actor: "i18n:govoplan-audit.actor.f1a20803",
|
||||
close: "i18n:govoplan-audit.close.f1a20804",
|
||||
details: "i18n:govoplan-audit.details.f1a20805",
|
||||
eventDetails: "i18n:govoplan-audit.audit_event_details.f1a20806",
|
||||
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",
|
||||
noRecords: "i18n:govoplan-audit.no_audit_records_match_the_current_scope_and_filters.f1a20810",
|
||||
object: "i18n:govoplan-audit.object.f1a20811",
|
||||
reload: "i18n:govoplan-audit.reload_audit_evidence.f1a20812",
|
||||
scopeLabel: "i18n:govoplan-audit.scope.f1a20813",
|
||||
system: "i18n:govoplan-audit.system.f1a20814",
|
||||
systemAudit: "i18n:govoplan-audit.system_audit.f1a20815",
|
||||
systemDescription: "i18n:govoplan-audit.system_level_administrative_history_showing_value0_value1_of_value2.f1a20816",
|
||||
tenantAudit: "i18n:govoplan-audit.tenant_audit.f1a20817",
|
||||
tenantContext: "i18n:govoplan-audit.tenant_context.f1a20818",
|
||||
tenantDescription: "i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819",
|
||||
time: "i18n:govoplan-audit.time.f1a20820",
|
||||
value: "i18n:govoplan-audit.value.f1a20821"
|
||||
} as const;
|
||||
|
||||
const DEFAULT_QUERY: DataGridQueryState = {
|
||||
sort: { columnId: "time", direction: "desc" },
|
||||
@@ -113,28 +144,49 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
||||
}, []);
|
||||
|
||||
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
|
||||
{ id: "time", header: "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: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" },
|
||||
{ id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
|
||||
{ id: "object", header: "Object", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "-"} ${row.object_id || ""}`.trim() },
|
||||
...(systemMode ? [{ id: "tenant", header: "Tenant context", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "-" }] : []),
|
||||
{ id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "inspect", label: "Inspect audit event", icon: <Search />, onClick: () => setSelected(row) }]} /> }
|
||||
{ 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 },
|
||||
{ id: "action", header: I18N.actionLabel, width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
|
||||
{ id: "object", header: I18N.object, width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "-"} ${row.object_id || ""}`.trim() },
|
||||
...(systemMode ? [{ id: "tenant", header: I18N.tenantContext, width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "-" }] : []),
|
||||
{ id: "actions", header: I18N.actions, width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "inspect", label: I18N.inspect, icon: <Search aria-hidden="true" size={16} />, onClick: () => setSelected(row) }]} /> }
|
||||
], [systemMode]);
|
||||
|
||||
const detailColumns = useMemo<DataGridColumn<AuditDetailRow>[]>(() => [
|
||||
{ id: "field", header: I18N.details, minWidth: 180, resizable: true, value: (row) => row.field },
|
||||
{ id: "value", header: I18N.value, minWidth: 260, resizable: true, fill: true, value: (row) => row.value }
|
||||
], []);
|
||||
const detailRows = useMemo(() => auditDetailRows(selected?.details ?? {}), [selected]);
|
||||
|
||||
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const lastShown = Math.min(total, page * pageSize);
|
||||
const pageDescription = systemMode
|
||||
? `System-level administrative history, showing ${firstShown}-${lastShown} of ${total}.`
|
||||
: `Tenant-level administrative history for the active tenant, showing ${firstShown}-${lastShown} of ${total}.`;
|
||||
const pageDescription = i18nMessage(
|
||||
systemMode ? I18N.systemDescription : I18N.tenantDescription,
|
||||
{ value0: firstShown, value1: lastShown, value2: total }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title={systemMode ? "System audit" : "Tenant audit"}
|
||||
title={systemMode ? I18N.systemAudit : I18N.tenantAudit}
|
||||
description={pageDescription}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>Reload</Button>}>
|
||||
actions={(
|
||||
<>
|
||||
<DocumentationHelpLink
|
||||
reference={{
|
||||
topicId: "audit.read-authorized-evidence",
|
||||
documentationType: "user"
|
||||
}} />
|
||||
<Button
|
||||
onClick={() => setReloadToken((value) => value + 1)}
|
||||
disabled={loading}
|
||||
disabledReason={loading ? I18N.loading : undefined}>
|
||||
{I18N.reload}
|
||||
</Button>
|
||||
</>
|
||||
)}>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id={systemMode ? "admin-system-audit-v6" : "admin-tenant-audit-v6"}
|
||||
@@ -142,7 +194,7 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
||||
columns={columns}
|
||||
initialFit="container"
|
||||
getRowKey={(row) => row.id}
|
||||
emptyText="No administrative audit records found."
|
||||
emptyText={I18N.noRecords}
|
||||
className="admin-audit-grid"
|
||||
initialSort={{ columnId: "time", direction: "desc" }}
|
||||
pagination={{
|
||||
@@ -159,18 +211,30 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
||||
/>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
<Dialog open={Boolean(selected)} title="Audit event details" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>Close</Button>}>
|
||||
<Dialog
|
||||
open={Boolean(selected)}
|
||||
title={I18N.eventDetails}
|
||||
onClose={() => setSelected(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<Button onClick={() => setSelected(null)}>{I18N.close}</Button>}>
|
||||
{selected && (
|
||||
<>
|
||||
<dl className="admin-details-grid">
|
||||
<div><dt>Scope</dt><dd>{selected.scope}</dd></div>
|
||||
<div><dt>Action</dt><dd>{selected.action}</dd></div>
|
||||
<div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div>
|
||||
<div><dt>Object</dt><dd>{selected.object_type || "-"} {selected.object_id || ""}</dd></div>
|
||||
<div><dt>Tenant context</dt><dd>{selected.tenant_id || "-"}</dd></div>
|
||||
<div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div>
|
||||
<div><dt>{I18N.scopeLabel}</dt><dd>{selected.scope}</dd></div>
|
||||
<div><dt>{I18N.actionLabel}</dt><dd>{selected.action}</dd></div>
|
||||
<div><dt>{I18N.actor}</dt><dd>{selected.actor_email || I18N.system}</dd></div>
|
||||
<div><dt>{I18N.object}</dt><dd>{selected.object_type || "-"} {selected.object_id || ""}</dd></div>
|
||||
<div><dt>{I18N.tenantContext}</dt><dd>{selected.tenant_id || "-"}</dd></div>
|
||||
<div><dt>{I18N.time}</dt><dd>{formatDateTime(selected.created_at)}</dd></div>
|
||||
</dl>
|
||||
<pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre>
|
||||
{detailRows.length ? (
|
||||
<DataGrid
|
||||
id="admin-audit-event-details-grid"
|
||||
rows={detailRows}
|
||||
columns={detailColumns}
|
||||
getRowKey={(row) => row.id}
|
||||
initialFit="container" />
|
||||
) : <p className="muted">{I18N.noDetails}</p>}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
@@ -178,6 +242,23 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
||||
);
|
||||
}
|
||||
|
||||
function auditDetailRows(details: Record<string, unknown>): AuditDetailRow[] {
|
||||
return Object.entries(details)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([field, value]) => ({
|
||||
id: field,
|
||||
field,
|
||||
value: auditDetailValue(value)
|
||||
}));
|
||||
}
|
||||
|
||||
function auditDetailValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function compareAuditEvents(sortBy: AuditSortBy, sortDirection: "asc" | "desc"): (left: AuditAdminItem, right: AuditAdminItem) => number {
|
||||
return (left, right) => {
|
||||
const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
en: {
|
||||
"i18n:govoplan-audit.action.f1a20801": "Action",
|
||||
"i18n:govoplan-audit.actions.f1a20802": "Actions",
|
||||
"i18n:govoplan-audit.actor.f1a20803": "Actor",
|
||||
"i18n:govoplan-audit.close.f1a20804": "Close",
|
||||
"i18n:govoplan-audit.details.f1a20805": "Detail",
|
||||
"i18n:govoplan-audit.audit_event_details.f1a20806": "Audit event details",
|
||||
"i18n:govoplan-audit.inspect_audit_event.f1a20807": "Inspect audit event",
|
||||
"i18n:govoplan-audit.audit_evidence_is_loading.f1a20808": "Audit evidence is loading.",
|
||||
"i18n:govoplan-audit.no_additional_details_were_recorded.f1a20809": "No additional details were recorded.",
|
||||
"i18n:govoplan-audit.no_audit_records_match_the_current_scope_and_filters.f1a20810": "No audit records match the current scope and filters.",
|
||||
"i18n:govoplan-audit.object.f1a20811": "Object",
|
||||
"i18n:govoplan-audit.reload_audit_evidence.f1a20812": "Reload audit evidence",
|
||||
"i18n:govoplan-audit.scope.f1a20813": "Scope",
|
||||
"i18n:govoplan-audit.system.f1a20814": "System",
|
||||
"i18n:govoplan-audit.system_audit.f1a20815": "System audit",
|
||||
"i18n:govoplan-audit.system_level_administrative_history_showing_value0_value1_of_value2.f1a20816": "System-level administrative history, showing {value0}-{value1} of {value2}.",
|
||||
"i18n:govoplan-audit.tenant_audit.f1a20817": "Tenant audit",
|
||||
"i18n:govoplan-audit.tenant_context.f1a20818": "Tenant context",
|
||||
"i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819": "Tenant-level administrative history for the active tenant, showing {value0}-{value1} of {value2}.",
|
||||
"i18n:govoplan-audit.time.f1a20820": "Time",
|
||||
"i18n:govoplan-audit.value.f1a20821": "Value"
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-audit.action.f1a20801": "Aktion",
|
||||
"i18n:govoplan-audit.actions.f1a20802": "Aktionen",
|
||||
"i18n:govoplan-audit.actor.f1a20803": "Akteur",
|
||||
"i18n:govoplan-audit.close.f1a20804": "Schließen",
|
||||
"i18n:govoplan-audit.details.f1a20805": "Detail",
|
||||
"i18n:govoplan-audit.audit_event_details.f1a20806": "Details des Auditereignisses",
|
||||
"i18n:govoplan-audit.inspect_audit_event.f1a20807": "Auditereignis prüfen",
|
||||
"i18n:govoplan-audit.audit_evidence_is_loading.f1a20808": "Auditnachweise werden geladen.",
|
||||
"i18n:govoplan-audit.no_additional_details_were_recorded.f1a20809": "Es wurden keine zusätzlichen Details aufgezeichnet.",
|
||||
"i18n:govoplan-audit.no_audit_records_match_the_current_scope_and_filters.f1a20810": "Keine Auditaufzeichnungen entsprechen dem aktuellen Bereich und den Filtern.",
|
||||
"i18n:govoplan-audit.object.f1a20811": "Objekt",
|
||||
"i18n:govoplan-audit.reload_audit_evidence.f1a20812": "Auditnachweise neu laden",
|
||||
"i18n:govoplan-audit.scope.f1a20813": "Geltungsbereich",
|
||||
"i18n:govoplan-audit.system.f1a20814": "System",
|
||||
"i18n:govoplan-audit.system_audit.f1a20815": "Systemaudit",
|
||||
"i18n:govoplan-audit.system_level_administrative_history_showing_value0_value1_of_value2.f1a20816": "Systemweite administrative Historie, angezeigt werden {value0}-{value1} von {value2}.",
|
||||
"i18n:govoplan-audit.tenant_audit.f1a20817": "Mandantenaudit",
|
||||
"i18n:govoplan-audit.tenant_context.f1a20818": "Mandantenkontext",
|
||||
"i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819": "Administrative Historie des aktiven Mandanten, angezeigt werden {value0}-{value1} von {value2}.",
|
||||
"i18n:govoplan-audit.time.f1a20820": "Zeit",
|
||||
"i18n:govoplan-audit.value.f1a20821": "Wert"
|
||||
}
|
||||
};
|
||||
+9
-3
@@ -1,7 +1,12 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import { type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
|
||||
const AdminAuditPanel = lazy(() => import("./features/audit/AdminAuditPanel"));
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
|
||||
const auditAdminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
@@ -10,7 +15,7 @@ const auditAdminSections: AdminSectionsUiCapability = {
|
||||
moduleId: "audit",
|
||||
kind: "management",
|
||||
surfaceId: "audit.admin.system",
|
||||
label: "Audit",
|
||||
label: "i18n:govoplan-audit.system_audit.f1a20815",
|
||||
group: "SYSTEM",
|
||||
order: 90,
|
||||
allOf: ["system:audit:read"],
|
||||
@@ -25,7 +30,7 @@ const auditAdminSections: AdminSectionsUiCapability = {
|
||||
moduleId: "audit",
|
||||
kind: "management",
|
||||
surfaceId: "audit.admin.tenant",
|
||||
label: "Audit",
|
||||
label: "i18n:govoplan-audit.tenant_audit.f1a20817",
|
||||
group: "TENANT",
|
||||
order: 100,
|
||||
allOf: ["audit:read"],
|
||||
@@ -41,12 +46,13 @@ const auditAdminSections: AdminSectionsUiCapability = {
|
||||
export const auditModule: PlatformWebModule = {
|
||||
id: "audit",
|
||||
label: "Audit",
|
||||
version: "0.1.6",
|
||||
version: "0.1.8",
|
||||
dependencies: ["access", "admin"],
|
||||
viewSurfaces: [
|
||||
{ id: "audit.admin.system", moduleId: "audit", kind: "section", label: "System audit", order: 90 },
|
||||
{ id: "audit.admin.tenant", moduleId: "audit", kind: "section", label: "Tenant audit", order: 100 }
|
||||
],
|
||||
translations,
|
||||
uiCapabilities: {
|
||||
"admin.sections": auditAdminSections
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user