Files
archivmail/src/components/admin/tabs/RoutingRulesTab.tsx
T
sysopsandClaude Sonnet 5 767373b206 feat(PROJ-46): E-Mail als primärer Login-Identifier für Tenant-User
Tenant-User (tenant_id IS NOT NULL) melden sich künftig per E-Mail an statt
per Username — behebt Verwechslungen wie im Support-Fall vom 2026-06-13
(Login schlug trotz Passwort-Reset fehl, weil E-Mail statt Username
verwendet wurde). Nicht-Tenant-User (Superadmin/System) können weiterhin
Username ODER E-Mail nutzen.

Neue Store.VerifyLogin() prüft erst per E-Mail (alle User), fällt dann auf
Username zurück (nur tenant_id IS NULL). VerifyPassword() bleibt für den
IMAP-Server-Login-Pfad (PROJ-26) unverändert. Bewusster Breaking Change für
Tenant-User, Datenqualität vorab geprüft (0 Kollisionen).

Security-Nachtrag: bcrypt-Dummy-Compare im "user not found"-Pfad ergänzt,
um Timing-basierte Identifier-Enumeration zu verhindern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:37:20 +02:00

565 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
getRoutingRules,
createRoutingRule,
updateRoutingRule,
deleteRoutingRule,
dryRunRoutingRule,
getTenants,
type RoutingRule,
type RoutingRuleInput,
type RoutingMatchType,
type RoutingDryRunResult,
type Tenant,
} from "@/lib/api";
const MATCH_LABELS: Record<RoutingMatchType, string> = {
from_domain: "Absender-Domain",
to_domain: "Empfänger-Domain",
from_addr: "Absender-Adresse",
to_addr: "Empfänger-Adresse",
};
const MATCH_PLACEHOLDER: Record<RoutingMatchType, string> = {
from_domain: "z.B. kunde.de oder *.kunde.de",
to_domain: "z.B. firma.de",
from_addr: "z.B. buchhaltung@kunde.de",
to_addr: "z.B. archiv@firma.de",
};
interface EditState {
id: number | null; // null = create
tenant_id: string; // "" = noch nicht gewählt (nur superadmin relevant)
match_type: RoutingMatchType;
pattern: string;
priority: string;
}
const EMPTY_EDIT: EditState = {
id: null,
tenant_id: "",
match_type: "from_domain",
pattern: "",
priority: "0",
};
function formatDate(value: string | null): string {
if (!value) return "";
const d = new Date(value);
if (isNaN(d.getTime())) return value;
return d.toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export function RoutingRulesTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const [rules, setRules] = useState<RoutingRule[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [edit, setEdit] = useState<EditState | null>(null);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState("");
// Dry-Run innerhalb des Anlege-/Bearbeiten-Dialogs
const [dryRunning, setDryRunning] = useState(false);
const [dryRunResult, setDryRunResult] = useState<RoutingDryRunResult | null>(null);
const [dryRunError, setDryRunError] = useState("");
const [deleteRule, setDeleteRule] = useState<RoutingRule | null>(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(() => {
setLoading(true);
setError("");
const loaders: [Promise<RoutingRule[]>, Promise<Tenant[]>] = [
getRoutingRules(),
isSuperAdmin ? getTenants() : Promise.resolve([]),
];
Promise.all(loaders)
.then(([rs, ts]) => {
setRules(rs);
setTenants(ts);
})
.catch(() => setError("Routing-Regeln konnten nicht geladen werden"))
.finally(() => setLoading(false));
}, [isSuperAdmin]);
useEffect(() => {
load();
}, [load]);
const tenantName = (id: number): React.ReactNode => {
const t = tenants.find((x) => x.id === id);
return t ? t.name : `#${id}`;
};
const resetDryRun = () => {
setDryRunResult(null);
setDryRunError("");
};
const openCreate = () => {
setEdit({ ...EMPTY_EDIT });
setFormError("");
resetDryRun();
};
const openEdit = (r: RoutingRule) => {
setEdit({
id: r.id,
tenant_id: String(r.tenant_id),
match_type: r.match_type,
pattern: r.pattern,
priority: String(r.priority),
});
setFormError("");
resetDryRun();
};
const buildInput = (): RoutingRuleInput | null => {
if (!edit) return null;
const pattern = edit.pattern.trim();
if (!pattern) {
setFormError("Muster darf nicht leer sein");
return null;
}
const priority = parseInt(edit.priority, 10);
if (isNaN(priority)) {
setFormError("Priorität muss eine Zahl sein");
return null;
}
const input: RoutingRuleInput = {
match_type: edit.match_type,
pattern,
priority,
};
if (isSuperAdmin) {
if (edit.tenant_id === "") {
setFormError("Bitte einen Mandanten auswählen");
return null;
}
input.tenant_id = parseInt(edit.tenant_id, 10);
}
return input;
};
const handleSave = async () => {
if (!edit) return;
setFormError("");
const input = buildInput();
if (!input) return;
setSaving(true);
try {
if (edit.id === null) {
await createRoutingRule(input);
} else {
await updateRoutingRule(edit.id, input);
}
setEdit(null);
load();
} catch (e: unknown) {
setFormError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
};
const handleDryRun = async () => {
if (!edit) return;
setDryRunError("");
setDryRunResult(null);
const pattern = edit.pattern.trim();
if (!pattern) {
setDryRunError("Muster darf nicht leer sein");
return;
}
setDryRunning(true);
try {
const res = await dryRunRoutingRule({
match_type: edit.match_type,
pattern,
});
setDryRunResult(res);
} catch (e: unknown) {
setDryRunError(e instanceof Error ? e.message : "Dry-Run fehlgeschlagen");
} finally {
setDryRunning(false);
}
};
const handleDelete = async () => {
if (!deleteRule) return;
setDeleting(true);
try {
await deleteRoutingRule(deleteRule.id);
setDeleteRule(null);
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen");
setDeleteRule(null);
} finally {
setDeleting(false);
}
};
return (
<div className="mt-4 space-y-4">
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Routing-Regeln</CardTitle>
<CardDescription>
Ordnen eingehende Mails automatisch einem Mandanten zu nach
Absender-/Empfänger-Domain oder -Adresse. Wildcard-Domains via{" "}
<code className="text-xs">*.kunde.de</code>.
</CardDescription>
</div>
<Button size="sm" onClick={openCreate}>
Regel hinzufügen
</Button>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription className="text-xs">
<strong>Priorität:</strong> Höhere Zahl = höhere Priorität und
gewinnt, wenn mehrere Regeln zutreffen (bei Gleichstand die zuerst
angelegte Regel). Regeln greifen nur beim Import neuer Mails {" "}
<strong>bereits archivierte Mails werden nicht rückwirkend
umgeroutet.</strong>
</AlertDescription>
</Alert>
{error && <p className="text-sm text-destructive">{error}</p>}
{loading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : rules.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Noch keine Routing-Regeln definiert.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Prio</TableHead>
<TableHead>Typ</TableHead>
<TableHead>Muster</TableHead>
{isSuperAdmin && <TableHead>Mandant</TableHead>}
<TableHead>Angelegt</TableHead>
<TableHead className="w-40"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{MATCH_LABELS[r.match_type] ?? r.match_type}</TableCell>
<TableCell className="font-mono text-xs break-all">
{r.pattern}
</TableCell>
{isSuperAdmin && <TableCell>{tenantName(r.tenant_id)}</TableCell>}
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatDate(r.created_at)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={() => openEdit(r)}
>
Bearbeiten
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => setDeleteRule(r)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Create / Edit Dialog */}
<Dialog
open={!!edit}
onOpenChange={(o) => {
if (!o) {
setEdit(null);
resetDryRun();
}
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{edit?.id === null ? "Regel hinzufügen" : "Regel bearbeiten"}
</DialogTitle>
<DialogDescription>
Legt fest, welche eingehenden Mails automatisch welchem Mandanten
zugeordnet werden.
</DialogDescription>
</DialogHeader>
{edit && (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Match-Typ</Label>
<Select
value={edit.match_type}
onValueChange={(v) => {
setEdit({ ...edit, match_type: v as RoutingMatchType });
resetDryRun();
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(MATCH_LABELS) as RoutingMatchType[]).map((k) => (
<SelectItem key={k} value={k}>
{MATCH_LABELS[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="routing-pattern">Muster</Label>
<Input
id="routing-pattern"
placeholder={MATCH_PLACEHOLDER[edit.match_type]}
value={edit.pattern}
onChange={(e) => {
setEdit({ ...edit, pattern: e.target.value });
resetDryRun();
}}
/>
</div>
{isSuperAdmin && (
<div className="space-y-2">
<Label>Mandant</Label>
<Select
value={edit.tenant_id}
onValueChange={(v) => setEdit({ ...edit, tenant_id: v })}
>
<SelectTrigger>
<SelectValue placeholder="Mandant auswählen" />
</SelectTrigger>
<SelectContent>
{tenants.map((t) => (
<SelectItem key={t.id} value={String(t.id)}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="routing-priority">Priorität</Label>
<Input
id="routing-priority"
type="number"
value={edit.priority}
onChange={(e) => setEdit({ ...edit, priority: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Höhere Zahl = höhere Priorität. Bei mehreren Treffern gewinnt
die Regel mit der höchsten Priorität.
</p>
</div>
{/* Dry-Run */}
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">Test (Dry-Run)</p>
<p className="text-xs text-muted-foreground">
Zeigt, wie viele bereits archivierte Mails dieses Muster
treffen würde (nur zur Vorschau).
</p>
</div>
<Button
type="button"
size="sm"
variant="secondary"
disabled={dryRunning}
onClick={handleDryRun}
>
{dryRunning ? "Teste..." : "Dry-Run"}
</Button>
</div>
{dryRunError && (
<p className="text-sm text-destructive">{dryRunError}</p>
)}
{dryRunResult && (
<div className="space-y-2">
<p className="text-sm">
<Badge>{dryRunResult.match_count} Treffer</Badge>{" "}
<span className="text-xs text-muted-foreground">
(Stichprobe max. {dryRunResult.sample_limit})
</span>
</p>
{dryRunResult.sample && dryRunResult.sample.length > 0 ? (
<div className="max-h-48 overflow-y-auto rounded border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Von</TableHead>
<TableHead>An</TableHead>
<TableHead>Betreff</TableHead>
<TableHead>Datum</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dryRunResult.sample.map((s) => (
<TableRow key={s.id}>
<TableCell className="max-w-[10rem] truncate text-xs">
{s.mail_from}
</TableCell>
<TableCell className="max-w-[10rem] truncate text-xs">
{s.mail_to}
</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs">
{s.subject}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatDate(s.received_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-xs text-muted-foreground">
Keine passenden Mails im Archiv gefunden.
</p>
)}
</div>
)}
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setEdit(null);
resetDryRun();
}}
>
Abbrechen
</Button>
<Button disabled={saving} onClick={handleSave}>
{saving ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<Dialog
open={!!deleteRule}
onOpenChange={(o) => {
if (!o) setDeleteRule(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Regel löschen</DialogTitle>
<DialogDescription>
Die Regel wird gelöscht. Bereits archivierte Mails bleiben ihrem
bisherigen Mandanten zugeordnet.
</DialogDescription>
</DialogHeader>
{deleteRule && (
<p className="py-2 text-sm">
<span className="font-mono">{deleteRule.pattern}</span> (
{MATCH_LABELS[deleteRule.match_type]})
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteRule(null)}>
Abbrechen
</Button>
<Button
variant="destructive"
disabled={deleting}
onClick={handleDelete}
>
{deleting ? "Lösche..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}