ubiquitous-invention/apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx
Randall Stillwell 336a5890a8 feat(audit): append-only audit_log, workspace archive cascade + restore, audit view
Soft-delete cascade was the missing half of archive: stamping
workspaces.archived_at alone left objects visible to anyone with a
direct id. The cascade runs in one transaction so the partial state
isn't reachable, and restore inverts it for any archived row in the
workspace — provenance-blind on purpose until we have a use case
that needs to distinguish per-workspace from per-object archives.

audit_log keeps the keyset index on (workspace_id, created_at) and
the actor_user_id FK with onDelete set null. recordAudit() refuses
to write a null actor without a metadata.system_actor label so the
audit view always has something to render. workspaces and invites
mutations call recordAudit on success; objects-router instrumentation
and the markdown importer's system-actor flow are filed as P2
follow-ups because each needs a thoughtful "what's audit-worthy?"
pass, not mechanical wiring.

Settings → Audit log lives at /<slug>/settings/audit, owner-gated,
keyset-paginated. ACTION_LABELS is small on purpose; new actions
fall back to their raw key so missing a label degrades gracefully.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:35:16 -05:00

189 lines
7.8 KiB
TypeScript

"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import { History, Loader2, ScrollText, ShieldOff } from "lucide-react";
import { api } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Owner-only debugging view over `audit_log`. Intentionally dumb: no
* filters, no search, no time-zone toggles. The job is "show me what
* happened, most recent first." If we add filters later they should be
* server-side keyset queries, not client-side slicing.
*/
const ACTION_LABELS: Record<string, string> = {
"workspace.create": "Workspace created",
"workspace.update": "Workspace updated",
"workspace.archive": "Workspace archived",
"workspace.restore": "Workspace restored",
"member.role_change": "Role changed",
"member.remove": "Member removed",
"member.leave": "Member left",
"invite.create": "Invite sent",
"invite.revoke": "Invite revoked",
"invite.accept": "Invite accepted",
};
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action;
}
function formatTimestamp(when: Date | string): string {
const d = when instanceof Date ? when : new Date(when);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function metadataSummary(metadata: Record<string, unknown> | null): string {
if (!metadata || Object.keys(metadata).length === 0) return "—";
// Truncate aggressively. The expanded raw JSON is one row away via the
// <details> below; this is just the at-a-glance hint.
const json = JSON.stringify(metadata);
return json.length > 120 ? `${json.slice(0, 117)}` : json;
}
export default function AuditLogPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const [cursor, setCursor] = React.useState<string | undefined>(undefined);
const auditQuery = api.audit.list.useQuery(
{ workspace: workspaceSlug ?? "", cursorCreatedAt: cursor, limit: 50 },
{ enabled: Boolean(workspaceSlug) },
);
const isForbidden = auditQuery.error?.data?.code === "FORBIDDEN";
return (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<ScrollText className="size-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">Audit log</h1>
<p className="text-xs text-muted-foreground">
Append-only record of meaningful actions in this workspace. Owner-only.
</p>
</div>
</div>
{isForbidden ? (
<div className="flex flex-col items-center gap-3 rounded-lg border bg-muted/40 px-6 py-12 text-center">
<ShieldOff className="size-6 text-muted-foreground" />
<p className="text-sm font-medium">Owner-only view</p>
<p className="max-w-sm text-xs text-muted-foreground">
Only the workspace owner can read the audit log. Ask an owner to share
specific events if you need them for support.
</p>
</div>
) : auditQuery.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : auditQuery.error ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
Couldn&apos;t load the audit log: {auditQuery.error.message}
</div>
) : !auditQuery.data || auditQuery.data.rows.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-lg border bg-muted/40 px-6 py-12 text-center">
<History className="size-6 text-muted-foreground" />
<p className="text-sm font-medium">No events yet</p>
<p className="max-w-sm text-xs text-muted-foreground">
Actions like creating invites, changing roles, or archiving the workspace
will appear here.
</p>
</div>
) : (
<>
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">When</th>
<th className="px-4 py-2 text-left font-medium">Action</th>
<th className="px-4 py-2 text-left font-medium">Actor</th>
<th className="px-4 py-2 text-left font-medium">Target</th>
<th className="px-4 py-2 text-left font-medium">Details</th>
</tr>
</thead>
<tbody className="divide-y">
{auditQuery.data.rows.map((row) => (
<tr key={row.id} className="hover:bg-muted/30">
<td className="whitespace-nowrap px-4 py-2 text-xs tabular-nums text-muted-foreground">
{formatTimestamp(row.createdAt)}
</td>
<td className="px-4 py-2 font-medium">{actionLabel(row.action)}</td>
<td className="px-4 py-2 text-xs">
{row.actorUserId ? (
<span>
{row.actorName ?? row.actorEmail ?? row.actorUserId.slice(0, 8)}
</span>
) : (
<span className="italic text-muted-foreground">
system
{row.metadata && typeof (row.metadata as { system_actor?: unknown }).system_actor === "string"
? `: ${(row.metadata as { system_actor: string }).system_actor}`
: ""}
</span>
)}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">{row.targetType}</code>
{row.targetId ? (
<span className="ml-1 font-mono text-[10px]">{row.targetId.slice(0, 8)}</span>
) : null}
</td>
<td className="px-4 py-2">
{row.metadata && Object.keys(row.metadata).length > 0 ? (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
{metadataSummary(row.metadata)}
</summary>
<pre className="mt-1 overflow-x-auto rounded bg-muted px-2 py-1 text-[10px]">
{JSON.stringify(row.metadata, null, 2)}
</pre>
</details>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{auditQuery.data.nextCursor ? (
<div className="mt-4 flex justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setCursor(auditQuery.data.nextCursor ?? undefined)}
disabled={auditQuery.isFetching}
>
{auditQuery.isFetching ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Load older"
)}
</Button>
</div>
) : null}
</>
)}
</div>
);
}