8fc050a282
Strukturierter Test-Katalog für manuelle Abnahmetests vor einem Release. Security-System (10 Abschnitte): - Login + Rate-Limiting - Passwort-Reset-Flow (Mitarbeiter + Portal) - Rate-Limiting Passwort-Reset - Berechtigungen (RBAC) - Portal-Isolation (DSGVO-kritisch) - Session-Invalidation - Audit-Log - DSGVO-Features (Export, Löschanfragen, Einwilligungen) - Verschlüsselte Credentials - DSGVO-Einwilligung sperrt Tabs für Mitarbeiter Email-Log-System (5 Abschnitte): - Email-Log-Seite (UI, Filter, Suche, Pagination) - Alle 6 Kontexte durchspielen: consent-link, authorization-request, customer-email, birthday-greeting, birthday-greeting-auto, password-reset - Fehlgeschlagener Versand wird geloggt (Test mit falschem SMTP-Passwort) - Details-Modal mit SMTP-Details - Automatisches Logging (Kontext, triggeredBy) Außerdem: Neue Kontexte in EmailLogs.tsx CONTEXT_LABELS ergänzt (birthday-greeting, birthday-greeting-auto, password-reset). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
309 lines
13 KiB
TypeScript
309 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { emailLogApi, EmailLog } from '../../services/api';
|
|
import Card from '../../components/ui/Card';
|
|
import Button from '../../components/ui/Button';
|
|
import Input from '../../components/ui/Input';
|
|
import Badge from '../../components/ui/Badge';
|
|
import Modal from '../../components/ui/Modal';
|
|
import { ArrowLeft, CheckCircle2, XCircle, Mail, Server, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
const CONTEXT_LABELS: Record<string, string> = {
|
|
'consent-link': 'Datenschutz-Link',
|
|
'authorization-request': 'Vollmacht-Anfrage',
|
|
'customer-email': 'Kunden-E-Mail',
|
|
'birthday-greeting': 'Geburtstagsgruß (manuell)',
|
|
'birthday-greeting-auto': 'Geburtstagsgruß (automatisch)',
|
|
'password-reset': 'Passwort-Reset',
|
|
};
|
|
|
|
export default function EmailLogs() {
|
|
const navigate = useNavigate();
|
|
const [page, setPage] = useState(1);
|
|
const [search, setSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
|
const [contextFilter, setContextFilter] = useState<string>('');
|
|
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
|
|
|
const { data: statsData } = useQuery({
|
|
queryKey: ['email-log-stats'],
|
|
queryFn: () => emailLogApi.getStats(),
|
|
});
|
|
|
|
const { data: logsData, isLoading } = useQuery({
|
|
queryKey: ['email-logs', page, search, statusFilter, contextFilter],
|
|
queryFn: () => emailLogApi.getLogs({
|
|
page,
|
|
limit: 30,
|
|
success: statusFilter || undefined,
|
|
search: search || undefined,
|
|
context: contextFilter || undefined,
|
|
}),
|
|
});
|
|
|
|
const stats = statsData?.data;
|
|
const logs = logsData?.data || [];
|
|
const pagination = logsData?.pagination;
|
|
|
|
const formatDate = (date: string) =>
|
|
new Date(date).toLocaleDateString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
});
|
|
|
|
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">E-Mail-Versandlog</h1>
|
|
</div>
|
|
|
|
{/* Statistiken */}
|
|
{stats && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<Card>
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
|
|
<p className="text-sm text-gray-500">Gesamt</p>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-green-600">{stats.success}</p>
|
|
<p className="text-sm text-gray-500">Erfolgreich</p>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-red-600">{stats.failed}</p>
|
|
<p className="text-sm text-gray-500">Fehlgeschlagen</p>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-blue-600">{stats.last24h}</p>
|
|
<p className="text-sm text-gray-500">Letzte 24h</p>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filter */}
|
|
<Card className="mb-6">
|
|
<div className="flex gap-4 flex-wrap">
|
|
<div className="flex-1 min-w-[200px]">
|
|
<Input
|
|
placeholder="Suche (Absender, Empfänger, Betreff...)"
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
|
/>
|
|
</div>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
>
|
|
<option value="">Alle Status</option>
|
|
<option value="true">Erfolgreich</option>
|
|
<option value="false">Fehlgeschlagen</option>
|
|
</select>
|
|
<select
|
|
value={contextFilter}
|
|
onChange={(e) => { setContextFilter(e.target.value); setPage(1); }}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
|
>
|
|
<option value="">Alle Typen</option>
|
|
<option value="consent-link">Datenschutz-Link</option>
|
|
<option value="authorization-request">Vollmacht-Anfrage</option>
|
|
<option value="customer-email">Kunden-E-Mail</option>
|
|
</select>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Log-Tabelle */}
|
|
<Card>
|
|
{isLoading ? (
|
|
<div className="text-center py-8 text-gray-500">Laden...</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500">Keine E-Mail-Logs vorhanden.</div>
|
|
) : (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Status</th>
|
|
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Zeitpunkt</th>
|
|
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Typ</th>
|
|
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Von</th>
|
|
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">An</th>
|
|
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">Betreff</th>
|
|
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm">SMTP</th>
|
|
<th className="text-left py-3 px-3 font-medium text-gray-600 text-sm"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.map((log) => (
|
|
<tr key={log.id} className="border-b hover:bg-gray-50">
|
|
<td className="py-2 px-3">
|
|
{log.success ? (
|
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-red-500" />
|
|
)}
|
|
</td>
|
|
<td className="py-2 px-3 text-xs text-gray-500 whitespace-nowrap">
|
|
{formatDate(log.sentAt)}
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">
|
|
{CONTEXT_LABELS[log.context] || log.context}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 px-3 text-sm truncate max-w-[150px]">{log.fromAddress}</td>
|
|
<td className="py-2 px-3 text-sm truncate max-w-[150px]">{log.toAddress}</td>
|
|
<td className="py-2 px-3 text-sm truncate max-w-[200px]">{log.subject}</td>
|
|
<td className="py-2 px-3 text-xs text-gray-500 whitespace-nowrap">
|
|
{log.smtpServer}:{log.smtpPort}
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
<button
|
|
onClick={() => setSelectedLog(log)}
|
|
className="text-blue-600 hover:underline text-xs"
|
|
>
|
|
Details
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{pagination && pagination.totalPages > 1 && (
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<p className="text-sm text-gray-500">
|
|
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage(page - 1)}
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={page >= pagination.totalPages}
|
|
onClick={() => setPage(page + 1)}
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Detail-Modal */}
|
|
{selectedLog && (
|
|
<Modal
|
|
isOpen={true}
|
|
onClose={() => setSelectedLog(null)}
|
|
title="E-Mail-Log Details"
|
|
>
|
|
<div className="space-y-4">
|
|
{/* Status */}
|
|
<div className="flex items-center gap-2">
|
|
{selectedLog.success ? (
|
|
<Badge variant="success">Erfolgreich</Badge>
|
|
) : (
|
|
<Badge variant="danger">Fehlgeschlagen</Badge>
|
|
)}
|
|
<span className="text-sm text-gray-500">{formatDate(selectedLog.sentAt)}</span>
|
|
</div>
|
|
|
|
{/* E-Mail-Details */}
|
|
<div className="border rounded-lg p-4 space-y-2">
|
|
<div className="flex items-start gap-2">
|
|
<Mail className="w-4 h-4 text-gray-400 mt-0.5" />
|
|
<div className="flex-1">
|
|
<div className="grid grid-cols-[80px_1fr] gap-1 text-sm">
|
|
<span className="text-gray-500">Von:</span>
|
|
<span>{selectedLog.fromAddress}</span>
|
|
<span className="text-gray-500">An:</span>
|
|
<span>{selectedLog.toAddress}</span>
|
|
<span className="text-gray-500">Betreff:</span>
|
|
<span>{selectedLog.subject}</span>
|
|
<span className="text-gray-500">Typ:</span>
|
|
<span>{CONTEXT_LABELS[selectedLog.context] || selectedLog.context}</span>
|
|
{selectedLog.triggeredBy && (
|
|
<>
|
|
<span className="text-gray-500">Ausgelöst von:</span>
|
|
<span>{selectedLog.triggeredBy}</span>
|
|
</>
|
|
)}
|
|
{selectedLog.messageId && (
|
|
<>
|
|
<span className="text-gray-500">Message-ID:</span>
|
|
<span className="font-mono text-xs break-all">{selectedLog.messageId}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SMTP-Details */}
|
|
<div className="border rounded-lg p-4 space-y-2">
|
|
<div className="flex items-start gap-2">
|
|
<Server className="w-4 h-4 text-gray-400 mt-0.5" />
|
|
<div className="flex-1">
|
|
<h4 className="text-sm font-medium mb-2">SMTP-Verbindung</h4>
|
|
<div className="grid grid-cols-[100px_1fr] gap-1 text-sm">
|
|
<span className="text-gray-500">Server:</span>
|
|
<span className="font-mono text-xs">{selectedLog.smtpServer}:{selectedLog.smtpPort}</span>
|
|
<span className="text-gray-500">Verschlüsselung:</span>
|
|
<span>{selectedLog.smtpEncryption}</span>
|
|
<span className="text-gray-500">Benutzer:</span>
|
|
<span className="font-mono text-xs">{selectedLog.smtpUser}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SMTP-Antwort */}
|
|
{selectedLog.smtpResponse && (
|
|
<div className="border rounded-lg p-4">
|
|
<h4 className="text-sm font-medium mb-2">SMTP-Antwort</h4>
|
|
<pre className="text-xs bg-gray-50 p-3 rounded overflow-x-auto whitespace-pre-wrap font-mono text-gray-700">
|
|
{selectedLog.smtpResponse}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* Fehlermeldung */}
|
|
{selectedLog.errorMessage && (
|
|
<div className="border border-red-200 rounded-lg p-4 bg-red-50">
|
|
<h4 className="text-sm font-medium text-red-800 mb-2">Fehlermeldung</h4>
|
|
<pre className="text-xs whitespace-pre-wrap font-mono text-red-700">
|
|
{selectedLog.errorMessage}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|