docs(quick-260929-dzu): eigene Module fuer jeden Benutzer, Browser-Pruefung

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 10:51:17 +02:00
parent 8f41bd26bd
commit b15a43f3d3
3 changed files with 239 additions and 2 deletions
@@ -0,0 +1,66 @@
---
quick_id: 260929-dzu
type: quick
wave: 1
autonomous: true
---
# Quick 260929-dzu: Eigene Module für jeden Benutzer (persönlich)
## User request (29.09.2026)
"Jeder User soll eigene Module anlegen können. nicht nur admins."
Decision (AskUserQuestion, locked): **"Nur er selbst"** — a normal user's entries are visible ONLY to that user.
Admins keep creating shared entries (visible to everyone) on /admin/custom-modules as today.
No user can put anything into another user's sidebar.
## Existing state (quick 260929-9wc, commits b9d87be, e7fc4de)
- Prisma `CustomModule { id, tenantId, name, url, category, createdAt, updatedAt }`, migration
`20260929120000_custom_module` with RLS (tenant only, pattern ProxmoxServer).
- API `apps/api/src/custom-modules/*`: GET list/one for any authenticated user; POST/PATCH/DELETE admin only;
https-only, no credentials in URL.
- Web: sidebar loads `listCustomModules()`, frame page `/modules/custom/[id]`, admin page `/admin/custom-modules`
with `CustomModuleFormModal` + `DeleteCustomModuleDialog`, `bumpSidebarRefresh` after changes.
## Task 1: Model + API (tests first)
- Add nullable `ownerUserId String?` (+ relation to User with onDelete: Cascade, index `[tenantId, ownerUserId]`)
via NEW migration (e.g. `20260929130000_custom_module_owner`). `null` = shared (admin-made), set = personal.
- RLS: extend the existing policy the way user-scoped tables already do it (find the pattern used by e.g.
DashboardImage / Favorite / other tables with a user dimension). Personal rows must only be readable/writable by
their owner; shared rows readable by the whole tenant. If the project's RLS pattern handles the user dimension
in the service layer instead, follow that pattern and document it. Update the RLS inventory test and
`docs/mandantentrennung-zugriffsklassifikation.md` (re-measure totals as last time).
- Service/controller:
- `GET /custom-modules` → shared rows + rows owned by the caller. Response carries `personal: boolean` (or `ownerUserId === me`).
- `GET /custom-modules/:id` → 404 unless shared or owned by caller.
- `POST /custom-modules` → any authenticated user; body flag `shared?: boolean`. `shared: true` only allowed for admins
(403 otherwise); default personal (ownerUserId = caller). The admin page sends `shared: true`.
- `PATCH` / `DELETE` → personal rows: only the owner (404 for others, do not leak existence); shared rows: admin only (403 for non-admin).
Ownership/shared-ness cannot be changed via PATCH.
- Keep URL validation. Keep static routes before `:id`.
- Admin page list: `GET /custom-modules?scope=shared` (admin) or filter client-side — pick the simplest; the admin page shows only shared entries; the settings page only the caller's personal ones.
- Tests: service + controller specs for all permission cases (user A cannot see/edit/delete user B's entry; non-admin cannot create/edit/delete shared; admin personal vs shared).
- verify: `pnpm --filter @tessera/api exec vitest run src/custom-modules` + RLS inventory test green; migrate local DB (db container IP 172.19.x, tessera/tessera_dev), rebuild api, curl check.
## Task 2: Web — settings section
- Settings: new section/page "Eigene Module" in the user settings (`apps/web/src/app/(portal)/settings/`, follow how
`general` / `dashboard` sub-pages and their nav are built). Reuse `CustomModuleFormModal` and
`DeleteCustomModuleDialog` (move to a shared location if needed, e.g. `components/custom-modules/`) — one form, two callers.
Intro text (Sie-Form): e.g. "Nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie."
- Admin page: shows only shared entries; intro text states they are visible for all users.
- Sidebar: unchanged behavior, shows shared + own personal entries (API already filters). `bumpSidebarRefresh` after changes on the settings page too.
- de + en texts; umlaut dictionary if needed.
- Tests: component tests for the settings page (create/edit/delete, list only personal), admin page still passes `shared: true`.
- verify: `pnpm --filter @tessera/web exec vitest run` green; `pnpm turbo run type-check lint` green; biome web ≤ 55, api ≤ 82.
## Task 3: CHANGELOG + rebuild
- CHANGELOG `## Unveröffentlicht` → adjust the existing "Eigene Module" bullet under "Neu" (not released yet, so rewrite it):
every user can add own entries under "Einstellungen → Eigene Module", visible only to them; administrators can additionally add entries for everyone under "Verwaltung → Eigene Module". Plain German, Sie-Form.
- Update `docs/anleitung-anwender.md` (and admin guide if it mentions custom modules) accordingly.
- `docker compose up -d --build web api`.
- Commits per task, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. NEVER git push.
- Browser check is done by the orchestrator (normal user + admin, dark mode).
@@ -0,0 +1,170 @@
---
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.