Files
tessera-ctl/.planning/quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/260929-9wc-PLAN.md
T
2026-09-29 07:35:42 +02:00

48 KiB
Raw Blame History

phase, plan, quick_id, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan quick_id type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260929-9wc 01 260929-9wc execute 1
true
QUICK-260929-9wc
packages/shared/src/index.ts
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/20260929120000_custom_module/migration.sql (neu)
apps/api/src/custom-modules/dto/custom-module.dto.ts (neu)
apps/api/src/custom-modules/dto/custom-module.dto.spec.ts (neu)
apps/api/src/custom-modules/custom-modules.service.ts (neu)
apps/api/src/custom-modules/custom-modules.service.spec.ts (neu)
apps/api/src/custom-modules/custom-modules.controller.ts (neu)
apps/api/src/custom-modules/custom-modules.controller.spec.ts (neu)
apps/api/src/custom-modules/custom-modules.module.ts (neu)
apps/api/src/app.module.ts
docs/mandantentrennung-zugriffsklassifikation.md
apps/web/src/lib/custom-modules-api.ts (neu)
apps/web/src/lib/custom-modules-api.test.ts (neu)
apps/web/src/lib/stores/nav-store.test.ts (neu)
apps/web/src/components/layout/sidebar.tsx
apps/web/src/components/layout/sidebar.test.tsx
apps/web/src/components/modules/custom-module-view.tsx (neu)
apps/web/src/components/modules/custom-module-view.test.tsx (neu)
apps/web/src/app/(portal)/modules/custom/[id]/page.tsx (neu)
apps/web/src/app/(portal)/admin/custom-modules/page.tsx (neu)
apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx (neu)
apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx (neu)
apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx (neu)
apps/web/src/components/admin/admin-sidebar.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
apps/web/src/messages/umlaut-dictionary.ts
apps/web/src/messages/module-categories.spec.ts (neu)
CHANGELOG.md
tokens raw_tokens tasks confidence
150000 150000 3 low
truths artifacts key_links
Ein Administrator legt unter Verwaltung > Eigene Module einen Eintrag mit Name, https-Adresse und einer der fünf Seitenleisten-Kategorien an, ändert ihn und löscht ihn (D-01, D-07)
Jeder angemeldete Benutzer sieht jedes eigene Modul als Eintrag unter der gewählten Kategorie in der Seitenleiste (auch eingeklappt und in der Suche); nach Anlegen, Ändern oder Löschen zieht die Seitenleiste ohne Neuladen nach (D-01, D-05)
Ein Klick öffnet /modules/custom/<id>: ein eingebetteter Rahmen füllt den Inhaltsbereich mit exakt dem Sandbox-Wert XFRAME_SANDBOX und referrerPolicy no-referrer, darüber steht immer sichtbar der Knopf „In neuem Tab öffnen“ (echter Link, target _blank, rel noopener noreferrer); die Kopfzeile zeigt den Namen des Eintrags (D-06)
Eine Adresse, die nicht https ist oder Zugangsdaten enthält, lehnt die API mit 400 und das Formular mit einer Meldung ab; eine solche Adresse wird nie als Rahmen oder Link gerendert (D-04, D-06)
POST/PATCH/DELETE /custom-modules sind nur für ADMIN und SUPER_ADMIN offen (sonst 403), GET /custom-modules und GET /custom-modules/:id für jeden angemeldeten Benutzer, ohne Anmeldung 401 (D-04)
Die Tabelle CustomModule trägt tenantId, ENABLE/FORCE ROW LEVEL SECURITY und tenant_isolation_policy; jeder Zugriff im Dienst läuft über `const tenantPrisma = forTenant(this.prisma, tenantId)`; rls-coverage.spec.ts und rls-access-inventory.spec.ts sind grün (D-03)
Alle neuen Texte stehen deutsch (Sie-Form) und englisch; CHANGELOG nennt die Neuerung unter „Unveröffentlicht“ > „Neu“ in Alltagssprache (D-08, D-09)
path provides
apps/api/prisma/migrations/20260929120000_custom_module/migration.sql Tabelle CustomModule mit tenantId, Index, RLS ENABLE/FORCE, tenant_isolation_policy ohne Benutzerdimension, ohne system_read_policy
path provides
apps/api/src/custom-modules/custom-modules.controller.ts GET '' und GET ':id' (jeder Angemeldete), POST/PATCH ':id'/DELETE ':id' mit @Roles(ADMIN, SUPER_ADMIN); list vor getOne deklariert
path provides
apps/api/src/custom-modules/custom-modules.service.ts list/getOne/create/update/remove, je Methode ein forTenant-Klient, Fremd-Mandant oder unbekannte id -> NotFoundException
path provides
apps/api/src/custom-modules/dto/custom-module.dto.ts CreateCustomModuleDto/UpdateCustomModuleDto: Name 1-100 Zeichen, Adresse nur https ohne Zugangsdaten max 2048, Kategorie @IsIn(MODULE_CATEGORIES)
path provides
packages/shared/src/index.ts MODULE_CATEGORIES = ['domain-tools','security-tools','fleet','infrastructure','procurement'] + Typ ModuleCategory
path provides
apps/web/src/lib/custom-modules-api.ts CustomModule-Typ, listCustomModules/getCustomModule/createCustomModule/updateCustomModule/deleteCustomModule, checkCustomModuleUrl
path provides
apps/web/src/components/modules/custom-module-view.tsx Rahmen-Ansicht mit Leiste (Name, Hinweis, „In neuem Tab öffnen“) und Vollflächen-iframe
path provides
apps/web/src/app/(portal)/admin/custom-modules/page.tsx Verwaltungsseite: Liste, Anlegen/Bearbeiten (Formular-Dialog), Löschen (Bestätigung)
from to via pattern
apps/web/src/components/layout/sidebar.tsx GET /custom-modules listCustomModules() im selben Effekt wie /modules/active, ausgelöst durch sidebarRefreshKey listCustomModules
from to via pattern
apps/web/src/app/(portal)/admin/custom-modules/page.tsx apps/web/src/components/layout/sidebar.tsx useMarketplaceStore bumpSidebarRefresh() nach jedem erfolgreichen Speichern/Löschen bumpSidebarRefresh
from to via pattern
apps/web/src/components/modules/custom-module-view.tsx apps/web/src/components/dashboard/widgets/xframe-config.ts Import XFRAME_SANDBOX — ein Sandbox-Wert für XFrame und eigene Module XFRAME_SANDBOX
from to via pattern
apps/api/src/custom-modules/custom-modules.service.ts apps/api/src/prisma/prisma-tenant.extension.ts const tenantPrisma = forTenant(this.prisma, tenantId) const tenantPrisma = forTenant(this.prisma, tenantId)
from to via pattern
apps/api/src/app.module.ts apps/api/src/custom-modules/custom-modules.module.ts imports: [..., CustomModulesModule] CustomModulesModule
from to via pattern
docs/mandantentrennung-zugriffsklassifikation.md apps/api/src/prisma/rls-access-inventory.spec.ts Bestandsaufnahme-Zeile custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden custom-modules.service.ts | customModule

