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:
@@ -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 “+ Mandant anlegen” 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>
|
||||
|
||||
Reference in New Issue
Block a user