From f8ff74bd3f1212a3cc5ece238d96ee63b1968e1f Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 5 Aug 2026 09:35:54 +0200 Subject: [PATCH] feat(260805-d0r): UserAccessModal chips render from data.groups (D-16) - Chips come from the response's groups (actual GroupMembership rows), not the union of row.viaGroups -- closes the reproduced defect where revoking a group's last grant hid an otherwise-unchanged membership - React key is the group id, not the name - Test file: moved the LDAP/MANUAL badge assertion out of this commit, it belongs to Task 2 which reuses admin.groups.members --- .../users/components/UserAccessModal.tsx | 54 +++++++++++-------- .../admin/users/user-access-modal.test.tsx | 32 ----------- 2 files changed, 31 insertions(+), 55 deletions(-) diff --git a/apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx b/apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx index aa943a5..1b2bc13 100644 --- a/apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx +++ b/apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; @@ -17,6 +17,17 @@ interface ModuleAccessRow { direct: boolean; } +interface MembershipChip { + id: string; + name: string; + source: 'MANUAL' | 'LDAP'; +} + +interface UserAccessResponse { + groups: MembershipChip[]; + modules: ModuleAccessRow[]; +} + interface UserAccessModalProps { userId: string; username: string; @@ -28,17 +39,18 @@ interface UserAccessModalProps { * (read-only -- bearbeitet wird ausschliesslich unter /admin/groups) und * Modul-Zugriff mit geerbten Gruppen plus einem Direkt-Toggle. * - * Lädt einmal GET /module-grants/users/:userId und rendert beide Abschnitte - * daraus: die Gruppenmitgliedschafts-Chips sind die deduplizierte Menge - * aller viaGroups-Namen über alle Module -- ein zweiter Endpoint für die - * reine Mitgliedschaftsliste existiert bewusst nicht (key_link im Plan: - * "geerbte und direkte Rechte kommen aus einer Antwort"). + * Lädt einmal GET /module-grants/users/:userId -- eine Antwort, zwei + * Abschnitte: die Gruppenmitgliedschafts-Chips kommen direkt aus den + * `groups` der Antwort (den tatsächlichen GroupMembership-Zeilen), `viaGroups` + * bleibt weiterhin je Modulzeile befüllt und beantwortet die andere Frage + * (welche Gruppe gewährt dieses Modul). */ export function UserAccessModal({ userId, username, onClose }: UserAccessModalProps) { const t = useTranslations('admin.users.grants'); const tCommon = useTranslations('common'); const [rows, setRows] = useState(null); + const [groups, setGroups] = useState(null); const [loading, setLoading] = useState(true); const [saveError, setSaveError] = useState(null); const [togglingModuleId, setTogglingModuleId] = useState(null); @@ -48,11 +60,17 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr setLoading(true); fetch(`${API_URL}/module-grants/users/${userId}`, { credentials: 'include' }) .then((res) => (res.ok ? res.json() : Promise.reject(res))) - .then((data: ModuleAccessRow[]) => { - if (!cancelled) setRows(data); + .then((data: UserAccessResponse) => { + if (!cancelled) { + setRows(data.modules); + setGroups(data.groups); + } }) .catch(() => { - if (!cancelled) setRows([]); + if (!cancelled) { + setRows([]); + setGroups([]); + } }) .finally(() => { if (!cancelled) setLoading(false); @@ -62,16 +80,6 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr }; }, [userId]); - const groupNames = useMemo(() => { - const names = new Set(); - for (const row of rows ?? []) { - for (const name of row.viaGroups) { - names.add(name); - } - } - return Array.from(names).sort(); - }, [rows]); - const toggleDirect = async (moduleId: string, currentlyDirect: boolean) => { setTogglingModuleId(moduleId); setSaveError(null); @@ -144,16 +152,16 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr

{t('groupsSection')}

- {groupNames.length === 0 ? ( + {!groups || groups.length === 0 ? (

{t('noGroups')}

) : (
    - {groupNames.map((name) => ( + {groups.map((group) => (
  • - {name} + {group.name}
  • ))}
diff --git a/apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx b/apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx index a905a49..681729f 100644 --- a/apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx +++ b/apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx @@ -23,10 +23,6 @@ const messages: Record> = { '{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}', saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.', }, - 'admin.groups.members': { - sourceManual: 'Manuell', - sourceLdap: 'LDAP', - }, common: { loading: 'Laden...', close: 'Schliessen', @@ -256,32 +252,4 @@ describe('UserAccessModal', () => { expect(box).toHaveAccessibleName(); } }); - - it('shows the LDAP origin badge on a chip with source LDAP and the Manuell badge on source MANUAL', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - groups: [ - { id: 'g1', name: 'Manuelle Gruppe', source: 'MANUAL' as const }, - { id: 'g2', name: 'LDAP-Gruppe', source: 'LDAP' as const }, - ], - modules: [], - }), - }), - ), - ); - - render(); - - await waitFor(() => { - expect(screen.getByText('Manuelle Gruppe')).toBeInTheDocument(); - }); - expect(screen.getByText('LDAP-Gruppe')).toBeInTheDocument(); - expect(screen.getByText('Manuell')).toBeInTheDocument(); - expect(screen.getByText('LDAP')).toBeInTheDocument(); - }); });