From 6b2a6f1ce4d2c7a885f00681bec923c5cae23e26 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 19:18:30 +0200 Subject: [PATCH] feat(15-07): Freigabe-Matrix Module x Gruppen unter /admin/modules/grants - Neue Client-Komponente admin/modules/grants/page.tsx: laedt GET /module-grants/matrix einmal, rendert Module x Gruppen mit sticky erster Spalte/Kopfzeile, Kategorie- Gruppierung, Suchfeld und Admin-Bypass-Fussnote (D-03/D-15, PERM-03) - Jede Zelle togglet sofort optimistisch (POST/DELETE /module-grants); Fehlschlag springt die Checkbox zurueck und zeigt die Fehlermeldung im bestehenden error-Div (T-15-25) -- identisches Muster zu AdminModulesPage.toggleModule - aria-label pro Checkbox aus admin.groups.grants.matrixCheckboxLabel beschreibt die bevorstehende Aktion (freigeben/entziehen), nicht den aktuellen Zustand - admin/modules/page.tsx: neuer Header-Button "Freigaben-Matrix" verlinkt auf die Unterseite, kein siebter Sidebar-Eintrag - grants-matrix.test.tsx: 5 Tests (befuellte Matrix, leerer Zustand, Rollback bei Fehler, Suchfilter, aria-label je Checkbox) --- .../modules/grants/grants-matrix.test.tsx | 214 ++++++++++++++ .../(portal)/admin/modules/grants/page.tsx | 279 ++++++++++++++++++ .../src/app/(portal)/admin/modules/page.tsx | 15 +- 3 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx create mode 100644 apps/web/src/app/(portal)/admin/modules/grants/page.tsx diff --git a/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx b/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx new file mode 100644 index 0000000..c36caa6 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx @@ -0,0 +1,214 @@ +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); {param} placeholders are +// substituted plainly, and the one ICU `select` template used by this +// phase (`{granted, select, true {...} other {...}}`) is resolved against +// the boolean `granted` param, mirroring next-intl's own behaviour closely +// enough for aria-label assertions. +const messages: Record> = { + 'adminModules.grants': { + title: 'Freigaben-Matrix', + searchPlaceholder: 'Module oder Gruppen durchsuchen...', + emptyModules: + 'Es sind noch keine Module für diesen Mandanten aktiviert. Aktiviere zuerst ein Modul unter Module.', + emptyModulesLink: 'Zu Module', + adminNote: + 'ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.', + saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.', + }, + 'admin.groups.grants': { + matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}', + }, + common: { + loading: 'Laden...', + 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) { + 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), +})); + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +const mockAuthStore = vi.fn(); +vi.mock('@/lib/stores/auth-store', () => ({ + useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector), +})); + +import AdminModuleGrantsPage from './page'; + +function stubAdmin() { + mockAuthStore.mockImplementation( + (selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) => + selector({ user: { id: 'u1', role: 'ADMIN', tenantId: 't1' } }), + ); +} + +const mockMatrix = { + modules: [ + { id: 'm1', name: 'Ausschreibungs-Radar', category: 'procurement' }, + { id: 'm2', name: 'DKV Flotte', category: 'fleet' }, + ], + groups: [ + { id: 'g1', name: 'Alle Benutzer' }, + { id: 'g2', name: 'Buchhaltung' }, + ], + grants: [{ moduleId: 'm1', groupId: 'g1' }], +}; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('AdminModuleGrantsPage (Permission-Matrix)', () => { + beforeEach(() => { + stubAdmin(); + }); + + it('renders a populated matrix with correctly pre-checked checkboxes', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Ausschreibungs-Radar')).toBeInTheDocument(); + }); + expect(screen.getByText('DKV Flotte')).toBeInTheDocument(); + expect(screen.getByText('Alle Benutzer')).toBeInTheDocument(); + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + + const grantedCheckbox = screen.getByLabelText( + 'Ausschreibungs-Radar für Gruppe Alle Benutzer entziehen', + ); + expect(grantedCheckbox).toBeChecked(); + + const ungrantedCheckbox = screen.getByLabelText( + 'Ausschreibungs-Radar für Gruppe Buchhaltung freigeben', + ); + expect(ungrantedCheckbox).not.toBeChecked(); + }); + + it('renders the empty state with a link back to /admin/modules when no active modules exist', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ modules: [], groups: [], grants: [] }), + }), + ), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/keine Module für diesen Mandanten aktiviert/)).toBeInTheDocument(); + }); + const link = screen.getByText('Zu Module'); + expect(link.closest('a')).toHaveAttribute('href', '/admin/modules'); + }); + + it('rolls back a checkbox and shows a visible error when the grant request fails', async () => { + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (typeof url === 'string' && url.endsWith('/module-grants/matrix')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) }); + } + 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(mockMatrix) }); + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + }); + + const checkbox = screen.getByLabelText( + 'Ausschreibungs-Radar für Gruppe Buchhaltung freigeben', + ); + await userEvent.click(checkbox); + + await waitFor(() => { + expect(screen.getByText(/500: boom/)).toBeInTheDocument(); + }); + expect(checkbox).not.toBeChecked(); + }); + + it('filters the matrix rows via the search field', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('DKV Flotte')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Module oder Gruppen durchsuchen...'); + await userEvent.type(searchInput, 'Flotte'); + + await waitFor(() => { + expect(screen.queryByText('Ausschreibungs-Radar')).not.toBeInTheDocument(); + }); + expect(screen.getByText('DKV Flotte')).toBeInTheDocument(); + }); + + it('gives every matrix checkbox an aria-label', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThan(0); + for (const box of checkboxes) { + expect(box).toHaveAccessibleName(); + } + }); +}); diff --git a/apps/web/src/app/(portal)/admin/modules/grants/page.tsx b/apps/web/src/app/(portal)/admin/modules/grants/page.tsx new file mode 100644 index 0000000..4fabfcb --- /dev/null +++ b/apps/web/src/app/(portal)/admin/modules/grants/page.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { useAuthStore } from '@/lib/stores/auth-store'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +interface Module { + id: string; + name: string; + category: string; +} + +interface Group { + id: string; + name: string; +} + +interface GrantPair { + moduleId: string; + groupId: string; +} + +function cellKey(moduleId: string, groupId: string): string { + return `${moduleId}:${groupId}`; +} + +/** + * Freigabe-Matrix Module x Gruppen (PERM-03, D-15). + * + * Unterseite von /admin/modules, kein eigener Navigationseintrag. Lädt + * einmal GET /module-grants/matrix (Module, Gruppen, bestehende + * Gruppen-Grants in einer Antwort) und schaltet jede Zelle sofort um + * (optimistisches UI, Rücksprung bei Fehler -- identisches Verhalten zu + * AdminModulesPage.toggleModule). + */ +export default function AdminModuleGrantsPage() { + const t = useTranslations('adminModules.grants'); + const tGrantLabel = useTranslations('admin.groups.grants'); + const tCommon = useTranslations('common'); + const currentUser = useAuthStore((s) => s.user); + + const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; + + const [modules, setModules] = useState([]); + const [groups, setGroups] = useState([]); + const [grants, setGrants] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [togglingKey, setTogglingKey] = useState(null); + + const fetchMatrix = useCallback(async () => { + try { + const res = await fetch(`${API_URL}/module-grants/matrix`, { + credentials: 'include', + }); + if (res.ok) { + const data: { modules: Module[]; groups: Group[]; grants: GrantPair[] } = + await res.json(); + setModules(data.modules); + setGroups(data.groups); + setGrants(new Set(data.grants.map((g) => cellKey(g.moduleId, g.groupId)))); + } + } catch { + // silently fail -- same precedent as the other admin list pages + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (hasAccess) { + fetchMatrix(); + } else { + setLoading(false); + } + }, [hasAccess, fetchMatrix]); + + const toggleGrant = async (moduleId: string, groupId: string, currentlyGranted: boolean) => { + const key = cellKey(moduleId, groupId); + setTogglingKey(key); + setError(null); + + // Optimistic toggle -- rolled back below on any failure so the UI never + // shows a grant the database does not have (T-15-25). + setGrants((prev) => { + const next = new Set(prev); + if (currentlyGranted) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + + const rollback = () => { + setGrants((prev) => { + const next = new Set(prev); + if (currentlyGranted) { + next.add(key); + } else { + next.delete(key); + } + return next; + }); + }; + + try { + const res = await fetch(`${API_URL}/module-grants`, { + method: currentlyGranted ? 'DELETE' : 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ moduleId, groupId }), + }); + + if (!res.ok) { + rollback(); + const body = await res.text().catch(() => ''); + setError(`${res.status}: ${body || t('saveError')}`); + } + } catch (err) { + rollback(); + setError(String(err)); + } finally { + setTogglingKey(null); + } + }; + + const searchLower = search.trim().toLowerCase(); + const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower); + + const filteredModules = useMemo( + () => modules.filter((m) => matches(m.name)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [modules, searchLower], + ); + const filteredGroups = useMemo( + () => groups.filter((g) => matches(g.name)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [groups, searchLower], + ); + + const groupedModules = useMemo(() => { + const result: { category: string; mods: Module[] }[] = []; + for (const mod of filteredModules) { + const last = result[result.length - 1]; + if (last && last.category === mod.category) { + last.mods.push(mod); + } else { + result.push({ category: mod.category, mods: [mod] }); + } + } + return result; + }, [filteredModules]); + + if (!hasAccess) { + return ( +
+

