From 050d0703401906f8946480e062215e7170eb990a Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 19:25:15 +0200 Subject: [PATCH] feat(15-07): Benutzer-Detail mit geerbten Rechten und Direkt-Freigaben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue UserAccessModal.tsx: laedt einmal GET /module-grants/users/:userId und rendert daraus zwei Abschnitte -- Gruppenmitgliedschaften (read-only Chip-Liste, dedupliziert aus allen viaGroups-Namen; Bearbeitung bleibt ausschliesslich unter /admin/groups, D-16) und Modul-Zugriff (Modul | erbende Gruppen als Chips oder "–" | Direkt-Checkbox) - Direkt-Checkbox verhaelt sich identisch zur Matrix-Zelle: optimistisches Toggle via POST/DELETE /module-grants mit moduleId+userId, Rollback samt sichtbarer Fehlermeldung bei Fehlschlag (T-15-25), aria-label pro Zeile aus admin.users.grants.directCheckboxLabel - admin/users/page.tsx: vierter Aktionsbutton "Details" je Zeile oeffnet das Modal - user-access-modal.test.tsx: 5 Tests (Chip-Liste + Leerzustand, Modultabelle mit geerbtem/nicht-geerbtem Modul, Rollback bei Fehler, Hinweistext ohne aktive Module, aria-label je Checkbox) --- .../users/components/UserAccessModal.tsx | 242 ++++++++++++++++++ .../web/src/app/(portal)/admin/users/page.tsx | 17 ++ .../admin/users/user-access-modal.test.tsx | 197 ++++++++++++++ 3 files changed, 456 insertions(+) create mode 100644 apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx create mode 100644 apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx diff --git a/apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx b/apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx new file mode 100644 index 0000000..aa943a5 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx @@ -0,0 +1,242 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +interface ModuleSummary { + id: string; + name: string; + category: string; +} + +interface ModuleAccessRow { + module: ModuleSummary; + viaGroups: string[]; + direct: boolean; +} + +interface UserAccessModalProps { + userId: string; + username: string; + onClose: () => void; +} + +/** + * Benutzer-Detail-Zugriff (D-16, Surface Contract 3): Gruppenmitgliedschaften + * (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"). + */ +export function UserAccessModal({ userId, username, onClose }: UserAccessModalProps) { + const t = useTranslations('admin.users.grants'); + const tCommon = useTranslations('common'); + + const [rows, setRows] = useState(null); + const [loading, setLoading] = useState(true); + const [saveError, setSaveError] = useState(null); + const [togglingModuleId, setTogglingModuleId] = useState(null); + + useEffect(() => { + let cancelled = false; + 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); + }) + .catch(() => { + if (!cancelled) setRows([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [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); + + // Optimistic toggle -- rolled back below on failure (T-15-25): the UI + // must never show a grant the database does not have. + setRows((prev) => + prev + ? prev.map((r) => (r.module.id === moduleId ? { ...r, direct: !currentlyDirect } : r)) + : prev, + ); + + const rollback = () => { + setRows((prev) => + prev + ? prev.map((r) => (r.module.id === moduleId ? { ...r, direct: currentlyDirect } : r)) + : prev, + ); + }; + + try { + const res = await fetch(`${API_URL}/module-grants`, { + method: currentlyDirect ? 'DELETE' : 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ moduleId, userId }), + }); + + if (!res.ok) { + rollback(); + const body = await res.text().catch(() => ''); + setSaveError(`${res.status}: ${body || t('saveError')}`); + } + } catch (err) { + rollback(); + setSaveError(String(err)); + } finally { + setTogglingModuleId(null); + } + }; + + return ( +
+
+
+

{t('modalTitle', { username })}

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

{tCommon('loading')}

+ ) : ( +
+
+

{t('groupsSection')}

+ {groupNames.length === 0 ? ( +

{t('noGroups')}

+ ) : ( +
    + {groupNames.map((name) => ( +
  • + {name} +
  • + ))} +
+ )} +
+ +
+

{t('accessSection')}

+ {!rows || rows.length === 0 ? ( +

{t('noActiveModules')}

+ ) : ( +
+ + + + + + + + + + {rows.map((row) => { + const isToggling = togglingModuleId === row.module.id; + return ( + + + + + + ); + })} + +
+ {t('moduleColumn')} + + {t('viaGroupsColumn')} + + {t('directColumn')} +
+ {row.module.name} + + {row.viaGroups.length === 0 ? ( + t('noInheritance') + ) : ( + + {row.viaGroups.map((name) => ( + + {name} + + ))} + + )} + + + toggleDirect(row.module.id, row.direct)} + aria-label={t('directCheckboxLabel', { + module: row.module.name, + user: username, + // Describes the action the click performs (grant vs. + // revoke), not the current state -- identical + // reasoning to the matrix checkbox. + granted: String(!row.direct), + })} + className="h-4 w-4 rounded border-input" + /> + {isToggling && ( + + )} + +
+
+ )} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/admin/users/page.tsx b/apps/web/src/app/(portal)/admin/users/page.tsx index ec85774..4dc0b2d 100644 --- a/apps/web/src/app/(portal)/admin/users/page.tsx +++ b/apps/web/src/app/(portal)/admin/users/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useAuthStore } from '@/lib/stores/auth-store'; +import { UserAccessModal } from './components/UserAccessModal'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; @@ -40,6 +41,7 @@ export default function AdminUsersPage() { const [showForm, setShowForm] = useState(false); const [editingUser, setEditingUser] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); + const [detailsUser, setDetailsUser] = useState(null); const [formData, setFormData] = useState({ username: '', email: '', @@ -245,6 +247,12 @@ export default function AdminUsersPage() {
+
)} + + {/* User-Detail-Zugriff (D-16): geerbte + direkte Modulfreigaben */} + {detailsUser && ( + setDetailsUser(null)} + /> + )} ); } 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 new file mode 100644 index 0000000..b04aea1 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx @@ -0,0 +1,197 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Namespace-aware next-intl mock -- same convention as +// admin/groups/groups-page.test.tsx and admin/modules/grants/grants-matrix.test.tsx. +// {param} placeholders are substituted plainly; the one ICU `select` template +// used here (`directCheckboxLabel`) is resolved against the string-coerced +// `granted` param. +const messages: Record> = { + 'admin.users.grants': { + detailsButton: 'Details', + modalTitle: '{username} — Zugriff', + groupsSection: 'Gruppenmitgliedschaften', + noGroups: 'Dieser Benutzer ist keiner Gruppe zugeordnet.', + accessSection: 'Modul-Zugriff', + moduleColumn: 'Modul', + viaGroupsColumn: 'Über Gruppe(n)', + directColumn: 'Direkt', + noInheritance: '–', + noActiveModules: 'Für diesen Mandanten sind keine Module aktiviert.', + directCheckboxLabel: + '{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}', + saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.', + }, + common: { + loading: 'Laden...', + close: 'Schliessen', + }, +}; + +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) { + val = val.replace( + /\{granted,\s*select,\s*true\s*\{([^}]*)\}\s*other\s*\{([^}]*)\}\}/, + (_match: string, whenTrue: string, whenOther: string) => + String(params.granted) === 'true' ? whenTrue : whenOther, + ); + for (const [k, v] of Object.entries(params)) { + if (k === 'granted') continue; + val = val.replace(`{${k}}`, String(v)); + } + } + return val; +} + +vi.mock('next-intl', () => ({ + useTranslations: (ns: string) => (key: string, params?: Record) => + resolve(ns, key, params), +})); + +import { UserAccessModal } from './components/UserAccessModal'; + +const mockAccessRows = [ + { + module: { id: 'm1', name: 'Ausschreibungs-Radar', category: 'procurement' }, + viaGroups: ['Alle Benutzer'], + direct: false, + }, + { + module: { id: 'm2', name: 'DKV Flotte', category: 'fleet' }, + viaGroups: [], + direct: true, + }, +]; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('UserAccessModal', () => { + it('renders the group-membership chip list, and the empty state when the user has none', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) })), + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('Alle Benutzer').length).toBeGreaterThan(0); + }); + + cleanup(); + + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve( + mockAccessRows.map((row) => ({ ...row, viaGroups: [] as string[] })), + ), + }), + ), + ); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Dieser Benutzer ist keiner Gruppe zugeordnet.'), + ).toBeInTheDocument(); + }); + }); + + it('renders the module access table with one inherited and one non-inherited module', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) })), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Ausschreibungs-Radar')).toBeInTheDocument(); + }); + expect(screen.getByText('DKV Flotte')).toBeInTheDocument(); + // m1 is inherited via 'Alle Benutzer' -- shown as a chip in the via-groups column. + expect(screen.getAllByText('Alle Benutzer').length).toBeGreaterThan(0); + // m2 has no inheriting groups -- renders the em-dash placeholder. + expect(screen.getByText('–')).toBeInTheDocument(); + }); + + it('rolls back the direct checkbox and shows a visible error when the request fails', async () => { + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (typeof url === 'string' && url.endsWith('/module-grants/users/u1')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) }); + } + if (typeof url === 'string' && url.endsWith('/module-grants') && init?.method === 'POST') { + return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) }); + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText('Ausschreibungs-Radar')).toBeInTheDocument(); + }); + + const checkbox = screen.getByLabelText( + 'Ausschreibungs-Radar direkt für Maxi Musterfrau freigeben', + ); + expect(checkbox).not.toBeChecked(); + await userEvent.click(checkbox); + + await waitFor(() => { + expect(screen.getByText(/500: boom/)).toBeInTheDocument(); + }); + expect(checkbox).not.toBeChecked(); + }); + + it('shows the hint text when the tenant has no active modules', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([]) })), + ); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Für diesen Mandanten sind keine Module aktiviert.'), + ).toBeInTheDocument(); + }); + }); + + it('gives every direct checkbox an aria-label', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) })), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('DKV Flotte')).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBe(2); + for (const box of checkboxes) { + expect(box).toHaveAccessibleName(); + } + }); +});