feat(PROJ-22): LDAP Web-GUI + feat(PROJ-21): Multi-Tenancy Phase 1

PROJ-22 – LDAP Web-GUI Konfiguration & Test:
- internal/ldapconfig/store.go: AES-256-GCM Passwortspeicherung, CRUD Upsert (id=1)
- internal/ldapauth/client.go: TestConnection (RootDSE, UserCount) + Authenticate (2-step bind)
- internal/auth/auth.go: LDAP-Fallback in Login(), Gruppen-Rollenzuordnung, issueToken helper
- internal/api/ldap_tenants.go: GET/PUT/DELETE/POST-test /api/admin/ldap mit Audit-Log
- go.mod: github.com/go-ldap/ldap/v3 v3.4.8 hinzugefügt
- Frontend: LDAPConfig/LDAPTestResult Typen, LDAP-Tab mit Gruppen-Mappings + Testergebnis

PROJ-21 Phase 1+6+7 – Multi-Tenancy Grundstruktur:
- internal/tenantstore/store.go: tenants, tenant_domains, tenant_ldap Schema; Migration users/audit_log
- API: 8 Tenant-Routen (CRUD + Domain-Management) via SetTenants()
- cmd/archivmail/main.go: ldapSt + tenantSt initialisiert
- Frontend: Mandanten-Tab mit Tabelle, Domain-Dialog, Deaktivieren/Löschen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 20:27:56 +01:00
parent 4f0670d94c
commit ac91dceac2
13 changed files with 2063 additions and 11 deletions
+660
View File
@@ -19,6 +19,17 @@ import {
getUploadProgress,
getSecurityAudit,
fixSecurityIssue,
getLDAPConfig,
saveLDAPConfig,
deleteLDAPConfig,
testLDAPConfig,
getTenants,
createTenant,
updateTenant,
deleteTenant,
getTenantDomains,
addTenantDomain,
removeTenantDomain,
type User,
type AuditEntry,
type SMTPStatus,
@@ -28,6 +39,10 @@ import {
type UploadJob,
type SecurityCheck,
type SecurityAuditResult,
type LDAPConfig,
type LDAPTestResult,
type Tenant,
type TenantDomain,
} from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
@@ -137,6 +152,45 @@ export default function AdminPage() {
const [uploadLoading, setUploadLoading] = useState(false);
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// LDAP state
const [ldapConfig, setLdapConfig] = useState<LDAPConfig | null>(null);
const [ldapLoading, setLdapLoading] = useState(false);
const [ldapSaving, setLdapSaving] = useState(false);
const [ldapTesting, setLdapTesting] = useState(false);
const [ldapError, setLdapError] = useState("");
const [ldapTestResult, setLdapTestResult] = useState<LDAPTestResult | null>(null);
const [ldapForm, setLdapForm] = useState<LDAPConfig>({
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 [ldapChangePassword, setLdapChangePassword] = useState(false);
// Tenants state
const [tenants, setTenants] = useState<Tenant[]>([]);
const [tenantsLoading, setTenantsLoading] = useState(false);
const [tenantsError, setTenantsError] = useState("");
const [tenantDialogOpen, setTenantDialogOpen] = useState(false);
const [newTenantName, setNewTenantName] = useState("");
const [newTenantSlug, setNewTenantSlug] = useState("");
const [tenantCreateLoading, setTenantCreateLoading] = useState(false);
const [tenantCreateError, setTenantCreateError] = useState("");
const [tenantDeleteId, setTenantDeleteId] = useState<number | null>(null);
const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false);
const [domainDialogTenant, setDomainDialogTenant] = useState<Tenant | null>(null);
const [tenantDomains, setTenantDomains] = useState<TenantDomain[]>([]);
const [domainsLoading, setDomainsLoading] = useState(false);
const [newDomain, setNewDomain] = useState("");
const [addDomainLoading, setAddDomainLoading] = useState(false);
const [domainError, setDomainError] = useState("");
const loadDashboard = useCallback(async () => {
setDashLoading(true);
try {
@@ -382,6 +436,168 @@ export default function AdminPage() {
}
}
// LDAP handlers
const loadLDAP = useCallback(async () => {
setLdapLoading(true);
setLdapError("");
try {
const cfg = await getLDAPConfig();
if (cfg) {
setLdapConfig(cfg);
setLdapForm({ ...cfg, bind_password: "" });
setLdapChangePassword(false);
}
} catch {
setLdapError("LDAP-Konfiguration konnte nicht geladen werden.");
} finally {
setLdapLoading(false);
}
}, []);
async function handleSaveLDAP(e: React.FormEvent) {
e.preventDefault();
setLdapSaving(true);
setLdapError("");
try {
const payload: Partial<LDAPConfig> = { ...ldapForm };
if (!ldapChangePassword) {
delete payload.bind_password;
}
await saveLDAPConfig(payload);
await loadLDAP();
} catch (err: unknown) {
setLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
} finally {
setLdapSaving(false);
}
}
async function handleTestLDAP() {
setLdapTesting(true);
setLdapError("");
setLdapTestResult(null);
try {
const payload = ldapConfig
? { use_saved: true }
: { use_saved: false, ...ldapForm };
const result = await testLDAPConfig(payload as Parameters<typeof testLDAPConfig>[0]);
setLdapTestResult(result);
} catch (err: unknown) {
setLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen.");
} finally {
setLdapTesting(false);
}
}
async function handleDeleteLDAP() {
setLdapSaving(true);
setLdapError("");
try {
await deleteLDAPConfig();
setLdapConfig(null);
setLdapForm({
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: [],
});
setLdapTestResult(null);
} catch (err: unknown) {
setLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setLdapSaving(false);
}
}
// Tenants handlers
const loadTenants = useCallback(async () => {
setTenantsLoading(true);
setTenantsError("");
try {
const data = await getTenants();
setTenants(data || []);
} catch {
setTenantsError("Mandanten konnten nicht geladen werden.");
} finally {
setTenantsLoading(false);
}
}, []);
async function handleCreateTenant(e: React.FormEvent) {
e.preventDefault();
setTenantCreateLoading(true);
setTenantCreateError("");
try {
await createTenant(newTenantName, newTenantSlug);
setTenantDialogOpen(false);
setNewTenantName("");
setNewTenantSlug("");
await loadTenants();
} catch (err: unknown) {
setTenantCreateError(err instanceof Error ? err.message : "Erstellen fehlgeschlagen.");
} finally {
setTenantCreateLoading(false);
}
}
async function handleToggleTenant(t: Tenant) {
try {
await updateTenant(t.id, { active: !t.active });
await loadTenants();
} catch { /* ignore */ }
}
async function handleDeleteTenant() {
if (!tenantDeleteId) return;
setTenantDeleteLoading(true);
try {
await deleteTenant(tenantDeleteId);
setTenantDeleteId(null);
await loadTenants();
} catch { /* ignore */ } finally {
setTenantDeleteLoading(false);
}
}
async function openDomainDialog(t: Tenant) {
setDomainDialogTenant(t);
setDomainsLoading(true);
setDomainError("");
setNewDomain("");
try {
const domains = await getTenantDomains(t.id);
setTenantDomains(domains || []);
} catch { setDomainError("Domains konnten nicht geladen werden."); }
finally { setDomainsLoading(false); }
}
async function handleAddDomain() {
if (!domainDialogTenant || !newDomain) return;
setAddDomainLoading(true);
setDomainError("");
try {
await addTenantDomain(domainDialogTenant.id, newDomain);
setNewDomain("");
const domains = await getTenantDomains(domainDialogTenant.id);
setTenantDomains(domains || []);
} catch (err: unknown) {
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht hinzugefügt werden.");
} finally {
setAddDomainLoading(false);
}
}
async function handleRemoveDomain(domainId: number) {
if (!domainDialogTenant) return;
setDomainError("");
try {
await removeTenantDomain(domainDialogTenant.id, domainId);
const domains = await getTenantDomains(domainDialogTenant.id);
setTenantDomains(domains || []);
} catch (err: unknown) {
setDomainError(err instanceof Error ? err.message : "Domain konnte nicht entfernt werden.");
}
}
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
return (
@@ -404,7 +620,9 @@ export default function AdminPage() {
<TabsTrigger value="users">Benutzer</TabsTrigger>
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
<TabsTrigger value="import">Import</TabsTrigger>
<TabsTrigger value="ldap" onClick={loadLDAP}>LDAP</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
<TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>
<TabsTrigger value="modules">Module</TabsTrigger>
</TabsList>
@@ -1343,6 +1561,448 @@ export default function AdminPage() {
})()}
</TabsContent>
{/* ── LDAP ── */}
<TabsContent value="ldap" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">LDAP / Active Directory</h2>
<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={ldapForm.enabled}
onChange={(e) => setLdapForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</div>
</div>
{ldapError && (
<Alert variant="destructive">
<AlertDescription>{ldapError}</AlertDescription>
</Alert>
)}
{ldapLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<form onSubmit={handleSaveLDAP} 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="ldap-url">Server-URL</Label>
<Input
id="ldap-url"
placeholder="ldap://dc.example.com:389"
value={ldapForm.url}
onChange={(e) => setLdapForm((f) => ({ ...f, url: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-bind-dn">Bind-DN (Service-Account)</Label>
<Input
id="ldap-bind-dn"
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
value={ldapForm.bind_dn}
onChange={(e) => setLdapForm((f) => ({ ...f, bind_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-pw">Bind-Passwort</Label>
{ldapConfig && !ldapChangePassword ? (
<div className="flex gap-2">
<Input id="ldap-pw" type="password" value="••••••" readOnly className="flex-1" />
<Button type="button" variant="outline" size="sm" onClick={() => setLdapChangePassword(true)}>
Ändern
</Button>
</div>
) : (
<Input
id="ldap-pw"
type="password"
placeholder="Neues Passwort eingeben"
value={ldapForm.bind_password}
onChange={(e) => setLdapForm((f) => ({ ...f, bind_password: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="ldap-base-dn">Base-DN</Label>
<Input
id="ldap-base-dn"
placeholder="DC=example,DC=com"
value={ldapForm.base_dn}
onChange={(e) => setLdapForm((f) => ({ ...f, base_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-filter">User-Filter</Label>
<Input
id="ldap-filter"
placeholder="(sAMAccountName=%s)"
value={ldapForm.user_filter}
onChange={(e) => setLdapForm((f) => ({ ...f, user_filter: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-role">Standard-Rolle</Label>
<Select
value={ldapForm.default_role}
onValueChange={(v) => setLdapForm((f) => ({ ...f, default_role: v }))}
>
<SelectTrigger id="ldap-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="admin">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={ldapForm.tls}
onChange={(e) => setLdapForm((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={ldapForm.tls_skip_verify}
onChange={(e) => setLdapForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
/>
TLS-Zertifikat ignorieren
{ldapForm.tls_skip_verify && (
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
)}
</label>
</div>
<Separator />
{/* Group mappings */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Gruppen-Rollenzuordnung</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setLdapForm((f) => ({
...f,
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
}))
}
>
+ Hinzufügen
</Button>
</div>
{ldapForm.group_mappings.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
) : (
<div className="space-y-2">
{ldapForm.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 = [...ldapForm.group_mappings];
gms[i] = { ...gms[i], group_dn: e.target.value };
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
/>
<Select
value={gm.role}
onValueChange={(v) => {
const gms = [...ldapForm.group_mappings];
gms[i] = { ...gms[i], role: v };
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
const gms = ldapForm.group_mappings.filter((_, j) => j !== i);
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
Entfernen
</Button>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Test result */}
{ldapTestResult && (
<Card>
<CardContent className="pt-4 space-y-2">
<div className="flex items-center gap-2">
<Badge variant={ldapTestResult.ok ? "default" : "destructive"}>
{ldapTestResult.ok ? "Verbunden" : "Fehler"}
</Badge>
<span className="text-sm">{ldapTestResult.message}</span>
{ldapTestResult.latency_ms > 0 && (
<span className="text-xs text-muted-foreground">{ldapTestResult.latency_ms} ms</span>
)}
</div>
{ldapTestResult.server_info && (
<p className="text-xs text-muted-foreground font-mono">{ldapTestResult.server_info}</p>
)}
{ldapTestResult.users_found > 0 && (
<p className="text-sm">{ldapTestResult.users_found} Benutzer gefunden</p>
)}
{ldapTestResult.error_detail && (
<p className="text-xs text-destructive font-mono">{ldapTestResult.error_detail}</p>
)}
</CardContent>
</Card>
)}
{/* Action bar */}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={handleTestLDAP} disabled={ldapTesting || ldapSaving}>
{ldapTesting ? "Teste..." : "Verbindung testen"}
</Button>
<Button type="submit" disabled={ldapSaving || ldapTesting}>
{ldapSaving ? "Speichern..." : "Speichern"}
</Button>
{ldapConfig && (
<Button
type="button"
variant="destructive"
disabled={ldapSaving}
onClick={handleDeleteLDAP}
>
Konfiguration löschen
</Button>
)}
</div>
</form>
)}
{ldapConfig && (
<p className="text-xs text-muted-foreground">
Zuletzt geändert: {ldapConfig.updated_at ? new Date(ldapConfig.updated_at).toLocaleString("de-DE") : ""}
{ldapConfig.updated_by ? ` von ${ldapConfig.updated_by}` : ""}
</p>
)}
</TabsContent>
{/* ── Mandanten ── */}
<TabsContent value="tenants" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Mandantenverwaltung</h2>
<Dialog open={tenantDialogOpen} onOpenChange={setTenantDialogOpen}>
<DialogTrigger asChild>
<Button size="sm">+ Mandant anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Mandanten anlegen</DialogTitle>
<DialogDescription>Name und URL-Slug für den neuen Mandanten eingeben.</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateTenant} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tenant-name">Name</Label>
<Input
id="tenant-name"
value={newTenantName}
required
onChange={(e) => {
setNewTenantName(e.target.value);
setNewTenantSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Slug</Label>
<Input
id="tenant-slug"
value={newTenantSlug}
required
onChange={(e) => setNewTenantSlug(e.target.value)}
/>
</div>
{tenantCreateError && (
<p className="text-sm text-destructive">{tenantCreateError}</p>
)}
<DialogFooter>
<Button type="submit" disabled={tenantCreateLoading}>
{tenantCreateLoading ? "Erstellen..." : "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{tenantsError && (
<Alert variant="destructive">
<AlertDescription>{tenantsError}</AlertDescription>
</Alert>
)}
{tenantsLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : tenants.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Mandanten vorhanden. Klicke auf &ldquo;+ Mandant anlegen&rdquo; um den ersten Mandanten zu erstellen.
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-center">Domains</TableHead>
<TableHead className="text-center">Nutzer</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</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.user_count ?? 0}</TableCell>
<TableCell>
<Badge variant={t.active ? "default" : "secondary"}>
{t.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" onClick={() => openDomainDialog(t)}>
Domains
</Button>
<Button size="sm" variant="outline" onClick={() => handleToggleTenant(t)}>
{t.active ? "Deaktivieren" : "Aktivieren"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setTenantDeleteId(t.id)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
{/* Tenant delete confirmation */}
<Dialog open={tenantDeleteId !== null} onOpenChange={(open) => { if (!open) setTenantDeleteId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mandant löschen</DialogTitle>
<DialogDescription>
Alle Daten dieses Mandanten (Domains, LDAP-Konfiguration) werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setTenantDeleteId(null)}>Abbrechen</Button>
<Button variant="destructive" disabled={tenantDeleteLoading} onClick={handleDeleteTenant}>
{tenantDeleteLoading ? "Löschen..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Domain management dialog */}
<Dialog open={domainDialogTenant !== null} onOpenChange={(open) => { if (!open) setDomainDialogTenant(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Domains: {domainDialogTenant?.name}</DialogTitle>
<DialogDescription>E-Mail-Domains diesem Mandanten zuweisen.</DialogDescription>
</DialogHeader>
{domainError && (
<Alert variant="destructive">
<AlertDescription>{domainError}</AlertDescription>
</Alert>
)}
{domainsLoading ? (
<Skeleton className="h-24 w-full" />
) : (
<div className="space-y-2">
{tenantDomains.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Domains zugewiesen.</p>
) : (
tenantDomains.map((d) => (
<div key={d.id} className="flex items-center justify-between rounded border px-3 py-2">
<span className="font-mono text-sm">{d.domain}</span>
<Button size="sm" variant="destructive" onClick={() => handleRemoveDomain(d.id)}>
Entfernen
</Button>
</div>
))
)}
</div>
)}
<div className="flex gap-2 pt-2">
<Input
placeholder="example.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddDomain(); } }}
/>
<Button onClick={handleAddDomain} disabled={addDomainLoading || !newDomain}>
{addDomainLoading ? "..." : "Hinzufügen"}
</Button>
</div>
</DialogContent>
</Dialog>
</TabsContent>
<TabsContent value="modules" className="mt-4">
<ModulesTab />
</TabsContent>