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,198 @@
|
|||||||
|
// L6 Benachrichtigungen & Audit — Posteingang (nur eigene, Mandantentrennung, Rollen),
|
||||||
|
// Einstellungen je Nutzer, Mandanten-Mailkonfiguration (tenant:manage, Validierung),
|
||||||
|
// Audit-Viewer (nur mit audit:read, mandantengebunden, Filter, Diff).
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-benachrichtigungen-inbox-audit.ts
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
import { prisma, dbForTenant } from "../src/server/db";
|
||||||
|
import { provisionTenant } from "../src/server/provision";
|
||||||
|
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
|
||||||
|
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
|
||||||
|
import { bellSummary, listNotifications, markAllRead, markRead, safeLink } from "../src/server/services/notifications/inbox";
|
||||||
|
import { getPreferences } from "../src/server/services/notifications/preferences";
|
||||||
|
import { getMailSettings, splitAddressList, updateMailSettings } from "../src/server/services/notifications/mail-settings";
|
||||||
|
import { diffAudit, getAuditEntry, queryAuditLog } from "../src/server/services/audit/viewer";
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const ok = (cond: boolean, msg: string) => {
|
||||||
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function expectCode(fn: () => Promise<unknown>, code: ServiceError["code"] | "zod", msg: string) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
ok(false, `${msg} — kein Fehler`);
|
||||||
|
} catch (err) {
|
||||||
|
const actual = err instanceof ServiceError ? err.code : err instanceof ZodError ? "zod" : (err as Error).message;
|
||||||
|
ok(actual === code, `${msg}${actual === code ? "" : ` — erhalten: ${actual}`}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUG_A = "zz-l6-inbox-a";
|
||||||
|
const SLUG_B = "zz-l6-inbox-b";
|
||||||
|
const MAIL_DOMAIN = "zz-l6-inbox.test";
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } });
|
||||||
|
const ids = tenants.map((t) => t.id);
|
||||||
|
if (ids.length) {
|
||||||
|
const where = { tenantId: { in: ids } };
|
||||||
|
await prisma.notification.deleteMany({ where });
|
||||||
|
await prisma.notificationPreference.deleteMany({ where });
|
||||||
|
await prisma.mailLog.deleteMany({ where });
|
||||||
|
await prisma.auditLog.deleteMany({ where });
|
||||||
|
await prisma.tenantModule.deleteMany({ where });
|
||||||
|
await prisma.tenantSettings.deleteMany({ where });
|
||||||
|
await prisma.user.deleteMany({ where });
|
||||||
|
await prisma.role.deleteMany({ where });
|
||||||
|
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
await prisma.identity.deleteMany({ where: { email: { endsWith: `@${MAIL_DOMAIN}` }, memberships: { none: {} } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(tenantId: string, local: string, name: string, role: RoleKey) {
|
||||||
|
const email = `${local}@${MAIL_DOMAIN}`;
|
||||||
|
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
||||||
|
const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } });
|
||||||
|
return prisma.user.create({
|
||||||
|
data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE", userRoles: { create: [{ roleId: roleRow.id }] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctxFor = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
|
||||||
|
db: dbForTenant(tenantId),
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
permissions: new Set(ROLE_DEFS[role].permissions),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
const tA = await provisionTenant(prisma, { name: "L6 Inbox A", slug: SLUG_A, admin: { email: `admin-a@${MAIL_DOMAIN}`, name: "Admin A", password: "Zz-Test-1234!" } });
|
||||||
|
const tB = await provisionTenant(prisma, { name: "L6 Inbox B", slug: SLUG_B, admin: { email: `admin-b@${MAIL_DOMAIN}`, name: "Admin B", password: "Zz-Test-1234!" } });
|
||||||
|
const adminA = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tA.id, email: `admin-a@${MAIL_DOMAIN}` } } });
|
||||||
|
const adminB = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tB.id, email: `admin-b@${MAIL_DOMAIN}` } } });
|
||||||
|
const backoffice = await createUser(tA.id, "backoffice-a", "Bea Backoffice", "backoffice");
|
||||||
|
const tech1 = await createUser(tA.id, "tech1-a", "Max Monteur", "technician");
|
||||||
|
const tech2 = await createUser(tA.id, "tech2-a", "Ole Ohneteam", "technician");
|
||||||
|
|
||||||
|
const adminACtx = ctxFor(tA.id, adminA.id, "tenant-admin");
|
||||||
|
const adminBCtx = ctxFor(tB.id, adminB.id, "tenant-admin");
|
||||||
|
const boCtx = ctxFor(tA.id, backoffice.id, "backoffice");
|
||||||
|
const tech1Ctx = ctxFor(tA.id, tech1.id, "technician");
|
||||||
|
const tech2Ctx = ctxFor(tA.id, tech2.id, "technician");
|
||||||
|
|
||||||
|
const mk = (tenantId: string, userId: string, type: string, title: string, readAt: Date | null = null) =>
|
||||||
|
prisma.notification.create({ data: { tenantId, userId, type, title, message: "Text", entityType: "work_order", entityId: "wo-zz", link: "/m/orders/wo-zz", readAt } });
|
||||||
|
const n1 = await mk(tA.id, tech1.id, "work_order.assigned", "Neuer Auftrag A-1");
|
||||||
|
await mk(tA.id, tech1.id, "work_order.changed", "Auftrag A-1 geändert");
|
||||||
|
await mk(tA.id, tech1.id, "work_order.cancelled", "Auftrag A-2 storniert", new Date());
|
||||||
|
const nB = await mk(tB.id, adminB.id, "work_order.assigned", "Fremd B-1");
|
||||||
|
|
||||||
|
// ---------- 1) Posteingang: nur eigene Benachrichtigungen ----------
|
||||||
|
const list1 = await listNotifications(tech1Ctx, {});
|
||||||
|
ok(list1.total === 3, "(1) Monteur sieht seine 3 Benachrichtigungen");
|
||||||
|
ok((await listNotifications(tech1Ctx, { status: "unread" })).total === 2, "(1) Filter ungelesen");
|
||||||
|
ok((await listNotifications(tech1Ctx, { status: "read" })).total === 1, "(1) Filter gelesen");
|
||||||
|
ok((await listNotifications(tech1Ctx, { type: "work_order.changed" })).total === 1, "(1) Filter Typ");
|
||||||
|
ok((await listNotifications(tech1Ctx, { type: "no.such_type", status: "bogus" })).total === 3, "(1) ungültige Filterwerte fallen auf Standard zurück");
|
||||||
|
ok((await listNotifications(tech2Ctx, {})).total === 0, "(1) anderer Monteur sieht keine fremden Benachrichtigungen");
|
||||||
|
ok((await listNotifications(adminACtx, {})).total === 0, "(1) auch der Mandantenadmin sieht keine fremden Benachrichtigungen");
|
||||||
|
const bell = await bellSummary(tech1Ctx);
|
||||||
|
ok(bell.unread === 2 && bell.latest.length === 3, "(1) Glocke: Zähler ungelesen + letzte Einträge");
|
||||||
|
|
||||||
|
// ---------- 2) Rollen/Scope: fremde Benachrichtigung → not_found ----------
|
||||||
|
await expectCode(() => markRead(tech2Ctx, n1.id), "not_found", "(2) Monteur ohne Zuordnung kann fremde Benachrichtigung nicht als gelesen markieren");
|
||||||
|
await expectCode(() => markRead(adminBCtx, n1.id), "not_found", "(2) Mandant B kann Benachrichtigung von A nicht ändern");
|
||||||
|
await expectCode(() => markRead(tech1Ctx, nB.id), "not_found", "(2) Mandant A kann Benachrichtigung von B nicht ändern");
|
||||||
|
ok((await prisma.notification.findUniqueOrThrow({ where: { id: n1.id } })).readAt === null, "(2) fremder Zugriff hat nichts verändert");
|
||||||
|
await expectCode(() => listNotifications({ ...tech1Ctx, permissions: new Set() }, {}), "forbidden", "(2) ohne notification:read → forbidden");
|
||||||
|
|
||||||
|
// ---------- 3) Mandantentrennung markAllRead ----------
|
||||||
|
await markAllRead(adminBCtx);
|
||||||
|
ok((await prisma.notification.count({ where: { tenantId: tA.id, readAt: null } })) === 2, "(3) markAllRead in B lässt A unberührt");
|
||||||
|
ok((await prisma.notification.findUniqueOrThrow({ where: { id: nB.id } })).readAt !== null, "(3) markAllRead in B markiert eigene");
|
||||||
|
|
||||||
|
// ---------- 4) markRead eigene + Audit + sicherer Link ----------
|
||||||
|
const res = await markRead(tech1Ctx, n1.id);
|
||||||
|
ok(res.link === "/m/orders/wo-zz", "(4) markRead liefert relativen Link");
|
||||||
|
ok((await prisma.notification.findUniqueOrThrow({ where: { id: n1.id } })).readAt !== null, "(4) markRead setzt readAt");
|
||||||
|
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "notification", entityId: n1.id, actorId: tech1.id } })) === 1, "(4) markRead schreibt Audit-Eintrag");
|
||||||
|
ok(safeLink("https://evil.test") === null && safeLink("//evil.test") === null && safeLink("/\\evil") === null && safeLink("/work-orders/1") === "/work-orders/1",
|
||||||
|
"(4) safeLink lässt nur relative Pfade zu (kein Open Redirect)");
|
||||||
|
|
||||||
|
// ---------- 5) Einstellungen je Nutzer ----------
|
||||||
|
const prefs = await getPreferences(tech1Ctx);
|
||||||
|
ok(prefs.every((p) => p.email) && prefs.filter((p) => p.mandatory).map((p) => p.type).sort().join() === "emergency.completed,emergency.created",
|
||||||
|
"(5) Standard: alles an, Notdienst als Pflicht markiert");
|
||||||
|
|
||||||
|
// ---------- 6) Mandanten-Mailkonfiguration ----------
|
||||||
|
await expectCode(() => updateMailSettings(tech1Ctx, { mailFromName: "X", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "forbidden",
|
||||||
|
"(6) Monteur darf Mailkonfiguration nicht ändern");
|
||||||
|
await expectCode(() => updateMailSettings(boCtx, { mailFromName: "X", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "forbidden",
|
||||||
|
"(6) Backoffice ohne tenant:manage darf Mailkonfiguration nicht ändern");
|
||||||
|
await expectCode(() => getMailSettings(tech1Ctx), "forbidden", "(6) Monteur darf Mailkonfiguration nicht lesen");
|
||||||
|
ok(splitAddressList("A@x.de, b@x.de;\nA@x.de c@x.de").join() === "a@x.de,b@x.de,c@x.de", "(6) Adressliste: trennt, normalisiert, dedupliziert");
|
||||||
|
const saved = await updateMailSettings(adminACtx, {
|
||||||
|
mailFromName: "Musterbau Büro",
|
||||||
|
mailReplyTo: "Buero@Musterbau.test",
|
||||||
|
emergencyRecipients: "notdienst@musterbau.test\nchef@musterbau.test",
|
||||||
|
billingRecipients: "abrechnung@musterbau.test",
|
||||||
|
});
|
||||||
|
ok(saved.mailReplyTo === "buero@musterbau.test" && saved.emergencyRecipients.length === 2, "(6) Admin speichert Mailkonfiguration");
|
||||||
|
const rowA = await prisma.tenantSettings.findUniqueOrThrow({ where: { tenantId: tA.id } });
|
||||||
|
ok(rowA.mailFromName === "Musterbau Büro" && rowA.billingRecipients.join() === "abrechnung@musterbau.test", "(6) Werte in tenant_settings persistiert");
|
||||||
|
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "tenant_mail_settings", actorId: adminA.id } })) === 1, "(6) Audit-Eintrag mit before/after");
|
||||||
|
const sB = await getMailSettings(adminBCtx);
|
||||||
|
ok(sB.emergencyRecipients.length === 0 && sB.mailFromName === null, "(6) Mandant B sieht die Mailkonfiguration von A nicht");
|
||||||
|
await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "Evil\r\nBcc: x@y.z", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "zod",
|
||||||
|
"(6) Absendername mit Zeilenumbruch abgelehnt (Header-Injection)");
|
||||||
|
await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "", mailReplyTo: "", emergencyRecipients: "kein-mail", billingRecipients: "" }), "zod",
|
||||||
|
"(6) ungültige Empfängeradresse abgelehnt");
|
||||||
|
await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "", mailReplyTo: "", emergencyRecipients: Array.from({ length: 21 }, (_, i) => `n${i}@x.de`).join("\n"), billingRecipients: "" }), "zod",
|
||||||
|
"(6) mehr als 20 Empfänger abgelehnt");
|
||||||
|
|
||||||
|
// ---------- 7) Audit-Viewer ----------
|
||||||
|
await prisma.auditLog.create({ data: { tenantId: tB.id, actorId: adminB.id, action: "update", entity: "zz_marker", entityId: "b-secret", before: { a: 1 }, after: { a: 2 } } });
|
||||||
|
const markerA = await prisma.auditLog.create({
|
||||||
|
data: { tenantId: tA.id, actorId: backoffice.id, action: "update", entity: "work_order", entityId: "wo-zz-audit", before: { status: "planned", title: "Alt" }, after: { status: "assigned", title: "Alt", team: "Nord" } },
|
||||||
|
});
|
||||||
|
await expectCode(() => queryAuditLog(tech1Ctx, {}), "forbidden", "(7) Monteur ohne audit:read → forbidden");
|
||||||
|
await expectCode(() => queryAuditLog(ctxFor(tA.id, tech2.id, "team-lead"), {}), "forbidden", "(7) Teamleiter ohne audit:read → forbidden");
|
||||||
|
await expectCode(() => getAuditEntry(tech1Ctx, markerA.id), "forbidden", "(7) Detail ohne audit:read → forbidden");
|
||||||
|
const all = await queryAuditLog(boCtx, {});
|
||||||
|
ok(all.total > 0, "(7) Backoffice mit audit:read sieht Einträge");
|
||||||
|
const allIds = (await queryAuditLog(boCtx, { page: 1 })).rows.map((r) => r.id);
|
||||||
|
const foreign = await prisma.auditLog.count({ where: { id: { in: allIds }, NOT: { tenantId: tA.id } } });
|
||||||
|
ok(foreign === 0, "(7) Audit-Viewer liefert ausschließlich Einträge des eigenen Mandanten");
|
||||||
|
ok((await queryAuditLog(boCtx, { entity: "zz_marker" })).total === 0, "(7) Eintrag von Mandant B ist in A nicht auffindbar");
|
||||||
|
const bEntry = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: tB.id, entity: "zz_marker" } });
|
||||||
|
await expectCode(() => getAuditEntry(boCtx, bEntry.id), "not_found", "(7) Detail eines B-Eintrags aus A → not_found");
|
||||||
|
ok((await queryAuditLog(boCtx, { entity: "work_order", entityId: "wo-zz" })).total === 1, "(7) Filter Objektart + Objekt-ID");
|
||||||
|
ok((await queryAuditLog(boCtx, { actorId: backoffice.id, action: "update" })).rows.every((r) => r.actorId === backoffice.id && r.action === "update"), "(7) Filter Benutzer + Aktion");
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
ok((await queryAuditLog(boCtx, { from: today, to: today, entity: "work_order" })).total === 1, "(7) Filter Zeitraum (heute) findet Eintrag");
|
||||||
|
ok((await queryAuditLog(boCtx, { from: "2000-01-01", to: "2000-01-02" })).total === 0, "(7) Filter Zeitraum (Vergangenheit) leer");
|
||||||
|
ok(all.facets.entities.includes("work_order") && !all.facets.entities.includes("zz_marker"), "(7) Objektart-Auswahl nur aus eigenem Mandanten");
|
||||||
|
const detail = await getAuditEntry(boCtx, markerA.id);
|
||||||
|
const byKey = Object.fromEntries(detail.diff.map((d) => [d.key, d]));
|
||||||
|
ok(byKey.status?.changed === true && byKey.title?.changed === false && byKey.team?.before === null && byKey.team?.after === "Nord", "(7) Detail: before/after-Diff korrekt");
|
||||||
|
ok(detail.actorName === "Bea Backoffice" && detail.ip === null && detail.userAgent === null, "(7) Detail: Akteurname, IP/User-Agent nicht erfasst");
|
||||||
|
ok(diffAudit(undefined, undefined).length === 0 && diffAudit(null, { x: [1] })[0]?.after === "[1]", "(7) diffAudit: leere und verschachtelte Werte");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
failures++;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await cleanup().catch((e) => console.error("cleanup:", e));
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log(failures ? `\n${failures} Fehler.` : "\nAlle Prüfungen bestanden.");
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
});
|
||||||
@@ -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",
|
import: "Auftragsimport",
|
||||||
report: "Bericht",
|
report: "Bericht",
|
||||||
emergency: "Notdienst",
|
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",
|
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