fix: Mandantenverwaltung LDAP-Status und Nutzer-Listing

- tenantstore.List(): LEFT JOIN tenant_ldap hinzugefügt — ldap_enabled + ldap_url
  werden jetzt im GET /api/tenants Response mitgeliefert
- Tenant-Struct: Felder LDAPEnabled *bool + LDAPURL string ergänzt
- Neuer Endpunkt GET /api/tenants/{id}/users → listet Nutzer eines Mandanten
- api.ts: getTenantUsers() Funktion + tenant_id Feld im User Interface
- Admin-Panel: "Nutzer"-Button im Mandanten-Tab öffnet Dialog mit Nutzerliste

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-20 02:01:12 +01:00
parent b3074b303e
commit 9db433c4c1
4 changed files with 96 additions and 6 deletions
+18
View File
@@ -40,6 +40,7 @@ func (s *Server) SetTenants(store *tenantstore.Store) {
s.mux.HandleFunc("GET /api/tenants/{id}/domains", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantDomains))) s.mux.HandleFunc("GET /api/tenants/{id}/domains", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantDomains)))
s.mux.HandleFunc("POST /api/tenants/{id}/domains", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleAddTenantDomain))) s.mux.HandleFunc("POST /api/tenants/{id}/domains", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleAddTenantDomain)))
s.mux.HandleFunc("DELETE /api/tenants/{id}/domains/{did}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleRemoveTenantDomain))) s.mux.HandleFunc("DELETE /api/tenants/{id}/domains/{did}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleRemoveTenantDomain)))
s.mux.HandleFunc("GET /api/tenants/{id}/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantUsers)))
} }
// ── LDAP handlers ──────────────────────────────────────────────────────────── // ── LDAP handlers ────────────────────────────────────────────────────────────
@@ -385,6 +386,23 @@ func (s *Server) handleRemoveTenantDomain(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (s *Server) handleListTenantUsers(w http.ResponseWriter, r *http.Request) {
tenantID, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
users, err := s.users.ListByTenant(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list tenant users")
return
}
if users == nil {
users = []*userstore.User{}
}
writeJSON(w, http.StatusOK, users)
}
// ── PROJ-23: Per-Tenant LDAP handlers (Phase B) ───────────────────────────── // ── PROJ-23: Per-Tenant LDAP handlers (Phase B) ─────────────────────────────
// SetTenantLDAP wires the per-tenant LDAP config store into the API server and // SetTenantLDAP wires the per-tenant LDAP config store into the API server and
+9 -4
View File
@@ -22,6 +22,8 @@ type Tenant struct {
// Computed fields populated by List. // Computed fields populated by List.
DomainCount int `json:"domain_count,omitempty"` DomainCount int `json:"domain_count,omitempty"`
UserCount int `json:"user_count,omitempty"` UserCount int `json:"user_count,omitempty"`
LDAPEnabled *bool `json:"ldap_enabled,omitempty"`
LDAPURL string `json:"ldap_url,omitempty"`
} }
// TenantDomain is an e-mail domain assigned to a tenant. // TenantDomain is an e-mail domain assigned to a tenant.
@@ -111,16 +113,19 @@ func (s *Store) Create(ctx context.Context, name, slug string) (*Tenant, error)
return s.Get(ctx, id) return s.Get(ctx, id)
} }
// List returns all tenants with computed domain_count and user_count. // List returns all tenants with computed domain_count, user_count, and LDAP status.
func (s *Store) List(ctx context.Context) ([]Tenant, error) { func (s *Store) List(ctx context.Context) ([]Tenant, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT t.id, t.name, t.slug, t.active, t.created_at, SELECT t.id, t.name, t.slug, t.active, t.created_at,
COUNT(DISTINCT td.id) AS domain_count, COUNT(DISTINCT td.id) AS domain_count,
COUNT(DISTINCT u.id) AS user_count COUNT(DISTINCT u.id) AS user_count,
tl.enabled AS ldap_enabled,
tl.url AS ldap_url
FROM tenants t FROM tenants t
LEFT JOIN tenant_domains td ON td.tenant_id = t.id LEFT JOIN tenant_domains td ON td.tenant_id = t.id
LEFT JOIN users u ON u.tenant_id = t.id LEFT JOIN users u ON u.tenant_id = t.id
GROUP BY t.id LEFT JOIN tenant_ldap tl ON tl.tenant_id = t.id
GROUP BY t.id, tl.enabled, tl.url
ORDER BY t.id ORDER BY t.id
`) `)
if err != nil { if err != nil {
@@ -131,7 +136,7 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
var tenants []Tenant var tenants []Tenant
for rows.Next() { for rows.Next() {
var t Tenant var t Tenant
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount); err != nil { if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL); err != nil {
return nil, fmt.Errorf("tenantstore: scan: %w", err) return nil, fmt.Errorf("tenantstore: scan: %w", err)
} }
tenants = append(tenants, t) tenants = append(tenants, t)
+62
View File
@@ -24,6 +24,7 @@ import {
deleteLDAPConfig, deleteLDAPConfig,
testLDAPConfig, testLDAPConfig,
getTenants, getTenants,
getTenantUsers,
createTenant, createTenant,
updateTenant, updateTenant,
deleteTenant, deleteTenant,
@@ -238,6 +239,12 @@ export default function AdminPage() {
// Superadmin: tenant LDAP dialog // Superadmin: tenant LDAP dialog
const [tenantLdapDialogId, setTenantLdapDialogId] = useState<number | null>(null); const [tenantLdapDialogId, setTenantLdapDialogId] = useState<number | null>(null);
// Tenant users dialog
const [tenantUsersDialogId, setTenantUsersDialogId] = useState<number | null>(null);
const [tenantUsersDialogName, setTenantUsersDialogName] = useState("");
const [tenantUsers, setTenantUsers] = useState<User[]>([]);
const [tenantUsersLoading, setTenantUsersLoading] = useState(false);
// Labels state // Labels state
const [adminLabels, setAdminLabels] = useState<MailLabel[]>([]); const [adminLabels, setAdminLabels] = useState<MailLabel[]>([]);
const [adminLabelsLoading, setAdminLabelsLoading] = useState(false); const [adminLabelsLoading, setAdminLabelsLoading] = useState(false);
@@ -747,6 +754,18 @@ export default function AdminPage() {
finally { setDomainsLoading(false); } finally { setDomainsLoading(false); }
} }
async function openUsersDialog(t: Tenant) {
setTenantUsersDialogId(t.id);
setTenantUsersDialogName(t.name);
setTenantUsersLoading(true);
setTenantUsers([]);
try {
const users = await getTenantUsers(t.id);
setTenantUsers(users || []);
} catch { /* ignore */ }
finally { setTenantUsersLoading(false); }
}
async function handleAddDomain() { async function handleAddDomain() {
if (!domainDialogTenant || !newDomain) return; if (!domainDialogTenant || !newDomain) return;
setAddDomainLoading(true); setAddDomainLoading(true);
@@ -2477,6 +2496,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={() => openUsersDialog(t)}>
Nutzer
</Button>
<Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}> <Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}>
LDAP LDAP
</Button> </Button>
@@ -2564,6 +2586,46 @@ export default function AdminPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Tenant users dialog */}
<Dialog open={tenantUsersDialogId !== null} onOpenChange={(open) => { if (!open) setTenantUsersDialogId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nutzer: {tenantUsersDialogName}</DialogTitle>
<DialogDescription>Dem Mandanten zugewiesene Benutzerkonten.</DialogDescription>
</DialogHeader>
{tenantUsersLoading ? (
<Skeleton className="h-24 w-full" />
) : tenantUsers.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">Keine Benutzer diesem Mandanten zugewiesen.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzername</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenantUsers.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell className="text-sm text-muted-foreground">{u.email}</TableCell>
<TableCell><Badge variant="outline">{u.role}</Badge></TableCell>
<TableCell>
<Badge variant={u.active ? "default" : "secondary"}>
{u.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</DialogContent>
</Dialog>
{/* Tenant LDAP dialog (superadmin) */} {/* Tenant LDAP dialog (superadmin) */}
{tenantLdapDialogId !== null && ( {tenantLdapDialogId !== null && (
<TenantLDAPDialog <TenantLDAPDialog
+5
View File
@@ -49,6 +49,7 @@ export interface User {
email: string; email: string;
role: string; role: string;
active: boolean; active: boolean;
tenant_id?: number;
} }
export interface MeResponse { export interface MeResponse {
@@ -705,6 +706,10 @@ export async function getTenants(): Promise<Tenant[]> {
return request<Tenant[]>("/api/tenants"); return request<Tenant[]>("/api/tenants");
} }
export async function getTenantUsers(tenantId: number): Promise<User[]> {
return request<User[]>(`/api/tenants/${tenantId}/users`);
}
export async function createTenant(name: string, slug: string): Promise<Tenant> { export async function createTenant(name: string, slug: string): Promise<Tenant> {
return request<Tenant>("/api/tenants", { return request<Tenant>("/api/tenants", {
method: "POST", method: "POST",