--- phase: quick-260929-9wc plan: 01 quick_id: 260929-9wc subsystem: api, web, prisma tags: [custom-modules, sidebar, iframe, rls, admin] status: complete requires: [] provides: - Tabelle CustomModule mit Zeilenschutz (Migration 20260929120000) - API /custom-modules (GET fuer jeden Angemeldeten, POST/PATCH/DELETE nur Admin) - Seitenleisten-Eintraege und Rahmen-Seite /modules/custom/[id] - Verwaltungsseite Verwaltung > Eigene Module - MODULE_CATEGORIES in @tessera/shared affects: [sidebar, admin-sidebar, docs/mandantentrennung-zugriffsklassifikation.md] key-files: created: - apps/api/prisma/migrations/20260929120000_custom_module/migration.sql - apps/api/src/custom-modules/ (Controller, Dienst, Modul, DTO, je mit Spec) - apps/web/src/lib/custom-modules-api.ts - apps/web/src/components/modules/custom-module-view.tsx - apps/web/src/app/(portal)/modules/custom/[id]/page.tsx - apps/web/src/app/(portal)/admin/custom-modules/ (page, FormModal, DeleteDialog, Test) - apps/web/src/messages/module-categories.spec.ts modified: - apps/api/prisma/schema.prisma - apps/api/src/app.module.ts - packages/shared/src/index.ts - apps/web/src/components/layout/sidebar.tsx (+ Test) - apps/web/src/components/admin/admin-sidebar.tsx - apps/web/src/messages/de.json, en.json - apps/web/src/app/(portal)/modules/module-layouts.test.tsx - docs/mandantentrennung-zugriffsklassifikation.md - CHANGELOG.md decisions: - "Gruppen-Einschraenkung (D-02) zurueckgestellt, siehe Bewusst offen" - "Eigene Module haengen an keiner Modul-Aktivierung (kein @UseModule, kein ModuleAccessGate)" - "Seitenleiste vereinheitlicht Module und eigene Module in einem internen Eintragstyp; eingebaute Module stehen je Kategorie vor eigenen" - "https-Regel im Web bleibt EINE (isHttpsUrl aus xframe-config), Sandbox-Wert wird importiert, nicht kopiert" duration: ca. 10 Minuten reine Ausfuehrung completed: 2026-09-29 commits: 3 plan_head_before: 643b1a2caa01a506b0b7aec8c6236f98769e19f0 plan_head_after: e48c0de23816702b42a4fb265a22298c32769206 actuals: tokens: 31000 tasks: 3 commits: 3 --- # Phase quick-260929-9wc Plan 01: Eigene Module Summary Administratoren binden externe https-Seiten als Seitenleisten-Eintraege ein (Name, Adresse, Kategorie); alle Benutzer sehen sie unter der Kategorie, ein Klick zeigt die Seite in einem Rahmen mit dem XFrame-Sandbox-Wert und immer sichtbarem Knopf „In neuem Tab öffnen“. Daten liegen mandantengetrennt mit Zeilenschutz in der neuen Tabelle `CustomModule`. ## Was gebaut wurde **Aufgabe 1 (Tracer), Commit b9d87be** - `MODULE_CATEGORIES` (fuenf Kennungen) + Typ `ModuleCategory` in `packages/shared`; Gleichlauf-Spec gegen `moduleCategories` in de.json/en.json. - Prisma-Modell `CustomModule` und handgeschriebene Migration `20260929120000_custom_module` (ENABLE/FORCE RLS, `tenant_isolation_policy` ohne Benutzerdimension, bewusst keine `system_read_policy`). - API `/custom-modules`: DTO (Name 1 bis 100, Adresse nur https ohne Zugangsdaten, max 2048, Kategorie per `@IsIn`), Dienst (je Methode `const tenantPrisma = forTenant(this.prisma, tenantId)`, `row.tenantId`-Pruefung, 404 bei fremd/unbekannt), Controller (`list` vor `getOne`, schreibende Routen `@Roles(ADMIN, SUPER_ADMIN)`), Modul in `app.module.ts`. - Zugriffsklassifikation nachgemessen fortgeschrieben (siehe Zahlen). - Web: `custom-modules-api.ts` (inkl. `checkCustomModuleUrl`), Seitenleiste mit vereinigter Eintragsliste (Gruppierung, Suche, eingeklappte Kacheln, Auswahlmarke, Kopfzeilen-Titel ueber `useNavStore` mit slug = id), `CustomModuleView` + Seite `/modules/custom/[id]`, Texte `customModules` de/en. - Lokal migriert (Container-IP, `prisma migrate deploy`), API neu gebaut; curl-Durchstich bestanden. **Aufgabe 2, Commit e7fc4de** - Verwaltungsseite `admin/custom-modules` (Liste, Leer-Zustand, Anlegen/Bearbeiten-Dialog mit Adresspruefung vor dem Senden, Loeschen mit Rueckfrage), Aufruf von `bumpSidebarRefresh` nach jedem erfolgreichen Speichern/Loeschen, Admin-Leisten-Eintrag hinter „Module“, Texte `admin.customModules` und `header.admin.customModules` de/en. **Aufgabe 3, Commit e48c0de** - CHANGELOG-Eintrag unter „Unveröffentlicht“ > „Neu“, alle Tore, Stack neu gebaut. ## Tore (gemessen) | Tor | Ergebnis | |-----|----------| | Web-Tests vollstaendig | 102 Dateien, 992 Tests, alle gruen | | API-Tests vollstaendig | 88 Dateien, 1495 Tests, alle gruen | | `pnpm turbo run type-check lint` | 9/9 Aufgaben erfolgreich | | Biome-Warnungen Web | 55 (Grundlinie 55) | | Biome-Warnungen API | 82 (Grundlinie 82) | | rls-coverage.spec / rls-access-inventory.spec | gruen (30 Zusicherungen im Inventar) | | `prisma migrate deploy` (zweiter Lauf) | „No pending migrations to apply.“ | | `prisma migrate diff` | enthaelt „CustomModule“ nicht | | curl-Durchstich | Anlegen, Liste, Einzelabruf ok; http 400; anonym 401; Loeschen; danach 404 | | Stack | web :3000/login 200, api /health ok, `GET /custom-modules` anonym 401 | | Nicht gepusht | `git branch -r --contains HEAD` leer | **Zugriffsklassifikation nachgemessen (Gate-Schleife, nur .ts ohne spec):** - Summe vorher gemessen 61/217/6 (Dokument nannte 61/216/6); Drift in `user`: gemessen 18 gebunden statt 17 (aus quick-260928-ujj), korrigiert. - `custom-modules`: 0/7/0 (list 1, getOne 1, create 1, update 2, remove 2). - Neue Summe: 61/224/6. - Klassen-Verteilung: Ueberschrift/Tabelle nannten 77 Paare/40 muss, Bestandsaufnahme hatte schon 78/41 (`grep -cE '^\| apps/api/src/'`); nach neuem Eintrag 79 Paare, davon 42 muss, 21 keine-mandantengebundene-tabelle, 14 beides, 2 bewusst-uebergreifend. Nachtrag-Absatz „quick-260929-9wc“ ergaenzt. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Endlosschleife beim Laden der Verwaltungsseite** - **Found during:** Aufgabe 2 (Test zaehlte 5 statt 2 Listenabrufe) - **Issue:** `fetchModules` hing per `useCallback` an `t` (Uebersetzungsfunktion); ein Mock liefert je Render eine neue Funktion, der Effekt lief erneut. Auch mit echtem next-intl fragil. - **Fix:** Fehler als Boolean `loadFailed` gefuehrt, Text erst im JSX uebersetzt; `fetchModules` ohne Abhaengigkeit. - **Files modified:** `apps/web/src/app/(portal)/admin/custom-modules/page.tsx` - **Commit:** e7fc4de **2. [Rule 3 - Blocking] Layout-Waechter der Modulordner** - **Found during:** Aufgabe 3 (voller Web-Testlauf) - **Issue:** `module-layouts.test.tsx` (T-e8k-04) verlangt in jedem nicht-dynamischen Ordner unter `modules/` eine `layout.tsx` mit ModuleAccessGate; der neue Ordner `custom/` ist bewusst fuer alle sichtbar (D-01) und hat keine Schranke. - **Fix:** Explizite, begruendete Ausnahmeliste `DIRS_WITHOUT_GATE = ['custom']` im Test, statt eine wirkungslose Durchreich-Layout-Datei anzulegen. - **Files modified:** `apps/web/src/app/(portal)/modules/module-layouts.test.tsx` - **Commit:** e48c0de **3. Plan-Feinheit (kein Regelfall):** Die Seitenleisten-Fehlerbehandlung fuer `listCustomModules` laesst bei Fehler den bisherigen Stand stehen (leer beim ersten Laden), wie der Modulabruf, statt aktiv zu leeren; Ergebnis beim ersten Laden identisch mit „leere Liste“. ## Bewusst offen - **Gruppen-Einschraenkung fuer eigene Module (D-02) zurueckgestellt.** `ModuleGrant.moduleId` ist ein Pflicht-Fremdschluessel auf `Module` (`onDelete: Cascade`); eigene Module sind keine `Module`-Zeilen. Eine Einschraenkung braeuchte eine neue Freigabetabelle oder einen Umbau von `ModuleGrant` samt `module-access.service.ts` und der Admin-Freigabeoberflaeche, also nicht „sehr wenig Aufwand“. Im Code nichts dafuer gebaut. ## Browser-Pruefung offen (Orchestrator) Playwright MCP steht in diesem Ausfuehrungskontext nicht zur Verfuegung. Der Orchestrator fuehrt die Pruefung durch: web :3000, Anmeldung admin / admin123, echte Navigation (`browser_navigate`), nie per `fetch()` aus der Seite messen, zuerst ueber den Theme-Knopf der Kopfzeile auf dunkel schalten. Stack ist neu gebaut und laeuft. 1. Verwaltung > „Eigene Module“ (neuer Eintrag in der Admin-Leiste hinter „Module“, „Module“ dabei nicht markiert): Leer-Zustand mit Knopf „Eigenes Modul anlegen“. 2. Anlegen mit Name „Beispielseite“, Adresse `http://example.com` -> Meldung, nichts gespeichert; dann `https://user:pw@example.com` -> Meldung; dann `https://example.com`, Kategorie „Infrastruktur“ -> gespeichert, Tabelle zeigt den Eintrag, die Seitenleiste zeigt „Beispielseite“ unter „Infrastruktur“ OHNE Neuladen. 3. Zweiter Eintrag „GitHub“, `https://github.com`, Kategorie „Sicherheit“ -> erscheint unter „Sicherheit“. 4. Klick auf „Beispielseite“: `/modules/custom/`, Kopfzeilen-Titel „Beispielseite“, Auswahlmarke am Eintrag, Rahmen fuellt den Inhaltsbereich ohne doppelten Rollbalken, „In neuem Tab öffnen“ sichtbar; iframe traegt den Sandbox-Wert `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox` und `referrerpolicy="no-referrer"`; der Link oeffnet einen neuen Tab mit example.com. 5. Klick auf „GitHub“: Rahmen zeigt die Einbettungssperre des Browsers, „In neuem Tab öffnen“ ist trotzdem sichtbar und funktioniert. 6. Seitenleiste eingeklappt: beide Eintraege als Kachel mit Namen im Tooltip; Suche „Beisp“ findet den Eintrag. 7. Bearbeiten: „Beispielseite“ in „Beispiel“ umbenennen -> Seitenleiste zieht sofort nach. Loeschen mit Rueckfrage -> Eintrag verschwindet aus Tabelle und Seitenleiste; alte Adresse `/modules/custom/` zeigt „Dieses Modul gibt es nicht mehr.“ 8. Sprache auf Englisch: keine rohen Uebersetzungsschluessel auf Verwaltungsseite und Rahmen-Seite. 9. Screenshots (dunkel) von Verwaltungsseite, Seitenleiste mit Eintraegen und Rahmen-Seite ablegen; danach die Testeintraege loeschen, damit die lokale Datenbank sauber bleibt. Hinweis: Der curl-Durchstich hat seinen Testeintrag bereits geloescht; die lokale Datenbank enthaelt keine eigenen Module. ## Known Stubs Keine. ## Threat Flags Keine neue Angriffsflaeche ausserhalb des Plan-Bedrohungsmodells (T-9WC-01 bis 07 umgesetzt: Rollen-Metadaten per Spec geprueft, tenantId nur aus `req.tenantId`, https-Regel in API und Web, exakter Sandbox-Wert, Referrer/`rel`, MaxLength, `whitelist: true`). ## Nichts gepusht Drei lokale Commits (b9d87be, e7fc4de, e48c0de), kein `git push`; die vorgemerkten Loeschungen von `.planning/.continue-here.md` und `.planning/HANDOFF.json` blieben unangetastet im Index. ## Self-Check: PASSED - Dateien vorhanden: Migration, `custom-modules.service.ts`/`controller.ts`/`module.ts`/`dto`, `custom-modules-api.ts`, `custom-module-view.tsx`, `modules/custom/[id]/page.tsx`, `admin/custom-modules/page.tsx` mit Komponenten (alle im Commit-Stat sichtbar). - Commits vorhanden: b9d87be, e7fc4de, e48c0de (`git log`), `commits: 3` gemessen ueber `rev-list` vom Ledger. ## Browser-Pruefung (Orchestrator, 29.09., dunkel) Durchgefuehrt per Playwright MCP auf :3000, Theme per Kopfzeilen-Knopf auf „Dunkel“: 1. Verwaltung > „Eigene Module“: Eintrag in der Admin-Leiste, Leer-Zustand korrekt. 2. http://example.com -> „Bitte geben Sie eine Adresse ein, die mit https:// beginnt.“; https://user:pw@example.com -> „Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.“; https://example.com / Infrastruktur -> gespeichert, Seitenleiste zeigt „Beispielseite“ ohne Neuladen. 3. „GitHub“ / Sicherheit erscheint unter „Sicherheit“. 4. Rahmen-Seite: Kopfzeilen-Titel, Auswahlmarke, kein doppelter Rollbalken; sandbox = `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`, referrerpolicy = no-referrer; Link target=_blank rel="noopener noreferrer". 5. GitHub: Einbettung per frame-ancestors blockiert, „In neuem Tab öffnen“ oeffnet github.com im neuen Tab. 6. Eingeklappt: Eintraege als Symbole; Suche „Beisp“ findet den Eintrag. 7. Umbenennen zieht Seitenleiste sofort nach; Loeschen mit Rueckfrage; alte Adresse zeigt „Dieses Modul gibt es nicht mehr.“ 8. Englisch: nicht per Oberflaeche umgeschaltet; stattdessen Schluessel-Paritaet de/en geprueft (keine fehlenden Schluessel, alle Texte ueber t()). 9. Testeintraege geloescht, lokale DB ohne eigene Module.