1"use client";2import { useState } from "react";3import { RefreshCw } from "lucide-react";4import { api, type AuditRow } from "@/lib/api";5import { useAsync, useDebounced } from "@/lib/hooks";6import { useAuth } from "@/lib/auth";7import { fmtInt, fmtTs } from "@/lib/format";8import { Banner, Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";9import { DataTable } from "@/components/DataTable";10import { JsonView } from "@/components/JsonView";1112const ACTIONS = ["", "auth.", "auth.login", "auth.mfa", "admin.", "admin.enqueue", "admin.key_create", "admin.key_revoke", "export.", "query.sql"];13const HIDDEN = new Set(["id", "at", "action", "actor", "principal", "target", "outcome", "ip", "request_id", "detail", "user_agent"]);1415export default function AuditPage() {16 const { isOwner } = useAuth();17 const [action, setAction] = useState("");18 const [limit, setLimit] = useState(200);19 const da = useDebounced(action.trim(), 300);20 const { data, error, loading, reload } = useAsync((s) => api.get<{ data: AuditRow[] }>("/admin/audit", { action: da || undefined, limit }, s), [da, limit], { enabled: isOwner });2122 if (!isOwner) return <Banner tone="warn">The audit log is restricted to the owner.</Banner>;2324 return (25 <>26 <PageHeader27 title="Audit log"28 subtitle="Every authentication event, admin action, export and SQL query, with request ids."29 actions={30 <>31 <Field label="Action prefix" className="w-48">32 <input className="input py-1 font-mono text-xs" list="audit-actions" value={action} onChange={(e) => setAction(e.target.value)} placeholder="admin." />33 <datalist id="audit-actions">34 {ACTIONS.filter(Boolean).map((a) => (35 <option key={a} value={a} />36 ))}37 </datalist>38 </Field>39 <Field label="Limit" className="w-24">40 <select className="input py-1 text-xs" value={limit} onChange={(e) => setLimit(Number(e.target.value))}>41 {[100, 200, 500, 1000, 2000].map((n) => (42 <option key={n} value={n}>43 {n}44 </option>45 ))}46 </select>47 </Field>48 <button className="btn btn-sm self-end" onClick={reload}>49 <RefreshCw className="h-3.5 w-3.5" /> Refresh50 </button>51 </>52 }53 />54 {error && <ErrorState error={error} retry={reload} />}55 <Card>56 <DataTable57 rows={data?.data}58 loading={loading}59 rowKey={(r, i) => String(r.id ?? `${r.at}-${i}`)}60 mobile="cards"61 maxHeight="75dvh"62 columns={[63 { key: "at", header: "At (UTC)", render: (r) => <span className="tnum text-xs whitespace-nowrap">{fmtTs(r.at)}</span> },64 { key: "action", header: "Action", mono: true },65 { key: "actor", header: "Actor", render: (r) => <span className="text-xs">{String(r.actor ?? r.principal ?? "—")}</span> },66 { key: "target", header: "Target", render: (r) => <span className="font-mono text-xs break-all">{r.target ?? "—"}</span> },67 { key: "outcome", header: "Outcome", render: (r) => (r.outcome ? <StatusBadge status={r.outcome === "denied" ? "denied" : r.outcome === "ok" ? "ok" : r.outcome} label={r.outcome} /> : <span className="text-ink-3">—</span>) },68 { key: "ip", header: "IP", hideSm: true, mono: true },69 {70 key: "detail",71 header: "Detail",72 render: (r) => {73 const d = r.detail ?? Object.fromEntries(Object.entries(r).filter(([k]) => !HIDDEN.has(k)));74 return Object.keys(d).length ? <JsonView value={d} collapsed /> : <span className="text-ink-3">—</span>;75 },76 },77 { key: "request_id", header: "Request", hideSm: true, render: (r) => <span className="font-mono text-[11px] text-ink-3">{r.request_id ?? "—"}</span> },78 ]}79 empty="No audit entries match."80 footer={<span>{fmtInt(data?.data.length ?? 0)} entries</span>}81 />82 </Card>83 </>84 );85}86