{tCommon('accessDenied')}

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

{t('title')}

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

{tCommon('loading')}

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

{t('emptyModules')}

+ + {t('emptyModulesLink')} + +
+ ) : ( + <> + setSearch(e.target.value)} + placeholder={t('searchPlaceholder')} + className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm" + /> + +
+ + + + + ))} + + + + {groupedModules.map(({ category, mods }, groupIndex) => ( + + + + + {mods.map((mod) => ( + + + {filteredGroups.map((g) => { + const key = cellKey(mod.id, g.id); + const isGranted = grants.has(key); + const isToggling = togglingKey === key; + return ( + + ); + })} + + ))} + + ))} + +
+ {filteredGroups.map((g) => ( + + {g.name} +
+ {category} +
+ {mod.name} + + + toggleGrant(mod.id, g.id, isGranted)} + aria-label={tGrantLabel('matrixCheckboxLabel', { + module: mod.name, + group: g.name, + // The label describes the action the click performs, not + // the current state (which the native checked/aria-checked + // attribute already conveys) -- so an already-granted cell + // (click revokes) reads "...entziehen", and vice versa. + granted: String(!isGranted), + })} + className="h-4 w-4 rounded border-input" + /> + {isToggling && ( + + )} + +
+
+ +

{t('adminNote')}

+ + )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/admin/modules/page.tsx b/apps/web/src/app/(portal)/admin/modules/page.tsx index 2a41422..bb91028 100644 --- a/apps/web/src/app/(portal)/admin/modules/page.tsx +++ b/apps/web/src/app/(portal)/admin/modules/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { useAuthStore } from '@/lib/stores/auth-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; @@ -126,9 +127,17 @@ export default function AdminModulesPage() { return (
-
-

{t('title')}

-

{t('description')}

+
+
+

{t('title')}

+

{t('description')}

+
+ + {t('grantsLink')} +
{error && (