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