karbe/src/app/admin/audit/page.tsx

134 lines
5.5 KiB
TypeScript

import Link from "next/link";
import { listAuditAdmin, listAuditScopes } from "@/lib/admin/audit";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
scope?: string;
actor?: string;
from?: string;
to?: string;
}>;
};
function parseDate(v?: string): Date | undefined {
if (!v) return undefined;
const d = new Date(v);
return isNaN(d.getTime()) ? undefined : d;
}
export default async function AuditAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
scope: sp.scope?.trim() || undefined,
actor: sp.actor?.trim() || undefined,
from: parseDate(sp.from),
to: parseDate(sp.to),
};
const [rows, scopes] = await Promise.all([listAuditAdmin(filters), listAuditScopes()]);
const dateTimeFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit", month: "short", year: "2-digit", hour: "2-digit", minute: "2-digit",
});
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Audit log</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} entrée{rows.length > 1 ? "s" : ""}
{rows.length === 300 ? " (limite atteinte — affinez les filtres)" : ""}
</p>
</div>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche événement, cible, acteur…"
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="scope"
defaultValue={filters.scope ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous scopes</option>
{scopes.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<input
type="text"
name="actor"
defaultValue={filters.actor ?? ""}
placeholder="Acteur (email)"
className="rounded-md border border-zinc-300 px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<label className="flex items-center gap-1 text-xs text-zinc-500">
Du
<input type="date" name="from" defaultValue={sp.from ?? ""} className="rounded-md border border-zinc-300 px-2 py-1 text-sm" />
</label>
<label className="flex items-center gap-1 text-xs text-zinc-500">
au
<input type="date" name="to" defaultValue={sp.to ?? ""} className="rounded-md border border-zinc-300 px-2 py-1 text-sm" />
</label>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.scope || filters.actor || filters.from || filters.to) ? (
<Link href="/admin/audit" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-3 py-2 text-left font-semibold">Quand</th>
<th className="px-3 py-2 text-left font-semibold">Scope</th>
<th className="px-3 py-2 text-left font-semibold">Événement</th>
<th className="px-3 py-2 text-left font-semibold">Cible</th>
<th className="px-3 py-2 text-left font-semibold">Acteur</th>
<th className="px-3 py-2 text-left font-semibold">Détails</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-sm text-zinc-500">
Aucune entrée d&apos;audit ne correspond aux filtres.
</td>
</tr>
) : null}
{rows.map((r) => (
<tr key={r.id} className="hover:bg-zinc-50 align-top">
<td className="px-3 py-2 text-[11px] font-mono text-zinc-500 whitespace-nowrap">
{dateTimeFmt.format(r.createdAt)}
</td>
<td className="px-3 py-2 text-xs text-zinc-700 whitespace-nowrap">{r.scope}</td>
<td className="px-3 py-2 font-mono text-xs text-zinc-900 whitespace-nowrap">{r.event}</td>
<td className="px-3 py-2 font-mono text-[11px] text-zinc-600">
{r.target ? r.target.slice(0, 24) + (r.target.length > 24 ? "…" : "") : "—"}
</td>
<td className="px-3 py-2 text-xs text-zinc-700">{r.actorEmail ?? "—"}</td>
<td className="px-3 py-2 font-mono text-[11px] text-zinc-600">
{r.details && typeof r.details === "object" && Object.keys(r.details as object).length > 0
? JSON.stringify(r.details)
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}