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("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
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ type Tenant struct {
|
|||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
// 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user