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>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,17 @@ const ENTITY_LABEL: Record<string, string> = {
|
||||
import: "Auftragsimport",
|
||||
report: "Bericht",
|
||||
emergency: "Notdienst",
|
||||
signature: "Unterschrift",
|
||||
import_job: "Auftragsimport",
|
||||
material_usage: "Material",
|
||||
time_entry: "Arbeitszeit",
|
||||
work_session: "Einsatzzeit",
|
||||
photo: "Foto",
|
||||
voice_note: "Sprachnotiz",
|
||||
notification: "Benachrichtigung",
|
||||
notification_settings: "Benachrichtigungseinstellungen",
|
||||
tenant_mail_settings: "E-Mail-Versand",
|
||||
sync_operation: "Synchronisation",
|
||||
document: "Dokument",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Read-only audit log viewer (spec §26). Requires `audit:read`; always tenant-bound via ctx.db
|
||||
* (the tenant client filters AuditLog by tenantId). Audit rows are never modified here.
|
||||
*/
|
||||
|
||||
export const AUDIT_PAGE_SIZE = 50;
|
||||
|
||||
const dateStr = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
const optStr = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.transform((v) => v || undefined)
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
export const auditFilterSchema = z.object({
|
||||
from: dateStr,
|
||||
to: dateStr,
|
||||
actorId: optStr(64),
|
||||
action: optStr(40),
|
||||
entity: optStr(60),
|
||||
entityId: optStr(128),
|
||||
page: z.coerce.number().int().min(1).max(100_000).catch(1),
|
||||
});
|
||||
export type AuditFilter = z.infer<typeof auditFilterSchema>;
|
||||
|
||||
export function auditWhere(f: AuditFilter): Prisma.AuditLogWhereInput {
|
||||
const createdAt: Prisma.DateTimeFilter = {};
|
||||
if (f.from) createdAt.gte = new Date(`${f.from}T00:00:00.000Z`);
|
||||
if (f.to) createdAt.lt = new Date(new Date(`${f.to}T00:00:00.000Z`).getTime() + 86_400_000);
|
||||
return {
|
||||
...(f.from || f.to ? { createdAt } : {}),
|
||||
...(f.actorId ? { actorId: f.actorId } : {}),
|
||||
...(f.action ? { action: f.action } : {}),
|
||||
...(f.entity ? { entity: f.entity } : {}),
|
||||
...(f.entityId ? { entityId: { contains: f.entityId } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function queryAuditLog(ctx: ServiceCtx, input: unknown) {
|
||||
assertCan(ctx, "audit:read");
|
||||
const filter = auditFilterSchema.parse(input ?? {});
|
||||
const where = auditWhere(filter);
|
||||
const [rows, total, actions, entities, users] = await Promise.all([
|
||||
ctx.db.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (filter.page - 1) * AUDIT_PAGE_SIZE,
|
||||
take: AUDIT_PAGE_SIZE,
|
||||
select: { id: true, createdAt: true, actorId: true, action: true, entity: true, entityId: true, scope: true },
|
||||
}),
|
||||
ctx.db.auditLog.count({ where }),
|
||||
ctx.db.auditLog.findMany({ distinct: ["action"], select: { action: true }, orderBy: { action: "asc" } }),
|
||||
ctx.db.auditLog.findMany({ distinct: ["entity"], select: { entity: true }, orderBy: { entity: "asc" } }),
|
||||
ctx.db.user.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
page: filter.page,
|
||||
pages: Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE)),
|
||||
filter,
|
||||
facets: { actions: actions.map((a) => a.action), entities: entities.map((e) => e.entity) },
|
||||
actors: users,
|
||||
};
|
||||
}
|
||||
|
||||
export type DiffRow = { key: string; before: string | null; after: string | null; changed: boolean };
|
||||
|
||||
function show(v: unknown): string | null {
|
||||
if (v === undefined) return null;
|
||||
if (v === null) return "null";
|
||||
return typeof v === "string" ? v : JSON.stringify(v);
|
||||
}
|
||||
|
||||
/** Field-wise before/after comparison (top-level keys; nested values rendered as JSON). */
|
||||
export function diffAudit(before: unknown, after: unknown): DiffRow[] {
|
||||
const b = before && typeof before === "object" && !Array.isArray(before) ? (before as Record<string, unknown>) : null;
|
||||
const a = after && typeof after === "object" && !Array.isArray(after) ? (after as Record<string, unknown>) : null;
|
||||
if (!b && !a) {
|
||||
if (before === undefined && after === undefined) return [];
|
||||
if (before == null && after == null) return [];
|
||||
return [{ key: "value", before: show(before ?? undefined), after: show(after ?? undefined), changed: show(before) !== show(after) }];
|
||||
}
|
||||
const keys = [...new Set([...Object.keys(b ?? {}), ...Object.keys(a ?? {})])].sort();
|
||||
return keys.map((key) => {
|
||||
const bv = show(b?.[key]);
|
||||
const av = show(a?.[key]);
|
||||
return { key, before: bv, after: av, changed: bv !== av };
|
||||
});
|
||||
}
|
||||
|
||||
/** Extract request metadata if a writer stored it (writeAuditLog does not capture it yet). */
|
||||
function requestMeta(...sources: unknown[]): { ip: string | null; userAgent: string | null } {
|
||||
for (const s of sources) {
|
||||
if (s && typeof s === "object") {
|
||||
const o = s as Record<string, unknown>;
|
||||
const ip = typeof o.ip === "string" ? o.ip : null;
|
||||
const userAgent = typeof o.userAgent === "string" ? o.userAgent : null;
|
||||
if (ip || userAgent) return { ip, userAgent };
|
||||
}
|
||||
}
|
||||
return { ip: null, userAgent: null };
|
||||
}
|
||||
|
||||
export async function getAuditEntry(ctx: ServiceCtx, id: unknown) {
|
||||
assertCan(ctx, "audit:read");
|
||||
const entryId = z.string().min(1).max(64).parse(id);
|
||||
const row = await ctx.db.auditLog.findFirst({ where: { id: entryId } });
|
||||
if (!row) throw new ServiceError("not_found", "audit entry not found");
|
||||
const actor = row.actorId ? await ctx.db.user.findFirst({ where: { id: row.actorId }, select: { name: true } }) : null;
|
||||
return {
|
||||
...row,
|
||||
actorName: actor?.name ?? null,
|
||||
diff: diffAudit(row.before ?? undefined, row.after ?? undefined),
|
||||
...requestMeta(row.after, row.before),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user