414 lines
16 KiB
TypeScript
414 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { auditLogApi, AuditLogSearchParams } from '../../services/api';
|
|
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
|
|
import Card from '../../components/ui/Card';
|
|
import Button from '../../components/ui/Button';
|
|
import Input from '../../components/ui/Input';
|
|
import Select from '../../components/ui/Select';
|
|
import { ArrowLeft, Download, Eye, Shield, ShieldCheck, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
|
|
|
const ACTION_OPTIONS = [
|
|
{ value: '', label: 'Alle Aktionen' },
|
|
{ value: 'CREATE', label: 'Erstellt' },
|
|
{ value: 'READ', label: 'Gelesen' },
|
|
{ value: 'UPDATE', label: 'Aktualisiert' },
|
|
{ value: 'DELETE', label: 'Gelöscht' },
|
|
{ value: 'EXPORT', label: 'Exportiert' },
|
|
{ value: 'ANONYMIZE', label: 'Anonymisiert' },
|
|
{ value: 'LOGIN', label: 'Login' },
|
|
{ value: 'LOGOUT', label: 'Logout' },
|
|
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
|
|
];
|
|
|
|
const SENSITIVITY_OPTIONS = [
|
|
{ value: '', label: 'Alle Stufen' },
|
|
{ value: 'LOW', label: 'Niedrig' },
|
|
{ value: 'MEDIUM', label: 'Mittel' },
|
|
{ value: 'HIGH', label: 'Hoch' },
|
|
{ value: 'CRITICAL', label: 'Kritisch' },
|
|
];
|
|
|
|
const RESOURCE_OPTIONS = [
|
|
{ value: '', label: 'Alle Ressourcen' },
|
|
{ value: 'Customer', label: 'Kunden' },
|
|
{ value: 'Contract', label: 'Verträge' },
|
|
{ value: 'User', label: 'Benutzer' },
|
|
{ value: 'BankCard', label: 'Bankdaten' },
|
|
{ value: 'IdentityDocument', label: 'Ausweisdokumente' },
|
|
{ value: 'Authentication', label: 'Authentifizierung' },
|
|
{ value: 'CustomerConsent', label: 'Einwilligungen' },
|
|
{ value: 'GDPR', label: 'DSGVO' },
|
|
];
|
|
|
|
function formatDate(date: string): string {
|
|
return new Date(date).toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function getActionColor(action: AuditAction): string {
|
|
switch (action) {
|
|
case 'CREATE': return 'bg-green-100 text-green-800';
|
|
case 'READ': return 'bg-blue-100 text-blue-800';
|
|
case 'UPDATE': return 'bg-yellow-100 text-yellow-800';
|
|
case 'DELETE': return 'bg-red-100 text-red-800';
|
|
case 'EXPORT': return 'bg-purple-100 text-purple-800';
|
|
case 'ANONYMIZE': return 'bg-orange-100 text-orange-800';
|
|
case 'LOGIN': return 'bg-teal-100 text-teal-800';
|
|
case 'LOGOUT': return 'bg-gray-100 text-gray-800';
|
|
case 'LOGIN_FAILED': return 'bg-red-200 text-red-900';
|
|
default: return 'bg-gray-100 text-gray-800';
|
|
}
|
|
}
|
|
|
|
function getSensitivityIcon(sensitivity: AuditSensitivity) {
|
|
switch (sensitivity) {
|
|
case 'LOW': return <Shield className="w-4 h-4 text-gray-400" />;
|
|
case 'MEDIUM': return <Shield className="w-4 h-4 text-blue-500" />;
|
|
case 'HIGH': return <ShieldAlert className="w-4 h-4 text-orange-500" />;
|
|
case 'CRITICAL': return <ShieldAlert className="w-4 h-4 text-red-500" />;
|
|
default: return <Shield className="w-4 h-4 text-gray-400" />;
|
|
}
|
|
}
|
|
|
|
interface DetailModalProps {
|
|
log: AuditLog;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function DetailModal({ log, onClose }: DetailModalProps) {
|
|
const parseChanges = (changes: string | undefined): Record<string, unknown> | null => {
|
|
if (!changes) return null;
|
|
try {
|
|
return JSON.parse(changes);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const before = parseChanges(log.changesBefore);
|
|
const after = parseChanges(log.changesAfter);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-semibold">Audit-Log Details</h2>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
|
<div>
|
|
<dt className="text-sm text-gray-500">Zeitpunkt</dt>
|
|
<dd className="font-medium">{formatDate(log.createdAt)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm text-gray-500">Benutzer</dt>
|
|
<dd className="font-medium">{log.userEmail}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm text-gray-500">Aktion</dt>
|
|
<dd><span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(log.action)}`}>{log.action}</span></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm text-gray-500">Ressource</dt>
|
|
<dd className="font-medium">{log.resourceType} {log.resourceId && `#${log.resourceId}`}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm text-gray-500">Endpoint</dt>
|
|
<dd className="font-mono text-sm">{log.httpMethod} {log.endpoint}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm text-gray-500">IP-Adresse</dt>
|
|
<dd className="font-mono text-sm">{log.ipAddress}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm text-gray-500">Sensitivität</dt>
|
|
<dd className="flex items-center gap-2">{getSensitivityIcon(log.sensitivity)} {log.sensitivity}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm text-gray-500">Dauer</dt>
|
|
<dd>{log.durationMs ? `${log.durationMs}ms` : '-'}</dd>
|
|
</div>
|
|
{log.resourceLabel && (
|
|
<div className="col-span-2">
|
|
<dt className="text-sm text-gray-500">Ressource-Bezeichnung</dt>
|
|
<dd className="font-medium">{log.resourceLabel}</dd>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{log.changesEncrypted && (
|
|
<div className="mb-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg text-sm">
|
|
Die Änderungsdaten sind verschlüsselt und können nur mit dem Encryption-Key eingesehen werden.
|
|
</div>
|
|
)}
|
|
|
|
{(before || after) && !log.changesEncrypted && (
|
|
<div className="border-t pt-4">
|
|
<h3 className="font-medium mb-3">Änderungen</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{before && (
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-500 mb-2">Vorher</h4>
|
|
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64">
|
|
{JSON.stringify(before, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
{after && (
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-500 mb-2">Nachher</h4>
|
|
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64">
|
|
{JSON.stringify(after, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{log.hash && (
|
|
<div className="border-t pt-4 mt-4">
|
|
<h3 className="font-medium mb-2">Integrität</h3>
|
|
<div className="text-xs font-mono bg-gray-50 p-2 rounded break-all">
|
|
<div><span className="text-gray-500">Hash:</span> {log.hash}</div>
|
|
{log.previousHash && <div className="mt-1"><span className="text-gray-500">Vorheriger:</span> {log.previousHash}</div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end mt-6 pt-4 border-t">
|
|
<Button variant="secondary" onClick={onClose}>Schließen</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AuditLogs() {
|
|
const navigate = useNavigate();
|
|
const [page, setPage] = useState(1);
|
|
const [filters, setFilters] = useState<AuditLogSearchParams>({
|
|
page: 1,
|
|
limit: 50,
|
|
});
|
|
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
|
|
|
const { data: logsData, isLoading, refetch } = useQuery({
|
|
queryKey: ['audit-logs', { ...filters, page }],
|
|
queryFn: () => auditLogApi.search({ ...filters, page }),
|
|
});
|
|
|
|
const verifyMutation = useMutation({
|
|
mutationFn: () => auditLogApi.verifyIntegrity(),
|
|
onSuccess: (result) => {
|
|
if (result.data?.valid) {
|
|
alert('Hash-Kette ist intakt. Keine Manipulationen festgestellt.');
|
|
} else {
|
|
alert(`Integritätsfehler gefunden:\n${result.data?.errors?.join('\n')}`);
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
alert(`Fehler bei der Integritätsprüfung: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
|
|
},
|
|
});
|
|
|
|
const logs = logsData?.data || [];
|
|
const pagination = logsData?.pagination;
|
|
|
|
const handleFilterChange = (key: keyof AuditLogSearchParams, value: string) => {
|
|
setFilters(prev => ({ ...prev, [key]: value || undefined }));
|
|
setPage(1);
|
|
};
|
|
|
|
const handleExport = async (format: 'json' | 'csv') => {
|
|
try {
|
|
const result = await auditLogApi.export({ ...filters, format });
|
|
const data = result.data;
|
|
const blob = new Blob(
|
|
[format === 'json' ? JSON.stringify(data, null, 2) : ''],
|
|
{ type: format === 'json' ? 'application/json' : 'text/csv' }
|
|
);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.${format}`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
alert(`Export fehlgeschlagen: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<Button variant="ghost" onClick={() => navigate('/settings')}>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Zurück
|
|
</Button>
|
|
<h1 className="text-2xl font-bold">Audit-Protokoll</h1>
|
|
</div>
|
|
|
|
{/* Filter */}
|
|
<Card className="mb-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
|
<Input
|
|
placeholder="Suche..."
|
|
value={filters.search || ''}
|
|
onChange={(e) => handleFilterChange('search', e.target.value)}
|
|
className="w-full"
|
|
/>
|
|
<Select
|
|
value={filters.action || ''}
|
|
onChange={(e) => handleFilterChange('action', e.target.value)}
|
|
options={ACTION_OPTIONS}
|
|
/>
|
|
<Select
|
|
value={filters.resourceType || ''}
|
|
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
|
|
options={RESOURCE_OPTIONS}
|
|
/>
|
|
<Select
|
|
value={filters.sensitivity || ''}
|
|
onChange={(e) => handleFilterChange('sensitivity', e.target.value as AuditSensitivity)}
|
|
options={SENSITIVITY_OPTIONS}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
|
<Input
|
|
type="date"
|
|
label="Von"
|
|
value={filters.startDate || ''}
|
|
onChange={(e) => handleFilterChange('startDate', e.target.value)}
|
|
/>
|
|
<Input
|
|
type="date"
|
|
label="Bis"
|
|
value={filters.endDate || ''}
|
|
onChange={(e) => handleFilterChange('endDate', e.target.value)}
|
|
/>
|
|
<div></div>
|
|
<div className="flex gap-2 justify-end items-end">
|
|
<Button variant="secondary" onClick={() => refetch()}>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
Aktualisieren
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => handleExport('json')}>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Export
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => verifyMutation.mutate()} disabled={verifyMutation.isPending}>
|
|
<ShieldCheck className="w-4 h-4 mr-2" />
|
|
{verifyMutation.isPending ? 'Prüfe...' : 'Integrität'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Tabelle */}
|
|
<Card>
|
|
{isLoading ? (
|
|
<div className="text-center py-8">Laden...</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500">Keine Audit-Logs gefunden.</div>
|
|
) : (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left py-3 px-4">Zeitpunkt</th>
|
|
<th className="text-left py-3 px-4">Benutzer</th>
|
|
<th className="text-left py-3 px-4">Aktion</th>
|
|
<th className="text-left py-3 px-4">Ressource</th>
|
|
<th className="text-left py-3 px-4">IP</th>
|
|
<th className="text-center py-3 px-4"></th>
|
|
<th className="text-center py-3 px-4"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.map((log) => (
|
|
<tr key={log.id} className="border-b hover:bg-gray-50">
|
|
<td className="py-3 px-4 whitespace-nowrap">{formatDate(log.createdAt)}</td>
|
|
<td className="py-3 px-4">
|
|
<div className="truncate max-w-[200px]" title={log.userEmail}>
|
|
{log.userEmail}
|
|
</div>
|
|
{log.userRole && <div className="text-xs text-gray-500">{log.userRole}</div>}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(log.action)}`}>
|
|
{log.action}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<div>{log.resourceType}</div>
|
|
{log.resourceLabel && (
|
|
<div className="text-xs text-gray-500 truncate max-w-[200px]" title={log.resourceLabel}>
|
|
{log.resourceLabel}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4 font-mono text-xs">{log.ipAddress}</td>
|
|
<td className="py-3 px-4 text-center">{getSensitivityIcon(log.sensitivity)}</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<Button variant="ghost" size="sm" onClick={() => setSelectedLog(log)}>
|
|
<Eye className="w-4 h-4" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{pagination && pagination.totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
|
<div className="text-sm text-gray-500">
|
|
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setPage(p => Math.min(pagination.totalPages, p + 1))}
|
|
disabled={page === pagination.totalPages}
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Detail Modal */}
|
|
{selectedLog && (
|
|
<DetailModal log={selectedLog} onClose={() => setSelectedLog(null)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|