L6 Benachrichtigungen & Audit: Audit-Viewer mit Filter und Diff
- /settings/audit (audit:read): Filter Zeitraum, Benutzer, Aktion, Objektart, Objekt-ID; Pagination; Detail-Popup mit before/after-Diff, Ergebnis, IP/User-Agent (derzeit nicht erfasst, Fundament-Bedarf). - Service services/audit/viewer.ts, strikt mandantengebunden über ctx.db. - Audit-Entity-Labels für alle Craftvia-Entitäten. - Test scripts/test-benachrichtigungen-inbox-audit.ts (46 Prüfungen): Posteingang, Mandantentrennung, Rollen/Scope, Mailkonfiguration, Audit-Viewer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowLeft, CircleCheck, CircleX } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAuditEntry, queryAuditLog, type AuditFilter } from "@/server/services/audit/viewer";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { pageCtx } from "@/server/services/notifications/page-ctx";
|
||||
|
||||
const fieldCls = "h-11 rounded-md border border-input bg-card px-3 text-sm";
|
||||
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
|
||||
export default async function AuditLogPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const ctx = await pageCtx();
|
||||
if (!ctx.permissions.has("audit:read")) redirect("/dashboard");
|
||||
const sp = await searchParams;
|
||||
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
|
||||
const data = await queryAuditLog(ctx, {
|
||||
from: one(sp.from), to: one(sp.to), actorId: one(sp.actorId), action: one(sp.action),
|
||||
entity: one(sp.entity), entityId: one(sp.entityId), page: one(sp.page),
|
||||
});
|
||||
const detailId = one(sp.detail);
|
||||
let detail: Awaited<ReturnType<typeof getAuditEntry>> | null = null;
|
||||
if (detailId) {
|
||||
try {
|
||||
detail = await getAuditEntry(ctx, detailId);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ServiceError)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const [t, format] = await Promise.all([getTranslations("notifications"), getFormatter()]);
|
||||
const actorNames = new Map(data.actors.map((a) => [a.id, a.name]));
|
||||
const actionLabel = (a: string) => (t.has(`audit.actions.${a}`) ? t(`audit.actions.${a}`) : a);
|
||||
const entityLabel = (e: string) => (t.has(`audit.entities.${e}`) ? t(`audit.entities.${e}`) : e);
|
||||
|
||||
const href = (patch: Partial<Record<keyof AuditFilter | "detail", string | number | undefined>>) => {
|
||||
const q = new URLSearchParams();
|
||||
const merged = { ...data.filter, detail: undefined, ...patch } as Record<string, string | number | undefined>;
|
||||
for (const [k, v] of Object.entries(merged)) if (v !== undefined && v !== "" && !(k === "page" && v === 1)) q.set(k, String(v));
|
||||
const s = q.toString();
|
||||
return `/settings/audit${s ? `?${s}` : ""}`;
|
||||
};
|
||||
const f = data.filter;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<Link href="/settings" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden /> {t("audit.back")}
|
||||
</Link>
|
||||
<PageHead crumb={t("audit.crumb")} title={t("audit.title")} sub={t("audit.sub")} />
|
||||
|
||||
<form method="get" className="shadow-card mb-4 grid gap-3 rounded-xl border bg-card p-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.from")}
|
||||
<input type="date" name="from" defaultValue={f.from ?? ""} className={fieldCls} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.to")}
|
||||
<input type="date" name="to" defaultValue={f.to ?? ""} className={fieldCls} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.actor")}
|
||||
<select name="actorId" defaultValue={f.actorId ?? ""} className={fieldCls}>
|
||||
<option value="">{t("audit.actorAll")}</option>
|
||||
{data.actors.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.action")}
|
||||
<select name="action" defaultValue={f.action ?? ""} className={fieldCls}>
|
||||
<option value="">{t("audit.actionAll")}</option>
|
||||
{data.facets.actions.map((a) => <option key={a} value={a}>{actionLabel(a)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.entity")}
|
||||
<select name="entity" defaultValue={f.entity ?? ""} className={fieldCls}>
|
||||
<option value="">{t("audit.entityAll")}</option>
|
||||
{data.facets.entities.map((e) => <option key={e} value={e}>{entityLabel(e)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.entityId")}
|
||||
<input name="entityId" defaultValue={f.entityId ?? ""} className={fieldCls} />
|
||||
</label>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button type="submit" className="min-h-11">{t("audit.apply")}</Button>
|
||||
<Link href="/settings/audit" className="flex min-h-11 items-center px-2 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
{t("audit.reset")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="mb-2 text-[12.5px] text-muted-foreground">{t("audit.total", { count: data.total })}</p>
|
||||
|
||||
{data.rows.length === 0 ? (
|
||||
<p className="shadow-card rounded-xl border bg-card p-6 text-sm text-muted-foreground">{t("audit.empty")}</p>
|
||||
) : (
|
||||
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[12px] text-muted-foreground">
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.time")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.actor")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.action")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.object")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.result")}</th>
|
||||
<th className="px-4 py-2.5"><span className="sr-only">{t("audit.details")}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.rows.map((r) => (
|
||||
<tr key={r.id} className="border-b align-top last:border-0">
|
||||
<td className="px-4 py-2 text-xs whitespace-nowrap text-muted-foreground">
|
||||
{format.dateTime(r.createdAt, { dateStyle: "medium", timeStyle: "medium" })}
|
||||
</td>
|
||||
<td className="px-4 py-2">{r.actorId ? (actorNames.get(r.actorId) ?? r.actorId.slice(0, 8)) : t("audit.system")}</td>
|
||||
<td className="px-4 py-2">{actionLabel(r.action)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="font-medium">{entityLabel(r.entity)}</span>
|
||||
{r.entityId && <span className="ml-1 font-mono text-[11px] text-muted-foreground">#{r.entityId.slice(0, 12)}</span>}
|
||||
{r.scope === "platform" && <span className="ml-1 text-[11px] text-muted-foreground">· {t("audit.platform")}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{r.action === "denied" ? (
|
||||
<Pill tone="risk"><CircleX className="size-3.5" aria-hidden /> {t("audit.resultDenied")}</Pill>
|
||||
) : (
|
||||
<Pill tone="ok"><CircleCheck className="size-3.5" aria-hidden /> {t("audit.resultOk")}</Pill>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<Link href={href({ detail: r.id, page: data.page })} scroll={false} className="inline-flex min-h-11 items-center font-semibold text-[var(--primary)]">
|
||||
{t("audit.details")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.pages > 1 && (
|
||||
<nav className="mt-4 flex items-center justify-between text-[13px]" aria-label={t("audit.title")}>
|
||||
{data.page > 1 ? <Link href={href({ page: data.page - 1 })} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">← {t("audit.previous")}</Link> : <span />}
|
||||
<span className="text-muted-foreground">{t("audit.page", { page: data.page, pages: data.pages })}</span>
|
||||
{data.page < data.pages ? <Link href={href({ page: data.page + 1 })} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">{t("audit.next")} →</Link> : <span />}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<Modal title={t("audit.detailTitle")} sub={`${actionLabel(detail.action)} · ${entityLabel(detail.entity)}`} closeHref={href({ page: data.page })} closeLabel={t("audit.close")}>
|
||||
<div className="max-h-[70vh] overflow-y-auto p-5">
|
||||
<dl className="grid gap-x-6 gap-y-2 text-[13px] sm:grid-cols-2">
|
||||
<div><dt className="text-muted-foreground">{t("audit.time")}</dt><dd>{format.dateTime(detail.createdAt, { dateStyle: "medium", timeStyle: "medium" })}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.actor")}</dt><dd>{detail.actorName ?? (detail.actorId ?? t("audit.system"))}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.entity")}</dt><dd>{entityLabel(detail.entity)}{detail.scope === "platform" ? ` · ${t("audit.platform")}` : ""}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.entityId")}</dt><dd className="font-mono text-[12px] break-all">{detail.entityId ?? "—"}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.result")}</dt><dd>{detail.action === "denied" ? t("audit.resultDenied") : t("audit.resultOk")}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.ip")}</dt><dd>{detail.ip ?? t("audit.notCaptured")}</dd></div>
|
||||
<div className="sm:col-span-2"><dt className="text-muted-foreground">{t("audit.userAgent")}</dt><dd className="break-all">{detail.userAgent ?? t("audit.notCaptured")}</dd></div>
|
||||
</dl>
|
||||
|
||||
{detail.diff.length === 0 ? (
|
||||
<p className="mt-5 text-sm text-muted-foreground">{t("audit.noValues")}</p>
|
||||
) : (
|
||||
<div className="mt-5 overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[560px] text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="px-3 py-2 font-semibold">{t("audit.field")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("audit.before")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("audit.after")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.diff.map((d) => (
|
||||
<tr key={d.key} className={d.changed ? "border-b bg-[var(--ui-primary-soft)] align-top last:border-0" : "border-b align-top last:border-0"}>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{d.key}
|
||||
{d.changed && <span className="ml-1.5 text-[10.5px] font-bold text-[var(--primary)] uppercase">{t("audit.changed")}</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono break-all whitespace-pre-wrap text-muted-foreground">{d.before ?? "—"}</td>
|
||||
<td className="px-3 py-2 font-mono break-all whitespace-pre-wrap">{d.after ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user