diff --git a/apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx b/apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx new file mode 100644 index 0000000..db54f64 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import type { Group } from '../page'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +interface LdapDirectoryEntry { + dn: string; + name: string; + type: 'group' | 'ou'; +} + +interface GroupFormModalProps { + group: Group | null; + onClose: () => void; + onSaved: () => void; +} + +/** + * Create/rename dialog for a Tessera group, including the optional AD + * binding (D-05, D-18): the AD group is picked from the existing LDAP + * group/OU discovery (GET /ldap/groups), never typed in by hand, and the + * selection is a radio list — exactly one AD group per Tessera group. + */ +export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps) { + const t = useTranslations('admin.groups'); + const tLdap = useTranslations('admin.ldap'); + const tCommon = useTranslations('common'); + + const [name, setName] = useState(group?.name ?? ''); + const [ldapDn, setLdapDn] = useState(group?.ldapDn ?? null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const [discovered, setDiscovered] = useState(null); + const [discovering, setDiscovering] = useState(false); + const [discoverError, setDiscoverError] = useState(null); + const [discoverSearch, setDiscoverSearch] = useState(''); + + const fetchLdapGroups = useCallback(async () => { + setDiscovering(true); + setDiscoverError(null); + try { + const res = await fetch(`${API_URL}/ldap/groups`, { + credentials: 'include', + }); + if (res.ok) { + setDiscovered(await res.json()); + } else { + setDiscoverError(t('ldapBind.discoverError')); + } + } catch { + setDiscoverError(t('ldapBind.discoverError')); + } finally { + setDiscovering(false); + } + }, [t]); + + // Only discover while unbound: a bound group shows the DN chip instead + // of the search UI (see render below), so there is nothing to fetch for. + useEffect(() => { + if (ldapDn === null && discovered === null && !discovering && !discoverError) { + fetchLdapGroups(); + } + }, [ldapDn, discovered, discovering, discoverError, fetchLdapGroups]); + + const filteredDiscovered = useMemo(() => { + const q = discoverSearch.trim().toLowerCase(); + if (!q) return discovered; + return discovered?.filter( + (entry) => entry.name.toLowerCase().includes(q) || entry.dn.toLowerCase().includes(q), + ); + }, [discovered, discoverSearch]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setSaveError(null); + try { + if (group) { + const res = await fetch(`${API_URL}/groups/${group.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name, ldapDn }), + }); + if (!res.ok) { + setSaveError(t('saveError')); + return; + } + } else { + // CreateGroupDto only accepts `name` — a binding chosen while + // creating is applied with a second PATCH once the group exists. + const createRes = await fetch(`${API_URL}/groups`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name }), + }); + if (!createRes.ok) { + setSaveError(t('saveError')); + return; + } + const created = await createRes.json(); + if (ldapDn) { + const bindRes = await fetch(`${API_URL}/groups/${created.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ ldapDn }), + }); + if (!bindRes.ok) { + setSaveError(t('saveError')); + return; + } + } + } + onSaved(); + onClose(); + } catch { + setSaveError(t('saveError')); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

+ {group ? t('edit') : t('create')} +

+
+
+ + setName(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +
+ +
+ + + {ldapDn !== null ? ( +
+

+ {t('ldapBind.bound', { ldapDn })} +

+ +
+ ) : ( +
+

{t('ldapBind.hint')}

+ + {discovering && ( +

{tCommon('loading')}

+ )} + + {discoverError && ( +
+ {discoverError} +
+ )} + + {!discovering && !discoverError && discovered && discovered.length > 0 && ( +
+ setDiscoverSearch(e.target.value)} + placeholder={t('ldapBind.searchPlaceholder')} + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm" + /> +
+ {filteredDiscovered && filteredDiscovered.length > 0 ? ( + filteredDiscovered.map((entry) => ( + + )) + ) : ( +

+ {t('ldapBind.noResults')} +

+ )} +
+
+ )} + + {!discovering && !discoverError && discovered && discovered.length === 0 && ( +

{t('ldapBind.noResults')}

+ )} +
+ )} +
+ + {saveError && ( +
+ {saveError} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx b/apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx new file mode 100644 index 0000000..a552dfe --- /dev/null +++ b/apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx @@ -0,0 +1,244 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Namespace-aware next-intl mock: dot-path keys resolve against the +// namespace passed to useTranslations(ns), and {param} placeholders are +// substituted the same way next-intl does for plain (non-ICU) values. +const messages: Record> = { + 'admin.groups': { + title: 'Gruppen', + create: 'Gruppe erstellen', + edit: 'Gruppe bearbeiten', + delete: 'Gruppe löschen', + noGroups: 'Keine Gruppen vorhanden', + noGroupsBody: 'Lege deine erste Gruppe an, um Modulzugriff gezielt zu vergeben.', + name: 'Name', + ldapBinding: 'AD-Bindung', + boundBadge: 'AD-gebunden', + manualBadge: 'Manuell', + defaultGroup: 'Standardgruppe', + setDefault: 'Als Standardgruppe festlegen', + isDefault: 'Standardgruppe', + memberCount: 'Mitglieder', + actions: 'Aktionen', + membersButton: 'Mitglieder', + saveError: 'Gruppe konnte nicht gespeichert werden. Bitte erneut versuchen.', + deleteError: 'Gruppe konnte nicht gelöscht werden. Bitte erneut versuchen.', + ldapBind: { + hint: 'Wähle eine AD-Gruppe aus der Liste, um die Mitgliedschaft automatisch zu synchronisieren.', + bound: 'Gebunden an: {ldapDn}', + unbind: 'Bindung entfernen', + searchPlaceholder: 'AD-Gruppen durchsuchen...', + discoverError: 'AD-Gruppen konnten nicht geladen werden. Bitte erneut versuchen.', + noResults: 'Keine AD-Gruppen gefunden.', + }, + members: { + title: 'Mitglieder', + remove: 'Mitglied entfernen', + ldapManaged: 'Wird über AD-Sync verwaltet', + sourceManual: 'Manuell', + sourceLdap: 'LDAP', + addTitle: 'Mitglied hinzufügen', + searchPlaceholder: 'Benutzer suchen...', + noMembers: 'Keine Mitglieder vorhanden.', + addError: 'Mitglied konnte nicht hinzugefügt werden. Bitte erneut versuchen.', + }, + deleteConfirm: { + title: 'Gruppe löschen', + body: + 'Diese Gruppe hat {memberCount} Mitglieder und {grantCount} Modul-Freigaben. Betroffene Benutzer verlieren den Zugriff, sofern sie ihn nicht anderweitig haben. Diese Aktion kann nicht rückgängig gemacht werden.', + }, + }, + 'admin.ldap': { + groupFilter: { typeOu: 'OU', typeGroup: 'Gruppe' }, + }, + common: { + loading: 'Laden...', + cancel: 'Abbrechen', + save: 'Speichern', + delete: 'Löschen', + edit: 'Bearbeiten', + accessDenied: 'Zugriff verweigert', + }, +}; + +function resolve(ns: string, key: string, params?: Record): string { + const parts = key.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let val: any = messages[ns] ?? {}; + for (const part of parts) { + val = val?.[part]; + } + if (typeof val !== 'string') return key; + if (params) { + for (const [k, v] of Object.entries(params)) { + val = val.replace(`{${k}}`, String(v)); + } + } + return val; +} + +vi.mock('next-intl', () => ({ + useTranslations: (ns: string) => (key: string, params?: Record) => + resolve(ns, key, params), +})); + +const mockAuthStore = vi.fn(); +vi.mock('@/lib/stores/auth-store', () => ({ + useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector), +})); + +import AdminGroupsPage from './page'; + +interface Group { + id: string; + tenantId: string; + name: string; + ldapDn: string | null; + isDefault: boolean; + createdAt: string; + updatedAt: string; + memberCount: number; +} + +const mockGroups: Group[] = [ + { + id: 'g1', + tenantId: 't1', + name: 'Alle Benutzer', + ldapDn: null, + isDefault: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + memberCount: 3, + }, + { + id: 'g2', + tenantId: 't1', + name: 'Buchhaltung', + ldapDn: 'CN=Buchhaltung,OU=Gruppen,DC=ctl,DC=local', + isDefault: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + memberCount: 1, + }, +]; + +function stubAdmin() { + mockAuthStore.mockImplementation( + (selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) => + selector({ user: { id: 'u1', role: 'ADMIN', tenantId: 't1' } }), + ); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('AdminGroupsPage', () => { + beforeEach(() => { + stubAdmin(); + }); + + it('renders the empty state with heading, body and CTA when no groups exist', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([]) })), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Keine Gruppen vorhanden')).toBeInTheDocument(); + }); + expect( + screen.getByText('Lege deine erste Gruppe an, um Modulzugriff gezielt zu vergeben.'), + ).toBeInTheDocument(); + // Two "Gruppe erstellen" CTAs exist once empty (header + empty-state body). + expect(screen.getAllByText('Gruppe erstellen').length).toBeGreaterThanOrEqual(1); + }); + + it('renders a populated table with name, AD-binding badge, default star and member count', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) })), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Alle Benutzer')).toBeInTheDocument(); + }); + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + expect(screen.getByText('AD-gebunden')).toBeInTheDocument(); + expect(screen.getByText('Manuell')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('toggles the default-group star optimistically and persists via PATCH', async () => { + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (typeof url === 'string' && url.endsWith('/groups') && (!init || init.method === undefined)) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) }); + } + if (typeof url === 'string' && url.includes('/groups/g2') && init?.method === 'PATCH') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ ...mockGroups[1], isDefault: true }) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) }); + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + }); + + const setDefaultButtons = screen.getAllByLabelText('Als Standardgruppe festlegen'); + // 'Buchhaltung' (g2) is the non-default row — its star button carries the "set" label. + await userEvent.click(setDefaultButtons[0]); + + await waitFor(() => { + const patchCall = fetchMock.mock.calls.find( + (call) => + typeof call[0] === 'string' && + call[0].includes('/groups/g2') && + (call[1] as RequestInit | undefined)?.method === 'PATCH', + ); + expect(patchCall).toBeDefined(); + const body = JSON.parse((patchCall?.[1] as RequestInit).body as string); + expect(body).toEqual({ isDefault: true }); + }); + }); + + it('rolls back the star toggle when the PATCH request fails', async () => { + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (typeof url === 'string' && url.endsWith('/groups') && !init) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) }); + } + if (typeof url === 'string' && url.includes('/groups/g2') && init?.method === 'PATCH') { + return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) }); + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + }); + + const setDefaultButtons = screen.getAllByLabelText('Als Standardgruppe festlegen'); + await userEvent.click(setDefaultButtons[0]); + + await waitFor(() => { + // The visible error div renders the status+body text. + expect(screen.getByText(/500: boom/)).toBeInTheDocument(); + }); + // Buchhaltung's star reverts to "set default" (not yet default again). + expect(screen.getAllByLabelText('Als Standardgruppe festlegen').length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/src/app/(portal)/admin/groups/page.tsx b/apps/web/src/app/(portal)/admin/groups/page.tsx new file mode 100644 index 0000000..bc737a7 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/groups/page.tsx @@ -0,0 +1,259 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useAuthStore } from '@/lib/stores/auth-store'; +import { GroupFormModal } from './components/GroupFormModal'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export interface Group { + id: string; + tenantId: string; + name: string; + ldapDn: string | null; + isDefault: boolean; + createdAt: string; + updatedAt: string; + memberCount: number; +} + +/** + * Admin group management page (PERM-01, D-14). Sixth admin nav entry. + * ADMIN and SUPER_ADMIN can access — the role check here is display only, + * not enforcement; every /groups route is protected server-side by + * RolesGuard (T-15-22). + */ +export default function AdminGroupsPage() { + const t = useTranslations('admin.groups'); + const tCommon = useTranslations('common'); + const currentUser = useAuthStore((s) => s.user); + + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [togglingDefaultId, setTogglingDefaultId] = useState(null); + + const [showFormModal, setShowFormModal] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [membersGroup, setMembersGroup] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const hasAccess = + currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; + + const fetchGroups = useCallback(async () => { + try { + const res = await fetch(`${API_URL}/groups`, { credentials: 'include' }); + if (res.ok) { + setGroups(await res.json()); + } + } catch { + // silently fail — matches the read-path precedent of the other admin pages + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (hasAccess) { + fetchGroups(); + } else { + setLoading(false); + } + }, [hasAccess, fetchGroups]); + + const openCreate = () => { + setEditingGroup(null); + setShowFormModal(true); + }; + + const openEdit = (group: Group) => { + setEditingGroup(group); + setShowFormModal(true); + }; + + const handleToggleDefault = async (group: Group) => { + setTogglingDefaultId(group.id); + setError(null); + const nextValue = !group.isDefault; + // Optimistic update, same pattern as AdminModulesPage.toggleModule. + setGroups((prev) => + prev.map((g) => (g.id === group.id ? { ...g, isDefault: nextValue } : g)), + ); + try { + const res = await fetch(`${API_URL}/groups/${group.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ isDefault: nextValue }), + }); + if (res.ok) { + // Setting one group's default flag unsets it on every other group + // in the tenant (D-13, server-side transaction) — a full refetch + // is the only way the table reflects that exclusivity correctly. + fetchGroups(); + } else { + setGroups((prev) => + prev.map((g) => (g.id === group.id ? { ...g, isDefault: group.isDefault } : g)), + ); + const body = await res.text().catch(() => ''); + setError(`${res.status}: ${body}`); + } + } catch (err) { + setGroups((prev) => + prev.map((g) => (g.id === group.id ? { ...g, isDefault: group.isDefault } : g)), + ); + setError(String(err)); + } finally { + setTogglingDefaultId(null); + } + }; + + if (!hasAccess) { + return ( +
+

{tCommon('accessDenied')}

+
+ ); + } + + return ( +
+
+

{t('title')}

+ +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +

{tCommon('loading')}

+ ) : groups.length === 0 ? ( +
+

{t('noGroups')}

+

{t('noGroupsBody')}

+ +
+ ) : ( +
+ + + + + + + + + + + + {groups.map((group) => ( + + + + + + + + ))} + +
+ {t('name')} + + {t('ldapBinding')} + + {t('defaultGroup')} + + {t('memberCount')} + + {t('actions')} +
{group.name} + + {group.ldapDn ? t('boundBadge') : t('manualBadge')} + + + + {group.memberCount} +
+ + + +
+
+
+ )} + + {showFormModal && ( + setShowFormModal(false)} + onSaved={fetchGroups} + /> + )} + + {/* GroupMembersModal / DeleteGroupDialog are wired in here by Task 3 + (member/delete state above is already in place for that). */} +
+ ); +}