/** * AuditLog — Admin page * * Displays the immutable audit trail with filtering, pagination, and CSV export. * Uses server-side pagination via the DataGrid's paginationMode="server" prop. * * Required packages (add to frontend/package.json dependencies): * "@mui/x-data-grid": "^6.x || ^7.x" * "@mui/x-date-pickers": "^6.x || ^7.x" * "date-fns": "^3.x" * "@date-io/date-fns": "^3.x" (adapter for MUI date pickers) * * Install: * npm install @mui/x-data-grid @mui/x-date-pickers date-fns @date-io/date-fns * * Route registration in App.tsx: * import AuditLog from './pages/admin/AuditLog'; * // Inside : * * * * } * /> */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, Autocomplete, Box, Button, Chip, CircularProgress, Container, Dialog, DialogContent, DialogTitle, Divider, IconButton, Paper, Skeleton, Stack, TextField, Tooltip, Typography, } from '@mui/material'; import { DataGrid, GridColDef, GridPaginationModel, GridRenderCellParams, GridRowParams, } from '@mui/x-data-grid'; import { de } from 'date-fns/locale'; import { format, parseISO } from 'date-fns'; import CloseIcon from '@mui/icons-material/Close'; import DownloadIcon from '@mui/icons-material/Download'; import FilterAltIcon from '@mui/icons-material/FilterAlt'; import DashboardLayout from '../../components/dashboard/DashboardLayout'; import { api } from '../../services/api'; import { fromGermanDate } from '../../utils/dateInput'; // --------------------------------------------------------------------------- // Types — mirror the backend AuditLogEntry interface // --------------------------------------------------------------------------- type AuditAction = | 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'EXPORT' | 'PERMISSION_DENIED' | 'PASSWORD_CHANGE' | 'ROLE_CHANGE'; type AuditResourceType = | 'MEMBER' | 'INCIDENT' | 'VEHICLE' | 'EQUIPMENT' | 'QUALIFICATION' | 'USER' | 'SYSTEM'; interface AuditLogEntry { id: string; user_id: string | null; user_email: string | null; action: AuditAction; resource_type: AuditResourceType; resource_id: string | null; old_value: Record | null; new_value: Record | null; ip_address: string | null; user_agent: string | null; metadata: Record; created_at: string; // ISO string from JSON } interface AuditLogPage { entries: AuditLogEntry[]; total: number; page: number; pages: number; } interface AuditFilters { userId?: string; action?: AuditAction[]; resourceType?: AuditResourceType[]; dateFrom?: string; dateTo?: string; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const ALL_ACTIONS: AuditAction[] = [ 'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'EXPORT', 'PERMISSION_DENIED', 'PASSWORD_CHANGE', 'ROLE_CHANGE', ]; const ALL_RESOURCE_TYPES: AuditResourceType[] = [ 'MEMBER', 'INCIDENT', 'VEHICLE', 'EQUIPMENT', 'QUALIFICATION', 'USER', 'SYSTEM', ]; // --------------------------------------------------------------------------- // Action chip colour map // --------------------------------------------------------------------------- const ACTION_COLORS: Record = { CREATE: 'success', UPDATE: 'primary', DELETE: 'error', LOGIN: 'info', LOGOUT: 'default', EXPORT: 'warning', PERMISSION_DENIED:'error', PASSWORD_CHANGE: 'warning', ROLE_CHANGE: 'warning', }; // --------------------------------------------------------------------------- // Utility helpers // --------------------------------------------------------------------------- function formatTimestamp(isoString: string): string { try { return format(parseISO(isoString), 'dd.MM.yyyy HH:mm:ss', { locale: de }); } catch { return isoString; } } function truncate(value: string | null | undefined, maxLength = 24): string { if (!value) return '—'; return value.length > maxLength ? value.substring(0, maxLength) + '…' : value; } // --------------------------------------------------------------------------- // JSON diff viewer (simple before / after display) // --------------------------------------------------------------------------- interface JsonDiffViewerProps { oldValue: Record | null; newValue: Record | null; } const JsonDiffViewer: React.FC = ({ oldValue, newValue }) => { if (!oldValue && !newValue) { return Keine Datenaenderung aufgezeichnet.; } const allKeys = Array.from( new Set([ ...Object.keys(oldValue ?? {}), ...Object.keys(newValue ?? {}), ]) ).sort(); return ( {oldValue && ( Vorher
              {JSON.stringify(oldValue, null, 2)}
            
)} {newValue && ( Nachher
              {JSON.stringify(newValue, null, 2)}
            
)} {/* Highlight changed fields */} {oldValue && newValue && allKeys.length > 0 && ( Geaenderte Felder {allKeys.map((key) => { const changed = JSON.stringify((oldValue as Record)[key]) !== JSON.stringify((newValue as Record)[key]); if (!changed) return null; return ( ); })} )}
); }; // --------------------------------------------------------------------------- // Entry detail dialog // --------------------------------------------------------------------------- interface EntryDialogProps { entry: AuditLogEntry | null; onClose: () => void; showIp: boolean; } const EntryDialog: React.FC = ({ entry, onClose, showIp }) => { if (!entry) return null; return ( Audit-Eintrag — {entry.action} / {entry.resource_type} Metadaten {[ ['Zeitpunkt', formatTimestamp(entry.created_at)], ['Benutzer', entry.user_email ?? '—'], ['Benutzer-ID', entry.user_id ?? '—'], ['Aktion', entry.action], ['Ressourcentyp', entry.resource_type], ['Ressourcen-ID', entry.resource_id ?? '—'], ...(showIp ? [['IP-Adresse', entry.ip_address ?? '—']] : []), ].map(([label, value]) => ( {label}: {value} ))} {Object.keys(entry.metadata ?? {}).length > 0 && ( Zusatzdaten
{JSON.stringify(entry.metadata, null, 2)}
)} Datenaenderung
); }; // --------------------------------------------------------------------------- // Filter panel // --------------------------------------------------------------------------- interface FilterPanelProps { filters: AuditFilters; onChange: (f: AuditFilters) => void; onReset: () => void; } const FilterPanel: React.FC = ({ filters, onChange, onReset }) => { return ( Filter {/* Date range */} onChange({ ...filters, dateFrom: e.target.value })} sx={{ minWidth: 160 }} InputLabelProps={{ shrink: true }} /> onChange({ ...filters, dateTo: e.target.value })} sx={{ minWidth: 160 }} InputLabelProps={{ shrink: true }} /> {/* Action multi-select */} onChange({ ...filters, action: value as AuditAction[] })} renderInput={(params) => ( )} renderTags={(value, getTagProps) => value.map((option, index) => ( )) } /> {/* Resource type multi-select */} onChange({ ...filters, resourceType: value as AuditResourceType[] })} renderInput={(params) => ( )} renderTags={(value, getTagProps) => value.map((option, index) => ( )) } /> ); }; // --------------------------------------------------------------------------- // Main page component // --------------------------------------------------------------------------- const DEFAULT_FILTERS: AuditFilters = { action: [], resourceType: [], dateFrom: '', dateTo: '', }; const AuditLog: React.FC = () => { // Grid state const [paginationModel, setPaginationModel] = useState({ page: 0, // DataGrid is 0-based pageSize: 25, }); const [rowCount, setRowCount] = useState(0); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Filters const [filters, setFilters] = useState(DEFAULT_FILTERS); const [appliedFilters, setApplied]= useState(DEFAULT_FILTERS); // Detail dialog const [selectedEntry, setSelected]= useState(null); // Export state const [exporting, setExporting] = useState(false); // The admin always sees IPs — toggle this based on role check if needed const showIp = true; // ------------------------------------------------------------------------- // Data fetching // ------------------------------------------------------------------------- const fetchData = useCallback(async ( pagination: GridPaginationModel, f: AuditFilters, ) => { setLoading(true); setError(null); try { const params: Record = { page: String(pagination.page + 1), // convert 0-based to 1-based pageSize: String(pagination.pageSize), }; if (f.dateFrom) { const iso = fromGermanDate(f.dateFrom); if (iso) params.dateFrom = new Date(iso).toISOString(); } if (f.dateTo) { const iso = fromGermanDate(f.dateTo); if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString(); } if (f.action && f.action.length > 0) { params.action = f.action.join(','); } if (f.resourceType && f.resourceType.length > 0) { params.resourceType = f.resourceType.join(','); } if (f.userId) params.userId = f.userId; const queryString = new URLSearchParams(params).toString(); const response = await api.get<{ success: boolean; data: AuditLogPage }>( `/admin/audit-log?${queryString}` ); setRows(response.data.data.entries); setRowCount(response.data.data.total); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unbekannter Fehler'; setError(`Audit-Log konnte nicht geladen werden: ${msg}`); } finally { setLoading(false); } }, []); // Fetch when pagination or applied filters change useEffect(() => { fetchData(paginationModel, appliedFilters); }, [paginationModel, appliedFilters, fetchData]); // ------------------------------------------------------------------------- // Filter handlers // ------------------------------------------------------------------------- const handleApplyFilters = () => { setApplied(filters); setPaginationModel((prev) => ({ ...prev, page: 0 })); }; const handleResetFilters = () => { setFilters(DEFAULT_FILTERS); setApplied(DEFAULT_FILTERS); setPaginationModel((prev) => ({ ...prev, page: 0 })); }; // ------------------------------------------------------------------------- // CSV export // ------------------------------------------------------------------------- const handleExport = async () => { setExporting(true); try { const params: Record = {}; if (appliedFilters.dateFrom) { const iso = fromGermanDate(appliedFilters.dateFrom); if (iso) params.dateFrom = new Date(iso).toISOString(); } if (appliedFilters.dateTo) { const iso = fromGermanDate(appliedFilters.dateTo); if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString(); } if (appliedFilters.action && appliedFilters.action.length > 0) { params.action = appliedFilters.action.join(','); } if (appliedFilters.resourceType && appliedFilters.resourceType.length > 0) { params.resourceType = appliedFilters.resourceType.join(','); } const queryString = new URLSearchParams(params).toString(); const response = await api.get( `/admin/audit-log/export?${queryString}`, { responseType: 'blob' } ); const url = URL.createObjectURL(response.data); const filename = `audit_log_${format(new Date(), 'yyyy-MM-dd_HH-mm')}.csv`; const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } catch { setError('CSV-Export fehlgeschlagen. Bitte versuchen Sie es erneut.'); } finally { setExporting(false); } }; // ------------------------------------------------------------------------- // Column definitions // ------------------------------------------------------------------------- const columns: GridColDef[] = useMemo(() => [ { field: 'created_at', headerName: 'Zeitpunkt', width: 160, valueFormatter: (value: string) => formatTimestamp(value), sortable: false, }, { field: 'user_email', headerName: 'Benutzer', flex: 1, minWidth: 160, renderCell: (params: GridRenderCellParams) => params.value ? ( {params.value} ) : ( ), sortable: false, }, { field: 'action', headerName: 'Aktion', width: 160, renderCell: (params: GridRenderCellParams) => ( ), sortable: false, }, { field: 'resource_type', headerName: 'Ressourcentyp', width: 140, sortable: false, }, { field: 'resource_id', headerName: 'Ressourcen-ID', width: 130, renderCell: (params: GridRenderCellParams) => ( {truncate(params.value, 12)} ), sortable: false, }, ...(showIp ? [{ field: 'ip_address', headerName: 'IP-Adresse', width: 140, renderCell: (params: GridRenderCellParams) => ( {params.value ?? '—'} ), sortable: false, } as GridColDef] : []), ], [showIp]); // ------------------------------------------------------------------------- // Loading skeleton // ------------------------------------------------------------------------- if (loading && rows.length === 0) { return ( ); } // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- return ( {/* Header */} Audit-Protokoll DSGVO Art. 5(2) — Unveraenderliches Protokoll aller Datenzugriffe {/* Error */} {error && ( setError(null)} sx={{ mb: 2 }}> {error} )} {/* Filter panel */} {/* Apply filters button */} {/* Data grid */} rows={rows} columns={columns} rowCount={rowCount} loading={loading} paginationMode="server" paginationModel={paginationModel} onPaginationModelChange={setPaginationModel} pageSizeOptions={[25, 50, 100]} disableRowSelectionOnClick={false} onRowClick={(params: GridRowParams) => setSelected(params.row) } sx={{ border: 'none', '& .MuiDataGrid-row': { cursor: 'pointer' }, '& .MuiDataGrid-row:hover': { backgroundColor: 'action.hover', }, }} localeText={{ noRowsLabel: 'Keine Eintraege gefunden', MuiTablePagination: { labelRowsPerPage: 'Eintraege pro Seite:', labelDisplayedRows: ({ from, to, count }) => `${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`, }, }} autoHeight /> {/* Detail dialog */} setSelected(null)} showIp={showIp} /> ); }; export default AuditLog;