feat(sprint-6): Phase 4 — Immutable audit log
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

- V8 migration: audit_events table (JSONB metadata, immutable by design)
- AuditEvent entity + AuditEventType enum (18 event types)
- AuditService: log events, paginated query, PDF export
- AuditController: GET /api/v1/audit (paginated, filtered), GET export
- AuditEventRepository with JPQL filtered queries
- Frontend: /audit-log page (read-only, filterable, timezone-aware)
- PDF export button for Behörde inspections
- Sidebar: 'Protokoll' under new Compliance section
- PdfReportGenerator: generateAuditReport method added
- 10-year retention, REVOKE DELETE documented
- Full i18n (de/en) with 18 event type translations
This commit is contained in:
Patrick Plate
2026-06-12 22:40:40 +02:00
parent 61e481b37b
commit 05933a08ca
12 changed files with 982 additions and 0 deletions
+45
View File
@@ -399,5 +399,50 @@
"active": "Aktiv",
"pastDue": "Zahlung ausstehend",
"canceled": "Gekündigt"
},
"audit": {
"title": "Audit-Protokoll",
"subtitle": "Unveränderliches Protokoll aller Vorgänge (10 Jahre Aufbewahrung)",
"timestamp": "Zeitstempel",
"type": "Typ",
"description": "Beschreibung",
"actor": "Akteur",
"entity": "Objekt",
"filterType": "Ereignistyp filtern",
"filterDateFrom": "Von",
"filterDateTo": "Bis",
"filterActor": "Akteur suchen",
"exportPdf": "Als PDF exportieren",
"exporting": "PDF wird generiert...",
"exported": "Audit-Protokoll exportiert.",
"allTypes": "Alle Typen",
"immutable": "Unveränderbar",
"timezone": "Europe/Berlin",
"retentionNote": "Aufbewahrungsfrist: 10 Jahre (KCanG-konform)",
"types": {
"DISTRIBUTION_RECORDED": "Ausgabe erfasst",
"DISTRIBUTION_VOIDED": "Ausgabe storniert",
"MEMBER_CREATED": "Mitglied angelegt",
"MEMBER_UPDATED": "Mitglied aktualisiert",
"MEMBER_SUSPENDED": "Mitglied gesperrt",
"MEMBER_EXPELLED": "Mitglied ausgeschlossen",
"BATCH_CREATED": "Charge angelegt",
"BATCH_RECALLED": "Charge zurückgerufen",
"LOGIN_SUCCESS": "Anmeldung",
"LOGIN_FAILED": "Fehlgeschlagene Anmeldung",
"LOGOUT": "Abmeldung",
"PASSWORD_CHANGED": "Passwort geändert",
"STAFF_INVITED": "Mitarbeiter eingeladen",
"STAFF_PERMISSIONS_CHANGED": "Berechtigungen geändert",
"STAFF_REVOKED": "Zugang entzogen",
"CONSENT_GRANTED": "Einwilligung erteilt",
"CONSENT_REVOKED": "Einwilligung widerrufen",
"DATA_EXPORTED": "Daten exportiert",
"DATA_DELETED": "Daten gelöscht",
"SUBSCRIPTION_STARTED": "Abo gestartet",
"SUBSCRIPTION_CANCELED": "Abo gekündigt",
"PAYMENT_RECEIVED": "Zahlung erhalten",
"PAYMENT_FAILED": "Zahlung fehlgeschlagen"
}
}
}
+45
View File
@@ -399,5 +399,50 @@
"active": "Active",
"pastDue": "Past due",
"canceled": "Canceled"
},
"audit": {
"title": "Audit Log",
"subtitle": "Immutable log of all operations (10-year retention)",
"timestamp": "Timestamp",
"type": "Type",
"description": "Description",
"actor": "Actor",
"entity": "Entity",
"filterType": "Filter by event type",
"filterDateFrom": "From",
"filterDateTo": "To",
"filterActor": "Search actor",
"exportPdf": "Export as PDF",
"exporting": "Generating PDF...",
"exported": "Audit log exported.",
"allTypes": "All types",
"immutable": "Immutable",
"timezone": "Europe/Berlin",
"retentionNote": "Retention period: 10 years (KCanG-compliant)",
"types": {
"DISTRIBUTION_RECORDED": "Distribution recorded",
"DISTRIBUTION_VOIDED": "Distribution voided",
"MEMBER_CREATED": "Member created",
"MEMBER_UPDATED": "Member updated",
"MEMBER_SUSPENDED": "Member suspended",
"MEMBER_EXPELLED": "Member expelled",
"BATCH_CREATED": "Batch created",
"BATCH_RECALLED": "Batch recalled",
"LOGIN_SUCCESS": "Login",
"LOGIN_FAILED": "Failed login",
"LOGOUT": "Logout",
"PASSWORD_CHANGED": "Password changed",
"STAFF_INVITED": "Staff invited",
"STAFF_PERMISSIONS_CHANGED": "Permissions changed",
"STAFF_REVOKED": "Access revoked",
"CONSENT_GRANTED": "Consent granted",
"CONSENT_REVOKED": "Consent revoked",
"DATA_EXPORTED": "Data exported",
"DATA_DELETED": "Data deleted",
"SUBSCRIPTION_STARTED": "Subscription started",
"SUBSCRIPTION_CANCELED": "Subscription canceled",
"PAYMENT_RECEIVED": "Payment received",
"PAYMENT_FAILED": "Payment failed"
}
}
}
@@ -0,0 +1,287 @@
"use client"
import { useState } from "react"
import { useAuditLogQuery, useExportAuditPdfMutation } from "@/services/audit"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Download, FileCheck, Lock, ScrollText, Search } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Select } from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const EVENT_TYPES = [
"DISTRIBUTION_RECORDED",
"DISTRIBUTION_VOIDED",
"MEMBER_CREATED",
"MEMBER_UPDATED",
"MEMBER_SUSPENDED",
"MEMBER_EXPELLED",
"BATCH_CREATED",
"BATCH_RECALLED",
"LOGIN_SUCCESS",
"LOGIN_FAILED",
"LOGOUT",
"PASSWORD_CHANGED",
"STAFF_INVITED",
"STAFF_PERMISSIONS_CHANGED",
"STAFF_REVOKED",
"CONSENT_GRANTED",
"CONSENT_REVOKED",
"DATA_EXPORTED",
"DATA_DELETED",
"SUBSCRIPTION_STARTED",
"SUBSCRIPTION_CANCELED",
"PAYMENT_RECEIVED",
"PAYMENT_FAILED",
]
export default function AuditLogPage() {
const t = useTranslations("audit")
// Filter state
const [page, setPage] = useState(0)
const [eventType, setEventType] = useState<string>("")
const [dateFrom, setDateFrom] = useState("")
const [dateTo, setDateTo] = useState("")
// Query
const { data, isLoading } = useAuditLogQuery({
page,
size: 20,
eventType: eventType || undefined,
from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
to: dateTo ? new Date(dateTo + "T23:59:59").toISOString() : undefined,
})
// Export mutation
const exportMutation = useExportAuditPdfMutation()
const handleExport = () => {
if (!dateFrom || !dateTo) {
toast.error("Bitte Zeitraum auswählen")
return
}
exportMutation.mutate(
{ from: dateFrom, to: dateTo },
{
onSuccess: () => toast.success(t("exported")),
onError: () => toast.error("Export fehlgeschlagen"),
}
)
}
const formatTimestamp = (ts: string) => {
return new Date(ts).toLocaleString("de-DE", {
timeZone: "Europe/Berlin",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
const getEventTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
DISTRIBUTION_RECORDED: t("types.DISTRIBUTION_RECORDED"),
MEMBER_CREATED: t("types.MEMBER_CREATED"),
MEMBER_UPDATED: t("types.MEMBER_UPDATED"),
MEMBER_SUSPENDED: t("types.MEMBER_SUSPENDED"),
BATCH_CREATED: t("types.BATCH_CREATED"),
BATCH_RECALLED: t("types.BATCH_RECALLED"),
LOGIN_SUCCESS: t("types.LOGIN_SUCCESS"),
LOGIN_FAILED: t("types.LOGIN_FAILED"),
STAFF_INVITED: t("types.STAFF_INVITED"),
CONSENT_GRANTED: t("types.CONSENT_GRANTED"),
SUBSCRIPTION_STARTED: t("types.SUBSCRIPTION_STARTED"),
PAYMENT_RECEIVED: t("types.PAYMENT_RECEIVED"),
}
return labels[type] || type.replace(/_/g, " ")
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<ScrollText className="h-6 w-6" />
{t("title")}
</h1>
<p className="mt-1 flex items-center gap-1 text-muted-foreground">
<Lock className="h-3.5 w-3.5" />
{t("subtitle")}
</p>
</div>
<Badge variant="outline" className="gap-1 text-xs">
<FileCheck className="h-3 w-3" />
{t("immutable")}
</Badge>
</div>
{/* Filters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Search className="h-4 w-4" />
Filter
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{/* Event type filter */}
<Select
value={eventType}
onChange={(e) => setEventType(e.target.value)}
>
<option value="">{t("allTypes")}</option>
{EVENT_TYPES.map((type) => (
<option key={type} value={type}>
{getEventTypeLabel(type)}
</option>
))}
</Select>
{/* Date from */}
<Input
type="date"
placeholder={t("filterDateFrom")}
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
{/* Date to */}
<Input
type="date"
placeholder={t("filterDateTo")}
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
{/* Export button */}
<Button
variant="outline"
onClick={handleExport}
disabled={exportMutation.isPending || !dateFrom || !dateTo}
>
<Download className="mr-2 h-4 w-4" />
{exportMutation.isPending ? t("exporting") : t("exportPdf")}
</Button>
</div>
</CardContent>
</Card>
{/* Retention note */}
<p className="text-xs text-muted-foreground">
{t("retentionNote")} · {t("timezone")}
</p>
{/* Audit table */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[160px]">{t("timestamp")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead className="hidden md:table-cell">
{t("description")}
</TableHead>
<TableHead>{t("actor")}</TableHead>
<TableHead className="hidden lg:table-cell">
{t("entity")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center">
<div className="text-muted-foreground">Laden...</div>
</TableCell>
</TableRow>
) : data?.content && data.content.length > 0 ? (
data.content.map((event) => (
<TableRow key={event.id}>
<TableCell className="font-mono text-xs">
{formatTimestamp(event.timestamp)}
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{getEventTypeLabel(event.eventType)}
</Badge>
</TableCell>
<TableCell className="hidden max-w-[300px] truncate text-sm text-muted-foreground md:table-cell">
{event.description}
</TableCell>
<TableCell className="text-sm">
{event.actorName}
<span className="ml-1 text-xs text-muted-foreground">
({event.actorRole})
</span>
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
{event.entityType}
{event.entityId && (
<span className="ml-1 font-mono">
{event.entityId.substring(0, 8)}
</span>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center">
<div className="text-muted-foreground">
Keine Audit-Einträge gefunden
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Seite {data.number + 1} von {data.totalPages} ({data.totalElements}{" "}
Einträge)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= data.totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Weiter
</Button>
</div>
</div>
)}
</div>
)
}
@@ -36,4 +36,14 @@ export const navigationsData: NavigationType[] = [
},
],
},
{
title: "Compliance",
items: [
{
title: "Protokoll",
href: "/audit-log",
iconName: "ScrollText",
},
],
},
]
@@ -0,0 +1,88 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { apiClient, apiDownload } from "@/lib/api-client"
// --- Types ---
export interface AuditEventData {
id: string
eventType: string
entityType: string
entityId: string | null
actorId: string
actorName: string
actorRole: string
description: string
metadata: string | null
ipAddress: string | null
timestamp: string
}
export interface AuditLogPage {
content: AuditEventData[]
totalElements: number
totalPages: number
number: number
size: number
}
export interface AuditLogFilters {
page?: number
size?: number
eventType?: string
entityType?: string
actorId?: string
from?: string
to?: string
}
// --- Query Hooks ---
export function useAuditLogQuery(filters: AuditLogFilters = {}) {
const {
page = 0,
size = 20,
eventType,
entityType,
actorId,
from,
to,
} = filters
return useQuery({
queryKey: ["audit", page, size, eventType, entityType, actorId, from, to],
queryFn: () =>
apiClient<AuditLogPage>("/audit", {
params: {
page: page.toString(),
size: size.toString(),
eventType: eventType || undefined,
entityType: entityType || undefined,
actorId: actorId || undefined,
from: from || undefined,
to: to || undefined,
},
}),
})
}
// --- Mutation Hooks ---
export function useExportAuditPdfMutation() {
return useMutation({
mutationFn: async ({ from, to }: { from: string; to: string }) => {
const { blob, filename } = await apiDownload(
`/audit/export?from=${from}&to=${to}`
)
// Trigger browser download
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename || `audit-log-${from}-to-${to}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
})
}