Files
archivmail/src/components/admin/tabs/TenantLDAPTab.tsx
T
sysops e0f6a818eb feat: LDAP-Test zeigt klickbare Filter-Vorschläge (UCS/AD-Erkennung)
Nach erfolgreichem Verbindungstest werden passende Filter-Vorschläge
angezeigt. Erkennt automatisch Univention UCS (posixAccount) vs.
Active Directory (sAMAccountName). Klick übernimmt den Filter direkt
ins Formular. Vorschläge berücksichtigen mailPrimaryAddress (UCS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 14:50:20 +01:00

415 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 { type TenantLDAPConfig, type LDAPTestResult, type LDAPFilterSuggestion } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface TenantLDAPTabProps {
tenantLdapConfig: TenantLDAPConfig | null;
tenantLdapLoading: boolean;
tenantLdapSaving: boolean;
tenantLdapTesting: boolean;
tenantLdapError: string;
tenantLdapTestResult: LDAPTestResult | null;
tenantLdapForm: TenantLDAPConfig;
setTenantLdapForm: React.Dispatch<React.SetStateAction<TenantLDAPConfig>>;
tenantLdapChangePassword: boolean;
setTenantLdapChangePassword: (v: boolean) => void;
ownLogoPreviewUrl: string | null;
ownLogoUploading: boolean;
ownLogoError: string;
onSave: (e: React.FormEvent) => void;
onTest: () => void;
onDelete: () => void;
onOwnLogoUpload: (file: File) => void;
onOwnLogoDelete: () => void;
}
export function TenantLDAPTab({
tenantLdapConfig,
tenantLdapLoading,
tenantLdapSaving,
tenantLdapTesting,
tenantLdapError,
tenantLdapTestResult,
tenantLdapForm,
setTenantLdapForm,
tenantLdapChangePassword,
setTenantLdapChangePassword,
ownLogoPreviewUrl,
ownLogoUploading,
ownLogoError,
onSave,
onTest,
onDelete,
onOwnLogoUpload,
onOwnLogoDelete,
}: TenantLDAPTabProps) {
return (
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">LDAP / Active Directory Mandantenkonfiguration</h2>
<p className="text-sm text-muted-foreground mt-1">Konfiguriere den LDAP-Server für deinen Mandanten.</p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
<input
type="checkbox"
className="h-4 w-4"
checked={tenantLdapForm.enabled}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</div>
</div>
{tenantLdapError && (
<Alert variant="destructive">
<AlertDescription>{tenantLdapError}</AlertDescription>
</Alert>
)}
{tenantLdapLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<form onSubmit={onSave} className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tldap-url">Server-URL</Label>
<Input
id="tldap-url"
placeholder="ldap://server:389 oder ldaps://server:636"
value={tenantLdapForm.url}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, url: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636
</p>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-bind-dn">Bind-DN (Service-Account)</Label>
<Input
id="tldap-bind-dn"
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
value={tenantLdapForm.bind_dn}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, bind_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-pw">Bind-Passwort</Label>
{tenantLdapConfig && !tenantLdapChangePassword ? (
<div className="flex gap-2">
<Input id="tldap-pw" type="password" value="••••••" readOnly className="flex-1" />
<Button type="button" variant="outline" size="sm" onClick={() => setTenantLdapChangePassword(true)}>
Ändern
</Button>
</div>
) : (
<Input
id="tldap-pw"
type="password"
placeholder="Neues Passwort eingeben"
value={tenantLdapForm.bind_password}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, bind_password: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="tldap-base-dn">Base-DN</Label>
<Input
id="tldap-base-dn"
placeholder="DC=example,DC=com"
value={tenantLdapForm.base_dn}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, base_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-filter">User-Filter</Label>
<Input
id="tldap-filter"
placeholder="(sAMAccountName=%s)"
value={tenantLdapForm.user_filter}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, user_filter: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-role">Standard-Rolle</Label>
<Select
value={tenantLdapForm.default_role}
onValueChange={(v) => setTenantLdapForm((f) => ({ ...f, default_role: v }))}
>
<SelectTrigger id="tldap-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={tenantLdapForm.tls}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, tls: e.target.checked }))}
/>
STARTTLS verwenden
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={tenantLdapForm.tls_skip_verify}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
/>
TLS-Zertifikat ignorieren
{tenantLdapForm.tls_skip_verify && (
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
)}
</label>
</div>
<Separator />
{/* Group mappings -- domain_admin: nur user + auditor */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Gruppen-Rollenzuordnung</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setTenantLdapForm((f) => ({
...f,
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
}))
}
>
+ Hinzufuegen
</Button>
</div>
{tenantLdapForm.group_mappings.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
) : (
<div className="space-y-2">
{tenantLdapForm.group_mappings.map((gm, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
className="flex-1"
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
value={gm.group_dn}
onChange={(e) => {
const gms = [...tenantLdapForm.group_mappings];
gms[i] = { ...gms[i], group_dn: e.target.value };
setTenantLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
/>
<Select
value={gm.role}
onValueChange={(v) => {
const gms = [...tenantLdapForm.group_mappings];
gms[i] = { ...gms[i], role: v };
setTenantLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
const gms = tenantLdapForm.group_mappings.filter((_, j) => j !== i);
setTenantLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
Entfernen
</Button>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Test result */}
{tenantLdapTestResult && (
<Card>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant={tenantLdapTestResult.ok ? "default" : "destructive"}>
{tenantLdapTestResult.ok ? "Verbunden" : "Fehler"}
</Badge>
<span className="text-sm">{tenantLdapTestResult.message}</span>
{tenantLdapTestResult.latency_ms > 0 && (
<span className="text-xs text-muted-foreground">{tenantLdapTestResult.latency_ms} ms</span>
)}
</div>
{tenantLdapTestResult.server_info && (
<p className="text-xs text-muted-foreground font-mono">{tenantLdapTestResult.server_info}</p>
)}
{tenantLdapTestResult.error_detail && (
<p className="text-xs text-destructive font-mono">{tenantLdapTestResult.error_detail}</p>
)}
{tenantLdapTestResult.ok && tenantLdapTestResult.users_found > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium">
{tenantLdapTestResult.users_found} Benutzer gefunden
{tenantLdapTestResult.users?.length < tenantLdapTestResult.users_found && (
<span className="text-muted-foreground font-normal"> (Vorschau: {tenantLdapTestResult.users?.length})</span>
)}
</p>
<div className="rounded border overflow-auto max-h-64">
<table className="w-full text-xs">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left px-2 py-1 font-medium">UID</th>
<th className="text-left px-2 py-1 font-medium">Name</th>
<th className="text-left px-2 py-1 font-medium">E-Mail</th>
</tr>
</thead>
<tbody>
{tenantLdapTestResult.users?.map((u, i) => (
<tr key={i} className="border-t">
<td className="px-2 py-1 font-mono">{u.uid || ""}</td>
<td className="px-2 py-1">{u.display_name || ""}</td>
<td className="px-2 py-1 text-muted-foreground">{u.mail || ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{tenantLdapTestResult.ok && tenantLdapTestResult.filter_suggestions?.length > 0 && (
<div className="space-y-2 pt-1">
<p className="text-xs font-medium text-muted-foreground">Filter-Vorschläge klicken zum Übernehmen:</p>
<div className="flex flex-wrap gap-2">
{tenantLdapTestResult.filter_suggestions.map((s: LDAPFilterSuggestion, i: number) => (
<button
key={i}
type="button"
title={s.description}
onClick={() => setTenantLdapForm((f) => ({ ...f, user_filter: s.filter }))}
className="inline-flex items-center px-2 py-1 rounded text-xs border border-border bg-muted hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer font-mono"
>
{s.label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
<span className="font-mono">%s</span> wird beim Login durch den Benutzernamen ersetzt
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Action bar */}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={onTest} disabled={tenantLdapTesting || tenantLdapSaving}>
{tenantLdapTesting ? "Teste..." : "Verbindung testen"}
</Button>
<Button type="submit" disabled={tenantLdapSaving || tenantLdapTesting}>
{tenantLdapSaving ? "Speichern..." : "Speichern"}
</Button>
{tenantLdapConfig && (
<Button
type="button"
variant="destructive"
disabled={tenantLdapSaving}
onClick={onDelete}
>
Konfiguration löschen
</Button>
)}
</div>
</form>
)}
{tenantLdapConfig && (
<p className="text-xs text-muted-foreground">
Zuletzt geändert: {tenantLdapConfig.updated_at ? new Date(tenantLdapConfig.updated_at).toLocaleString("de-DE") : ""}
{tenantLdapConfig.updated_by ? ` von ${tenantLdapConfig.updated_by}` : ""}
</p>
)}
{/* Logo section for domain_admin */}
<div className="space-y-3 pt-2">
<div>
<h3 className="text-base font-semibold">Mandanten-Logo</h3>
<p className="text-sm text-muted-foreground mt-1">Logo deines Mandanten hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).</p>
</div>
{ownLogoError && <p className="text-sm text-destructive">{ownLogoError}</p>}
{ownLogoPreviewUrl ? (
<div className="flex items-center gap-4">
<div className="flex items-center justify-center rounded border p-3 bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={ownLogoPreviewUrl} alt="Logo" className="max-h-20 max-w-40 object-contain" />
</div>
<div className="flex flex-col gap-2">
<Input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
disabled={ownLogoUploading}
onChange={(e) => { const f = e.target.files?.[0]; if (f) onOwnLogoUpload(f); }}
className="w-auto"
/>
<Button variant="destructive" size="sm" disabled={ownLogoUploading} onClick={onOwnLogoDelete}>
{ownLogoUploading ? "Bitte warten..." : "Logo entfernen"}
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-4">
<div className="flex items-center justify-center rounded border p-6 bg-muted/30 text-sm text-muted-foreground w-40">
Kein Logo
</div>
<Input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
disabled={ownLogoUploading}
onChange={(e) => { const f = e.target.files?.[0]; if (f) onOwnLogoUpload(f); }}
className="w-auto"
/>
</div>
)}
</div>
</div>
);
}