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:
@@ -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';
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</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 && (
|
||||
|
||||
Reference in New Issue
Block a user