R185-01 (MEDIUM): Die Flanke, die wir selbst gemeldet hatten, hat der
Tester live bestaetigt. seal-backlog war beim ZWEITEN Aufruf genauso
gegatet wie beim ersten ({"confirm":"SEAL"} -> 200), und das Ereignis
landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
automatische Rueckhalt des Gegenbuchs haengt an `valid` - und `valid`
ueberlebt ein ersetzendes Siegel per Konstruktion. Angriff: Altzeile per
DB-Zugriff loeschen, neu siegeln, Luecke ist beglaubigt, valid wieder
true. Live reproduziert.
Zwei Schichten, in seiner Reihenfolge:
1. Alarmkanal. Neuer SecurityEventType AUDIT_SEAL_CHANGED (Migration
20260826120000). Erstes Siegeln HIGH, Ersetzen CRITICAL - geht damit
ueber sendPendingCriticalAlerts sofort per Mail raus. Die Details
halten Wurzel vorher/nachher und den vollstaendigen Vorbefund fest.
2. Gate. Steht bereits ein Siegel, verlangt der Endpunkt
{"confirm":"RESEAL"} statt SEAL, mit einem Text, der sagt, was dabei
verloren geht. Ein Austausch der Beweisgrundlage soll nicht dasselbe
Wort haben wie das Einrichten.
Und im Gegenbuch selbst: dort stand fuer den Wurzelwechsel ein
console.warn, waehrend der Rueckgabecode auf 0 blieb - also exakt das
Muster, das wir dem CRM zweimal angekreidet haben (R179, R183-02), im
Werkzeug, das dagegen gebaut wurde. Jetzt exit 2, mit alter und neuer
Wurzel samt Blattzahl; "10 Blaetter -> 9 Blaetter" zeigt die Loeschung
sofort. Auch die Erstsiegelung meldet sich, statt stillschweigend
uebernommen zu werden.
Aufloesbar gemacht: der Alarm bricht ab, BEVOR angehaengt wird - ohne
Bestaetigungsweg haette auch ein legitimes Siegeln fuer immer alarmiert
(R183-03-Falle). Neu ist NOTARY_SEAL_ACK, bewusst nicht "true", sondern
die Wurzel selbst (mind. 16 Zeichen): ein stehen gelassener Wert passt
beim naechsten Wechsel nicht mehr und kann keinen weiteren Austausch
durchwinken.
R185-02 (LOW): GET /api/audit-logs?action=<x> gab ungueltige Enum-Werte
roh an die Spalte -> 500. Zweifach schlecht: fehlende Validierung und
Fehler-Orakel (200 vs 500 verraet die Enum-Mitglieder). Jetzt 400 mit
der erlaubten Menge im Klartext. Mitgenommen: sensitivity, Datumsfelder,
Zahlenfelder, Textlaengen und ein Deckel auf limit (200), ueber den sich
sonst die ganze Tabelle an der Seitenlogik vorbei ziehen liess. Beide
Endpunkte.
Getestet ueber HTTP gegen eine Wegwerf-DB, inkl. echtem Gegenbuch-Lauf
mit SSH-signiertem lokalem Repo. Zusaetzlich nachgeholt, was der Tester
nicht herstellen konnte: vollstaendig unsigniertes Protokoll ->
kein_siegel statt der frueheren falschen Entwarnung nicht_noetig, und
seal-backlog nennt den fehlenden Schluessel als naechsten Schritt.
Gegenrichtung geprueft, R183-03 bleibt behoben.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
435 lines
18 KiB
TypeScript
435 lines
18 KiB
TypeScript
import { useState } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import toast from 'react-hot-toast';
|
||
import { monitoringApi, type SecurityEventType, type SecuritySeverity } from '../../services/api';
|
||
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 Modal from '../../components/ui/Modal';
|
||
import { ArrowLeft, Send, RefreshCw, Mail, ShieldAlert, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react';
|
||
|
||
/**
|
||
* Liefert die anzuzeigenden Seitenzahlen für die Pagination.
|
||
* Bis zu 10 Seitenzahlen, current möglichst mittig.
|
||
*/
|
||
function paginationWindow(current: number, total: number, size = 10): number[] {
|
||
if (total <= size) return Array.from({ length: total }, (_, i) => i + 1);
|
||
let start = Math.max(1, current - Math.floor(size / 2));
|
||
let end = start + size - 1;
|
||
if (end > total) {
|
||
end = total;
|
||
start = end - size + 1;
|
||
}
|
||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||
}
|
||
|
||
const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
|
||
{ value: '', label: 'Alle Typen' },
|
||
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
|
||
{ value: 'LOGIN_SUCCESS', label: 'Login erfolgreich' },
|
||
{ value: 'RATE_LIMIT_HIT', label: 'Rate-Limit greift' },
|
||
{ value: 'ACCESS_DENIED', label: 'Zugriff verweigert (IDOR)' },
|
||
{ value: 'SSRF_BLOCKED', label: 'SSRF blockiert' },
|
||
{ value: 'PASSWORD_RESET_REQUEST', label: 'Passwort-Reset angefordert' },
|
||
{ value: 'PASSWORD_RESET_CONFIRM', label: 'Passwort-Reset bestätigt' },
|
||
{ value: 'LOGOUT', label: 'Logout' },
|
||
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
|
||
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
|
||
{ value: 'AUDIT_SEAL_CHANGED', label: 'Bestandssiegel gesetzt/ersetzt' },
|
||
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
|
||
];
|
||
|
||
const SEVERITY_OPTIONS: { value: SecuritySeverity | ''; label: string }[] = [
|
||
{ value: '', label: 'Alle Stufen' },
|
||
{ value: 'INFO', label: 'Info' },
|
||
{ value: 'LOW', label: 'Niedrig' },
|
||
{ value: 'MEDIUM', label: 'Mittel' },
|
||
{ value: 'HIGH', label: 'Hoch' },
|
||
{ value: 'CRITICAL', label: 'Kritisch' },
|
||
];
|
||
|
||
function severityClass(s: SecuritySeverity): string {
|
||
switch (s) {
|
||
case 'CRITICAL': return 'bg-red-100 text-red-800 border border-red-300';
|
||
case 'HIGH': return 'bg-orange-100 text-orange-800 border border-orange-300';
|
||
case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border border-yellow-300';
|
||
case 'LOW': return 'bg-blue-100 text-blue-800 border border-blue-300';
|
||
default: return 'bg-gray-100 text-gray-700 border border-gray-300';
|
||
}
|
||
}
|
||
|
||
function severityIcon(s: SecuritySeverity): string {
|
||
switch (s) {
|
||
case 'CRITICAL': return '🚨';
|
||
case 'HIGH': return '⚠️';
|
||
case 'MEDIUM': return '🟡';
|
||
case 'LOW': return '🟢';
|
||
default: return 'ℹ️';
|
||
}
|
||
}
|
||
|
||
export default function Monitoring() {
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(50);
|
||
const [filters, setFilters] = useState({
|
||
type: '' as SecurityEventType | '',
|
||
severity: '' as SecuritySeverity | '',
|
||
search: '',
|
||
ip: '',
|
||
});
|
||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||
const [clearOlderThanDays, setClearOlderThanDays] = useState<number | ''>('');
|
||
|
||
const [alertEmail, setAlertEmail] = useState('');
|
||
const [digestEnabled, setDigestEnabled] = useState(false);
|
||
|
||
// Settings laden
|
||
const { data: settingsData } = useQuery({
|
||
queryKey: ['monitoring-settings'],
|
||
queryFn: monitoringApi.getSettings,
|
||
});
|
||
|
||
// States nach Laden synchronisieren (nur initial)
|
||
if (settingsData?.data && alertEmail === '' && settingsData.data.alertEmail !== '') {
|
||
setAlertEmail(settingsData.data.alertEmail);
|
||
setDigestEnabled(settingsData.data.digestEnabled);
|
||
}
|
||
|
||
// Events laden
|
||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||
queryKey: ['monitoring-events', page, pageSize, filters],
|
||
queryFn: () => monitoringApi.getEvents({ page, limit: pageSize, ...filters }),
|
||
refetchInterval: 30_000, // alle 30s neu laden
|
||
});
|
||
|
||
const clearEvents = useMutation({
|
||
mutationFn: (olderThanDays?: number) => monitoringApi.clearEvents(olderThanDays),
|
||
onSuccess: (res) => {
|
||
toast.success(res.message || 'Events gelöscht');
|
||
setShowClearConfirm(false);
|
||
setClearOlderThanDays('');
|
||
queryClient.invalidateQueries({ queryKey: ['monitoring-events'] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message || 'Löschen fehlgeschlagen'),
|
||
});
|
||
|
||
const saveSettings = useMutation({
|
||
mutationFn: () => monitoringApi.updateSettings({ alertEmail, digestEnabled }),
|
||
onSuccess: () => {
|
||
toast.success('Einstellungen gespeichert');
|
||
queryClient.invalidateQueries({ queryKey: ['monitoring-settings'] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message || 'Speichern fehlgeschlagen'),
|
||
});
|
||
|
||
const testAlert = useMutation({
|
||
mutationFn: () => monitoringApi.testAlert(),
|
||
onSuccess: (res) => toast.success(res.message || 'Test-Alert versendet'),
|
||
onError: (e: Error) => toast.error(e.message || 'Test fehlgeschlagen'),
|
||
});
|
||
|
||
const runDigest = useMutation({
|
||
mutationFn: () => monitoringApi.runDigest(),
|
||
onSuccess: (res) => {
|
||
const r = res.data;
|
||
if (r?.sent) toast.success(`Digest mit ${r.eventCount} Events versendet`);
|
||
else toast(r?.reason || 'Kein Digest versendet', { icon: 'ℹ️' });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message || 'Digest fehlgeschlagen'),
|
||
});
|
||
|
||
const events = eventsData?.data || [];
|
||
const stats = eventsData?.stats;
|
||
const pagination = eventsData?.pagination;
|
||
|
||
return (
|
||
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
||
<div className="mb-6">
|
||
<Button variant="ghost" onClick={() => navigate('/settings')} className="mb-2">
|
||
<ArrowLeft className="w-4 h-4 mr-1" /> Zurück zu Einstellungen
|
||
</Button>
|
||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||
<ShieldAlert className="w-6 h-6 text-orange-500" /> Sicherheits-Monitoring
|
||
</h1>
|
||
<p className="text-gray-600 text-sm mt-1">
|
||
Sicherheitsrelevante Ereignisse + Alert-Einstellungen.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Settings */}
|
||
<Card className="mb-6">
|
||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||
<Mail className="w-5 h-5" /> Alert-Empfänger
|
||
</h2>
|
||
<div className="grid sm:grid-cols-2 gap-4 mb-4">
|
||
<div>
|
||
<Input
|
||
label="E-Mail-Adresse für Alerts"
|
||
type="email"
|
||
value={alertEmail}
|
||
onChange={(e) => setAlertEmail(e.target.value)}
|
||
placeholder="security@deine-firma.de"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">Leer lassen, um Alerts zu deaktivieren.</p>
|
||
</div>
|
||
<div className="flex items-end gap-3">
|
||
<label className="flex items-center gap-2 mb-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={digestEnabled}
|
||
onChange={(e) => setDigestEnabled(e.target.checked)}
|
||
className="w-4 h-4"
|
||
/>
|
||
<span className="text-sm">Stündlicher Digest (HIGH+MEDIUM Events)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-3 flex-wrap">
|
||
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>
|
||
Speichern
|
||
</Button>
|
||
<Button variant="secondary" onClick={() => testAlert.mutate()} disabled={!alertEmail || testAlert.isPending}>
|
||
<Send className="w-4 h-4 mr-1" /> Test-Alert senden
|
||
</Button>
|
||
<Button variant="secondary" onClick={() => runDigest.mutate()} disabled={!alertEmail || runDigest.isPending}>
|
||
<RefreshCw className="w-4 h-4 mr-1" /> Digest jetzt ausführen
|
||
</Button>
|
||
{settingsData?.data?.lastDigestAt && (
|
||
<span className="text-xs text-gray-500 self-center">
|
||
Letzter Digest: {new Date(settingsData.data.lastDigestAt).toLocaleString('de-DE')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 text-xs text-gray-600">
|
||
<strong>Sofort-Alert:</strong> CRITICAL-Events (z.B. Brute-Force-Verdacht) werden binnen 1 Minute per
|
||
E-Mail versendet.<br />
|
||
<strong>Digest:</strong> HIGH+MEDIUM-Events werden zur vollen Stunde gesammelt verschickt (wenn aktiviert).
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Stats-Cards */}
|
||
{stats && (
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'] as SecuritySeverity[]).map((sev) => (
|
||
<Card key={sev}>
|
||
<div className={`text-xs font-semibold ${severityClass(sev).split(' ').filter((c) => c.startsWith('text-'))[0]}`}>
|
||
{severityIcon(sev)} {sev}
|
||
</div>
|
||
<div className="text-2xl font-bold mt-1">{stats.bySeverity[sev] || 0}</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Filter */}
|
||
<Card className="mb-4">
|
||
<div className="grid sm:grid-cols-4 gap-3">
|
||
<Select
|
||
label="Typ"
|
||
value={filters.type}
|
||
onChange={(e) => { setFilters((f) => ({ ...f, type: e.target.value as any })); setPage(1); }}
|
||
options={TYPE_OPTIONS}
|
||
/>
|
||
<Select
|
||
label="Severity"
|
||
value={filters.severity}
|
||
onChange={(e) => { setFilters((f) => ({ ...f, severity: e.target.value as any })); setPage(1); }}
|
||
options={SEVERITY_OPTIONS}
|
||
/>
|
||
<Input
|
||
label="Suche (Nachricht/User/Endpoint)"
|
||
value={filters.search}
|
||
onChange={(e) => { setFilters((f) => ({ ...f, search: e.target.value })); setPage(1); }}
|
||
placeholder="z.B. admin@admin.com"
|
||
/>
|
||
<Input
|
||
label="IP-Adresse"
|
||
value={filters.ip}
|
||
onChange={(e) => { setFilters((f) => ({ ...f, ip: e.target.value })); setPage(1); }}
|
||
placeholder="z.B. 1.2.3.4"
|
||
/>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Tabelle */}
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||
<h2 className="text-lg font-semibold">Events</h2>
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-sm text-gray-600">Pro Seite:</label>
|
||
<select
|
||
value={pageSize}
|
||
onChange={(e) => { setPageSize(parseInt(e.target.value)); setPage(1); }}
|
||
className="px-2 py-1 border border-gray-300 rounded text-sm"
|
||
>
|
||
<option value={10}>10</option>
|
||
<option value={25}>25</option>
|
||
<option value={50}>50</option>
|
||
<option value={100}>100</option>
|
||
<option value={200}>200</option>
|
||
</select>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => queryClient.invalidateQueries({ queryKey: ['monitoring-events'] })}
|
||
disabled={eventsLoading}
|
||
title="Events jetzt neu laden. Auto-Refresh läuft sonst alle 30 s im Hintergrund."
|
||
>
|
||
<RefreshCw className={`w-4 h-4 mr-1 ${eventsLoading ? 'animate-spin' : ''}`} />
|
||
Aktualisieren
|
||
</Button>
|
||
<Button variant="secondary" size="sm" onClick={() => setShowClearConfirm(true)}>
|
||
<Trash2 className="w-4 h-4 mr-1" /> Log leeren
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{eventsLoading ? (
|
||
<div className="text-gray-500 py-4">Lade…</div>
|
||
) : events.length === 0 ? (
|
||
<div className="text-gray-500 py-8 text-center">Keine Events für diese Filter.</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 text-left">
|
||
<tr>
|
||
<th className="px-3 py-2 whitespace-nowrap">Zeit</th>
|
||
<th className="px-3 py-2">Severity</th>
|
||
<th className="px-3 py-2">Typ</th>
|
||
<th className="px-3 py-2">Nachricht</th>
|
||
<th className="px-3 py-2">Wer</th>
|
||
<th className="px-3 py-2">IP</th>
|
||
<th className="px-3 py-2">Endpoint</th>
|
||
<th className="px-3 py-2">Alert</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{events.map((e) => (
|
||
<tr key={e.id} className="border-t hover:bg-gray-50">
|
||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
|
||
{new Date(e.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })}
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${severityClass(e.severity)}`}>
|
||
{severityIcon(e.severity)} {e.severity}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 font-mono text-xs">{e.type}</td>
|
||
<td className="px-3 py-2">{e.message}</td>
|
||
<td className="px-3 py-2 text-xs">{e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–')}</td>
|
||
<td className="px-3 py-2 font-mono text-xs">{e.ipAddress || '–'}</td>
|
||
<td className="px-3 py-2 font-mono text-xs">{e.endpoint || '–'}</td>
|
||
<td className="px-3 py-2 text-xs">{e.alerted ? '✉️ ja' : '–'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
{pagination && pagination.totalPages > 1 && (
|
||
<div className="flex flex-wrap items-center justify-between gap-3 mt-4 text-sm">
|
||
<span className="text-gray-600">
|
||
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => setPage(1)}
|
||
disabled={page <= 1}
|
||
title="Erste Seite"
|
||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||
>
|
||
<ChevronsLeft className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||
disabled={page <= 1}
|
||
title="Vorherige Seite"
|
||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||
>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</button>
|
||
{paginationWindow(page, pagination.totalPages, 10).map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setPage(p)}
|
||
className={`min-w-[32px] px-2 py-1 rounded border text-sm ${
|
||
p === page
|
||
? 'bg-blue-600 text-white border-blue-600'
|
||
: 'border-gray-300 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||
disabled={page >= pagination.totalPages}
|
||
title="Nächste Seite"
|
||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||
>
|
||
<ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => setPage(pagination.totalPages)}
|
||
disabled={page >= pagination.totalPages}
|
||
title="Letzte Seite"
|
||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||
>
|
||
<ChevronsRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Clear-Confirm-Modal */}
|
||
<Modal
|
||
isOpen={showClearConfirm}
|
||
onClose={() => setShowClearConfirm(false)}
|
||
title="Security-Log leeren"
|
||
size="sm"
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-gray-700">
|
||
Sicher? Alle Events werden aus der Datenbank entfernt. Ein
|
||
Audit-Log-Eintrag mit deinem Namen bleibt erhalten.
|
||
</p>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nur Events älter als (Tage)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={clearOlderThanDays}
|
||
onChange={(e) => setClearOlderThanDays(e.target.value === '' ? '' : parseInt(e.target.value))}
|
||
placeholder="leer = alle löschen"
|
||
className="block w-full max-w-[200px] px-3 py-2 border border-gray-300 rounded text-sm"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Beispiel: 30 = nur Events älter als 30 Tage löschen.
|
||
</p>
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button variant="secondary" onClick={() => setShowClearConfirm(false)}>
|
||
Abbrechen
|
||
</Button>
|
||
<Button
|
||
onClick={() => clearEvents.mutate(clearOlderThanDays === '' ? undefined : Number(clearOlderThanDays))}
|
||
disabled={clearEvents.isPending}
|
||
className="!bg-red-600 hover:!bg-red-700"
|
||
>
|
||
<Trash2 className="w-4 h-4 mr-1" />
|
||
{clearOlderThanDays === '' ? 'Alle löschen' : `Älter als ${clearOlderThanDays} Tage löschen`}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|