feat(PROJ-23): Pro-Mandant LDAP Frontend
- domain_admin: neuer LDAP-Tab mit Tenant-Konfiguration (eigener Mandant) - Gruppen-Mapping auf user/auditor beschränkt (keine Eskalation) - Nutzt /api/tenant/ldap Endpunkte - superadmin: globaler LDAP-Tab umbenannt zu "LDAP (Global)" - superadmin: Mandantentabelle um LDAP-Statusspalte erweitert - Badge: Aktiv / Deaktiviert / --- - TenantLDAPDialog pro Mandant (alle Rollen bis domain_admin) - api.ts: TenantLDAPConfig Interface + 8 neue API-Funktionen - getTenantLDAPConfig/save/delete/test (domain_admin) - getAdminTenantLDAPConfig/save/delete/test (superadmin) - Tenant Interface: ldap_enabled + ldap_url Felder ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+749
-1
@@ -30,6 +30,14 @@ import {
|
||||
getTenantDomains,
|
||||
addTenantDomain,
|
||||
removeTenantDomain,
|
||||
getTenantLDAPConfig,
|
||||
saveTenantLDAPConfig,
|
||||
deleteTenantLDAPConfig,
|
||||
testTenantLDAPConfig,
|
||||
getAdminTenantLDAPConfig,
|
||||
saveAdminTenantLDAPConfig,
|
||||
deleteAdminTenantLDAPConfig,
|
||||
testAdminTenantLDAPConfig,
|
||||
type User,
|
||||
type AuditEntry,
|
||||
type SMTPStatus,
|
||||
@@ -41,6 +49,7 @@ import {
|
||||
type SecurityAuditResult,
|
||||
type LDAPConfig,
|
||||
type LDAPTestResult,
|
||||
type TenantLDAPConfig,
|
||||
type Tenant,
|
||||
type TenantDomain,
|
||||
} from "@/lib/api";
|
||||
@@ -192,6 +201,30 @@ export default function AdminPage() {
|
||||
const [addDomainLoading, setAddDomainLoading] = useState(false);
|
||||
const [domainError, setDomainError] = useState("");
|
||||
|
||||
// Tenant LDAP state (domain_admin own tenant)
|
||||
const [tenantLdapConfig, setTenantLdapConfig] = useState<TenantLDAPConfig | null>(null);
|
||||
const [tenantLdapLoading, setTenantLdapLoading] = useState(false);
|
||||
const [tenantLdapSaving, setTenantLdapSaving] = useState(false);
|
||||
const [tenantLdapTesting, setTenantLdapTesting] = useState(false);
|
||||
const [tenantLdapError, setTenantLdapError] = useState("");
|
||||
const [tenantLdapTestResult, setTenantLdapTestResult] = useState<LDAPTestResult | null>(null);
|
||||
const [tenantLdapForm, setTenantLdapForm] = useState<TenantLDAPConfig>({
|
||||
enabled: false,
|
||||
url: "ldap://",
|
||||
bind_dn: "",
|
||||
bind_password: "",
|
||||
base_dn: "",
|
||||
user_filter: "(sAMAccountName=%s)",
|
||||
tls: false,
|
||||
tls_skip_verify: false,
|
||||
default_role: "user",
|
||||
group_mappings: [],
|
||||
});
|
||||
const [tenantLdapChangePassword, setTenantLdapChangePassword] = useState(false);
|
||||
|
||||
// Superadmin: tenant LDAP dialog
|
||||
const [tenantLdapDialogId, setTenantLdapDialogId] = useState<number | null>(null);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setDashLoading(true);
|
||||
try {
|
||||
@@ -599,6 +632,78 @@ export default function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant LDAP handlers (domain_admin)
|
||||
const loadTenantLDAP = useCallback(async () => {
|
||||
setTenantLdapLoading(true);
|
||||
setTenantLdapError("");
|
||||
try {
|
||||
const cfg = await getTenantLDAPConfig();
|
||||
if (cfg) {
|
||||
setTenantLdapConfig(cfg);
|
||||
setTenantLdapForm({ ...cfg, bind_password: "" });
|
||||
setTenantLdapChangePassword(false);
|
||||
}
|
||||
} catch {
|
||||
setTenantLdapError("LDAP-Konfiguration konnte nicht geladen werden.");
|
||||
} finally {
|
||||
setTenantLdapLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSaveTenantLDAP(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setTenantLdapSaving(true);
|
||||
setTenantLdapError("");
|
||||
try {
|
||||
const payload: Partial<TenantLDAPConfig> = { ...tenantLdapForm };
|
||||
if (!tenantLdapChangePassword) {
|
||||
delete payload.bind_password;
|
||||
}
|
||||
await saveTenantLDAPConfig(payload);
|
||||
await loadTenantLDAP();
|
||||
} catch (err: unknown) {
|
||||
setTenantLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setTenantLdapSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestTenantLDAP() {
|
||||
setTenantLdapTesting(true);
|
||||
setTenantLdapError("");
|
||||
setTenantLdapTestResult(null);
|
||||
try {
|
||||
const payload = tenantLdapConfig
|
||||
? { use_saved: true }
|
||||
: { use_saved: false, ...tenantLdapForm };
|
||||
const result = await testTenantLDAPConfig(payload as Parameters<typeof testTenantLDAPConfig>[0]);
|
||||
setTenantLdapTestResult(result);
|
||||
} catch (err: unknown) {
|
||||
setTenantLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen.");
|
||||
} finally {
|
||||
setTenantLdapTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteTenantLDAP() {
|
||||
setTenantLdapSaving(true);
|
||||
setTenantLdapError("");
|
||||
try {
|
||||
await deleteTenantLDAPConfig();
|
||||
setTenantLdapConfig(null);
|
||||
setTenantLdapForm({
|
||||
enabled: false, url: "ldap://", bind_dn: "", bind_password: "",
|
||||
base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false,
|
||||
tls_skip_verify: false, default_role: "user", group_mappings: [],
|
||||
});
|
||||
setTenantLdapTestResult(null);
|
||||
} catch (err: unknown) {
|
||||
setTenantLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
||||
} finally {
|
||||
setTenantLdapSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
@@ -621,7 +726,10 @@ export default function AdminPage() {
|
||||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||||
<TabsTrigger value="import">Import</TabsTrigger>
|
||||
{isSuperAdmin && <TabsTrigger value="ldap" onClick={loadLDAP}>LDAP</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="ldap" onClick={loadLDAP}>LDAP (Global)</TabsTrigger>}
|
||||
{!isSuperAdmin && user?.role === "domain_admin" && (
|
||||
<TabsTrigger value="tenant-ldap" onClick={loadTenantLDAP}>LDAP</TabsTrigger>
|
||||
)}
|
||||
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
||||
@@ -1850,6 +1958,266 @@ export default function AdminPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Tenant LDAP (domain_admin) ── */}
|
||||
<TabsContent value="tenant-ldap" 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={handleSaveTenantLDAP} 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://dc.example.com:389"
|
||||
value={tenantLdapForm.url}
|
||||
onChange={(e) => setTenantLdapForm((f) => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
</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-2">
|
||||
<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.users_found > 0 && (
|
||||
<p className="text-sm">{tenantLdapTestResult.users_found} Benutzer gefunden</p>
|
||||
)}
|
||||
{tenantLdapTestResult.error_detail && (
|
||||
<p className="text-xs text-destructive font-mono">{tenantLdapTestResult.error_detail}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleTestTenantLDAP} 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={handleDeleteTenantLDAP}
|
||||
>
|
||||
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>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Mandanten ── */}
|
||||
<TabsContent value="tenants" className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1927,6 +2295,7 @@ export default function AdminPage() {
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead className="text-center">Domains</TableHead>
|
||||
<TableHead className="text-center">Nutzer</TableHead>
|
||||
<TableHead>LDAP</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
@@ -1938,6 +2307,15 @@ export default function AdminPage() {
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{t.slug}</TableCell>
|
||||
<TableCell className="text-center">{t.domain_count ?? 0}</TableCell>
|
||||
<TableCell className="text-center">{t.user_count ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
{t.ldap_enabled === true ? (
|
||||
<Badge variant="default" className="bg-green-100 text-green-800 hover:bg-green-100">Aktiv</Badge>
|
||||
) : t.ldap_url ? (
|
||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">Deaktiviert</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={t.active ? "default" : "secondary"}>
|
||||
{t.active ? "Aktiv" : "Inaktiv"}
|
||||
@@ -1948,6 +2326,9 @@ export default function AdminPage() {
|
||||
<Button size="sm" variant="outline" onClick={() => openDomainDialog(t)}>
|
||||
Domains
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}>
|
||||
LDAP
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
|
||||
{t.active ? "Deaktivieren" : "Aktivieren"}
|
||||
</Button>
|
||||
@@ -2031,6 +2412,14 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Tenant LDAP dialog (superadmin) */}
|
||||
{tenantLdapDialogId !== null && (
|
||||
<TenantLDAPDialog
|
||||
tenantID={tenantLdapDialogId}
|
||||
onClose={() => { setTenantLdapDialogId(null); loadTenants(); }}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="modules" className="mt-4">
|
||||
@@ -2226,3 +2615,362 @@ function ModulesTab() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tenant LDAP Dialog (superadmin) ─────────────────────────────────────────
|
||||
|
||||
function TenantLDAPDialog({ tenantID, onClose }: { tenantID: number; onClose: () => void }) {
|
||||
const [config, setConfig] = useState<TenantLDAPConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [testResult, setTestResult] = useState<LDAPTestResult | null>(null);
|
||||
const [changePassword, setChangePassword] = useState(false);
|
||||
const [form, setForm] = useState<TenantLDAPConfig>({
|
||||
enabled: false,
|
||||
url: "ldap://",
|
||||
bind_dn: "",
|
||||
bind_password: "",
|
||||
base_dn: "",
|
||||
user_filter: "(sAMAccountName=%s)",
|
||||
tls: false,
|
||||
tls_skip_verify: false,
|
||||
default_role: "user",
|
||||
group_mappings: [],
|
||||
});
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const cfg = await getAdminTenantLDAPConfig(tenantID);
|
||||
if (cfg) {
|
||||
setConfig(cfg);
|
||||
setForm({ ...cfg, bind_password: "" });
|
||||
setChangePassword(false);
|
||||
}
|
||||
} catch {
|
||||
setError("LDAP-Konfiguration konnte nicht geladen werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tenantID]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload: Partial<TenantLDAPConfig> = { ...form };
|
||||
if (!changePassword) {
|
||||
delete payload.bind_password;
|
||||
}
|
||||
await saveAdminTenantLDAPConfig(tenantID, payload);
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setError("");
|
||||
setTestResult(null);
|
||||
try {
|
||||
const payload = config
|
||||
? { use_saved: true }
|
||||
: { use_saved: false, ...form };
|
||||
const result = await testAdminTenantLDAPConfig(tenantID, payload as Parameters<typeof testAdminTenantLDAPConfig>[1]);
|
||||
setTestResult(result);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Test fehlgeschlagen.");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteAdminTenantLDAPConfig(tenantID);
|
||||
setConfig(null);
|
||||
setForm({
|
||||
enabled: false, url: "ldap://", bind_dn: "", bind_password: "",
|
||||
base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false,
|
||||
tls_skip_verify: false, default_role: "user", group_mappings: [],
|
||||
});
|
||||
setTestResult(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>LDAP-Konfiguration (Mandant #{tenantID})</DialogTitle>
|
||||
<DialogDescription>LDAP-Server für diesen Mandanten konfigurieren.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<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={form.enabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`atldap-url-${tenantID}`}>Server-URL</Label>
|
||||
<Input
|
||||
id={`atldap-url-${tenantID}`}
|
||||
placeholder="ldap://dc.example.com:389"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`atldap-bind-dn-${tenantID}`}>Bind-DN (Service-Account)</Label>
|
||||
<Input
|
||||
id={`atldap-bind-dn-${tenantID}`}
|
||||
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
|
||||
value={form.bind_dn}
|
||||
onChange={(e) => setForm((f) => ({ ...f, bind_dn: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`atldap-pw-${tenantID}`}>Bind-Passwort</Label>
|
||||
{config && !changePassword ? (
|
||||
<div className="flex gap-2">
|
||||
<Input id={`atldap-pw-${tenantID}`} type="password" value="••••••" readOnly className="flex-1" />
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setChangePassword(true)}>
|
||||
Ändern
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={`atldap-pw-${tenantID}`}
|
||||
type="password"
|
||||
placeholder="Neues Passwort eingeben"
|
||||
value={form.bind_password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, bind_password: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`atldap-base-dn-${tenantID}`}>Base-DN</Label>
|
||||
<Input
|
||||
id={`atldap-base-dn-${tenantID}`}
|
||||
placeholder="DC=example,DC=com"
|
||||
value={form.base_dn}
|
||||
onChange={(e) => setForm((f) => ({ ...f, base_dn: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`atldap-filter-${tenantID}`}>User-Filter</Label>
|
||||
<Input
|
||||
id={`atldap-filter-${tenantID}`}
|
||||
placeholder="(sAMAccountName=%s)"
|
||||
value={form.user_filter}
|
||||
onChange={(e) => setForm((f) => ({ ...f, user_filter: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`atldap-role-${tenantID}`}>Standard-Rolle</Label>
|
||||
<Select
|
||||
value={form.default_role}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, default_role: v }))}
|
||||
>
|
||||
<SelectTrigger id={`atldap-role-${tenantID}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="auditor">Auditor</SelectItem>
|
||||
<SelectItem value="domain_admin">Domain Admin</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={form.tls}
|
||||
onChange={(e) => setForm((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={form.tls_skip_verify}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
|
||||
/>
|
||||
TLS-Zertifikat ignorieren
|
||||
{form.tls_skip_verify && (
|
||||
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Group mappings -- superadmin per tenant: bis domain_admin */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Gruppen-Rollenzuordnung</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
|
||||
}))
|
||||
}
|
||||
>
|
||||
+ Hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
{form.group_mappings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{form.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 = [...form.group_mappings];
|
||||
gms[i] = { ...gms[i], group_dn: e.target.value };
|
||||
setForm((f) => ({ ...f, group_mappings: gms }));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={gm.role}
|
||||
onValueChange={(v) => {
|
||||
const gms = [...form.group_mappings];
|
||||
gms[i] = { ...gms[i], role: v };
|
||||
setForm((f) => ({ ...f, group_mappings: gms }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="auditor">Auditor</SelectItem>
|
||||
<SelectItem value="domain_admin">Domain Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const gms = form.group_mappings.filter((_, j) => j !== i);
|
||||
setForm((f) => ({ ...f, group_mappings: gms }));
|
||||
}}
|
||||
>
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={testResult.ok ? "default" : "destructive"}>
|
||||
{testResult.ok ? "Verbunden" : "Fehler"}
|
||||
</Badge>
|
||||
<span className="text-sm">{testResult.message}</span>
|
||||
{testResult.latency_ms > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{testResult.latency_ms} ms</span>
|
||||
)}
|
||||
</div>
|
||||
{testResult.server_info && (
|
||||
<p className="text-xs text-muted-foreground font-mono">{testResult.server_info}</p>
|
||||
)}
|
||||
{testResult.users_found > 0 && (
|
||||
<p className="text-sm">{testResult.users_found} Benutzer gefunden</p>
|
||||
)}
|
||||
{testResult.error_detail && (
|
||||
<p className="text-xs text-destructive font-mono">{testResult.error_detail}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleTest} disabled={testing || saving}>
|
||||
{testing ? "Teste..." : "Verbindung testen"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving || testing}>
|
||||
{saving ? "Speichern..." : "Speichern"}
|
||||
</Button>
|
||||
{config && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={saving}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Konfiguration löschen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Zuletzt geändert: {config.updated_at ? new Date(config.updated_at).toLocaleString("de-DE") : "–"}
|
||||
{config.updated_by ? ` von ${config.updated_by}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user