--- phase: quick-260929-dzu plan: 01 quick_id: 260929-dzu subsystem: api, web, prisma tags: [custom-modules, personal, rls, settings] status: complete requires: [260929-9wc] provides: - Spalte CustomModule.ownerUserId (NULL = gemeinsam, gesetzt = persoenlich), Migration 20260929130000 - Zeilenschutz mit Benutzerdimension nach Muster SearchProvider - API /custom-modules mit persoenlichen und gemeinsamen Eintraegen (Antwortfeld personal) - Einstellungen > Eigene Module (/settings/custom-modules) fuer jeden Benutzer - gemeinsame Oberflaeche CustomModuleManager (Formular, Loeschdialog, Liste) fuer Verwaltung und Einstellungen key-files: created: - apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql - apps/web/src/components/custom-modules/custom-module-manager.tsx - apps/web/src/app/(portal)/settings/custom-modules/page.tsx - apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx modified: - apps/api/prisma/schema.prisma - apps/api/src/custom-modules/ (Dienst, Controller, DTO, Specs) - docs/mandantentrennung-zugriffsklassifikation.md - apps/web/src/components/custom-modules/custom-module-form-modal.tsx (verschoben aus admin/custom-modules/components) - apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx (verschoben) - apps/web/src/app/(portal)/admin/custom-modules/page.tsx (+ Test) - apps/web/src/components/settings/settings-sidebar.tsx - apps/web/src/lib/custom-modules-api.ts - apps/web/src/messages/de.json, en.json - CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md decisions: - "RLS: Muster SearchProvider (nullable Besitzerspalte, vier Regeln je Befehl), nicht die einfache Muster DashboardImage (Pflicht-userId)" - "Gemeinsame Eintraege werden ohne Benutzerkontext geschrieben (forTenant ohne userId), persoenliche mit Benutzer" - "Rollenpruefung fuer gemeinsame Eintraege im Dienst statt per @Roles, weil sie vom Eintrag abhaengt" - "Filter fuer Verwaltung/Einstellungen im Web ueber personal, kein scope-Parameter in der API" - "Texte von Formular und Loeschdialog in eigenen Namensraum customModules.form, Umzug aus admin.customModules" completed: 2026-09-29 commits: 3 plan_head_before: 76a923450fd2f492ec046d5945a6965c8e94b707 plan_head_after: 8f41bd26bd3eddee1979485281cb375ade52949d actuals: tokens: 42000 tasks: 3 commits: 3 --- # Phase quick-260929-dzu Plan 01: Eigene Module fuer jeden Benutzer Summary Jeder angemeldete Benutzer legt unter Einstellungen > Eigene Module persoenliche Seitenleisten-Eintraege an, die nur er sieht; Administratoren pflegen weiter gemeinsame Eintraege unter Verwaltung > Eigene Module (Senden von `shared: true`). Niemand kann etwas in die Seitenleiste eines anderen Benutzers legen. ## Was gebaut wurde **Aufgabe 1, Commit c703d87 (Modell + API, Tests zuerst angepasst)** - Schema: `ownerUserId String?` mit Relation zu `User` (`onDelete: Cascade`), Index `[tenantId, ownerUserId]`; Gegenfeld `customModules` am `User`. - Migration `20260929130000_custom_module_owner` (von Hand, mit Kopfkommentar): Spalte, Index, Fremdschluessel, alte Regel ersetzt durch vier Regeln. - Dienst/Controller: `GET /custom-modules` liefert gemeinsame plus eigene Zeilen mit `personal: boolean` (ownerUserId wird nicht ausgeliefert); `GET :id` 404 bei fremdem persoenlichem Eintrag (auch fuer Administratoren); `POST` fuer jeden Angemeldeten, `shared: true` nur fuer ADMIN/SUPER_ADMIN (sonst 403), Standard persoenlich; `PATCH`/`DELETE`: persoenlich nur Besitzer (fremd: 404), gemeinsam nur Administrator (sonst 403). `shared`/`ownerUserId` sind per PATCH nicht aenderbar (`OmitType` im DTO plus `whitelist`). - Routen: `list` steht weiter vor `getOne`; kein `@Roles` mehr an den Schreibrouten, die Rollenpruefung sitzt im Dienst. - Specs: Dienst (22 Faelle: A sieht/aendert/loescht B nicht, Nicht-Admin nicht shared, Admin persoenlich vs. gemeinsam, RLS-Bindung mit/ohne Benutzer), Controller, DTO-Pipe-Faelle. - Zugriffsklassifikation nachgemessen (siehe unten). **Aufgabe 2, Commit ee97b4e (Web)** - Neue Seite `/settings/custom-modules` und Nav-Eintrag „Eigene Module“ unter „Allgemein“. - Gemeinsame Komponenten unter `components/custom-modules/`: `CustomModuleFormModal` und `DeleteCustomModuleDialog` (verschoben, Parameter `shared`) plus neu `CustomModuleManager` (Liste, Anlegen/Bearbeiten/Loeschen, `bumpSidebarRefresh`), aufgerufen mit `scope="shared"` (Verwaltung) oder `scope="personal"` (Einstellungen). Filter ueber `personal` im Web. - Verwaltung sendet beim Anlegen `shared: true`, zeigt nur gemeinsame Eintraege, Einleitung nennt „alle Benutzer“; Einstellungen senden kein `shared`, Einleitung: „Diese Einträge sehen nur Sie.“ - Texte de/en (Namensraeume `customModules.form`, `customModules.manage`, `settings.customModules`), Umlaut-Waechter gruen. - Tests: neuer Settings-Test (7), Admin-Test angepasst (`shared: true`, Filter; 14). **Aufgabe 3, Commit 8f41bd2 (Doku) + Neubau** - CHANGELOG-Punkt „Eigene Module“ umgeschrieben (Einstellungen fuer jeden, Verwaltung zusaetzlich fuer alle), `docs/anleitung-anwender.md` (Abschnitt „Allgemein > Eigene Module“) und `docs/anleitung-administration.md` (Unterabschnitt bei 5.). - `docker compose up -d --build web api`: web :3000/login 200, api /health ok, `GET /custom-modules` anonym 401, `/settings/custom-modules` ohne Anmeldung 307 (Umleitung auf Login). ## RLS-Muster und Begruendung Gefolgt bin ich dem Muster **SearchProvider** aus `20260911120000_rls_user_dimension_personal_tables`: nullable Besitzerspalte, vier nach Befehl getrennte Regeln. - SELECT: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId IS NULL` ODER `ownerUserId = current_user_id()`). - INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId = current_user_id()`). Warum nicht das einfachere Muster DashboardImage/Favorite (Pflicht-`userId`, eine Regel): eigene Module haben gemeinsame Zeilen (`NULL`), die jeder lesen, aber nur ein Administrator schreiben darf. Eine einzelne Regel, die die gemeinsame Zeile zum Lesen freigibt, wuerde sie auch zum Aendern/Loeschen freigeben (Praezedenz 260910-jab (3)), deshalb getrennte Befehle. Folge: ein Benutzerkontext kann gemeinsame Zeilen nicht schreiben; der Dienst bindet Schreibzugriffe auf gemeinsame Eintraege deshalb bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), nachdem er die Administrator-Rolle geprueft hat. Persoenliche Zugriffe binden mit Benutzer. Wie bei allen RLS-Regeln wirkt der Schutz erst mit dem Datenbankrollen-Schalter (heute AUS); bis dahin tragen die Anwendungspruefungen (`row.tenantId`, `ownerUserId`) den Schutz. ## Curl-Pruefung (lokal, echte API :3001) Benutzer: admin (SUPER_ADMIN), testuser (USER), curltmp (USER, nur fuer die Pruefung angelegt und danach geloescht). | Fall | Ergebnis | |------|----------| | USER legt Eintrag ohne shared an | 200, `personal: true` | | USER `shared: true` | 403 „Gemeinsame Einträge dürfen nur Administratoren anlegen“ | | Admin `shared: true` | 200, `personal: false` | | Admin ohne shared | 200, `personal: true` | | Liste USER | gemeinsam + eigener | | Liste zweiter USER | nur gemeinsam | | Liste Admin | nur gemeinsam (persoenliche Eintraege anderer nicht) | | zweiter USER: GET / PATCH / DELETE auf fremden persoenlichen Eintrag | 404 / 404 / 404 | | Admin: GET / DELETE auf persoenlichen Eintrag eines Benutzers | 404 / 404 | | USER GET gemeinsam | 200 | | USER PATCH / DELETE gemeinsam | 403 / 403 | | Admin PATCH gemeinsam (mit eingeschmuggeltem `shared:false`) | 200, bleibt gemeinsam | | USER PATCH eigenen mit `shared:true`, `ownerUserId:null` | 200, bleibt persoenlich | | http-Adresse | 400 | | anonym | 401 | | Benutzer loeschen -> seine persoenlichen Eintraege | Cascade, 0 Zeilen | Alle Testeintraege sind geloescht, `CustomModule` ist leer. ## Tore (gemessen) | Tor | Ergebnis | |-----|----------| | API-Tests vollstaendig | 88 Dateien, 1511 Tests gruen | | Web-Tests vollstaendig | 103 Dateien, 1003 Tests gruen | | `pnpm turbo run type-check lint --force` | 9/9 erfolgreich | | Biome-Warnungen Web / API | 55 (Grundlinie 55) / 82 (Grundlinie 82) | | rls-coverage / rls-access-inventory | gruen | | `prisma migrate deploy` lokal (Container-IP 172.19.0.2) | Migration angewendet, `migrate diff` danach leer | | Zugriffsklassifikation | Gate-Schleife 61/223/6 (vorher 61/224/6); `custom-modules` 0/6/0 | ## Testbenutzer fuer die Browser-Pruefung des Orchestrators Es gab lokal schon die Nicht-Admin-Konten `nutzer1` und `nutzer2`, deren Passwoerter aber nicht bekannt sind. Deshalb habe ich per Admin-API angelegt: Login **testuser**, Passwort **Test1234!test** (Rolle USER, `mustChangePassword` auf false gesetzt, damit die Anmeldung nicht auf die Passwort-Seite umleitet). Der Administrator ist wie gehabt admin / admin123. Vorschlag fuer die Browser-Pruefung (dunkel): als testuser unter Einstellungen > Allgemein > Eigene Module einen Eintrag anlegen (Seitenleiste zieht ohne Neuladen nach), als admin unter Verwaltung > Eigene Module einen gemeinsamen Eintrag anlegen (testuser sieht ihn in der Seitenleiste, kann ihn unter Einstellungen aber nicht bearbeiten), als admin pruefen, dass der persoenliche Eintrag von testuser weder in Seitenleiste noch Verwaltung erscheint. Danach die Testeintraege loeschen. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Festplatte voll (0 Byte frei) mitten in der Arbeit** - **Found during:** Aufgabe 2 (Biome meldete „No space left on device“) - **Issue:** die Docker-Build-Cache-Ablagen der Neubauten fuellten die Platte. - **Fix:** `docker builder prune -f` (nur Build-Cache, 17,97 GB, keine Images, Container oder Volumes); danach type-check/lint/Tests frisch und vollstaendig wiederholt, alle gruen. - **Commit:** kein Code betroffen. **2. [Rule 1 - Bug] Detektor-Vorgaben fuer `forTenant`** - **Found during:** Aufgabe 1 (rls-access-inventory schlug zweimal fehl) - **Issue:** eine Ternary-Bindung (`shared ? forTenant(..) : forTenant(..)`) und ein `client.customModule.create` in einer Hilfsfunktion werden vom Detektor nicht als Zuweisungsform/Modellaufruf erkannt. - **Fix:** je Zweig `const tenantPrisma = forTenant(...)` mit direktem Modellaufruf; Ausnahmeliste unveraendert leer. - **Files modified:** `apps/api/src/custom-modules/custom-modules.service.ts` - **Commit:** c703d87 **3. Plan-Feinheit:** Kein API-Parameter `scope`; die Verwaltung filtert im Web ueber `personal` (Plan liess beides zu, „das Einfachste“). Nebenwirkung: die Verwaltungsseite laedt auch die eigenen persoenlichen Eintraege des Administrators und blendet sie aus. **4. Plan-Feinheit:** Formular-/Dialog-Texte aus `admin.customModules` in den neuen Namensraum `customModules.form` umgezogen (beide Aufrufer teilen sie); Admin-Test entsprechend angepasst. Die Anleitung des Anwenders hatte den Punkt „Eigene Module“ vorher nicht, er ist jetzt neu beschrieben (der Plan sprach von „aktualisieren“). ## Hinweise - Zwischen c703d87 und ee97b4e liegt ein fremder Commit `bc4c011` (fix(web) Widgets nicht mehr zur Mitte versetzen), nicht von diesem Plan; er beruehrt CHANGELOG.md und `docs/anleitung-anwender.md` an anderen Stellen. Die 3 Commits dieses Plans sind c703d87, ee97b4e, 8f41bd2 (`git rev-list` ab dem Vorgaenger von c703d87 zaehlt 4 inklusive des fremden). Der Ledger nach Protokoll 0c wurde nicht vor dem ersten Commit angelegt, `plan_head_before` ist deshalb der Vorgaenger von c703d87. - Die Verwaltungs-Nav zeigt weiterhin „Eigene Module“; sie fuehrt jetzt auf die gemeinsamen Eintraege, die Einleitung nennt das. - Nichts gepusht. ## Known Stubs Keine. ## Threat Flags Keine neue Angriffsflaeche ausserhalb des bestehenden Modells: `ownerUserId` kommt nie aus dem Body (Whitelist, im Test belegt), `shared` ist per PATCH nicht setzbar, fremde persoenliche Eintraege sind ununterscheidbar 404. ## Self-Check: PASSED - Dateien vorhanden: Migration `20260929130000_custom_module_owner`, `custom-module-manager.tsx`, `settings/custom-modules/page.tsx`, Settings-Test. - Commits vorhanden: c703d87, ee97b4e, 8f41bd2 (`git log`); nichts gepusht (`git branch -r --contains HEAD` leer). ## Browser-Pruefung (Orchestrator, 29.09.) - testuser: Einstellungen > Eigene Module vorhanden; „Meine Seite“ angelegt -> sofort in eigener Seitenleiste (Infrastruktur). - admin: sieht „Meine Seite“ weder in Seitenleiste noch Verwaltung; Direktlink zeigt „Dieses Modul gibt es nicht mehr.“; Verwaltung heisst „Gemeinsamen Eintrag anlegen“. - admin legt „Firmenseite“ (Sicherheit) an -> testuser sieht sie in der Seitenleiste, nicht in seinen Einstellungen; DELETE als testuser -> 403. - Nebenbei: Widgets nicht mehr zentriert (bc4c011) — alle linken Kanten am Raster (272 px bei Rasterbeginn 260 + 12 Rand). - Testeintraege geloescht, CustomModule leer.