add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:50:14 +01:00
parent c5e8337a69
commit 620bacc6b5
46 changed files with 14095 additions and 1 deletions

View File

@@ -0,0 +1,733 @@
/**
* 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,
MenuItem,
Paper,
Select,
SelectChangeEvent,
Skeleton,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
DataGrid,
GridColDef,
GridPaginationModel,
GridRenderCellParams,
GridRowParams,
} from '@mui/x-data-grid';
import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
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';
// ---------------------------------------------------------------------------
// 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?: Date | null;
dateTo?: Date | null;
}
// ---------------------------------------------------------------------------
// 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.50',
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.50',
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 */}
<DatePicker
label="Von"
value={filters.dateFrom ?? null}
onChange={(date) => onChange({ ...filters, dateFrom: date })}
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }}
/>
<DatePicker
label="Bis"
value={filters.dateTo ?? null}
onChange={(date) => onChange({ ...filters, dateTo: date })}
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }}
/>
{/* 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: null,
dateTo: null,
};
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: Record<string, string> = {
page: String(pagination.page + 1), // convert 0-based to 1-based
pageSize: String(pagination.pageSize),
};
if (f.dateFrom) params.dateFrom = f.dateFrom.toISOString();
if (f.dateTo) params.dateTo = f.dateTo.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<string, string> = {};
if (appliedFilters.dateFrom) params.dateFrom = appliedFilters.dateFrom.toISOString();
if (appliedFilters.dateTo) params.dateTo = appliedFilters.dateTo.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>(
`/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 (
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={de}>
<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',
MuiTablePagination: {
labelRowsPerPage: 'Eintraege pro Seite:',
labelDisplayedRows: ({ from, to, count }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`,
},
}}
autoHeight
/>
</Paper>
{/* Detail dialog */}
<EntryDialog
entry={selectedEntry}
onClose={() => setSelected(null)}
showIp={showIp}
/>
</Container>
</DashboardLayout>
</LocalizationProvider>
);
};
export default AuditLog;