From 4985e434123f683a8e76166fe836eb59fca55ba0 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 19:02:11 +0200 Subject: [PATCH] feat(15-06): group member management + delete dialog with concrete impact numbers - GroupMembersModal.tsx: chip list of current members with source badge (MANUAL/LDAP); LDAP-sourced chips carry a disabled remove button with a "managed via AD sync" tooltip (D-19) instead of an active one; second section adds manual members via GET /users + POST /groups/:id/members, already-member candidates shown disabled (upsert on the API is folgenlos, no special-case needed) - DeleteGroupDialog.tsx: loads GET /groups/:id/impact and interpolates memberCount/grantCount into the confirmation text (D-17); deliberately breaks from the project's silent-delete-failure precedent -- stays open and shows a visible error on a failed DELETE, since a silent failure here would leave an admin believing a group (and its grants) is gone while it still grants access (T-15-24) - page.tsx: wires both dialogs in, refetches the group list after any member/delete mutation so member counts and badges stay current - groups-page.test.tsx: disabled-vs-active remove button by membership source, delete text shows both numbers, visible error + dialog stays open on failed delete --- .../groups/components/DeleteGroupDialog.tsx | 133 +++++++++ .../groups/components/GroupMembersModal.tsx | 267 ++++++++++++++++++ .../admin/groups/groups-page.test.tsx | 149 ++++++++++ .../src/app/(portal)/admin/groups/page.tsx | 19 +- 4 files changed, 566 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx create mode 100644 apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx diff --git a/apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx b/apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx new file mode 100644 index 0000000..1a54d25 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, 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 GroupImpact { + memberCount: number; + grantCount: number; +} + +interface DeleteGroupDialogProps { + group: Group; + onClose: () => void; + onDeleted: () => void; +} + +/** + * Delete-confirmation dialog for a group (D-17, PERM-01). + * + * DELIBERATE DEVIATION from the silent-fail precedent used by every other + * delete dialog in this project (AdminUsersPage, AdminTenantsPage): this + * dialog promises concrete numbers (memberCount/grantCount) and stays open + * with a visible error on failure instead of failing silently. A silent + * failure here would let an admin believe a group (and its grants) is + * gone while it still grants access — see T-15-24 in the plan's threat + * model. + */ +export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDialogProps) { + const t = useTranslations('admin.groups'); + const tCommon = useTranslations('common'); + + const [impact, setImpact] = useState(null); + const [loadingImpact, setLoadingImpact] = useState(true); + const [impactError, setImpactError] = useState(false); + + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`${API_URL}/groups/${group.id}/impact`, { + credentials: 'include', + }); + if (!cancelled) { + if (res.ok) { + setImpact(await res.json()); + } else { + setImpactError(true); + } + } + } catch { + if (!cancelled) setImpactError(true); + } finally { + if (!cancelled) setLoadingImpact(false); + } + })(); + return () => { + cancelled = true; + }; + }, [group.id]); + + const handleDelete = async () => { + setDeleting(true); + setDeleteError(null); + try { + const res = await fetch(`${API_URL}/groups/${group.id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (res.ok) { + onDeleted(); + onClose(); + } else { + // Stays open on purpose — see the DELIBERATE DEVIATION note above. + setDeleteError(t('deleteError')); + } + } catch { + setDeleteError(t('deleteError')); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+

{t('deleteConfirm.title')}

+ + {loadingImpact ? ( +

{tCommon('loading')}

+ ) : impactError ? ( +
+ {t('deleteError')} +
+ ) : ( +

+ {t('deleteConfirm.body', { + memberCount: impact?.memberCount ?? 0, + grantCount: impact?.grantCount ?? 0, + })} +

+ )} + + {deleteError && ( +
+ {deleteError} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx b/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx new file mode 100644 index 0000000..9de2e8e --- /dev/null +++ b/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx @@ -0,0 +1,267 @@ +'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'; + +type MembershipSource = 'MANUAL' | 'LDAP'; + +interface GroupMembership { + id: string; + groupId: string; + userId: string; + source: MembershipSource; + createdAt: string; + user: { + id: string; + username: string; + displayName: string | null; + email: string; + }; +} + +interface TenantUser { + id: string; + username: string; + displayName: string | null; + email: string; +} + +interface GroupMembersModalProps { + group: Group; + onClose: () => void; + onChanged: () => void; +} + +/** + * Member management dialog for a single group (D-14, D-19, D-20). + * + * LDAP-sourced memberships can only be removed by the AD sync — the + * remove button here is disabled for them with a tooltip explaining why + * (D-19). Manual memberships can be added and removed freely; adding a + * user who is already a member is a folgenlos upsert on the API side, + * so the search list simply marks already-members as disabled rather + * than needing special-case handling here. + */ +export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersModalProps) { + const t = useTranslations('admin.groups'); + const tCommon = useTranslations('common'); + + const [members, setMembers] = useState(null); + const [loadingMembers, setLoadingMembers] = useState(true); + + const [allUsers, setAllUsers] = useState(null); + const [userSearchQuery, setUserSearchQuery] = useState(''); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + const fetchMembers = useCallback(async () => { + try { + const res = await fetch(`${API_URL}/groups/${group.id}/members`, { + credentials: 'include', + }); + if (res.ok) { + setMembers(await res.json()); + } + } catch { + // silently fail — matches the read-path precedent of the other admin pages + } finally { + setLoadingMembers(false); + } + }, [group.id]); + + const fetchAllUsers = useCallback(async () => { + try { + const res = await fetch(`${API_URL}/users`, { credentials: 'include' }); + if (res.ok) { + setAllUsers(await res.json()); + } + } catch { + // silently fail + } + }, []); + + useEffect(() => { + fetchMembers(); + fetchAllUsers(); + // group.id is stable for the lifetime of this modal instance. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const memberUserIds = useMemo(() => new Set(members?.map((m) => m.userId) ?? []), [members]); + + const searchResults = useMemo(() => { + if (!allUsers) return null; + const q = userSearchQuery.trim().toLowerCase(); + if (!q) return allUsers; + return allUsers.filter( + (u) => + u.username.toLowerCase().includes(q) || + (u.displayName ?? '').toLowerCase().includes(q) || + u.email.toLowerCase().includes(q), + ); + }, [allUsers, userSearchQuery]); + + const toggleSelected = (userId: string) => { + setSelectedUserIds((prev) => + prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId], + ); + }; + + const handleRemove = async (userId: string) => { + try { + const res = await fetch(`${API_URL}/groups/${group.id}/members/${userId}`, { + method: 'DELETE', + credentials: 'include', + }); + if (res.ok) { + fetchMembers(); + onChanged(); + } + } catch { + // silently fail — mirrors the existing chip-remove precedent + } + }; + + const handleAdd = async () => { + if (selectedUserIds.length === 0) return; + setAdding(true); + setAddError(null); + try { + const res = await fetch(`${API_URL}/groups/${group.id}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ userIds: selectedUserIds }), + }); + if (res.ok) { + setSelectedUserIds([]); + fetchMembers(); + onChanged(); + } else { + setAddError(t('members.addError')); + } + } catch { + setAddError(t('members.addError')); + } finally { + setAdding(false); + } + }; + + return ( +
+
+

+ {t('members.title')} — {group.name} +

+ +
+ {loadingMembers ? ( +

{tCommon('loading')}

+ ) : !members || members.length === 0 ? ( +

{t('members.noMembers')}

+ ) : ( +
    + {members.map((m) => ( +
  • + + {m.user.displayName ?? m.user.username} + + + {m.source === 'LDAP' ? t('members.sourceLdap') : t('members.sourceManual')} + + +
  • + ))} +
+ )} +
+ +
+

{t('members.addTitle')}

+ setUserSearchQuery(e.target.value)} + placeholder={t('members.searchPlaceholder')} + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm mb-2" + /> + + {searchResults && searchResults.length > 0 && ( +
+ {searchResults.map((u) => { + const alreadyMember = memberUserIds.has(u.id); + return ( + + ); + })} +
+ )} + + {addError && ( +
+ {addError} +
+ )} + +
+ + +
+
+
+
+ ); +} 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 index a552dfe..d78d111 100644 --- a/apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx +++ b/apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx @@ -242,3 +242,152 @@ describe('AdminGroupsPage', () => { expect(screen.getAllByLabelText('Als Standardgruppe festlegen').length).toBeGreaterThan(0); }); }); + +interface GroupMembership { + id: string; + groupId: string; + userId: string; + source: 'MANUAL' | 'LDAP'; + createdAt: string; + user: { id: string; username: string; displayName: string | null; email: string }; +} + +const mockMembers: GroupMembership[] = [ + { + id: 'gm1', + groupId: 'g2', + userId: 'u10', + source: 'MANUAL', + createdAt: '2026-01-01T00:00:00.000Z', + user: { id: 'u10', username: 'manual.user', displayName: 'Manuelle Userin', email: 'manual@ctl.de' }, + }, + { + id: 'gm2', + groupId: 'g2', + userId: 'u11', + source: 'LDAP', + createdAt: '2026-01-01T00:00:00.000Z', + user: { id: 'u11', username: 'ad.user', displayName: 'AD Userin', email: 'ad@ctl.de' }, + }, +]; + +function buildRouterFetchMock(opts: { + impact?: { memberCount: number; grantCount: number }; + deleteOk?: boolean; +}) { + return vi.fn((url: string, init?: RequestInit) => { + if (typeof url !== 'string') { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + } + if (url.endsWith('/groups') && (!init || init.method === undefined)) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) }); + } + if (url.endsWith('/groups/g2/members') && (!init || init.method === undefined)) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockMembers) }); + } + if (url.endsWith('/users')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + } + if (url.endsWith('/groups/g2/impact')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(opts.impact ?? { memberCount: 4, grantCount: 2 }), + }); + } + if (url.endsWith('/groups/g2') && init?.method === 'DELETE') { + if (opts.deleteOk === false) { + return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('db down') }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + }); +} + +describe('GroupMembersModal (via AdminGroupsPage)', () => { + beforeEach(() => { + stubAdmin(); + }); + + it('disables the remove button for LDAP-sourced members and keeps it active for MANUAL members', async () => { + vi.stubGlobal('fetch', buildRouterFetchMock({})); + + render(); + + await waitFor(() => { + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + }); + + const membersButtons = screen.getAllByText('Mitglieder'); + // Header column label + per-row action buttons all render the same text; + // the row action for 'Buchhaltung' (g2) is the last "Mitglieder" button. + await userEvent.click(membersButtons[membersButtons.length - 1]); + + await waitFor(() => { + expect(screen.getByText('Manuelle Userin')).toBeInTheDocument(); + }); + expect(screen.getByText('AD Userin')).toBeInTheDocument(); + + const manualRemove = screen.getByLabelText('Mitglied entfernen'); + expect(manualRemove).not.toBeDisabled(); + + const ldapRemove = screen.getByLabelText('Wird über AD-Sync verwaltet'); + expect(ldapRemove).toBeDisabled(); + }); +}); + +describe('DeleteGroupDialog (via AdminGroupsPage)', () => { + beforeEach(() => { + stubAdmin(); + }); + + it('shows both concrete numbers from GET /groups/:id/impact in the delete text', async () => { + vi.stubGlobal('fetch', buildRouterFetchMock({ impact: { memberCount: 4, grantCount: 2 } })); + + render(); + + await waitFor(() => { + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByText('Löschen'); + await userEvent.click(deleteButtons[deleteButtons.length - 1]); + + await waitFor(() => { + expect(screen.getByText(/4 Mitglieder/)).toBeInTheDocument(); + }); + expect(screen.getByText(/2 Modul-Freigaben/)).toBeInTheDocument(); + }); + + it('stays open and shows a visible error when DELETE /groups/:id fails', async () => { + vi.stubGlobal( + 'fetch', + buildRouterFetchMock({ impact: { memberCount: 4, grantCount: 2 }, deleteOk: false }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByText('Löschen'); + await userEvent.click(deleteButtons[deleteButtons.length - 1]); + + await waitFor(() => { + expect(screen.getByText(/4 Mitglieder/)).toBeInTheDocument(); + }); + + // Confirm button inside the dialog also reads "Löschen" (tCommon.delete). + const confirmButtons = screen.getAllByText('Löschen'); + await userEvent.click(confirmButtons[confirmButtons.length - 1]); + + await waitFor(() => { + expect( + screen.getByText('Gruppe konnte nicht gelöscht werden. Bitte erneut versuchen.'), + ).toBeInTheDocument(); + }); + // The dialog is still open — its title is still on screen. + expect(screen.getByText('Gruppe löschen')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(portal)/admin/groups/page.tsx b/apps/web/src/app/(portal)/admin/groups/page.tsx index bc737a7..2bfab0d 100644 --- a/apps/web/src/app/(portal)/admin/groups/page.tsx +++ b/apps/web/src/app/(portal)/admin/groups/page.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useAuthStore } from '@/lib/stores/auth-store'; import { GroupFormModal } from './components/GroupFormModal'; +import { GroupMembersModal } from './components/GroupMembersModal'; +import { DeleteGroupDialog } from './components/DeleteGroupDialog'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; @@ -252,8 +254,21 @@ export default function AdminGroupsPage() { /> )} - {/* GroupMembersModal / DeleteGroupDialog are wired in here by Task 3 - (member/delete state above is already in place for that). */} + {membersGroup && ( + setMembersGroup(null)} + onChanged={fetchGroups} + /> + )} + + {deleteTarget && ( + setDeleteTarget(null)} + onDeleted={fetchGroups} + /> + )} ); }