134 lines
5.5 KiB
TypeScript
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'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>
|
|
);
|
|
}
|