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>
189 lines
7.8 KiB
TypeScript
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'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>
|
|
);
|
|
}
|