Files
opencrm/frontend/src/pages/settings/AuditLogs.tsx
T

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>
);
}