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:
sysops
2026-07-03 23:37:20 +02:00
co-authored by Claude Sonnet 5
parent 804cd62201
commit 767373b206
18 changed files with 1710 additions and 14 deletions
+2 -2
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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>
);
}
+17
View File
@@ -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,
+94
View File
@@ -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),
});
}