add features
This commit is contained in:
733
frontend/src/pages/admin/AuditLog.tsx
Normal file
733
frontend/src/pages/admin/AuditLog.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user