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:
@@ -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("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("GET /api/tenants/{id}/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantUsers)))
|
||||
}
|
||||
|
||||
// ── LDAP handlers ────────────────────────────────────────────────────────────
|
||||
@@ -385,6 +386,23 @@ func (s *Server) handleRemoveTenantDomain(w http.ResponseWriter, r *http.Request
|
||||
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) ─────────────────────────────
|
||||
|
||||
// SetTenantLDAP wires the per-tenant LDAP config store into the API server and
|
||||
|
||||
@@ -20,8 +20,10 @@ type Tenant struct {
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Computed fields populated by List.
|
||||
DomainCount int `json:"domain_count,omitempty"`
|
||||
UserCount int `json:"user_count,omitempty"`
|
||||
DomainCount int `json:"domain_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.
|
||||
@@ -111,16 +113,19 @@ func (s *Store) Create(ctx context.Context, name, slug string) (*Tenant, error)
|
||||
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) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT t.id, t.name, t.slug, t.active, t.created_at,
|
||||
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
|
||||
LEFT JOIN tenant_domains td ON td.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
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -131,7 +136,7 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
|
||||
var tenants []Tenant
|
||||
for rows.Next() {
|
||||
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)
|
||||
}
|
||||
tenants = append(tenants, t)
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
deleteLDAPConfig,
|
||||
testLDAPConfig,
|
||||
getTenants,
|
||||
getTenantUsers,
|
||||
createTenant,
|
||||
updateTenant,
|
||||
deleteTenant,
|
||||
@@ -238,6 +239,12 @@ export default function AdminPage() {
|
||||
// Superadmin: tenant LDAP dialog
|
||||
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
|
||||
const [adminLabels, setAdminLabels] = useState<MailLabel[]>([]);
|
||||
const [adminLabelsLoading, setAdminLabelsLoading] = useState(false);
|
||||
@@ -747,6 +754,18 @@ export default function AdminPage() {
|
||||
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() {
|
||||
if (!domainDialogTenant || !newDomain) return;
|
||||
setAddDomainLoading(true);
|
||||
@@ -2477,6 +2496,9 @@ export default function AdminPage() {
|
||||
<Button size="sm" variant="outline" onClick={() => openDomainDialog(t)}>
|
||||
Domains
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openUsersDialog(t)}>
|
||||
Nutzer
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}>
|
||||
LDAP
|
||||
</Button>
|
||||
@@ -2564,6 +2586,46 @@ export default function AdminPage() {
|
||||
</DialogContent>
|
||||
</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) */}
|
||||
{tenantLdapDialogId !== null && (
|
||||
<TenantLDAPDialog
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface User {
|
||||
email: string;
|
||||
role: string;
|
||||
active: boolean;
|
||||
tenant_id?: number;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
@@ -705,6 +706,10 @@ export async function getTenants(): Promise<Tenant[]> {
|
||||
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> {
|
||||
return request<Tenant>("/api/tenants", {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user