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) ?? [],
})),
};
}