Quick 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Einträge

Nutzerauftrag (29.09.): Der Administrator legt Seitenleisten-Einträge an, die externe Seiten per eingebettetem Rahmen in Tessera zeigen.

Festgelegte Punkte (mit dem Nutzer entschieden, nicht verhandelbar)

  • D-01 Der Admin legt Einträge an mit Name, https-Adresse und Seitenleisten-Kategorie (eine der bestehenden Kategorien). Einträge sind für ALLE Benutzer sichtbar.
  • D-02 Einschränkung auf Gruppen NUR, wenn der bestehende ModuleGrant/Gruppen-Mechanismus das mit sehr wenig Aufwand hergibt — sonst weglassen und als zurückgestellt notieren. Entscheidung beim Planen: zurückgestellt. Begründung (gemessen im Schema): ModuleGrant.moduleId ist ein Pflicht-Fremdschlüssel auf Module (onDelete: Cascade), eigene Module sind keine Module-Zeilen. Eine Einschränkung bräuchte eine neue Freigabetabelle oder einen Umbau von ModuleGrant samt module-access.service.ts und der Admin-Freigabeoberfläche — das ist nicht „sehr wenig Aufwand“. Im SUMMARY unter „Bewusst offen“ notieren; im Code nichts dafür bauen.
  • D-03 Prisma-Modell CustomModule + Migration MIT Zeilenschutz nach Muster ProxmoxServer (tenantId-Spalte, Regel, prisma-tenant-Erweiterung); RLS-Inventar-Test und docs/mandantentrennung-zugriffsklassifikation.md fortschreiben.
  • D-04 API: GET-Liste für jeden angemeldeten Benutzer; POST/PATCH/DELETE nur Admin; Adresse nur https.
  • D-05 Seitenleiste: jedes eigene Modul erscheint als Eintrag unter seiner Kategorie.
  • D-06 Seite /modules/custom/[id]: Rahmen über die ganze Fläche genau wie das XFrame-Widget (derselbe Sandbox-Wert ohne Navigation des obersten Fensters, referrerPolicy="no-referrer", nur https) PLUS immer sichtbarer Knopf „In neuem Tab öffnen“ (viele Seiten verbieten das Einbetten).
  • D-07 Verwaltungsoberfläche im Admin-Bereich: einfache Liste + Anlegen/Bearbeiten/Löschen im Stil der bestehenden Admin-Seiten (Vorbild admin/groups).
  • D-08 Texte deutsch und englisch; App-Texte im Deutschen in Sie-Form.
  • D-09 CHANGELOG unter „Unveröffentlicht“ > „Neu“, Alltagssprache für Nicht-Programmierer.
  • D-10 Tests: API-Dienst/Controller, Web-Komponenten, RLS-Inventar. Statische GET-Routen stehen im Controller VOR @Get(':id').
  • D-11 Abschluss: Browser-Prüfung mit Playwright MCP am lokalen Stack (web :3000, api :3001, admin / admin123) im DUNKELMODUS (Umschalten über den Theme-Knopf der Kopfzeile, nie per classList). Migration vom Host über die Container-IP (172.19.x, tessera:tessera_dev), danach docker compose up -d --build web api.
  • D-12 Nur lokal committen, NIEMALS git push.

Grundlagen (wiederverwenden, nicht neu erfinden)

  • Kategorien: Die Seitenleiste gruppiert nach Module.category; im Einsatz sind genau fünf Kennungen aus den Seeds (domain-tools, security-tools, fleet, infrastructure, procurement), deren Anzeigenamen in moduleCategories von de.json/en.json stehen und über useCategoryLabel() aufgelöst werden. Neu: diese Liste einmal als MODULE_CATEGORIES in packages/shared/src/index.ts — die API prüft per @IsIn, das Formular baut daraus die Auswahl.
  • Zeilenschutz-Vorbild: apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql (Kopfkommentar-Pflicht, ENABLE/FORCE, tenant_isolation_policy OHNE Benutzerdimension, weil Verwaltungsdaten des Mandanten). KEINE system_read_policy — es gibt keinen Hintergrunddienst.
  • API-Vorbild: apps/api/src/proxmox/proxmox.controller.ts (requireTenantId(req), @Roles(Role.ADMIN, Role.SUPER_ADMIN), tenantId nur aus req.tenantId) und proxmox.service.ts (je Methode const tenantPrisma = forTenant(this.prisma, tenantId);). Globale Wächter JwtAuthGuard/TenantGuard/RolesGuard stehen in app.module.ts; ValidationPipe mit whitelist: true, transform: true in main.ts.
  • Rahmen-Vorbild: apps/web/src/components/dashboard/widgets/xframe-config.ts (XFRAME_SANDBOX, Begründung im Dateikopf; isHttpsUrl aus picture-frame-config.ts) und xframe-widget.tsx (frameAttrs mit allow: '', referrerPolicy: 'no-referrer'; NewTabLink als echter Link).
  • Seitenleiste: apps/web/src/components/layout/sidebar.tsx lädt /modules/active, gruppiert nach Kategorie, Auffrischung über useMarketplaceStore sidebarRefreshKey/bumpSidebarRefresh; sie veröffentlicht die Liste in useNavStore, aus der resolvePageTitle den Kopfzeilen-Titel über Pfadsegment == slug findet.
  • Routen: Der statische Ordner modules/custom/[id] hat im App Router Vorrang vor modules/[category]/[moduleSlug] — kein Konflikt.

