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>
565 lines
18 KiB
TypeScript
565 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|