242 lines
9.2 KiB
TypeScript
242 lines
9.2 KiB
TypeScript
import Link from "next/link";
|
|
import { getAuditLog, getAuditFacets, formatRelative } from "@/lib/reports";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/**
|
|
* Audit log viewer. Cursor-paginated, with action / entity / actor filters
|
|
* driven by the GET query string. Server-rendered so the URL is the source
|
|
* of truth — bookmarking a filtered view just works, and page reloads don't
|
|
* drop state.
|
|
*
|
|
* The JSON `before`/`after` payloads can be long; we render them lazily
|
|
* inside a <details> block so the table stays scannable.
|
|
*/
|
|
export default async function AuditPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{
|
|
action?: string;
|
|
entity?: string;
|
|
actorId?: string;
|
|
cursor?: string;
|
|
}>;
|
|
}) {
|
|
const { action, entity, actorId, cursor } = await searchParams;
|
|
const [facets, { rows, nextCursor }] = await Promise.all([
|
|
getAuditFacets(),
|
|
getAuditLog({
|
|
limit: 50,
|
|
action: action || null,
|
|
entity: entity || null,
|
|
actorId: actorId || null,
|
|
cursor: cursor || null,
|
|
}),
|
|
]);
|
|
const now = new Date();
|
|
|
|
// Build a filter-preserving URL for the "Next page" link.
|
|
const nextHref = nextCursor
|
|
? `/admin/audit?${new URLSearchParams({
|
|
...(action ? { action } : {}),
|
|
...(entity ? { entity } : {}),
|
|
...(actorId ? { actorId } : {}),
|
|
cursor: nextCursor,
|
|
}).toString()}`
|
|
: null;
|
|
|
|
const filtersActive = !!(action || entity || actorId);
|
|
|
|
return (
|
|
<div className="mx-auto max-w-7xl px-4 py-8">
|
|
<div className="flex items-start justify-between gap-6 mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>
|
|
<p className="text-slate-500 mt-1 text-sm">
|
|
Every write — claim, release, close, create, update, delete, login — lands here.
|
|
Newest first.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── Filter bar ──────────────────────────────────────────── */}
|
|
<form method="get" className="mb-6 rounded-xl bg-white border border-slate-200 p-4">
|
|
<div className="grid gap-3 md:grid-cols-4">
|
|
<label className="block text-sm">
|
|
<span className="text-slate-700">Action</span>
|
|
<select
|
|
name="action"
|
|
defaultValue={action ?? ""}
|
|
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
|
|
>
|
|
<option value="">Any action</option>
|
|
{facets.actions.map((a) => (
|
|
<option key={a} value={a}>
|
|
{a}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block text-sm">
|
|
<span className="text-slate-700">Entity</span>
|
|
<select
|
|
name="entity"
|
|
defaultValue={entity ?? ""}
|
|
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
|
|
>
|
|
<option value="">Any entity</option>
|
|
{facets.entities.map((e) => (
|
|
<option key={e} value={e}>
|
|
{e}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block text-sm">
|
|
<span className="text-slate-700">Actor</span>
|
|
<select
|
|
name="actorId"
|
|
defaultValue={actorId ?? ""}
|
|
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
|
|
>
|
|
<option value="">Any user</option>
|
|
{facets.actors.map((u) => (
|
|
<option key={u.id} value={u.id}>
|
|
{u.name} ({u.role})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="flex items-end gap-2">
|
|
<button
|
|
type="submit"
|
|
className="h-10 rounded-md bg-slate-900 text-white text-sm font-medium px-4"
|
|
>
|
|
Filter
|
|
</button>
|
|
{filtersActive ? (
|
|
<Link
|
|
href="/admin/audit"
|
|
className="h-10 inline-flex items-center rounded-md border border-slate-300 text-sm font-medium px-4 text-slate-700 hover:bg-slate-50"
|
|
>
|
|
Clear
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{/* ─── Results ─────────────────────────────────────────────── */}
|
|
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
|
|
{rows.length === 0 ? (
|
|
<p className="px-4 py-10 text-center text-slate-500 text-sm">
|
|
No events match. Clear filters or widen your search.
|
|
</p>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
|
<tr>
|
|
<th className="px-4 py-2 font-medium w-[140px]">When</th>
|
|
<th className="px-4 py-2 font-medium w-[150px]">Actor</th>
|
|
<th className="px-4 py-2 font-medium w-[150px]">Action</th>
|
|
<th className="px-4 py-2 font-medium w-[220px]">Entity</th>
|
|
<th className="px-4 py-2 font-medium">Payload</th>
|
|
<th className="px-4 py-2 font-medium w-[100px]">IP</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={row.id} className="border-b border-slate-100 last:border-0 align-top">
|
|
<td className="px-4 py-2 text-slate-600 whitespace-nowrap">
|
|
<div>{formatRelative(row.at, now)}</div>
|
|
<div className="text-xs text-slate-400">{row.at.toLocaleString()}</div>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
{row.actor ? (
|
|
<>
|
|
<div className="text-slate-900">{row.actor.name}</div>
|
|
<div className="text-xs text-slate-500">{row.actor.role}</div>
|
|
</>
|
|
) : (
|
|
<span className="text-slate-400 italic">system</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<span className="inline-flex rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-0.5 font-mono">
|
|
{row.action}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2 font-mono text-xs text-slate-700 break-all">
|
|
{row.entity}
|
|
{row.entityId ? <span className="text-slate-500">/{row.entityId}</span> : null}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
{row.before || row.after ? (
|
|
<details>
|
|
<summary className="cursor-pointer text-xs text-blue-600">
|
|
{row.after ? "after" : "before"}
|
|
{row.before && row.after ? " / before" : ""}
|
|
</summary>
|
|
<div className="mt-2 space-y-2">
|
|
{row.after ? (
|
|
<JsonBlock label="after" value={row.after} />
|
|
) : null}
|
|
{row.before ? (
|
|
<JsonBlock label="before" value={row.before} />
|
|
) : null}
|
|
</div>
|
|
</details>
|
|
) : (
|
|
<span className="text-slate-400 text-xs">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-xs text-slate-500 font-mono">
|
|
{row.ipAddress ?? "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* ─── Pagination ──────────────────────────────────────────── */}
|
|
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
|
|
<div>{rows.length} row{rows.length === 1 ? "" : "s"} on this page</div>
|
|
{nextHref ? (
|
|
<Link
|
|
href={nextHref}
|
|
className="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm hover:bg-slate-50"
|
|
>
|
|
Older →
|
|
</Link>
|
|
) : (
|
|
<span className="text-slate-400">End of log</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pretty-prints a JSON audit payload. Falls back to the raw string if the
|
|
* row happens to contain non-JSON (older rows, or writes that logged a
|
|
* free-form message).
|
|
*/
|
|
function JsonBlock({ label, value }: { label: string; value: string }) {
|
|
let pretty = value;
|
|
try {
|
|
pretty = JSON.stringify(JSON.parse(value), null, 2);
|
|
} catch {
|
|
// leave as-is
|
|
}
|
|
return (
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-wide text-slate-500">{label}</div>
|
|
<pre className="mt-1 text-xs bg-slate-50 border border-slate-200 rounded-md p-2 overflow-x-auto">
|
|
{pretty}
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|