feat(sprint-6): Phase 4 — Immutable audit log
- 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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user