Verbindliche Regeln für alle Aufgaben

  • de.json mit echten Umlauten. umlaut-guard.spec.ts meldet jedes NEUE deutsche Wort mit ae/oe/ue/ss, das noch nicht auf der Liste steht (etwa „Adressen“ oder „müssen“) — ist es korrektes Deutsch, gehört es in UMLAUT_ALLOWLIST in apps/web/src/messages/umlaut-dictionary.ts. Jeder neue Schlüssel in de.json UND en.json (Schlüssel-Gleichheit wird geprüft).
  • Keine Großbuchstaben-Etiketten, keine Mittelpunkt-Ketten, kein Pfeilzeichen in Texten oder Knöpfen (Stil der letzten Quick-Aufträge). Keine neuen Pakete.
  • Biome-Grundlinie gemessen am 29.09.: Web 55 Warnungen, API 82 — darf nicht steigen.
  • Die bereits vorgemerkten Löschungen .planning/.continue-here.md und .planning/HANDOFF.json (Sitzungsübergabe) nicht wiederherstellen.
  • Commits nur lokal. Kein git push, auch nicht am Ende (D-12).
Administratoren binden externe Webseiten als „Eigene Module“ in die Seitenleiste ein: Name, https-Adresse, Kategorie. Alle Benutzer sehen die Einträge unter der gewählten Kategorie; ein Klick zeigt die Seite in einem abgesicherten, flächenfüllenden Rahmen mit immer sichtbarem „In neuem Tab öffnen“. Die Daten liegen mandantengetrennt mit Zeilenschutz in der Tabelle `CustomModule` (D-01 bis D-12; D-02 Gruppen-Einschränkung bewusst zurückgestellt).

