feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog

- Matrix und Benutzerdetails liefern und zeigen die Stufe, Auswahlfeld Benutzen/Verwalten
- Gruppen mit Verwalten sind in den Benutzerdetails markiert
- Administrationshandbuch, Anwenderanleitung und Changelog beschreiben die Stufen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 13:35:00 +02:00
parent c2ebc8daa0
commit eaf2c4574a
11 changed files with 463 additions and 31 deletions
@@ -495,7 +495,18 @@ describe('ModuleGrantsService.getMatrix', () => {
expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']);
expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']);
expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1' }]);
expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1', level: 'USE' }]);
});
it('261002-icv: jedes Grant-Element trägt die Stufe', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
const matrix = await service.getMatrix('t1');
expect(matrix.grants).toEqual([{ moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }]);
});
it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => {
@@ -552,11 +563,32 @@ describe('ModuleGrantsService.getUserAccess', () => {
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
viaGroups: ['Gruppe A'],
direct: false,
directLevel: null,
manageViaGroups: [],
},
]);
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]);
});
it('261002-icv: directLevel zeigt die Stufe der Direkt-Freigabe, manageViaGroups nennt Gruppen mit Verwalten', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Gruppe B' });
prisma.__seedMembership('g1', 'u1');
prisma.__seedMembership('g2', 'u1');
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g2', level: 'MANAGE' });
await service.grant('t1', { moduleId: 'mod-1', userId: 'u1', level: 'MANAGE' });
const row = (await service.getUserAccess('t1', 'u1')).modules[0];
expect(row.direct).toBe(true);
expect(row.directLevel).toBe('MANAGE');
expect([...row.viaGroups].sort()).toEqual(['Gruppe A', 'Gruppe B']);
expect(row.manageViaGroups).toEqual(['Gruppe B']);
});
it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
+22 -6
View File
@@ -226,7 +226,7 @@ export class ModuleGrantsService {
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, groupId: { not: null } },
select: { moduleId: true, groupId: true },
select: { moduleId: true, groupId: true, level: true },
}),
]);
@@ -242,6 +242,7 @@ export class ModuleGrantsService {
grants: groupGrants.map((g) => ({
moduleId: g.moduleId,
groupId: g.groupId,
level: g.level ?? ModuleGrantLevel.USE,
})),
};
}
@@ -255,7 +256,8 @@ export class ModuleGrantsService {
* dadurch sichtbar. `modules` beantwortet je aktivem Modul die andere
* Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein
* Direkt-Grant) und behält dafür je Eintrag exakt die Form
* { module, viaGroups, direct }.
* { module, viaGroups, direct }; seit 261002-icv kommen `directLevel` und
* `manageViaGroups` hinzu (Anzeige der Freigabestufe).
*
* Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide
* Projektionsstellen (viaGroups-Namen, groups[].name) liefern
@@ -282,7 +284,7 @@ export class ModuleGrantsService {
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, userId },
select: { moduleId: true },
select: { moduleId: true, level: true },
}),
// Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird
// über denselben tenantPrisma wie die drei Abfragen oben gesetzt —
@@ -299,13 +301,23 @@ export class ModuleGrantsService {
}),
]);
const directModuleIds = new Set(directGrants.map((g) => g.moduleId));
const directLevelByModule = new Map<string, ModuleGrantLevel>();
for (const g of directGrants) {
directLevelByModule.set(g.moduleId, g.level ?? ModuleGrantLevel.USE);
}
const groupNamesByModule = new Map<string, string[]>();
const manageGroupNamesByModule = new Map<string, string[]>();
for (const g of groupGrants) {
if (!g.group) continue;
const displayName = g.group.internalName ?? g.group.name;
const names = groupNamesByModule.get(g.moduleId) ?? [];
names.push(g.group.internalName ?? g.group.name);
names.push(displayName);
groupNamesByModule.set(g.moduleId, names);
if (g.level === ModuleGrantLevel.MANAGE) {
const manageNames = manageGroupNamesByModule.get(g.moduleId) ?? [];
manageNames.push(displayName);
manageGroupNamesByModule.set(g.moduleId, manageNames);
}
}
const modules = activations
@@ -328,7 +340,11 @@ export class ModuleGrantsService {
modules: modules.map((module) => ({
module,
viaGroups: groupNamesByModule.get(module.id) ?? [],
direct: directModuleIds.has(module.id),
direct: directLevelByModule.has(module.id),
// 261002-icv: Stufe der Direkt-Freigabe (null ohne Direkt-Grant) und
// die Gruppen, die Verwalten gewähren (Teilmenge von viaGroups).
directLevel: directLevelByModule.get(module.id) ?? null,
manageViaGroups: manageGroupNamesByModule.get(module.id) ?? [],
})),
};
}
@@ -17,11 +17,15 @@ const messages: Record<string, Record<string, unknown>> = {
emptyModulesLink: 'Zu Module',
adminNote:
'ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.',
levelExplanation: '„Benutzen“: Das Modul öffnen. „Verwalten“: zusätzlich die Einstellungen ändern.',
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
noSearchResults: 'Kein Treffer für "{search}" — weder bei den Modulen noch bei den Gruppen.',
},
'admin.groups.grants': {
matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}',
levelUse: 'Benutzen',
levelManage: 'Verwalten',
levelSelectLabel: 'Stufe für {module} in Gruppe {group}',
},
'adminModules.activationDialog': {
title: 'Modul aktivieren: {module}',
@@ -321,6 +325,121 @@ describe('AdminModuleGrantsPage (Permission-Matrix)', () => {
});
});
describe('AdminModuleGrantsPage — Freigabestufe (261002-icv)', () => {
beforeEach(() => {
stubAdmin();
});
const levelMatrix = {
...mockMatrix,
grants: [
{ moduleId: 'm1', groupId: 'g1', level: 'MANAGE' },
{ moduleId: 'm2', groupId: 'g1', level: 'USE' },
],
};
it('zeigt bei freigegebenen Zellen ein Stufenfeld mit der gelieferten Stufe, bei leeren Zellen keins', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) })),
);
render(<AdminModuleGrantsPage />);
const manage = (await screen.findByLabelText(
'Stufe für Ausschreibungs-Radar in Gruppe Alle Benutzer',
)) as HTMLSelectElement;
expect(manage.value).toBe('MANAGE');
const use = screen.getByLabelText('Stufe für DKV Flotte in Gruppe Alle Benutzer') as HTMLSelectElement;
expect(use.value).toBe('USE');
expect(screen.queryByLabelText('Stufe für Ausschreibungs-Radar in Gruppe Buchhaltung')).toBeNull();
});
it('Wechsel auf Verwalten sendet POST mit level MANAGE', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminModuleGrantsPage />);
const select = (await screen.findByLabelText(
'Stufe für DKV Flotte in Gruppe Alle Benutzer',
)) as HTMLSelectElement;
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => {
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({
moduleId: 'm2',
groupId: 'g1',
level: 'MANAGE',
});
});
expect(select.value).toBe('MANAGE');
});
it('ein fehlgeschlagener Stufenwechsel setzt das Auswahlfeld zurück', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminModuleGrantsPage />);
const select = (await screen.findByLabelText(
'Stufe für DKV Flotte in Gruppe Alle Benutzer',
)) as HTMLSelectElement;
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => expect(screen.getByText(/500: boom/)).toBeInTheDocument());
expect(select.value).toBe('USE');
});
it('Ankreuzen einer leeren Zelle sendet POST ohne level und zeigt Benutzen', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminModuleGrantsPage />);
const box = await screen.findByLabelText('Ausschreibungs-Radar für Gruppe Buchhaltung freigeben');
await userEvent.click(box);
await waitFor(() => {
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ moduleId: 'm1', groupId: 'g2' });
});
const select = (await screen.findByLabelText(
'Stufe für Ausschreibungs-Radar in Gruppe Buchhaltung',
)) as HTMLSelectElement;
expect(select.value).toBe('USE');
});
it('zeigt die Erklärung der Stufen unter der Matrix', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) })),
);
render(<AdminModuleGrantsPage />);
expect(await screen.findByText(/„Verwalten“: zusätzlich die Einstellungen ändern/)).toBeInTheDocument();
});
});
describe('ActivateModuleDialog', () => {
it('renders all three actions (cancel, configure later, grant now)', async () => {
vi.stubGlobal(
@@ -19,9 +19,13 @@ interface Group {
internalName?: string | null;
}
type GrantLevel = 'USE' | 'MANAGE';
interface GrantPair {
moduleId: string;
groupId: string;
/** Freigabestufe (261002-icv); fehlt sie, gilt Benutzen. */
level?: GrantLevel;
}
function cellKey(moduleId: string, groupId: string): string {
@@ -35,7 +39,9 @@ function cellKey(moduleId: string, groupId: string): string {
* 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).
* AdminModulesPage.toggleModule). Seit 261002-icv trägt jede freigegebene
* Zelle eine Stufe (Benutzen / Verwalten), die per Auswahlfeld gewechselt
* wird.
*/
export default function AdminModuleGrantsPage() {
const t = useTranslations('adminModules.grants');
@@ -47,7 +53,7 @@ export default function AdminModuleGrantsPage() {
const [modules, setModules] = useState<Module[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [grants, setGrants] = useState<Set<string>>(new Set());
const [grants, setGrants] = useState<Map<string, GrantLevel>>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
@@ -63,7 +69,11 @@ export default function AdminModuleGrantsPage() {
await res.json();
setModules(data.modules);
setGroups(data.groups);
setGrants(new Set(data.grants.map((g) => cellKey(g.moduleId, g.groupId))));
setGrants(
new Map(
data.grants.map((g) => [cellKey(g.moduleId, g.groupId), g.level ?? 'USE'] as const),
),
);
}
} catch {
// silently fail -- same precedent as the other admin list pages
@@ -87,21 +97,22 @@ export default function AdminModuleGrantsPage() {
// Optimistic toggle -- rolled back below on any failure so the UI never
// shows a grant the database does not have (T-15-25).
const previousLevel = grants.get(key);
setGrants((prev) => {
const next = new Set(prev);
const next = new Map(prev);
if (currentlyGranted) {
next.delete(key);
} else {
next.add(key);
next.set(key, 'USE');
}
return next;
});
const rollback = () => {
setGrants((prev) => {
const next = new Set(prev);
const next = new Map(prev);
if (currentlyGranted) {
next.add(key);
next.set(key, previousLevel ?? 'USE');
} else {
next.delete(key);
}
@@ -130,6 +141,37 @@ export default function AdminModuleGrantsPage() {
}
};
// Stufe einer bereits freigegebenen Zelle wechseln (261002-icv): POST mit
// `level` — der Server hebt/senkt die Stufe der bestehenden Freigabe.
// Optimistisch, bei Fehler Rücksprung auf die vorherige Stufe.
const changeLevel = async (moduleId: string, groupId: string, level: GrantLevel) => {
const key = cellKey(moduleId, groupId);
const previous = grants.get(key) ?? 'USE';
if (previous === level) return;
setTogglingKey(key);
setError(null);
setGrants((prev) => new Map(prev).set(key, level));
try {
const res = await fetch(`${API_URL}/module-grants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ moduleId, groupId, level }),
});
if (!res.ok) {
setGrants((prev) => new Map(prev).set(key, previous));
const body = await res.text().catch(() => '');
setError(`${res.status}: ${body || t('saveError')}`);
}
} catch (err) {
setGrants((prev) => new Map(prev).set(key, previous));
setError(String(err));
} finally {
setTogglingKey(null);
}
};
const searchLower = search.trim().toLowerCase();
// Both axes are searched independently, then combined with the following
@@ -263,10 +305,11 @@ export default function AdminModuleGrantsPage() {
{filteredGroups.map((g) => {
const key = cellKey(mod.id, g.id);
const isGranted = grants.has(key);
const level = grants.get(key) ?? 'USE';
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">
<span className="relative inline-flex items-center justify-center gap-2">
<input
type="checkbox"
checked={isGranted}
@@ -283,6 +326,23 @@ export default function AdminModuleGrantsPage() {
})}
className="h-4 w-4 rounded border-input"
/>
{isGranted && (
<select
value={level}
disabled={isToggling}
onChange={(e) =>
changeLevel(mod.id, g.id, e.target.value as GrantLevel)
}
aria-label={tGrantLabel('levelSelectLabel', {
module: mod.name,
group: g.name,
})}
className="h-7 rounded-md border border-input bg-background px-1 text-xs"
>
<option value="USE">{tGrantLabel('levelUse')}</option>
<option value="MANAGE">{tGrantLabel('levelManage')}</option>
</select>
)}
{isToggling && (
<span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
)}
@@ -299,6 +359,7 @@ export default function AdminModuleGrantsPage() {
</div>
)}
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</>
)}
@@ -11,10 +11,16 @@ interface ModuleSummary {
category: string;
}
type GrantLevel = 'USE' | 'MANAGE';
interface ModuleAccessRow {
module: ModuleSummary;
viaGroups: string[];
direct: boolean;
/** Stufe der Direkt-Freigabe (261002-icv); null ohne Direkt-Freigabe. */
directLevel?: GrantLevel | null;
/** Namen der Gruppen, die Verwalten gewähren (Teilmenge von viaGroups). */
manageViaGroups?: string[];
}
interface MembershipChip {
@@ -37,7 +43,9 @@ interface UserAccessModalProps {
/**
* 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.
* Modul-Zugriff mit geerbten Gruppen plus einem Direkt-Toggle. Seit 261002-icv
* trägt die Direkt-Freigabe eine Stufe (Benutzen / Verwalten, Auswahlfeld),
* und Gruppen, die Verwalten gewähren, sind in der Liste markiert.
*
* Lädt einmal GET /module-grants/users/:userId -- eine Antwort, zwei
* Abschnitte: die Gruppenmitgliedschafts-Chips kommen direkt aus den
@@ -49,6 +57,7 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
const t = useTranslations('admin.users.grants');
const tCommon = useTranslations('common');
const tMembers = useTranslations('admin.groups.members');
const tLevel = useTranslations('admin.groups.grants');
const [rows, setRows] = useState<ModuleAccessRow[] | null>(null);
const [groups, setGroups] = useState<MembershipChip[] | null>(null);
@@ -84,19 +93,28 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
const toggleDirect = async (moduleId: string, currentlyDirect: boolean) => {
setTogglingModuleId(moduleId);
setSaveError(null);
const previousLevel = rows?.find((r) => r.module.id === moduleId)?.directLevel ?? 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.map((r) =>
r.module.id === moduleId
? { ...r, direct: !currentlyDirect, directLevel: currentlyDirect ? null : 'USE' }
: r,
)
: prev,
);
const rollback = () => {
setRows((prev) =>
prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, direct: currentlyDirect } : r))
? prev.map((r) =>
r.module.id === moduleId
? { ...r, direct: currentlyDirect, directLevel: previousLevel }
: r,
)
: prev,
);
};
@@ -122,6 +140,41 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
}
};
// Stufe der Direkt-Freigabe wechseln (261002-icv): POST mit `level`,
// optimistisch, bei Fehler Rücksprung auf die vorherige Stufe.
const changeDirectLevel = async (moduleId: string, level: GrantLevel) => {
const previous = rows?.find((r) => r.module.id === moduleId)?.directLevel ?? 'USE';
if (previous === level) return;
setTogglingModuleId(moduleId);
setSaveError(null);
const setLevel = (value: GrantLevel) =>
setRows((prev) =>
prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, directLevel: value } : r))
: prev,
);
setLevel(level);
try {
const res = await fetch(`${API_URL}/module-grants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ moduleId, userId, level }),
});
if (!res.ok) {
setLevel(previous);
const body = await res.text().catch(() => '');
setSaveError(`${res.status}: ${body || t('saveError')}`);
}
} catch (err) {
setLevel(previous);
setSaveError(String(err));
} finally {
setTogglingModuleId(null);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@@ -217,13 +270,18 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{name}
{row.manageViaGroups?.includes(name) && (
<span className="ml-1 font-medium text-foreground">
· {t('manageMarker')}
</span>
)}
</span>
))}
</span>
)}
</td>
<td className="px-4 py-3 text-center">
<span className="relative inline-flex items-center justify-center">
<span className="relative inline-flex items-center justify-center gap-2">
<input
type="checkbox"
checked={row.direct}
@@ -239,6 +297,23 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
})}
className="h-4 w-4 rounded border-input"
/>
{row.direct && (
<select
value={row.directLevel ?? 'USE'}
disabled={isToggling}
onChange={(e) =>
changeDirectLevel(row.module.id, e.target.value as GrantLevel)
}
aria-label={t('directLevelLabel', {
module: row.module.name,
user: username,
})}
className="h-7 rounded-md border border-input bg-background px-1 text-xs"
>
<option value="USE">{tLevel('levelUse')}</option>
<option value="MANAGE">{tLevel('levelManage')}</option>
</select>
)}
{isToggling && (
<span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
)}
@@ -22,6 +22,12 @@ const messages: Record<string, Record<string, unknown>> = {
directCheckboxLabel:
'{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}',
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
directLevelLabel: 'Stufe der direkten Freigabe von {module} für {user}',
manageMarker: 'Verwalten',
},
'admin.groups.grants': {
levelUse: 'Benutzen',
levelManage: 'Verwalten',
},
'admin.groups.members': {
sourceManual: 'Manuell',
@@ -285,3 +291,90 @@ describe('UserAccessModal', () => {
expect(screen.getByText('LDAP')).toBeInTheDocument();
});
});
describe('UserAccessModal — Freigabestufe (261002-icv)', () => {
const levelAccess = {
groups: [{ id: 'g1', name: 'Alle Benutzer', source: 'MANUAL' as const }],
modules: [
{
module: { id: 'm1', name: 'Proxmox', category: 'infrastructure' },
viaGroups: ['Alle Benutzer'],
direct: false,
directLevel: null,
manageViaGroups: ['Alle Benutzer'],
},
{
module: { id: 'm2', name: 'DKV Flotte', category: 'fleet' },
viaGroups: [],
direct: true,
directLevel: 'USE' as const,
manageViaGroups: [],
},
],
};
it('zeigt bei der Direkt-Freigabe ein Stufenfeld und wechselt die Stufe per POST mit level', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelAccess) });
});
vi.stubGlobal('fetch', fetchMock);
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
const select = (await screen.findByLabelText(
'Stufe der direkten Freigabe von DKV Flotte für Maxi Musterfrau',
)) as HTMLSelectElement;
expect(select.value).toBe('USE');
// Ohne Direkt-Freigabe kein Stufenfeld.
expect(
screen.queryByLabelText('Stufe der direkten Freigabe von Proxmox für Maxi Musterfrau'),
).toBeNull();
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => {
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({
moduleId: 'm2',
userId: 'u1',
level: 'MANAGE',
});
});
expect(select.value).toBe('MANAGE');
});
it('setzt die Stufe bei einem Fehler zurück und zeigt die Meldung', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelAccess) });
});
vi.stubGlobal('fetch', fetchMock);
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
const select = (await screen.findByLabelText(
'Stufe der direkten Freigabe von DKV Flotte für Maxi Musterfrau',
)) as HTMLSelectElement;
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => expect(screen.getByText(/500: boom/)).toBeInTheDocument());
expect(select.value).toBe('USE');
});
it('markiert eine Gruppe, die Verwalten gewährt, mit „Verwalten“', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(levelAccess) })),
);
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
expect(await screen.findByText(/· Verwalten/)).toBeInTheDocument();
});
});
+8 -2
View File
@@ -553,6 +553,8 @@
"noInheritance": "–",
"noActiveModules": "Für diesen Mandanten sind keine Module aktiviert.",
"directCheckboxLabel": "{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}",
"directLevelLabel": "Stufe der direkten Freigabe von {module} für {user}",
"manageMarker": "Verwalten",
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen."
},
"errors": {
@@ -730,7 +732,10 @@
"body": "Diese Gruppe hat {memberCount, plural, one {# Mitglied} other {# Mitglieder}} und {grantCount, plural, one {# Modul-Freigabe} other {# Modul-Freigaben}}. Betroffene Benutzer verlieren den Zugriff, sofern sie ihn nicht anderweitig haben. Diese Aktion kann nicht rückgängig gemacht werden."
},
"grants": {
"matrixCheckboxLabel": "{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}"
"matrixCheckboxLabel": "{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}",
"levelUse": "Benutzen",
"levelManage": "Verwalten",
"levelSelectLabel": "Stufe für {module} in Gruppe {group}"
}
},
"customModules": {
@@ -860,7 +865,8 @@
"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.",
"levelExplanation": "„Benutzen“: Das Modul öffnen und damit arbeiten. „Verwalten“: zusätzlich die Einstellungen dieses Moduls ändern. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Hat jemand über mehrere Wege Zugriff, gilt die höhere Stufe.",
"adminNote": "Administratoren haben immer Zugriff auf alle aktiven Module und dürfen deren Einstellungen ändern – diese Matrix betrifft nur Benutzer ohne Administratorrechte.",
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.",
"noSearchResults": "Kein Treffer für „{search}\" — weder bei den Modulen noch bei den Gruppen."
},
+8 -2
View File
@@ -553,6 +553,8 @@
"noInheritance": "–",
"noActiveModules": "No modules are activated for this tenant.",
"directCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} directly for {user}",
"directLevelLabel": "Level of the direct grant of {module} for {user}",
"manageMarker": "Manage",
"saveError": "Could not save grant. Please try again."
},
"errors": {
@@ -730,7 +732,10 @@
"body": "This group has {memberCount, plural, one {# member} other {# members}} and {grantCount, plural, one {# module grant} other {# module grants}}. Affected users will lose access unless they have it another way. This action cannot be undone."
},
"grants": {
"matrixCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} for group {group}"
"matrixCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} for group {group}",
"levelUse": "Use",
"levelManage": "Manage",
"levelSelectLabel": "Level for {module} in group {group}"
}
},
"customModules": {
@@ -860,7 +865,8 @@
"searchPlaceholder": "Search modules or groups...",
"emptyModules": "No modules are activated for this tenant yet. Activate a module under Modules first.",
"emptyModulesLink": "Go to Modules",
"adminNote": "ADMIN and SUPER_ADMIN always have access to all active modules — this matrix only applies to the USER role.",
"levelExplanation": "“Use”: open the module and work with it. “Manage”: additionally change the settings of this module. Only administrators may grant access and activate modules. If someone has access through several routes, the higher level applies.",
"adminNote": "Administrators always have access to all active modules and may change their settings — this matrix only affects users without administrator rights.",
"saveError": "Could not save grant. Please try again.",
"noSearchResults": "No match for \"{search}\" — neither in the modules nor in the groups."
},