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
@@ -62,11 +62,11 @@ export default function AdminLoginPage() {
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Benutzername</Label>
|
||||
<Label htmlFor="username">Benutzername oder E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Benutzername"
|
||||
placeholder="Benutzername oder E-Mail-Adresse"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ModulesTab } from "@/components/admin/ModulesTab";
|
||||
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
|
||||
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
|
||||
import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab";
|
||||
import { RoutingRulesTab } from "@/components/admin/tabs/RoutingRulesTab";
|
||||
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
|
||||
import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab";
|
||||
import { DSGVOTab } from "@/components/admin/tabs/DSGVOTab";
|
||||
@@ -167,6 +168,7 @@ export default function AdminPage() {
|
||||
{isSuperAdmin && <TabsTrigger value="tenants" onClick={tenantsState.loadTenants}>Mandanten</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="archiving-rules">Regeln</TabsTrigger>}
|
||||
<TabsTrigger value="routing-rules">Routing-Regeln</TabsTrigger>
|
||||
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="smtp-out">SMTP-Out</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
||||
@@ -442,6 +444,10 @@ export default function AdminPage() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="routing-rules">
|
||||
<RoutingRulesTab isSuperAdmin={isSuperAdmin} />
|
||||
</TabsContent>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="smtp-out">
|
||||
<SMTPOutTab />
|
||||
|
||||
+5
-5
@@ -60,16 +60,16 @@ export default function LoginPage() {
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Benutzername</Label>
|
||||
<Label htmlFor="username">E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Benutzername"
|
||||
type="email"
|
||||
placeholder="E-Mail-Adresse"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
aria-label="Benutzername"
|
||||
autoComplete="email"
|
||||
aria-label="E-Mail-Adresse"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -188,6 +188,23 @@ export {
|
||||
exportReconciliationCSV,
|
||||
} from "./reconciliation";
|
||||
|
||||
export type {
|
||||
RoutingMatchType,
|
||||
RoutingRule,
|
||||
RoutingRulesResponse,
|
||||
RoutingRuleInput,
|
||||
RoutingDryRunSample,
|
||||
RoutingDryRunResult,
|
||||
RoutingDryRunInput,
|
||||
} from "./routing_rules";
|
||||
export {
|
||||
getRoutingRules,
|
||||
createRoutingRule,
|
||||
updateRoutingRule,
|
||||
deleteRoutingRule,
|
||||
dryRunRoutingRule,
|
||||
} from "./routing_rules";
|
||||
|
||||
export type { SavedSearch } from "./saved_searches";
|
||||
export {
|
||||
listSavedSearches,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { request } from "./core";
|
||||
|
||||
// PROJ-43: Tenant-Routing-Regeln — automatische Mandanten-Zuordnung nach
|
||||
// Domain/Absender-Mustern mit Priorität + Dry-Run.
|
||||
// CRUD unter /api/admin/routing-rules (domain_admin+; superadmin = alle Tenants,
|
||||
// domain_admin = eigener Tenant, serverseitig erzwungen).
|
||||
|
||||
// Match-Typen — müssen mit den Backend-Konstanten übereinstimmen.
|
||||
export type RoutingMatchType =
|
||||
| "from_domain"
|
||||
| "to_domain"
|
||||
| "from_addr"
|
||||
| "to_addr";
|
||||
|
||||
export interface RoutingRule {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
match_type: RoutingMatchType;
|
||||
pattern: string;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RoutingRulesResponse {
|
||||
rules: RoutingRule[] | null;
|
||||
}
|
||||
|
||||
// tenant_id optional: nur superadmin muss ihn setzen; domain_admin bekommt
|
||||
// serverseitig seinen eigenen Tenant erzwungen.
|
||||
export interface RoutingRuleInput {
|
||||
tenant_id?: number;
|
||||
match_type: RoutingMatchType;
|
||||
pattern: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface RoutingDryRunSample {
|
||||
id: string;
|
||||
mail_from: string;
|
||||
mail_to: string;
|
||||
subject: string;
|
||||
received_at: string | null;
|
||||
}
|
||||
|
||||
export interface RoutingDryRunResult {
|
||||
match_type: RoutingMatchType;
|
||||
pattern: string;
|
||||
match_count: number;
|
||||
sample_limit: number;
|
||||
sample: RoutingDryRunSample[] | null;
|
||||
}
|
||||
|
||||
// Dry-Run kann entweder gegen eine bestehende Regel (rule_id) oder gegen ein
|
||||
// noch nicht gespeichertes Muster (match_type + pattern) laufen.
|
||||
export type RoutingDryRunInput =
|
||||
| { rule_id: number; limit?: number }
|
||||
| { match_type: RoutingMatchType; pattern: string; limit?: number };
|
||||
|
||||
export async function getRoutingRules(): Promise<RoutingRule[]> {
|
||||
const data = await request<RoutingRulesResponse>("/api/admin/routing-rules");
|
||||
return data.rules ?? [];
|
||||
}
|
||||
|
||||
export async function createRoutingRule(
|
||||
input: RoutingRuleInput
|
||||
): Promise<{ id: number }> {
|
||||
return request<{ id: number }>("/api/admin/routing-rules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateRoutingRule(
|
||||
id: number,
|
||||
input: RoutingRuleInput
|
||||
): Promise<void> {
|
||||
await request<void>(`/api/admin/routing-rules/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRoutingRule(id: number): Promise<void> {
|
||||
await request<void>(`/api/admin/routing-rules/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function dryRunRoutingRule(
|
||||
input: RoutingDryRunInput
|
||||
): Promise<RoutingDryRunResult> {
|
||||
return request<RoutingDryRunResult>("/api/admin/routing-rules/dry-run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user