Purpose: Werkzeuge, für die es (noch) kein eigenes Tessera-Modul gibt, sind trotzdem aus der zentralen Plattform heraus erreichbar — der Kernnutzen „nicht zwischen Anwendungen wechseln“. Output: Tabelle + Migration mit Zeilenschutz, API /custom-modules, Seitenleisten-Einträge, Rahmen-Seite, Verwaltungsseite, Texte de/en, Tests, fortgeschriebene Zugriffsklassifikation, CHANGELOG.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @./CLAUDE.md @apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql @apps/api/src/proxmox/proxmox.controller.ts @apps/web/src/components/dashboard/widgets/xframe-config.ts @apps/web/src/components/layout/sidebar.tsx Aufgabe 1 (Tracer): Ein eigenes Modul von der Datenbank bis in Seitenleiste und Rahmen-Seite packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260929120000_custom_module/migration.sql, apps/api/src/custom-modules/dto/custom-module.dto.ts, apps/api/src/custom-modules/dto/custom-module.dto.spec.ts, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/custom-modules/custom-modules.controller.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/src/custom-modules/custom-modules.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/custom-modules-api.ts, apps/web/src/lib/custom-modules-api.test.ts, apps/web/src/lib/stores/nav-store.test.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/components/modules/custom-module-view.tsx, apps/web/src/components/modules/custom-module-view.test.tsx, apps/web/src/app/(portal)/modules/custom/[id]/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, apps/web/src/messages/module-categories.spec.ts Der lokale Stack läuft: `docker compose ps --format '{{.Service}} {{.State}}'` zeigt db, api und web als running. apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql, apps/api/src/proxmox/proxmox.controller.ts, apps/api/src/proxmox/proxmox.service.ts (nur createServer/updateServer/deleteServer, Zeilen 120-240), apps/api/src/proxmox/proxmox.service.spec.ts (Kopf bis makeFakePrisma), apps/api/src/tenders/tenders.controller.spec.ts (Reihenfolge-Test ab Zeile 365), apps/api/src/prisma/rls-access-inventory.spec.ts (Zeilen 1-80 und parseDocEntries), apps/web/src/components/dashboard/widgets/xframe-widget.tsx (Zeilen 95-115 und NewTabLink), apps/web/src/lib/favorites-api.test.ts (Kopf), apps/web/src/components/layout/sidebar.test.tsx - DTO: `https://example.com` mit Kategorie `infrastructure` und Name „Wiki“ ist gültig; `http://example.com`, `javascript:alert(1)`, `data:text/html,x`, `ftp://x`, unparsbarer Text, `https://user:pw@example.com` sind ungültig; Kategorie `other` ist ungültig; leerer oder nur aus Leerzeichen bestehender Name ist ungültig; Name über 100 und Adresse über 2048 Zeichen sind ungültig; Update-DTO akzeptiert Teilmengen, prüft aber jedes gesetzte Feld gleich - Dienst: create speichert tenantId aus dem Argument (nie aus dem DTO); list liefert nur Zeilen des Mandanten, nach Name sortiert; getOne/update/remove mit unbekannter id oder Zeile eines anderen Mandanten -> NotFoundException; forTenant wird je Methode mit (prisma, tenantId) aufgerufen - Controller: create/update/remove tragen ROLES_KEY [ADMIN, SUPER_ADMIN], list/getOne tragen keine Rollen; fehlendes req.tenantId -> ForbiddenException; tenantId kommt aus req.tenantId; `list` ist vor `getOne` deklariert - Web-Client: checkCustomModuleUrl('https://a.de') = 'ok', 'http://a.de' = 'notHttps', 'https://u:p@a.de' = 'credentials', 'kaputt' = 'notHttps'; listCustomModules ruft GET {API}/custom-modules mit credentials include - Seitenleiste: ein eigenes Modul mit Kategorie `infrastructure` erscheint unter dieser Kategorie als Link auf /modules/custom/; eine Kategorie, die nur eigene Module hat, erscheint trotzdem; auf /modules/custom/ trägt genau dieser Eintrag die Auswahlmarke; die bestehenden Abruf-Zählertests bleiben unverändert grün - Kopfzeilen-Titel: resolvePageTitle('/modules/custom/abc', [{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' }]) liefert { text: 'Wiki' } - Rahmen-Ansicht: rendert iframe mit src = Adresse, title = Name, sandbox exakt XFRAME_SANDBOX (enthält kein top-navigation-Token), referrerpolicy no-referrer, allow leer; der Link „In neuem Tab öffnen“ ist sichtbar mit href = Adresse, target _blank, rel „noopener noreferrer“; bei nicht gültiger Adresse kein iframe und kein Link, stattdessen Hinweistext; bei 404 der Nicht-gefunden-Text - Kategorien-Gleichlauf: jede Kennung aus MODULE_CATEGORIES hat einen Schlüssel in moduleCategories von de.json und en.json Tests zuerst schreiben (rot), dann bauen (grün). Reihenfolge der Arbeit:
  1. Gemeinsame Kategorienliste (D-01): in packages/shared/src/index.ts MODULE_CATEGORIES als as const-Liste der fünf Kennungen domain-tools, security-tools, fleet, infrastructure, procurement plus export type ModuleCategory, mit kurzem Kommentar, dass die Liste den Seed-Kategorien der Module und den Schlüsseln moduleCategories in den Übersetzungen entspricht. Neue Spec apps/web/src/messages/module-categories.spec.ts prüft den Gleichlauf mit de.json und en.json.

  2. Datenbank (D-03): in apps/api/prisma/schema.prisma hinter ProxmoxServerStatus das Modell CustomModule mit id String @id @default(uuid()), tenantId String, name String, url String, category String (Kommentar: eine der MODULE_CATEGORIES), createdAt DateTime @default(now()), updatedAt DateTime @updatedAt, @@index([tenantId]) — ohne Relation zu Tenant (Muster ProxmoxServer). Migration apps/api/prisma/migrations/20260929120000_custom_module/migration.sql von Hand nach Vorbild 20260923140000: deutscher Kopfkommentar (Zweck, Zeilenschutz OHNE Benutzerdimension weil Verwaltungsdaten des Mandanten, bewusst KEINE system_read_policy weil kein Hintergrunddienst, Rechte für tessera_app kommen über ALTER DEFAULT PRIVILEGES, Hinweis dass die Regeln erst mit der Anwendungsrolle wirken), dann CREATE TABLE "CustomModule" mit den Spalten in Prisma-Form (TIMESTAMP(3), updatedAt ohne Default), Primärschlüssel "CustomModule_pkey", Index "CustomModule_tenantId_idx", ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL SECURITY und CREATE POLICY tenant_isolation_policy ON "CustomModule" USING ("tenantId" = current_tenant_id());. Danach pnpm --filter @tessera/api exec prisma generate.

  3. DTO apps/api/src/custom-modules/dto/custom-module.dto.ts (D-04): CreateCustomModuleDto mit name (@Transform trimmt Zeichenketten, @IsString, @IsNotEmpty, @MaxLength(100)), url (@IsString, @MaxLength(2048), eigene @ValidatorConstraint nach Muster PmgOhneTokenConstraint in proxmox-server.dto.ts: gültig nur, wenn new URL(wert) ohne Fehler parst, protocol === 'https:', hostname nicht leer und username/password leer sind; Meldung deutsch in der ASCII-Schreibweise der übrigen API-Meldungen, z. B. „Nur https-Adressen ohne Zugangsdaten sind erlaubt.“), category (@IsIn([...MODULE_CATEGORIES]) aus @tessera/shared). UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto) aus @nestjs/mapped-types (Muster ldap-config.dto.ts). Spec dto/custom-module.dto.spec.ts mit plainToInstance + validate deckt die Fälle aus <behavior> ab.

  4. Dienst apps/api/src/custom-modules/custom-modules.service.ts (D-03, D-04): @Injectable mit PrismaService; Methoden list(tenantId), getOne(tenantId, id), create(tenantId, dto), update(tenantId, id, dto), remove(tenantId, id). JEDE Methode beginnt mit genau der Zuweisung const tenantPrisma = forTenant(this.prisma, tenantId); — rls-access-inventory.spec.ts erkennt nur diese Form, ein anderer Name oder ein Aufruf ohne Zuweisung macht die Spec rot. list filtert zusätzlich explizit where: { tenantId } und sortiert orderBy: { name: 'asc' }. getOne/update/remove lesen per findUnique({ where: { id } }) und werfen NotFoundException, wenn die Zeile fehlt oder row.tenantId !== tenantId (zweites Netz, weil der RLS-Schalter heute aus ist — Muster DashboardImage). Antworten wählen per select genau id, name, url, category, createdAt, updatedAt; wird dafür eine Konstante genutzt, muss sie in derselben Datei als Objektliteral stehen (die Inventar-Spec löst nur solche Konstanten auf). remove liefert { deleted: true }. Spec custom-modules.service.spec.ts nach Muster proxmox.service.spec.ts (vi.mock('../prisma/prisma-tenant.extension', ...) mit durchreichendem forTenant, Fake-Prisma mit Map).

  5. Controller apps/api/src/custom-modules/custom-modules.controller.ts (D-04, D-10): @Controller('custom-modules'), requireTenantId(req) wie im Proxmox-Controller. Deklarationsreihenfolge verbindlich: list (@Get()), dann getOne (@Get(':id')), dann create (@Post()), update (@Patch(':id')), remove (@Delete(':id')); die drei schreibenden mit @Roles(Role.ADMIN, Role.SUPER_ADMIN). Kopfkommentar: jede künftige statische GET-Route MUSS über getOne stehen (sonst fängt :id sie ab). Kein @UseModule — eigene Module hängen an keiner Modul-Aktivierung, sichtbar für alle (D-01). Spec custom-modules.controller.spec.ts nach Muster bug-reports.controller.spec.ts/tenders.controller.spec.ts: Rollen-Metadaten per Reflect.getMetadata(ROLES_KEY, ...), Reihenfolge per Object.getOwnPropertyNames(CustomModulesController.prototype), tenantId-Weitergabe, ForbiddenException ohne Mandant.

  6. apps/api/src/custom-modules/custom-modules.module.ts (Controller + Dienst; PrismaModule ist global — prüfen, wie ProxmoxModule an PrismaService kommt, und genauso verfahren) und Aufnahme von CustomModulesModule in imports von apps/api/src/app.module.ts.

  7. Zugriffsklassifikation (D-03) in docs/mandantentrennung-zugriffsklassifikation.md, alle Zahlen NACHGEMESSEN, nicht abgeschrieben: (a) in der Bestandsaufnahme-Tabelle (Kopf | Datei | Modell | Klasse | Stand | Begründung |) hinter den Proxmox-Zeilen die Zeile | apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** ... | mit Begründung (Admin-verwaltete Seitenleisten-Einträge, tenantId-Spalte, tenant_isolation_policy ohne Benutzerdimension, Migration 20260929120000, keine system_read_policy, je Methode ein forTenant-Klient, Besitzprüfung row.tenantId -> 404). (b) In der Übersicht je Bereich eine Zeile custom-modules vor der Summenzeile. Gemessen wird mit der Gate-Schleife über for d in apps/api/src/*/ mit den drei Greps this\.prisma\.[a-zA-Z]*, tenantPrisma\.[a-zA-Z]*\. und systemPrisma\.[a-zA-Z]*\. (nur .ts ohne spec). Beim Planen gemessen: Summe vorher 61/217/6, die Tabelle nennt aber 61/216/6 — die Zeile user nennt 17 gebunden, gemessen sind 18 (Drift aus quick-260928-ujj, Hintergrund pro Benutzer). Diese Drift in der Zeile user und in der Summenzeile mit „Nachgemessen quick-260929-9wc“ korrigieren, dann die neue Summe eintragen. (c) Klassen-Verteilung: Überschrift und Tabelle nennen 77 Paare/40 muss-mandantengebunden, die Bestandsaufnahme hat beim Planen aber schon 78 Zeilen/41 muss (gezählt mit grep -cE '^\| apps/api/src/'); nach dem neuen Eintrag nachzählen (erwartet 79/42), Überschrift, Tabelle und einen Nachtrag-Absatz „quick-260929-9wc“ entsprechend fortschreiben (Drift benennen, dann +1).

  8. Web-Client apps/web/src/lib/custom-modules-api.ts nach Muster favorites-api.ts/proxmox-api.ts (NEXT_PUBLIC_API_URL, credentials: 'include'): Typ CustomModule (id, name, url, category, createdAt, updatedAt), listCustomModules(), getCustomModule(id) (liefert null bei 404), createCustomModule(input), updateCustomModule(id, input), deleteCustomModule(id) — Fehler werfen mit Status und Servermeldung. Dazu die reine Funktion checkCustomModuleUrl(value): 'ok' | 'notHttps' | 'credentials', die für die https-Prüfung isHttpsUrl aus xframe-config.ts nutzt (EINE https-Regel im Web) und Zugangsdaten per URL-Parser erkennt. Test custom-modules-api.test.ts.

  9. Seitenleiste apps/web/src/components/layout/sidebar.tsx (D-05): im bestehenden Abruf-Effekt (derselbe Auslöser sidebarRefreshKey) zusätzlich listCustomModules() laden, Fehler still wie beim Modulabruf (leere Liste). Einträge vereinheitlichen (z. B. interner Typ mit key, name, category, href, tileSlug): Module behalten href = /modules/<kategorie>/<slug> und ihre Aktiv-Regel, eigene Module bekommen href = /modules/custom/<id> und das allgemeine Kachelsymbol (ModuleTile mit einer Kennung ohne eigenes Symbol, z. B. custom). Gruppierung, Suche, eingeklappte Kachelliste und der Leer-Zustand arbeiten auf der vereinigten Liste; innerhalb einer Kategorie stehen eingebaute Module vor eigenen. Für den Kopfzeilen-Titel die vereinigte Liste in useNavStore veröffentlichen, eigene Module mit slug = ihre id (resolvePageTitle findet das Pfadsegment dann ohne Änderung) — Test in neuer Datei apps/web/src/lib/stores/nav-store.test.ts. In sidebar.test.tsx @/lib/custom-modules-api per vi.mock ersetzen (Standard: leere Liste), damit die bestehenden Zähltests auf fetch unverändert gelten; neue Tests für die Fälle aus <behavior>.

  10. Rahmen-Seite (D-06): apps/web/src/app/(portal)/modules/custom/[id]/page.tsx als Server-Komponente, die params (Promise, Muster [moduleSlug]/page.tsx) auflöst und <CustomModuleView id={id} /> rendert — ohne ModuleAccessGate, weil eigene Module für alle sichtbar sind (D-01). apps/web/src/components/modules/custom-module-view.tsx (Client): lädt per getCustomModule(id); Ladezustand, Nicht-gefunden-Text, sonst eine schmale Leiste (Name, kurzer Hinweis dass manche Seiten das Einbetten verbieten, rechts der Link „In neuem Tab öffnen“ als echter <a> mit target="_blank" und rel="noopener noreferrer", als Knopf gestaltet und immer sichtbar) und darunter das iframe, das die restliche Höhe füllt (Behälter z. B. flex flex-col mit Höhe calc(100vh - var(--header-height) - 1.5rem), iframe flex-1 w-full rounded-lg border-0 bg-background). iframe-Attribute wie frameAttrs im XFrame-Widget: src, title = Name, sandbox={XFRAME_SANDBOX} (importiert aus xframe-config.ts, NICHT kopieren), allow="", referrerPolicy="no-referrer". iframe und Link nur, wenn checkCustomModuleUrl(url) === 'ok', sonst Hinweistext. Test custom-module-view.test.tsx mit gemocktem getCustomModule.

  11. Texte (D-08) im neuen Namensraum customModules in de.json und en.json: mindestens openInNewTab („In neuem Tab öffnen“ / „Open in new tab“), embedHint (z. B. „Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.“), notFound („Dieses Modul gibt es nicht mehr.“), invalidUrl. Neue Wörter mit ae/oe/ue/ss nach der Umlaut-Regel oben behandeln.

  12. Datenbank lokal migrieren und API neu bauen (D-11): Container-IP holen mit docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1, dann DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy; danach docker compose up -d --build api und warten, bis curl -sf http://localhost:3001/health antwortet. Kontrolle, dass keine Schemaabweichung zu CustomModule bleibt: pnpm --filter @tessera/api exec prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --script darf „CustomModule“ nicht enthalten (andere, schon vorher bestehende Abweichungen aus handgeschriebenem SQL sind nicht Gegenstand dieser Aufgabe).

  13. Lokal committen (z. B. feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite), NICHT pushen (D-12). cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/custom-modules src/prisma && pnpm --filter @tessera/web exec vitest run src/components/layout/sidebar.test.tsx src/components/modules/custom-module-view.test.tsx src/lib/custom-modules-api.test.ts src/lib/stores/nav-store.test.ts src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && grep -q 'CREATE POLICY tenant_isolation_policy ON "CustomModule"' apps/api/prisma/migrations/20260929120000_custom_module/migration.sql && grep -q '| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden |' docs/mandantentrennung-zugriffsklassifikation.md && grep -q 'XFRAME_SANDBOX' apps/web/src/components/modules/custom-module-view.tsx && J=$(mktemp) && curl -sf -c "$J" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && ID=$(curl -sf -b "$J" -H 'Content-Type: application/json' -d '{"name":"Tracer","url":"https://example.com","category":"infrastructure"}' http://localhost:3001/custom-modules | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).id') && curl -sf -b "$J" http://localhost:3001/custom-modules | grep -q "$ID" && curl -sf -b "$J" "http://localhost:3001/custom-modules/$ID" | grep -q 'example.com' && test "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" -H 'Content-Type: application/json' -d '{"name":"X","url":"http://example.com","category":"infrastructure"}' http://localhost:3001/custom-modules)" = 400 && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/custom-modules)" = 401 && curl -sf -b "$J" -X DELETE "http://localhost:3001/custom-modules/$ID" >/dev/null && test "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" "http://localhost:3001/custom-modules/$ID")" = 404 Tabelle CustomModule mit Zeilenschutz ist lokal angelegt; die neu gebaute API nimmt einen https-Eintrag vom Admin an, liefert ihn in Liste und Einzelabruf, lehnt http mit 400 und Anonyme mit 401 ab, löscht ihn (danach 404); Seitenleiste und Rahmen-Seite sind komponentengetestet; RLS-Specs grün, Zugriffsklassifikation nachgemessen fortgeschrieben; lokal committet, nicht gepusht.

Aufgabe 2: Verwaltungsseite „Eigene Module“ — Liste, Anlegen, Bearbeiten, Löschen apps/web/src/app/(portal)/admin/custom-modules/page.tsx, apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx, apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx, apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx, apps/web/src/components/admin/admin-sidebar.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx, apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx (Kopf mit dem next-intl-Mock), apps/web/src/components/admin/admin-sidebar.tsx, apps/web/src/lib/custom-modules-api.ts (aus Aufgabe 1) - Ohne Einträge: Leer-Zustand mit Überschrift, kurzer Erklärung und Knopf „Eigenes Modul anlegen“ - Mit Einträgen: Tabelle mit Name (Link auf /modules/custom/), Adresse, Kategorie als Anzeigename (useCategoryLabel), Aktionen Bearbeiten und Löschen - Anlegen: Formular mit Name, Adresse, Kategorie-Auswahl aus MODULE_CATEGORIES; http-Adresse oder Adresse mit Zugangsdaten zeigt die passende Meldung und ruft createCustomModule NICHT auf; leerer Name ebenso; gültige Eingabe ruft createCustomModule mit getrimmtem Namen, lädt die Liste neu und ruft bumpSidebarRefresh genau einmal - Bearbeiten: Formular ist mit den Werten vorbelegt, Speichern ruft updateCustomModule(id, ...) und bumpSidebarRefresh - Löschen: Bestätigungsdialog nennt den Namen; Bestätigen ruft deleteCustomModule(id), Liste neu, bumpSidebarRefresh; Abbrechen ruft nichts - Serverfehler beim Speichern bleibt im Dialog sichtbar, Dialog bleibt offen - Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text statt der Seite (nur Anzeige; durchgesetzt wird serverseitig) Tests zuerst (`custom-modules-page.test.tsx`, Muster `groups-page.test.tsx`: namensraumfähiger next-intl-Mock, `@/lib/custom-modules-api` und `@/lib/stores/marketplace-store` per `vi.mock`, Auth-Store mit Rolle ADMIN bzw. USER), dann bauen (D-07):
  1. Seite apps/web/src/app/(portal)/admin/custom-modules/page.tsx (Client) im Aufbau von admin/groups/page.tsx: Rollen-Anzeigeprüfung ADMIN/SUPER_ADMIN (sonst common.accessDenied), Überschrift „Eigene Module“ mit Knopf „Eigenes Modul anlegen“ (btn btn-primary), darunter ein Satz Erklärung (externe Webseiten als Einträge in der Seitenleiste, alle Benutzer sehen sie), Fehlerzeile im Stil der Gruppenseite, Leer-Zustand bzw. Tabelle (overflow-x-auto rounded-md border border-border, Kopf bg-muted/50) mit Name (Link auf die Rahmen-Seite), Adresse (gekürzt mit truncate und title), Kategorie über useCategoryLabel(), Aktionen Bearbeiten/Löschen. Nach jedem erfolgreichen Anlegen, Ändern oder Löschen: Liste neu laden und useMarketplaceStore.getState().bumpSidebarRefresh() (bzw. über den Hook) aufrufen, damit die Seitenleiste ohne Neuladen nachzieht (D-05).

  2. components/CustomModuleFormModal.tsx nach Muster GroupFormModal.tsx (gleicher Dialog-Rahmen, gleiche Knopfklassen): Felder Name (Pflicht, maxLength 100), Adresse (type="url", maxLength 2048, Platzhaltertext https://…), Kategorie (<select> über MODULE_CATEGORIES aus @tessera/shared, beschriftet mit useCategoryLabel(), Vorgabe beim Anlegen: infrastructure). Vor dem Senden checkCustomModuleUrl aus Aufgabe 1 anwenden und je Ergebnis eine eigene übersetzte Meldung zeigen; Name wird getrimmt. Beim Bearbeiten nur updateCustomModule, beim Anlegen nur createCustomModule. Serverfehler im Dialog anzeigen.

  3. components/DeleteCustomModuleDialog.tsx nach Muster DeleteGroupDialog.tsx: Rückfrage mit Namen, Bestätigen/Abbrechen.

  4. apps/web/src/components/admin/admin-sidebar.tsx: neuer Eintrag direkt hinter „Module“ mit href: '/admin/custom-modules', label: t('admin.customModules'), show: true, Symbol im Stil der übrigen 16-px-Strichsymbole (z. B. Fenster mit Pfeil nach außen oder Puzzleteil). Der Pfad beginnt NICHT mit /admin/modules, damit „Module“ nicht mitmarkiert wird.

  5. Texte (D-08) in de.json und en.json: header.admin.customModules („Eigene Module“ / „Custom modules“) und Namensraum admin.customModules mit Titel, Erklärung, Anlegen, Bearbeiten, Löschen, Feldbeschriftungen (Name, Adresse, Kategorie), Aktionen-Spalte, Leer-Zustand (Überschrift + Satz), Löschrückfrage mit {name} (z. B. „Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.“), Meldungen nameRequired, urlNotHttps („Bitte geben Sie eine Adresse ein, die mit https:// beginnt.“), urlCredentials („Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.“), Speichern-Fehler. Sie-Form. Neue Wörter mit ae/oe/ue/ss nach der Umlaut-Regel behandeln.

  6. Lokal committen (z. B. feat(web): Verwaltung „Eigene Module“), NICHT pushen (D-12). cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/custom-modules" src/components/layout/sidebar.test.tsx src/messages && pnpm --filter @tessera/web exec tsc --noEmit && grep -q "/admin/custom-modules" apps/web/src/components/admin/admin-sidebar.tsx && grep -q "bumpSidebarRefresh" "apps/web/src/app/(portal)/admin/custom-modules/page.tsx" && grep -q "MODULE_CATEGORIES" "apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx" && node -e 'for (const f of ["de","en"]) { const m = require("./apps/web/src/messages/" + f + ".json"); if (!m.header.admin.customModules) throw new Error(f + ": header.admin.customModules fehlt"); for (const k of ["title","create","urlNotHttps","urlCredentials","nameRequired"]) if (!(k in m.admin.customModules)) throw new Error(f + ": admin.customModules." + k + " fehlt"); for (const k of ["openInNewTab","embedHint","notFound"]) if (!(k in m.customModules)) throw new Error(f + ": customModules." + k + " fehlt"); }' Unter Verwaltung > Eigene Module listet die Seite alle Einträge des Mandanten; Anlegen, Bearbeiten und Löschen funktionieren mit Prüfung der Adresse im Formular und ziehen die Seitenleiste sofort nach; Texte de/en vollständig; Tests grün; lokal committet, nicht gepusht.

Aufgabe 3: CHANGELOG, alle Tore, Stack neu bauen, Browser-Prüfung im Dunkelmodus CHANGELOG.md CHANGELOG.md (Zeilen 1-45) 1. CHANGELOG (D-09): unter `## Unveröffentlicht` (heute leer) einen Abschnitt `### Neu` mit einem Punkt in Alltagssprache und Sie-Form, Stil der Einträge von 1.5.x, sinngemäß: „Eigene Module: Als Administrator können Sie unter „Verwaltung“ > „Eigene Module“ andere Webseiten in die Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Alle Benutzer sehen die Einträge; ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“.“ Keine Fachbegriffe wie iframe, Sandbox, API, RLS.
  1. Alle Tore laufen lassen und die gemessenen Zahlen im SUMMARY festhalten: vollständige Web- und API-Testläufe, pnpm turbo run type-check lint, Biome-Warnungen Web höchstens 55 und API höchstens 82.

  2. Stack neu bauen (D-11): Migration ist aus Aufgabe 1 bereits angewendet (zur Sicherheit erneut prisma migrate deploy über die Container-IP, muss „No pending migrations“ melden), dann docker compose up -d --build web api; warten, bis http://localhost:3001/health und http://localhost:3000/login antworten.

  3. Lokal committen (z. B. docs(changelog): eigene Module unter Unveröffentlicht), NICHT pushen (D-12). Zum Schluss prüfen, dass HEAD auf keinem entfernten Zweig liegt.

  4. Browser-Prüfung (D-11) nach der Liste in <verification> — Playwright MCP, echte Navigation, dunkel über den Theme-Knopf. Ist Playwright MCP im Ausführungskontext nicht verfügbar, die Prüfung im SUMMARY als „an den Orchestrator übergeben“ vermerken; der Orchestrator führt sie dann durch. cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api exec vitest run && pnpm turbo run type-check lint && W=$(pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${W:-0}" -le 55 && A=$(pnpm --filter @tessera/api exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${A:-0}" -le 82 && sed -n '/^## Unveröffentlicht/,/^## 1.5.2/p' CHANGELOG.md | grep -q "Eigene Module" && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/login)" = 200 && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/custom-modules)" = 401 && test -z "$(git branch -r --contains HEAD)" Browser-Prüfung im Dunkelmodus nach den Schritten 1-9 in <verification> (Playwright MCP, lokaler Stack nach docker compose up -d --build web api). CHANGELOG nennt die Neuerung unter „Unveröffentlicht“ > „Neu“; alle Test-, Typ- und Lint-Tore grün, Biome-Grundlinie gehalten; web und api laufen neu gebaut; Browser-Prüfung im Dunkelmodus durchgeführt (oder ausdrücklich an den Orchestrator übergeben); alle Commits lokal, nichts gepusht.

<threat_model>

Trust Boundaries

Boundary Description
Browser -> API /custom-modules Nicht vertrauenswürdige Eingaben (Name, Adresse, Kategorie, id) und Rollenanspruch aus der Sitzung
Admin-Eingabe -> alle Benutzer des Mandanten Eine vom Admin gespeicherte Adresse wird jedem Benutzer als Rahmen und Link ausgeliefert
Tessera-Seite -> eingebettete Fremdseite Fremder Inhalt läuft im Rahmen innerhalb des Tessera-Tabs
API -> PostgreSQL Mandantentrennung über tenantId, forTenant und tenant_isolation_policy

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-9WC-01 Elevation of Privilege CustomModulesController POST/PATCH/DELETE high mitigate @Roles(Role.ADMIN, Role.SUPER_ADMIN) an den drei schreibenden Methoden, globaler RolesGuard; Controller-Spec prüft die Metadaten; GET-Routen bewusst ohne Rolle (D-04)
T-9WC-02 Information Disclosure CustomModulesService, Tabelle CustomModule high mitigate tenantId ausschließlich aus req.tenantId; je Methode const tenantPrisma = forTenant(this.prisma, tenantId); list filtert zusätzlich where: { tenantId }; getOne/update/remove prüfen row.tenantId !== tenantId -> 404; Migration mit ENABLE/FORCE RLS und tenant_isolation_policy; rls-coverage/rls-access-inventory grün
T-9WC-03 Tampering Adresse (DTO + Web-Rendering) high mitigate API: eigene Constraint über den URL-Parser, nur https:, Hostname nötig, max 2048; Web: iframe und Link nur bei checkCustomModuleUrl(url) === 'ok' — javascript:, data: und http: werden nie gerendert, auch nicht bei manipulierter Datenbankzeile
T-9WC-04 Spoofing Eingebettete Fremdseite medium mitigate sandbox={XFRAME_SANDBOX} (ohne Navigation des obersten Fensters und ohne allow-modals, Begründung in xframe-config.ts), allow=""; Test prüft den exakten Sandbox-Wert
T-9WC-05 Information Disclosure Referrer an Fremdseite low mitigate referrerPolicy="no-referrer" am iframe, rel="noopener noreferrer" am Link „In neuem Tab öffnen“
T-9WC-06 Information Disclosure Zugangsdaten in der Adresse medium mitigate API und Formular lehnen Adressen mit Benutzername/Kennwort ab — sonst sähe jeder Benutzer die Zugangsdaten in der Adresse
T-9WC-07 Denial of Service Name/Adresse-Felder low mitigate @MaxLength(100) Name, @MaxLength(2048) Adresse, Kategorie per @IsIn auf fünf Werte begrenzt; ValidationPipe whitelist: true verwirft Zusatzfelder (z. B. untergeschobenes tenantId)
T-9WC-08 Spoofing Admin bindet eine täuschend echte Fremdseite ein low accept Der Admin ist vertrauenswürdig (ASVS L1); Einträge sind nur für Admins änderbar, der Name steht sichtbar in Leiste und Kopfzeile
T-9WC-SC Tampering npm/pip/cargo installs high accept Dieser Plan installiert keine Pakete; alle genutzten Bibliotheken (class-validator, @nestjs/mapped-types, Prisma) sind bereits im Lockfile
</threat_model>
Executor (Tore in Aufgabe 3 gebündelt): - `pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/api exec vitest run` vollständig grün - `pnpm turbo run type-check lint` grün; Biome-Warnungen Web höchstens 55, API höchstens 82 - API-Durchstich per curl aus Aufgabe 1 (Anlegen, Liste, Einzelabruf, 400 bei http, 401 anonym, Löschen, 404 danach) - `test -z "$(git branch -r --contains HEAD)"` — nichts gepusht

Browser-Prüfung (D-11) — Playwright MCP gegen web :3000, Anmeldung admin / admin123, IMMER echte Navigation (browser_navigate) und gerenderten Inhalt auslesen, nie per fetch() aus der Seite messen. Zuerst über den Theme-Knopf der Kopfzeile auf dunkel schalten (nicht per classList):

  1. Verwaltung > „Eigene Module“ (neuer Eintrag in der Admin-Leiste, „Module“ ist 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/<id>, Kopfzeilen-Titel „Beispielseite“, Auswahlmarke am Eintrag, der Rahmen füllt den Inhaltsbereich ohne doppelten Rollbalken, „In neuem Tab öffnen“ sichtbar; im Accessibility-Snapshot/DOM trägt das iframe den Sandbox-Wert aus XFRAME_SANDBOX und referrerpolicy="no-referrer". Der Link öffnet einen neuen Tab mit example.com.
  5. Klick auf „GitHub“: der Rahmen zeigt die Einbettungssperre des Browsers, der Knopf „In neuem Tab öffnen“ ist trotzdem sichtbar und funktioniert.
  6. Seitenleiste eingeklappt: beide Einträge als Kachel mit Namen im Tooltip; Suche „Beisp“ findet den Eintrag.
  7. Bearbeiten: „Beispielseite“ in „Beispiel“ umbenennen -> Seitenleiste zieht sofort nach. Löschen mit Rückfrage -> Eintrag verschwindet aus Tabelle und Seitenleiste; die alte Adresse /modules/custom/<id> zeigt „Dieses Modul gibt es nicht mehr.“
  8. Sprache auf Englisch: keine rohen Übersetzungsschlüssel auf Verwaltungsseite und Rahmen-Seite.
  9. Screenshots (dunkel) von Verwaltungsseite, Seitenleiste mit Einträgen und Rahmen-Seite ablegen; danach die Testeinträge löschen, damit die lokale Datenbank sauber bleibt.

<success_criteria>

  • Admins verwalten eigene Module (Name, https-Adresse, Kategorie) unter Verwaltung > Eigene Module; alle Benutzer sehen sie unter der Kategorie in der Seitenleiste (D-01, D-05, D-07).
  • Die Rahmen-Seite bettet nur https-Adressen ein, mit dem XFrame-Sandbox-Wert und ohne Referrer, und zeigt immer „In neuem Tab öffnen“ (D-06).
  • API: GET für jeden Angemeldeten, Schreiben nur Admin, http und Zugangsdaten in der Adresse werden abgewiesen; list steht vor getOne (D-04, D-10).
  • Tabelle CustomModule mit Zeilenschutz; Zugriffsklassifikation nachgemessen fortgeschrieben (inkl. der beim Planen gefundenen Drift in user und der Klassen-Verteilung); RLS-Specs grün (D-03).
  • Texte de/en in Sie-Form, CHANGELOG ergänzt (D-08, D-09); alle Tore grün, Biome-Grundlinie gehalten.
  • Browser-Prüfung im Dunkelmodus bestanden (D-11); alle Commits nur lokal (D-12).
  • Gruppen-Einschränkung bewusst NICHT gebaut, im SUMMARY als zurückgestellt begründet (D-02). </success_criteria>
Create `.planning/quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/260929-9wc-SUMMARY.md` when done (deutsch, Muster der letzten Quick-Summaries: Was gebaut wurde, Abweichungen, Tore mit gemessenen Zahlen inkl. Biome-Warnungen und nachgemessener Klassifikationszahlen, Ergebnis der Browser-Prüfung mit Screenshot-Pfaden, „Bewusst offen“: Gruppen-Einschränkung für eigene Module (D-02, Begründung ModuleGrant-Fremdschlüssel auf Module), Hinweis dass nichts gepusht wurde).