This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user