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)
This commit is contained in:
2026-08-04 19:18:30 +02:00
parent a3e8d0a158
commit 6b2a6f1ce4
3 changed files with 505 additions and 3 deletions
@@ -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<string, Record<string, unknown>> = {
'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, unknown>): 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<string, unknown>) =>
resolve(ns, key, params),
}));
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
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(<AdminModuleGrantsPage />);
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(<AdminModuleGrantsPage />);
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(<AdminModuleGrantsPage />);
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(<AdminModuleGrantsPage />);
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(<AdminModuleGrantsPage />);
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();
}
});
});
@@ -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<Module[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [grants, setGrants] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [togglingKey, setTogglingKey] = useState<string | null>(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 (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
</div>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? (
<div className="rounded-md border border-border bg-card p-6 text-center">
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link
href="/admin/modules"
className="mt-2 inline-block text-sm font-medium text-primary hover:underline"
>
{t('emptyModulesLink')}
</Link>
</div>
) : (
<>
<input
type="text"
value={search}
onChange={(e) => 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"
/>
<div className="overflow-x-auto overflow-y-auto max-h-[70vh] rounded-md border border-border">
<table className="w-full text-sm border-collapse">
<thead>
<tr>
<th className="sticky left-0 top-0 z-20 min-w-[180px] max-w-[260px] border-b border-r border-border bg-card px-4 py-3 text-left" />
{filteredGroups.map((g) => (
<th
key={g.id}
title={g.name}
className="sticky top-0 z-10 min-w-[120px] max-w-[160px] truncate border-b border-border bg-card px-4 py-3 text-left text-xs font-medium text-muted-foreground"
>
{g.name}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{groupedModules.map(({ category, mods }, groupIndex) => (
<Fragment key={`cat-${category}-${groupIndex}`}>
<tr>
<td
colSpan={filteredGroups.length + 1}
className="bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground"
>
{category}
</td>
</tr>
{mods.map((mod) => (
<tr key={mod.id} className="hover:bg-muted/30 transition-colors">
<td
title={mod.name}
className="sticky left-0 z-10 min-w-[180px] max-w-[260px] truncate border-r border-border bg-card px-4 py-3 font-medium text-foreground"
>
{mod.name}
</td>
{filteredGroups.map((g) => {
const key = cellKey(mod.id, g.id);
const isGranted = grants.has(key);
const isToggling = togglingKey === key;
return (
<td key={g.id} className="px-4 py-3 text-center">
<span className="relative inline-flex items-center justify-center">
<input
type="checkbox"
checked={isGranted}
disabled={isToggling}
onChange={() => 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 && (
<span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
)}
</span>
</td>
);
})}
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</>
)}
</div>
);
}
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
@@ -126,10 +127,18 @@ export default function AdminModulesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p> <p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div> </div>
<Link
href="/admin/modules/grants"
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
{t('grantsLink')}
</Link>
</div>
{error && ( {error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">