Bei der Gegenprobe zur neuen Rolle "Gegenbuch" gefunden: Ein Konto mit ausschliesslich audit:read bekam auf GET /audit-logs/export eine 200. Die Berechtigung audit:export stand im Katalog und in der Rollenverwaltung - und wurde nirgends geprueft. Der Unterschied ist nicht kosmetisch. Blaettern zeigt 50 Zeilen; der Export liefert in einem Zug das gesamte Protokoll inklusive changesBefore und changesAfter, also der vollstaendigen Vorher/Nachher-Datensaetze, dazu resourceLabel mit Klartextnamen, IP-Adressen und User-Agents. Live nachgewiesen auf Staging: 43 Eintraege mit gefuellter resourceLabel allein fuer resourceType=Customer. Damit konnte ausgerechnet das Dienstkonto des Gegenbuchs Personendaten exportieren - das Konto, dessen Passwort im Klartext in der .env auf der Notar-Maschine liegt, und dem README und Rollenname "nur Pruefwerte lesen" zusichern. /audit-logs/export verlangt jetzt audit:export. Betroffen ist genau eine Rolle: Gegenbuch, und zwar gewollt. Die DSGVO-Rolle traegt audit:* vollstaendig und behaelt den Export. In der Oberflaeche erscheinen JSON- und CSV-Knopf nur noch mit audit:export - sonst stuenden dort Knoepfe, die zuverlaessig 403 liefern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
512 lines
20 KiB
TypeScript
512 lines
20 KiB
TypeScript
import { useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '../../context/AuthContext';
|
||
import { auditLogApi, AuditLogSearchParams, authApi } 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 AuditIntegrityCard from './AuditIntegrityCard';
|
||
import { ArrowLeft, Download, Eye, Shield, 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' },
|
||
{ value: 'TOKEN_REFRESH', label: 'Sitzung verlängert' },
|
||
];
|
||
|
||
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: 'Ausweise' },
|
||
{ value: 'Address', label: 'Adressen' },
|
||
{ value: 'Meter', label: 'Zähler' },
|
||
{ value: 'ContractTask', label: 'Aufgaben' },
|
||
{ value: 'Authentication', label: 'Anmeldung' },
|
||
{ value: 'CustomerConsent', label: 'Einwilligungen' },
|
||
{ value: 'GDPR', label: 'DSGVO / Vollmacht' },
|
||
{ value: 'EmailProviderConfig', label: 'E-Mail-Provider' },
|
||
{ value: 'AppSetting', label: 'Einstellungen' },
|
||
];
|
||
|
||
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';
|
||
case 'TOKEN_REFRESH': return 'bg-slate-50 text-slate-500';
|
||
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" />;
|
||
}
|
||
}
|
||
|
||
const RESOURCE_TYPE_LABELS: Record<string, string> = {
|
||
Customer: 'Kunde',
|
||
Contract: 'Vertrag',
|
||
BankCard: 'Bankverbindung',
|
||
IdentityDocument: 'Ausweis',
|
||
Address: 'Adresse',
|
||
Meter: 'Zähler',
|
||
MeterReading: 'Zählerstand',
|
||
User: 'Benutzer',
|
||
CustomerConsent: 'Einwilligung',
|
||
RepresentativeAuthorization: 'Vollmacht',
|
||
ContractTask: 'Aufgabe',
|
||
EnergyContractDetails: 'Strom/Gas-Details',
|
||
EmailProviderConfig: 'E-Mail-Provider',
|
||
AppSetting: 'Einstellung',
|
||
GDPR: 'Datenschutz',
|
||
Authentication: 'Anmeldung',
|
||
};
|
||
|
||
const FIELD_LABELS: Record<string, string> = {
|
||
firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail', phone: 'Telefon', mobile: 'Mobil',
|
||
salutation: 'Anrede', companyName: 'Firma', type: 'Typ', notes: 'Notizen',
|
||
street: 'Straße', houseNumber: 'Hausnummer', postalCode: 'PLZ', city: 'Stadt', country: 'Land',
|
||
meterNumber: 'Zählernummer', location: 'Standort', tariffModel: 'Tarifmodell',
|
||
isActive: 'Aktiv', isDefault: 'Standard',
|
||
status: 'Status', source: 'Quelle',
|
||
contractNumber: 'Vertragsnummer', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
||
portalUsername: 'Portal-Benutzername', portalEnabled: 'Portal aktiv',
|
||
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus',
|
||
name: 'Name', domain: 'Domain', apiUrl: 'API-URL',
|
||
documentNumber: 'Dokumentennummer', expiryDate: 'Ablaufdatum',
|
||
iban: 'IBAN', bic: 'BIC', bankName: 'Bank', accountHolder: 'Kontoinhaber',
|
||
isGranted: 'Vollmacht erteilt', documentPath: 'Dokument-Pfad',
|
||
value: 'Zählerstand', valueNt: 'NT-Zählerstand', readingDate: 'Ablesedatum',
|
||
privacyPolicyPath: 'Datenschutz-PDF',
|
||
};
|
||
|
||
function formatValue(value: unknown): string {
|
||
if (value === null || value === undefined) return '-';
|
||
if (typeof value === 'boolean') return value ? 'Ja' : 'Nein';
|
||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||
try { return new Date(value).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } catch { return value; }
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
function renderChangeDiff(before: Record<string, unknown>, after: Record<string, unknown>) {
|
||
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||
const changes: { field: string; from: unknown; to: unknown }[] = [];
|
||
|
||
for (const key of allKeys) {
|
||
// Technische Felder überspringen
|
||
if (['id', 'createdAt', 'updatedAt', 'passwordEncrypted', 'portalPasswordEncrypted', 'portalPasswordHash', 'systemEmailPasswordEncrypted'].includes(key)) continue;
|
||
const fromVal = before[key];
|
||
const toVal = after[key];
|
||
if (JSON.stringify(fromVal) !== JSON.stringify(toVal)) {
|
||
changes.push({ field: key, from: fromVal, to: toVal });
|
||
}
|
||
}
|
||
|
||
if (changes.length === 0) {
|
||
return <p className="text-sm text-gray-500 italic">Keine sichtbaren Änderungen</p>;
|
||
}
|
||
|
||
return changes.map(({ field, from, to }) => (
|
||
<div key={field} className="flex items-start gap-3 p-2 bg-gray-50 rounded text-sm">
|
||
<span className="font-medium text-gray-700 min-w-[150px]">
|
||
{FIELD_LABELS[field] || field}
|
||
</span>
|
||
<span className="text-red-600 line-through">{formatValue(from)}</span>
|
||
<span className="text-gray-400">→</span>
|
||
<span className="text-green-700 font-medium">{formatValue(to)}</span>
|
||
</div>
|
||
));
|
||
}
|
||
|
||
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">{RESOURCE_TYPE_LABELS[log.resourceType] || 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>
|
||
{before && after ? (
|
||
<div className="space-y-2">
|
||
{renderChangeDiff(before, after)}
|
||
</div>
|
||
) : (
|
||
<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">Daten</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>
|
||
)}
|
||
|
||
|
||
<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();
|
||
// Export haengt an `audit:export`, Lesen an `audit:read` – ein Konto darf
|
||
// blaettern duerfen, ohne das gesamte Protokoll herausziehen zu koennen.
|
||
// Ohne diese Abfrage stuenden hier Knoepfe, die zuverlaessig 403 liefern.
|
||
const { hasPermission } = useAuth();
|
||
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 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 {
|
||
if (format === 'csv') {
|
||
// CSV direkt als Download – mit kurzlebigem Download-Token (60s),
|
||
// damit das langlebige Access-JWT nicht in Logs/Referer/History landet.
|
||
const downloadToken = await authApi.getDownloadToken();
|
||
const params = new URLSearchParams();
|
||
params.set('format', 'csv');
|
||
// ALLE aktiven Filter mitgeben (Pentest R186-01). Vorher standen hier
|
||
// nur fuenf – wer im Suchfeld eingrenzte und dann CSV klickte, bekam
|
||
// stillschweigend das gesamte Protokoll statt seiner Auswahl.
|
||
// `page`/`limit` gehoeren nicht dazu: der Export ist bewusst
|
||
// vollstaendig ueber die gefilterte Menge.
|
||
for (const [schluessel, wert] of Object.entries(filters)) {
|
||
if (schluessel === 'page' || schluessel === 'limit') continue;
|
||
if (wert === undefined || wert === null || wert === '') continue;
|
||
params.set(schluessel, String(wert));
|
||
}
|
||
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
|
||
} else {
|
||
const result = await auditLogApi.export({ ...filters, format });
|
||
const blob = new Blob(
|
||
[JSON.stringify(result.data, null, 2)],
|
||
{ type: 'application/json' }
|
||
);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.json`;
|
||
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>
|
||
|
||
<AuditIntegrityCard />
|
||
|
||
{/* 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>
|
||
{hasPermission('audit:export') && (
|
||
<Button variant="secondary" onClick={() => handleExport('json')}>
|
||
<Download className="w-4 h-4 mr-2" />
|
||
JSON
|
||
</Button>
|
||
)}
|
||
{hasPermission('audit:export') && (
|
||
<Button variant="secondary" onClick={() => handleExport('csv')}>
|
||
<Download className="w-4 h-4 mr-2" />
|
||
CSV
|
||
</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">
|
||
{log.resourceLabel ? (
|
||
<div className="text-sm truncate max-w-[300px]" title={log.resourceLabel}>
|
||
{log.resourceLabel}
|
||
</div>
|
||
) : (
|
||
<div className="text-sm text-gray-500">{log.resourceType}</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>
|
||
);
|
||
}
|