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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
804cd62201
commit
767373b206
@@ -0,0 +1,564 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user