// Audit trail viewer.
//
// Wraps `GET /v1/audit`. Super-admin only — the endpoint is gated
// server-side, and a non-super-admin caller gets a 403 rendered as
// a toast.
//
// Pagination is forward-only ("Show older" button). The page shows
// timestamp + event-kind + actor/target DIDs + a collapsible JSON
// detail panel per row. Auto-refreshes when the operator clicks
// Refresh — we deliberately don't poll, because the audit log can
// grow large and a poll would refetch the whole page each tick.
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ClipboardList, RefreshCw } from "lucide-react";
import { getJson } from "@/lib/api";
import { formatIso } from "@/lib/format";
import { useToast } from "@/lib/toast";
// Human-readable label per event kind. Falls through to the
// camelCase tag for variants we haven't catalogued yet (new event
// types just show as raw kind until this map is updated, which is a
// quieter failure mode than throwing).
const EVENT_DESCRIPTIONS: Record<string, string> = {
AdminUiServed: "Admin UI bundle pinned at daemon boot",
CommunityInstalled: "Community installation completed",
EmergencyBootstrapInvoked: "Emergency bootstrap triggered",
AdminPasskeyRegistered: "Admin registered a passkey",
AdminPasskeyRevoked: "Admin revoked a passkey",
ConfigChanged: "Daemon configuration changed",
ConfigReloaded: "Daemon configuration reloaded",
RestartRequested: "Daemon restart requested",
CommunityProfileUpdated: "Community profile updated",
AuditKeyRotated: "Audit key rotated",
MemberUpdated: "Member record updated",
RoleChanged: "Member role changed",
AdminPromoted: "Member promoted to admin",
JoinRequestSubmitted: "Applicant submitted a join request",
JoinRequestApproved: "Join request approved",
JoinRequestRejected: "Join request rejected",
MemberAdded: "New member joined",
MemberRemoved: "Member removed from community",
PolicyUploaded: "Policy revision uploaded",
PolicyActivated: "Policy activated for a purpose",
VmcIssued: "Membership credential (VMC) issued",
VecIssued: "Role credential (VEC) issued",
MembershipRenewed: "Membership renewed",
StatusListFlipped: "Status-list bit flipped",
DidRotated: "Member rotated their DID",
RegistryStatusChanged: "Trust-registry reachability changed",
RegistrySyncSucceeded: "Trust-registry sync succeeded",
RegistrySyncFailed: "Trust-registry sync failed",
RegistryRecordPolicyOverride: "Registry record disposition overridden",
CrossCommunitySessionMinted: "Cross-community session issued",
VrcPublished: "Relationship credential (VRC) published",
VrcRevoked: "Relationship credential revoked",
PersonhoodAsserted: "Personhood asserted",
PersonhoodRevoked: "Personhood revoked",
CustomEndorsementIssued: "Custom endorsement issued",
CustomEndorsementRevoked: "Custom endorsement revoked",
EndorsementTypeRegistered: "Endorsement type registered",
EndorsementTypeDeleted: "Endorsement type deleted",
WebsiteFileWritten: "Public website file written",
WebsiteFileDeleted: "Public website file deleted",
WebsiteBundleDeployed: "Public website bundle deployed",
WebsiteGenerationRolledBack: "Public website rolled back",
};
// Events that fire on a schedule or at daemon-internal lifecycle
// transitions, not in response to an operator/member action. The
// audit log keeps them for security pinning + completeness, but the
// default UI filters them so "who did what" reads cleanly.
const SYSTEM_EVENT_KINDS = new Set([
"AdminUiServed",
"RegistryStatusChanged",
"RegistrySyncSucceeded",
"RegistrySyncFailed",
]);
const TRUST_TASK = "https://trusttasks.org/openvtc/vtc/audit/list/1.0";
interface AuditEnvelope {
event_id: string;
event_version: number;
schema_version: number;
timestamp: string;
audit_key_id: string;
actor_did_hash: string;
actor_did_plain: string | null;
target_did_hash: string | null;
target_did_plain: string | null;
event: Record<string, unknown> | string;
}
interface Paginated<T> {
items: T[];
next_cursor?: string | null;
total_estimate?: number | null;
}
async function fetchAuditPage(
cursor: string | null,
limit: number,
): Promise<Paginated<AuditEnvelope>> {
const q = new URLSearchParams();
if (cursor) q.set("cursor", cursor);
q.set("limit", String(limit));
return getJson<Paginated<AuditEnvelope>>(`/v1/audit?${q}`, {
trustTask: TRUST_TASK,
});
}
export function Audit() {
const [cursor, setCursor] = useState<string | null>(null);
const [items, setItems] = useState<AuditEnvelope[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [showSystem, setShowSystem] = useState(false);
const [filterText, setFilterText] = useState("");
const toast = useToast();
const query = useQuery({
queryKey: ["audit", cursor],
queryFn: () => fetchAuditPage(cursor, 50),
staleTime: 0,
});
// Accumulate pages: first page replaces, subsequent appends. We
// could let TanStack manage this with `useInfiniteQuery` instead,
// but the read-once pattern keeps it simpler.
useEffect(() => {
if (!query.data) return;
if (cursor === null) {
setItems(query.data.items);
} else {
setItems((prev) => [...prev, ...query.data!.items]);
}
setNextCursor(query.data.next_cursor ?? null);
}, [query.data, cursor]);
useEffect(() => {
if (query.error) toast.pushFromError(query.error, "Failed to load audit");
}, [query.error, toast]);
// Visible items = the accumulated set minus system events (when
// hidden) minus rows that don't match the free-text filter.
// Filter matches against event kind, description, and either DID
// — case-insensitive substring so an operator pasting half a DID
// still finds the row.
const visibleItems = useMemo(() => {
const needle = filterText.trim().toLowerCase();
return items.filter((env) => {
const kind = eventKind(env.event);
if (!showSystem && SYSTEM_EVENT_KINDS.has(kind)) return false;
if (!needle) return true;
const haystack = [
kind,
EVENT_DESCRIPTIONS[kind] ?? "",
env.actor_did_plain ?? "",
env.target_did_plain ?? "",
]
.join(" ")
.toLowerCase();
return haystack.includes(needle);
});
}, [items, showSystem, filterText]);
const hiddenCount = items.length - visibleItems.length;
return (
<section className="page">
<h2>Audit trail</h2>
<p className="lead">
Tamper-evident operations log. Newest first. Super-admin only —
envelopes carry plaintext actor / target DIDs until an RTBF
redaction nulls them.
</p>
<section className="card">
<div className="toolbar">
<label className="field inline">
<span className="field-label">Filter</span>
<input
type="search"
placeholder="kind, description, actor or target DID"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
</label>
<label
className="field inline"
style={{ flex: "0 0 auto", minWidth: 0 }}
>
<span className="field-label">Show system events</span>
<input
type="checkbox"
checked={showSystem}
onChange={(e) => setShowSystem(e.target.checked)}
style={{ width: "auto", height: "auto" }}
/>
</label>
<span className="muted">
{visibleItems.length} of {items.length}
{hiddenCount > 0 ? ` (${hiddenCount} filtered)` : ""}
{nextCursor ? ", more available" : ""}
</span>
<div className="spacer" />
<button
type="button"
className="secondary"
disabled={query.isFetching && cursor === null}
aria-busy={query.isFetching && cursor === null}
onClick={async () => {
// Refresh resets the accumulator + re-fetches the first
// page. We can't lean on the `useEffect` below to wire
// the response into state: React Query's default
// `structuralSharing` keeps `query.data` byte-stable
// when the response is identical, so the effect's
// dependency array never sees a new reference and the
// post-clear `setItems([])` would stick. Pull the
// result out of `refetch()`'s promise and assign
// explicitly instead.
setCursor(null);
setItems([]);
setNextCursor(null);
const result = await query.refetch();
if (result.data) {
setItems(result.data.items);
setNextCursor(result.data.next_cursor ?? null);
}
}}
>
<RefreshCw size={14} aria-hidden="true" />
{query.isFetching && cursor === null ? "Refreshing…" : "Refresh"}
</button>
</div>
</section>
<section className="card">
<table className="data-table">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Actor</th>
<th>Target</th>
<th></th>
</tr>
</thead>
<tbody>
{visibleItems.length === 0 && !query.isPending && (
<tr>
<td colSpan={5}>
<div className="empty-state">
<span className="empty-icon" aria-hidden="true">
<ClipboardList />
</span>
<h4>
{items.length === 0
? "No audit entries yet"
: "No entries match this filter"}
</h4>
<p>
{items.length === 0
? "Audit envelopes appear here once the community starts emitting events."
: "Clear the search box or enable system events to widen the view."}
</p>
</div>
</td>
</tr>
)}
{query.isPending && cursor === null && (
<tr>
<td colSpan={5}>Loading…</td>
</tr>
)}
{visibleItems.map((env) => (
<AuditRow key={env.event_id} env={env} />
))}
</tbody>
</table>
{nextCursor && (
<div className="pagination">
<button
type="button"
className="secondary"
disabled={query.isFetching}
aria-busy={query.isFetching && cursor !== null}
onClick={() => setCursor(nextCursor)}
>
{query.isFetching && cursor !== null ? "Loading…" : "Show older"}
</button>
</div>
)}
</section>
</section>
);
}
function AuditRow({ env }: { env: AuditEnvelope }) {
const [open, setOpen] = useState(false);
const kind = eventKind(env.event);
const description = EVENT_DESCRIPTIONS[kind];
return (
<>
<tr>
<td title={env.timestamp}>{formatIso(env.timestamp)}</td>
<td>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span>{description ?? kind}</span>
{description && (
<code
className="muted"
style={{ fontSize: "var(--text-xs)" }}
>
{kind}
</code>
)}
</div>
</td>
<td>
{env.actor_did_plain ? (
<code className="truncate" title={env.actor_did_plain}>
{env.actor_did_plain}
</code>
) : (
<span className="muted" title="Redacted by RTBF">
redacted
</span>
)}
</td>
<td>
{env.target_did_plain ? (
<code className="truncate" title={env.target_did_plain}>
{env.target_did_plain}
</code>
) : env.target_did_hash ? (
<span className="muted" title="Redacted by RTBF">
redacted
</span>
) : (
<span className="muted">—</span>
)}
</td>
<td>
<button
type="button"
className="link"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{open ? "Hide" : "Details"}
</button>
</td>
</tr>
{open && (
<tr>
<td colSpan={5}>
<pre className="audit-detail">{formatEvent(env)}</pre>
</td>
</tr>
)}
</>
);
}
function eventKind(event: AuditEnvelope["event"]): string {
// The event field is serde-tagged: `{ kind: "MemberAdded", … }`
// or `"MemberAdded"` for unit variants. Surface the tag for the
// table column; the JSON detail row shows the full payload.
if (typeof event === "string") return event;
if (event && typeof event === "object") {
const obj = event as Record<string, unknown>;
for (const key of Object.keys(obj)) {
return key;
}
}
return "Unknown";
}
function formatEvent(env: AuditEnvelope): string {
try {
return JSON.stringify(env, null, 2);
} catch {
return String(env);
}
}