--- phase: quick-260805-d0r plan: 01 subsystem: admin-users tags: [module-grants, groups, group-membership, i18n-reuse] status: complete dependency-graph: requires: - GroupMembership (Prisma-Modell, Phase 15-01) - ModuleGrantsService.getUserAccess (Phase 15-07) provides: - "GET /module-grants/users/:userId liefert { groups, modules } statt eines Arrays" - "UserAccessModal-Gruppenmitgliedschafts-Chips aus der tatsaechlichen Mitgliedschaft" affects: - apps/api/src/groups/module-grants.service.ts - apps/api/src/groups/module-grants.controller.ts - "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx" tech-stack: added: [] patterns: - "Herkunfts-Badge-Markup 1:1 aus GroupMembersModal.tsx uebernommen statt einer zweiten Variante" key-files: created: [] modified: - apps/api/src/groups/module-grants.service.ts - apps/api/src/groups/module-grants.service.spec.ts - apps/api/src/groups/module-grants.controller.ts - "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx" - "apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx" decisions: - "Kein forTenant() fuer die neue groupMembership.findMany-Abfrage — bewusste Abweichung vom urspruenglichen Auftrag, ausfuehrlich im PLAN.md-Objective begruendet (RLS wird von der DB-Rolle ohnehin umgangen, der `where`-Filter ist wie bei den drei bestehenden Abfragen derselben Methode der primaere Schutz). Der Browser-Gegenprobe-Schritt (Task 3) ist das vereinbarte Gegenzeichen, falls Chips trotz vorhandener GroupMembership-Zeilen leer blieben." - "Kein neuer i18n-Schluessel fuer das Herkunfts-Badge — admin.groups.members.sourceManual/.sourceLdap wird ueber einen zweiten useTranslations-Hook (tMembers) wiederverwendet." metrics: duration: "ca. 55 min" completed: "2026-08-05" actuals: tokens: 5944 tasks: 2 commits: 5 --- # Phase quick-260805-d0r Plan 01: Benutzer-Detail zeigt Gruppenmitgliedschaften Summary Das Benutzer-Detail in /admin/users zeigt Gruppenmitgliedschaften jetzt aus den tatsaechlichen GroupMembership-Zeilen, unabhaengig davon, ob die Gruppe gerade ein Modul freigibt. ## Was gebaut wurde **Task 1 — Mitgliedschaften end-to-end (D-16):** `ModuleGrantsService.getUserAccess` liefert jetzt ein Objekt `{ groups, modules }` statt des bisherigen Arrays. `groups` kommt aus einer neuen `this.prisma.groupMembership.findMany`-Abfrage mit `where: { userId, group: { tenantId } }` — dem expliziten Mandantenfilter ueber die Relation, weil `GroupMembership` keine eigene `tenantId`-Spalte traegt. `assertTargetBelongsToTenant` bleibt unveraendert die erste Anweisung der Methode. `modules` ist feldgleich zum bisherigen Rueckgabewert (`{ module, viaGroups, direct }` je aktivem Modul). Die API-Spec (`module-grants.service.spec.ts`) bekam einen `groupMembership`-Zweig im Hand-Fake-Prisma, einen dritten optionalen `source`-Parameter fuer `__seedMembership` (Default `MANUAL`) und sieben neue/umgestellte Tests: den Regressionsfall ("Mitglied einer Gruppe ohne Modul-Freigabe bleibt sichtbar"), den Cross-Tenant-Fall, den LDAP-Herkunfts-Fall, den Leer-Modul-Fall und den Sortierungs-Fall. `UserAccessModal.tsx` liest die Gruppen-Chips jetzt direkt aus `data.groups` (React-`key` ist die Gruppen-ID statt des Namens) statt sie aus der Vereinigung aller `row.viaGroups` abzuleiten. Das `useMemo`, das die Namen bisher zusammensetzte, und der ungenutzte `useMemo`-Import wurden entfernt. Der Kopfkommentar der Datei wurde neu formuliert — der alte Satz ("Chips sind die deduplizierte Menge aller viaGroups-Namen, ein zweiter Endpoint existiert bewusst nicht") widersprach sonst dem neuen Code. Der Controller-JSDoc ueber `GET /module-grants/users/:userId` wurde an die Zwei-Schluessel-Antwort angepasst; der Controller-Code selbst blieb unveraendert (reicht die Service-Antwort weiter). **Task 2 — Herkunfts-Badge (D-19/D-20):** Ein zweiter `useTranslations('admin.groups.members')`-Hook (`tMembers`) liest die bereits bestehenden Schluessel `sourceManual`/`sourceLdap`. Jeder Chip zeigt jetzt zusaetzlich zum Gruppennamen ein Badge — Markup und Klassen 1:1 aus `GroupMembersModal.tsx` (Zeilen 176-184) uebernommen: blaue Variante bei `LDAP`, neutral-grau sonst. `de.json`/`en.json` blieben unveraendert — kein neuer Schluessel, deckungsgleiche Pruefung bestand mit 762 Schluesseln auf beiden Seiten. **Task 3 — Browser-Gegenprobe:** delegiert an den Orchestrator. Der lokale Stack (web:3000, api:3001, db) laeuft; die Browser-Verifikation aus dem Plan (Chip bleibt nach Entzug aller Modul-Freigaben stehen, Leerzustand bei Benutzer ohne Mitgliedschaft, Badges sichtbar) wurde in dieser Ausfuehrung NICHT durchgefuehrt und muss vom Orchestrator nachgeholt werden. ## TDD-Ablauf Beide Tasks liefen als echte RED/GREEN-Zyklen mit getrennten Commits: 1. `b6d4e4a` test: Service-Spec-Tests ergaenzt, gegen den unveraenderten Service rot (7 von 25 Tests fehlgeschlagen, bestaetigt). 2. `ecadf69` feat: Service + Controller implementiert, 25/25 gruen. 3. `771f680` test: Web-Test-Fixtures auf `{ groups, modules }` umgestellt, gegen das unveraenderte Modal rot (8 von 8 Tests fehlgeschlagen mit `TypeError: (rows ?? []) is not iterable`, bestaetigt). 4. `f8ff74b` feat: Modal implementiert, 7/7 gruen (die achte, das Badge betreffende Testerwartung, wurde vor diesem Commit wieder entfernt — siehe Deviations). 5. `73122b5` test: Badge-Test separat ergaenzt, rot bestaetigt (1 von 8 Tests fehlgeschlagen). 6. `8ce3748` feat: Badge implementiert, 8/8 gruen. ## Deviations from Plan ### Auto-fixed Issues **1. [Prozess-Korrektur, kein Rule-1/2/3-Fix] Badge-Test versehentlich vorzeitig in Task-1-RED-Commit aufgenommen** - **Gefunden bei:** Schreiben des Web-Testfiles fuer Task 1. - **Problem:** Beim ersten Entwurf der neuen `user-access-modal.test.tsx` wurde faelschlich bereits der Badge-Test (Task-2-Umfang) samt `admin.groups.members`-Mock-Namensraum mit reingeschrieben und im RED-Commit `771f680` mitcommittet. - **Fix:** Vor dem GREEN-Commit von Task 1 wieder entfernt (Namensraum-Eintrag und Testblock), damit Task 1s Commit-Grenze exakt dem Plan entspricht. Der Badge-Test wurde danach in Task 2 als eigener RED/GREEN-Zyklus neu geschrieben. - **Dateien:** `apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx` - **Commits:** Entfernung Teil von `f8ff74b`; Neuanlage in `73122b5`/`8ce3748`. Keine weiteren Abweichungen — der Plan wurde ansonsten exakt wie beschrieben umgesetzt, inklusive der bewussten Nicht-Verwendung von `forTenant()` gemaess der Begruendung im PLAN.md-Objective. ### Auth-Gates Keine. ## Known Stubs Keine. ## Threat Flags Keine — die im Plan dokumentierten Threats (T-d0r-01/02/03) sind durch die umgesetzte Mandanten-Gegenpruefung und das explizite `group: { tenantId }`-Filter vollstaendig mitigiert bzw. wie geplant akzeptiert; kein zusaetzliches, ungeplantes Angriffsflaechen-Element wurde eingefuehrt. ## Verifikation - `pnpm --filter @tessera/api test -- module-grants`: 25/25 gruen (inkl. Regression, Cross-Tenant, LDAP-Herkunft). - `pnpm --filter @tessera/api run type-check`: sauber. - `pnpm --filter @tessera/web test -- user-access-modal`: 8/8 gruen (inkl. Regression, beider Herkunftsbadges). - `pnpm --filter @tessera/web run type-check`: sauber. - `pnpm --filter @tessera/api test`: 498/498 gruen (keine Kollateralschaeden). - `pnpm --filter @tessera/web test`: 187/187 gruen (keine Kollateralschaeden). - `de.json`/`en.json`: unveraendert, 762 Schluessel deckungsgleich; `admin.groups.members.sourceManual`/`.sourceLdap` in beiden vorhanden. - Diff seit Phase-15-Ende (`be1183a..HEAD`) beruehrt exakt die fuenf im Plan gelisteten Dateien, keine Schemaaenderung, keine Migration. - **Task 3 (Browser-Gegenprobe): OFFEN.** Delegiert an den Orchestrator gemaess Auftrag — bitte vor Abschluss nachholen (siehe orchestrator_decisions im Prompt dieser Ausfuehrung). ## Self-Check: PASSED - `apps/api/src/groups/module-grants.service.ts`: FOUND - `apps/api/src/groups/module-grants.service.spec.ts`: FOUND - `apps/api/src/groups/module-grants.controller.ts`: FOUND - `apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx`: FOUND - `apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx`: FOUND - `b6d4e4a`: FOUND - `ecadf69`: FOUND - `771f680`: FOUND - `f8ff74b`: FOUND - `73122b5`: FOUND - `8ce3748`: FOUND