Files
dashboard/frontend/src/pages/admin/AuditLog.tsx
Matthias Hochmeister 215528a521 update
2026-03-16 14:41:08 +01:00

743 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <Routes>:
* <Route
* path="/admin/audit-log"
* element={
* <ProtectedRoute requireRole="admin">
* <AuditLog />
* </ProtectedRoute>
* }
* />
*/
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<string, unknown> | null;
new_value: Record<string, unknown> | null;
ip_address: string | null;
user_agent: string | null;
metadata: Record<string, unknown>;
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<AuditAction, 'success' | 'primary' | 'error' | 'warning' | 'default' | 'info'> = {
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<string, unknown> | null;
newValue: Record<string, unknown> | null;
}
const JsonDiffViewer: React.FC<JsonDiffViewerProps> = ({ oldValue, newValue }) => {
if (!oldValue && !newValue) {
return <Typography variant="body2" color="text.secondary">Keine Datenaenderung aufgezeichnet.</Typography>;
}
const allKeys = Array.from(
new Set([
...Object.keys(oldValue ?? {}),
...Object.keys(newValue ?? {}),
])
).sort();
return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{oldValue && (
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography variant="overline" color="error.main">Vorher</Typography>
<Paper
variant="outlined"
sx={{
p: 1.5, mt: 0.5,
backgroundColor: 'error.light',
fontFamily: 'monospace',
fontSize: '0.75rem',
overflowX: 'auto',
maxHeight: 320,
}}
>
<pre style={{ margin: 0 }}>
{JSON.stringify(oldValue, null, 2)}
</pre>
</Paper>
</Box>
)}
{newValue && (
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography variant="overline" color="success.main">Nachher</Typography>
<Paper
variant="outlined"
sx={{
p: 1.5, mt: 0.5,
backgroundColor: 'success.light',
fontFamily: 'monospace',
fontSize: '0.75rem',
overflowX: 'auto',
maxHeight: 320,
}}
>
<pre style={{ margin: 0 }}>
{JSON.stringify(newValue, null, 2)}
</pre>
</Paper>
</Box>
)}
{/* Highlight changed fields */}
{oldValue && newValue && allKeys.length > 0 && (
<Box sx={{ width: '100%', mt: 1 }}>
<Typography variant="overline">Geaenderte Felder</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
{allKeys.map((key) => {
const changed =
JSON.stringify((oldValue as Record<string, unknown>)[key]) !==
JSON.stringify((newValue as Record<string, unknown>)[key]);
if (!changed) return null;
return (
<Chip
key={key}
label={key}
size="small"
color="warning"
variant="outlined"
/>
);
})}
</Box>
</Box>
)}
</Box>
);
};
// ---------------------------------------------------------------------------
// Entry detail dialog
// ---------------------------------------------------------------------------
interface EntryDialogProps {
entry: AuditLogEntry | null;
onClose: () => void;
showIp: boolean;
}
const EntryDialog: React.FC<EntryDialogProps> = ({ entry, onClose, showIp }) => {
if (!entry) return null;
return (
<Dialog open={!!entry} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="h6">
Audit-Eintrag {entry.action} / {entry.resource_type}
</Typography>
<IconButton onClick={onClose} size="small" aria-label="Schliessen">
<CloseIcon />
</IconButton>
</Stack>
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<Box>
<Typography variant="overline">Metadaten</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '4px 16px',
mt: 0.5,
}}
>
{[
['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]) => (
<React.Fragment key={label}>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{label}:
</Typography>
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
{value}
</Typography>
</React.Fragment>
))}
</Box>
</Box>
{Object.keys(entry.metadata ?? {}).length > 0 && (
<Box>
<Typography variant="overline">Zusatzdaten</Typography>
<Paper variant="outlined" sx={{ p: 1, mt: 0.5, fontFamily: 'monospace', fontSize: '0.75rem' }}>
<pre style={{ margin: 0 }}>{JSON.stringify(entry.metadata, null, 2)}</pre>
</Paper>
</Box>
)}
<Divider />
<Box>
<Typography variant="overline">Datenaenderung</Typography>
<Box sx={{ mt: 1 }}>
<JsonDiffViewer oldValue={entry.old_value} newValue={entry.new_value} />
</Box>
</Box>
</Stack>
</DialogContent>
</Dialog>
);
};
// ---------------------------------------------------------------------------
// Filter panel
// ---------------------------------------------------------------------------
interface FilterPanelProps {
filters: AuditFilters;
onChange: (f: AuditFilters) => void;
onReset: () => void;
}
const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, onReset }) => {
return (
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterAltIcon fontSize="small" />
Filter
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{/* Date range */}
<TextField
label="Von"
size="small"
placeholder="TT.MM.JJJJ"
value={filters.dateFrom ?? ''}
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
sx={{ minWidth: 160 }}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Bis"
size="small"
placeholder="TT.MM.JJJJ"
value={filters.dateTo ?? ''}
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
sx={{ minWidth: 160 }}
InputLabelProps={{ shrink: true }}
/>
{/* Action multi-select */}
<Autocomplete
multiple
options={ALL_ACTIONS}
value={filters.action ?? []}
onChange={(_, value) => onChange({ ...filters, action: value as AuditAction[] })}
renderInput={(params) => (
<TextField {...params} label="Aktionen" size="small" sx={{ minWidth: 200 }} />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option}
label={option}
size="small"
color={ACTION_COLORS[option]}
/>
))
}
/>
{/* Resource type multi-select */}
<Autocomplete
multiple
options={ALL_RESOURCE_TYPES}
value={filters.resourceType ?? []}
onChange={(_, value) => onChange({ ...filters, resourceType: value as AuditResourceType[] })}
renderInput={(params) => (
<TextField {...params} label="Ressourcentypen" size="small" sx={{ minWidth: 200 }} />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip {...getTagProps({ index })} key={option} label={option} size="small" />
))
}
/>
<Button variant="outlined" size="small" onClick={onReset} sx={{ alignSelf: 'flex-end' }}>
Zuruecksetzen
</Button>
</Box>
</Stack>
</Paper>
);
};
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
const DEFAULT_FILTERS: AuditFilters = {
action: [],
resourceType: [],
dateFrom: '',
dateTo: '',
};
const AuditLog: React.FC = () => {
// Grid state
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0, // DataGrid is 0-based
pageSize: 25,
});
const [rowCount, setRowCount] = useState(0);
const [rows, setRows] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters
const [filters, setFilters] = useState<AuditFilters>(DEFAULT_FILTERS);
const [appliedFilters, setApplied]= useState<AuditFilters>(DEFAULT_FILTERS);
// Detail dialog
const [selectedEntry, setSelected]= useState<AuditLogEntry | null>(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 = new URLSearchParams();
params.set('page', String(pagination.page + 1));
params.set('pageSize', String(pagination.pageSize));
if (f.dateFrom) {
const iso = fromGermanDate(f.dateFrom);
if (iso) params.set('dateFrom', new Date(iso).toISOString());
}
if (f.dateTo) {
const iso = fromGermanDate(f.dateTo);
if (iso) params.set('dateTo', new Date(iso + 'T23:59:59').toISOString());
}
if (f.action && f.action.length > 0) {
f.action.forEach((a) => params.append('action', a));
}
if (f.resourceType && f.resourceType.length > 0) {
f.resourceType.forEach((rt) => params.append('resourceType', rt));
}
if (f.userId) params.set('userId', f.userId);
const queryString = params.toString();
const response = await api.get<{ success: boolean; data: AuditLogPage }>(
`/api/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: any) => ({ ...prev, page: 0 }));
};
const handleResetFilters = () => {
setFilters(DEFAULT_FILTERS);
setApplied(DEFAULT_FILTERS);
setPaginationModel((prev: any) => ({ ...prev, page: 0 }));
};
// -------------------------------------------------------------------------
// CSV export
// -------------------------------------------------------------------------
const handleExport = async () => {
setExporting(true);
try {
const params: Record<string, string> = {};
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<Blob>(
`/api/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<AuditLogEntry>[] = 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<AuditLogEntry>) =>
params.value ? (
<Tooltip title={params.row.user_id ?? ''}>
<Typography variant="body2" noWrap>{params.value}</Typography>
</Tooltip>
) : (
<Typography variant="body2" color="text.disabled"></Typography>
),
sortable: false,
},
{
field: 'action',
headerName: 'Aktion',
width: 160,
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
<Chip
label={params.value as string}
size="small"
color={ACTION_COLORS[params.value as AuditAction] ?? 'default'}
/>
),
sortable: false,
},
{
field: 'resource_type',
headerName: 'Ressourcentyp',
width: 140,
sortable: false,
},
{
field: 'resource_id',
headerName: 'Ressourcen-ID',
width: 130,
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
<Tooltip title={params.value ?? ''}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
{truncate(params.value, 12)}
</Typography>
</Tooltip>
),
sortable: false,
},
...(showIp ? [{
field: 'ip_address',
headerName: 'IP-Adresse',
width: 140,
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
{params.value ?? '—'}
</Typography>
),
sortable: false,
} as GridColDef<AuditLogEntry>] : []),
], [showIp]);
// -------------------------------------------------------------------------
// Loading skeleton
// -------------------------------------------------------------------------
if (loading && rows.length === 0) {
return (
<DashboardLayout>
<Container maxWidth="xl">
<Skeleton variant="text" width={300} height={48} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" height={80} sx={{ mb: 2, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 1 }} />
</Container>
</DashboardLayout>
);
}
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<DashboardLayout>
<Container maxWidth="xl">
{/* Header */}
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ mb: 3 }}
>
<Box>
<Typography variant="h4">Audit-Protokoll</Typography>
<Typography variant="body2" color="text.secondary">
DSGVO Art. 5(2) Unveraenderliches Protokoll aller Datenzugriffe
</Typography>
</Box>
<Button
variant="contained"
startIcon={exporting ? <CircularProgress size={16} color="inherit" /> : <DownloadIcon />}
disabled={exporting}
onClick={handleExport}
>
CSV-Export
</Button>
</Stack>
{/* Error */}
{error && (
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* Filter panel */}
<FilterPanel
filters={filters}
onChange={setFilters}
onReset={handleResetFilters}
/>
{/* Apply filters button */}
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="outlined" size="small" onClick={handleApplyFilters}>
Filter anwenden
</Button>
</Box>
{/* Data grid */}
<Paper variant="outlined" sx={{ width: '100%' }}>
<DataGrid<AuditLogEntry>
rows={rows}
columns={columns}
rowCount={rowCount}
loading={loading}
paginationMode="server"
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[25, 50, 100]}
disableRowSelectionOnClick={false}
onRowClick={(params: GridRowParams<AuditLogEntry>) =>
setSelected(params.row)
}
sx={{
border: 'none',
'& .MuiDataGrid-row': { cursor: 'pointer' },
'& .MuiDataGrid-row:hover': {
backgroundColor: 'action.hover',
},
}}
localeText={{
noRowsLabel: 'Keine Eintraege gefunden',
paginationRowsPerPage: 'Eintraege pro Seite:',
paginationDisplayedRows: ({ from, to, count }: { from: any; to: any; count: any }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`,
}}
autoHeight
/>
</Paper>
{/* Detail dialog */}
<EntryDialog
entry={selectedEntry}
onClose={() => setSelected(null)}
showIp={showIp}
/>
</Container>
</DashboardLayout>
);
};
export default AuditLog;