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:
sysops
2026-03-18 00:27:51 +01:00
parent e333e34a03
commit 9e7add31cd
2 changed files with 814 additions and 1 deletions
+749 -1
View File
@@ -30,6 +30,14 @@ import {
getTenantDomains, getTenantDomains,
addTenantDomain, addTenantDomain,
removeTenantDomain, removeTenantDomain,
getTenantLDAPConfig,
saveTenantLDAPConfig,
deleteTenantLDAPConfig,
testTenantLDAPConfig,
getAdminTenantLDAPConfig,
saveAdminTenantLDAPConfig,
deleteAdminTenantLDAPConfig,
testAdminTenantLDAPConfig,
type User, type User,
type AuditEntry, type AuditEntry,
type SMTPStatus, type SMTPStatus,
@@ -41,6 +49,7 @@ import {
type SecurityAuditResult, type SecurityAuditResult,
type LDAPConfig, type LDAPConfig,
type LDAPTestResult, type LDAPTestResult,
type TenantLDAPConfig,
type Tenant, type Tenant,
type TenantDomain, type TenantDomain,
} from "@/lib/api"; } from "@/lib/api";
@@ -192,6 +201,30 @@ export default function AdminPage() {
const [addDomainLoading, setAddDomainLoading] = useState(false); const [addDomainLoading, setAddDomainLoading] = useState(false);
const [domainError, setDomainError] = useState(""); 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 () => { const loadDashboard = useCallback(async () => {
setDashLoading(true); setDashLoading(true);
try { 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); const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
return ( return (
@@ -621,7 +726,10 @@ export default function AdminPage() {
<TabsTrigger value="users">Benutzer</TabsTrigger> <TabsTrigger value="users">Benutzer</TabsTrigger>
<TabsTrigger value="audit">Audit-Log</TabsTrigger> <TabsTrigger value="audit">Audit-Log</TabsTrigger>
<TabsTrigger value="import">Import</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="security">Security</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>} {isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>} {isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
@@ -1850,6 +1958,266 @@ export default function AdminPage() {
)} )}
</TabsContent> </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 ── */} {/* ── Mandanten ── */}
<TabsContent value="tenants" className="mt-4 space-y-4"> <TabsContent value="tenants" className="mt-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -1927,6 +2295,7 @@ export default function AdminPage() {
<TableHead>Slug</TableHead> <TableHead>Slug</TableHead>
<TableHead className="text-center">Domains</TableHead> <TableHead className="text-center">Domains</TableHead>
<TableHead className="text-center">Nutzer</TableHead> <TableHead className="text-center">Nutzer</TableHead>
<TableHead>LDAP</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead> <TableHead className="text-right">Aktionen</TableHead>
</TableRow> </TableRow>
@@ -1938,6 +2307,15 @@ export default function AdminPage() {
<TableCell className="font-mono text-xs text-muted-foreground">{t.slug}</TableCell> <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.domain_count ?? 0}</TableCell>
<TableCell className="text-center">{t.user_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> <TableCell>
<Badge variant={t.active ? "default" : "secondary"}> <Badge variant={t.active ? "default" : "secondary"}>
{t.active ? "Aktiv" : "Inaktiv"} {t.active ? "Aktiv" : "Inaktiv"}
@@ -1948,6 +2326,9 @@ export default function AdminPage() {
<Button size="sm" variant="outline" onClick={() => openDomainDialog(t)}> <Button size="sm" variant="outline" onClick={() => openDomainDialog(t)}>
Domains Domains
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}>
LDAP
</Button>
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}> <Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
{t.active ? "Deaktivieren" : "Aktivieren"} {t.active ? "Deaktivieren" : "Aktivieren"}
</Button> </Button>
@@ -2031,6 +2412,14 @@ export default function AdminPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Tenant LDAP dialog (superadmin) */}
{tenantLdapDialogId !== null && (
<TenantLDAPDialog
tenantID={tenantLdapDialogId}
onClose={() => { setTenantLdapDialogId(null); loadTenants(); }}
/>
)}
</TabsContent> </TabsContent>
<TabsContent value="modules" className="mt-4"> <TabsContent value="modules" className="mt-4">
@@ -2226,3 +2615,362 @@ function ModulesTab() {
</div> </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>
);
}
+65
View File
@@ -678,6 +678,8 @@ export interface Tenant {
created_at: string; created_at: string;
domain_count?: number; domain_count?: number;
user_count?: number; user_count?: number;
ldap_enabled?: boolean;
ldap_url?: string;
} }
export interface TenantDomain { export interface TenantDomain {
@@ -734,3 +736,66 @@ export async function removeTenantDomain(
method: "DELETE", method: "DELETE",
}); });
} }
// ── PROJ-23: Pro-Mandant LDAP (tenant_ldap) ──────────────────────────────
export interface TenantLDAPConfig extends LDAPConfig {
tenant_id?: number;
}
// domain_admin -- eigener Mandant
export async function getTenantLDAPConfig(): Promise<TenantLDAPConfig | null> {
try {
return await request<TenantLDAPConfig>("/api/tenant/ldap");
} catch (e: unknown) {
if (e instanceof Error && e.message.includes("404")) return null;
if (e instanceof Error && e.message.includes("no ldap config")) return null;
throw e;
}
}
export async function saveTenantLDAPConfig(cfg: Partial<TenantLDAPConfig>): Promise<void> {
await request<void>("/api/tenant/ldap", { method: "PUT", body: JSON.stringify(cfg) });
}
export async function deleteTenantLDAPConfig(): Promise<void> {
await request<void>("/api/tenant/ldap", { method: "DELETE" });
}
export async function testTenantLDAPConfig(
payload: { use_saved: boolean } & Partial<TenantLDAPConfig>
): Promise<LDAPTestResult> {
return request<LDAPTestResult>("/api/tenant/ldap/test", {
method: "POST",
body: JSON.stringify(payload),
});
}
// superadmin -- beliebiger Mandant per ID
export async function getAdminTenantLDAPConfig(tenantID: number): Promise<TenantLDAPConfig | null> {
try {
return await request<TenantLDAPConfig>(`/api/admin/tenants/${tenantID}/ldap`);
} catch (e: unknown) {
if (e instanceof Error && e.message.includes("404")) return null;
if (e instanceof Error && e.message.includes("no ldap config")) return null;
throw e;
}
}
export async function saveAdminTenantLDAPConfig(tenantID: number, cfg: Partial<TenantLDAPConfig>): Promise<void> {
await request<void>(`/api/admin/tenants/${tenantID}/ldap`, { method: "PUT", body: JSON.stringify(cfg) });
}
export async function deleteAdminTenantLDAPConfig(tenantID: number): Promise<void> {
await request<void>(`/api/admin/tenants/${tenantID}/ldap`, { method: "DELETE" });
}
export async function testAdminTenantLDAPConfig(
tenantID: number,
payload: { use_saved: boolean } & Partial<TenantLDAPConfig>
): Promise<LDAPTestResult> {
return request<LDAPTestResult>(`/api/admin/tenants/${tenantID}/ldap/test`, {
method: "POST",
body: JSON.stringify(payload),
});
}