15 Commits

Author SHA1 Message Date
schalli 76a923450f fix(desktop): neue Fenster im System-Browser oeffnen
Tessera CI/CD / Lint & Type Check (push) Successful in 59s
Tessera CI/CD / Tests (push) Successful in 1m39s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 8m42s
Tessera CI/CD / Build & Publish Images (push) Successful in 5m7s
Die Webansicht verwarf window.open und Links mit target=_blank still:
Suche-Widget und "In neuem Tab oeffnen" (XFrame, eigene Module,
Favoriten) taten im Client nichts. on_new_window reicht http/https-
Adressen an den System-Browser weiter und lehnt das neue Fenster ab;
andere Schemata werden verworfen (external_target, mit Test).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:08:41 +02:00
schalli a435a30c34 docs(quick-260929-dmx): feineres Raster, Kalender schmaler, Browser-Pruefung
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:56:27 +02:00
schalli 46ebb4e7ce docs(changelog): Widget-Raster feiner, Kalender schmaler
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:55:07 +02:00
schalli 9c9e1420fe feat(260929-dmx): Widget-Breiten im 48er-Raster, Kalender schmaler ziehbar
- minW/defaultW aller Widgets verdoppelt (gleiche Bildschirmbreite)
- Kalender minW 8 (rund 250 px), defaultW 16
- Test: bestehender Kalender bekommt das neue Minimum in jedem Breakpoint

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:53:08 +02:00
schalli 97744b59cd feat(260929-dmx): Widget-Raster horizontal doppelt so fein (48 Spalten, Version 3)
- COLS lg 48 / md 40 / sm 24 / xs 16 / xxs 4, Zeilenhoehe und Abstand unveraendert
- Migration stufenweise: v1->v2 wie bisher, v2->v3 nur x/w/minW/maxW x2
- Rueckfallwerte fuer Widgets ohne Eintrag auf 8 Einheiten

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:51:26 +02:00
schalli acd3c7a05f style(web): Widgets heben sich beim Darueberfahren nicht mehr an
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
Hover-Anheben samt Schatten ersatzlos entfernt (Wunsch des Nutzers:
Kacheln statisch, unabhaengig von Maus/Touch). Einblenden beim Laden
bleibt. CHANGELOG und Anwender-Anleitung angepasst.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:34:45 +02:00
schalli b9c05791b2 docs(quick-260929-d37): Desktop-App nur einmal starten
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m19s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 6m58s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:27:27 +02:00
schalli 0751198822 docs(changelog): Desktop-App startet nicht mehr doppelt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:26:55 +02:00
schalli c0b145a9b0 fix(desktop): nur eine Instanz — zweiter Start holt das Fenster nach vorne
- tauri-plugin-single-instance als erstes Plugin registriert
- show_main_window-Helper ersetzt die dreifach duplizierte Fenster-Sequenz

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:26:44 +02:00
schalli bc260100f6 docs(quick-260929-9wc): eigene Module, Browser-Pruefung dunkel bestanden
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:35:42 +02:00
schalli e48c0de238 docs(changelog): eigene Module unter Unveröffentlicht
- Neu-Eintrag in Alltagssprache
- Layout-Wächter der Modulordner kennt den Ordner custom als bewusste Ausnahme ohne Zugriffsschranke

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:31:34 +02:00
schalli e7fc4de430 feat(web): Verwaltung „Eigene Module“
- Liste, Anlegen, Bearbeiten und Löschen unter Verwaltung, Adresse wird im Formular geprüft
- Seitenleiste zieht nach jedem Speichern oder Löschen ohne Neuladen nach
- Texte deutsch und englisch, Eintrag in der Admin-Leiste hinter „Module“

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:27:16 +02:00
schalli b9d87be360 feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite
- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000
- API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten
- Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“
- MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:25:33 +02:00
schalli 643b1a2caa wip: quick-auftraege nach 1.5.2 pausiert, eigene Module offen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:23:26 +02:00
schalli 12eea333ba feat(web): Widget-Titel je Widget ausblenden, Seitenleiste gegliedert
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m17s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m24s
Neuer Schalter im Bearbeitungsmodus (neben dem Griff) setzt hideTitle in
der Widget-Konfiguration (setWidgetConfig im Dashboard-Store, optimistisch
mit Ruecksetzen); in der Ansicht entfaellt die Titelzeile per
data-hide-title, Aktionen der Titelzeile bleiben oben rechts. Kategorien der
Seitenleiste 14 px, Moduleintraege 13 px / 32 px hoch. Version 1.5.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:21:26 +02:00
52 changed files with 3335 additions and 182 deletions
+5 -2
View File
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-28 - Quick 260928-ujj: Design Mosaik aus dem Klon uebernommen (inkl. Resize-Fix), Dashboard-Hintergrund pro Benutzer in der Datenbank; Freigabe als 1.5.0
Last activity: 2026-09-29 - Quick 260929-9wc (Eigene Module) + 260929-d37 (Desktop-App nur einmal starten); main auf beta gepusht, live unveraendert auf 1.5.2
Progress: [██████████] 99%
@@ -477,6 +477,9 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260924-m4n | **Flackernden Test entschaerft, alte Bildspalte entfernt.** (1) `tenant-selector.test.tsx`: Ursache war das Laden der Bausteine INNERHALB des ersten Tests (zaehlte in dessen 5-s-Grenze) -> Import vorab, Doppelfall getrennt, dasselbe in zwei weiteren Marktplatz-Tests; langsamster Web-Test jetzt < 2 s (mit 2 Kernen 1,3 s); act()-Warnungen der Proxmox-Kachel weg. (2) DashboardImage Stufe 2: Migration `20260924120000_dashboard_image_drop_data` mit Schutz (bricht ab, wenn noch Zeilen ohne `storagePath`; Zeilenschutz fuer die Pruefung abgeschaltet, sonst saehe sie still 0), `storagePath` NOT NULL, `data` weg, `system_read_policy` weg, Bootstrap-Umzug + `forSystem()` entfernt, Upload legt Zeile gleich mit Pfad an. Vorbedingung alpha geprueft (0 von 3 ohne Pfad); Live nicht pruefbar. Rueckweg bei Abbruch in `docs/anleitung-betrieb.md` Kap. 4. Browser/API: Bilder laden, Upload+Anzeige+Loeschen ok. api 1364, web 865 gruen. | 2026-09-24 | b10734f,dd54ec5 | [260924-m4n-flackernden-test-entschaerfen-und-dashbo](./quick/260924-m4n-flackernden-test-entschaerfen-und-dashbo/) |
| 260925-bow | **Was-ist-neu-Fenster nach Versionswechsel.** Spalte `User.lastSeenReleaseVersion` (Migration 20260925120000), Versionsnummer allein aus der API (`GET /users/me/release-notice`, Semver-Funktionen in packages/shared), Fenster im Portal-Rahmen einmal nach Versionswechsel, gemerkt erst beim Schliessen (`POST`), nur freigegebene Versionen (`dev` nie), hoechstens 3 Versionen + Hinweis auf aeltere + Link /changelog; neue Konten bekommen die laufende Version eingetragen; vorhandene ohne Stand sehen nur die aktuelle. Changelog-Text bleibt serverseitig. Browser: 1.3.0 -> Fenster 1.4.0, Verstanden merkt 1.4.0, kein zweites Mal; 1.0.0 -> 1.4.0/1.3.1/1.3.0 + „2 aelteren Versionen“; Link-Kontrast nachgebessert. api 1435, web 924 gruen. | 2026-09-25 | 59db32a,187fb76,5ae9aaa,b3b7b5d | [260925-bow-was-ist-neu-fenster-beim-ersten-anmelden](./quick/260925-bow-was-ist-neu-fenster-beim-ersten-anmelden/) |
| 260928-ujj | **Design Mosaik uebernommen + Hintergrund pro Benutzer.** Merge design/mosaik (76d17fe, inkl. RESIZE_AXIS_FALLBACK), Spalte `User.dashboardBackground` JSONB (Migration 20260928120000), `PATCH /users/me/dashboard-background` mit `parseDashboardBackground` aus packages/shared (Preset-Liste, imageId nur UUID), Web liest aus Sitzung, alte localStorage-Wahl einmalig uebernommen. Browser: Duenen gewaehlt, DB-Zeile gesetzt, nach localStorage-Loeschen weiter sichtbar. api 1462, web 952 gruen; Freigabe als 1.5.0. | 2026-09-28 | 9fa0a3f,0aaa152,cb45d26 | [260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-](./quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/) |
| 260929-9wc | **Eigene Module (nur lokal, nicht gepusht).** Modell `CustomModule` + Migration 20260929120000 mit RLS (Muster ProxmoxServer), `/custom-modules` (GET alle Angemeldeten, POST/PATCH/DELETE Admin, nur https ohne Zugangsdaten), `MODULE_CATEGORIES` in packages/shared, Seitenleisten-Eintrag unter gewaehlter Kategorie, Rahmen-Seite `/modules/custom/[id]` mit XFRAME_SANDBOX + no-referrer + „In neuem Tab öffnen“, Verwaltung `/admin/custom-modules`, Zugriffsklassifikation 61/224/6. Gruppen-Beschraenkung zurueckgestellt (ModuleGrant haengt an Module). api 1495, web 992 gruen; Browser dunkel 9 Schritte bestanden. | 2026-09-29 | b9d87be,e7fc4de,e48c0de | [260929-9wc-eigene-module-admin-legt-seitenleisten-e](./quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/) |
| 260929-d37 | **Desktop-App nur einmal starten.** User-Meldung Windows 11: beim Systemstart zwei Instanzen/zwei Tray-Symbole. `tauri-plugin-single-instance` 2.4.5 als erstes Plugin, zweiter Start ruft `show_main_window` (neuer Helper, ersetzt 3 Kopien) und beendet sich. cargo build/test (44)/clippy gruen. Windows-Pruefung offen (VM 8233 oder User-PC nach naechster Desktop-Version). | 2026-09-29 | c0b145a,0751198 | [260929-d37-desktop-client-nur-einmal-starten-single](./quick/260929-d37-desktop-client-nur-einmal-starten-single/) |
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
## Deferred Items
@@ -522,4 +525,4 @@ Last session: 2026-09-22T13:40:00Z
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; seitdem Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, Freigabe 1.3.0, Bilder in den Dateibereich.
Stopped at: hk4 fertig und nachgewiesen. Dem Nutzer vorgelegt: erst das Aufraeumen (Modul bringt seine Kachel selbst mit), dann Proxmox-Modul + Kachel — Antwort steht aus.
Resume file: None
Last activity: 2026-09-28 - Quick 260928-ujj: Design Mosaik aus dem Klon uebernommen (inkl. Resize-Fix), Dashboard-Hintergrund pro Benutzer in der Datenbank; Freigabe als 1.5.0
Last activity: 2026-09-29 - Quick 260929-9wc (Eigene Module) + 260929-d37 (Desktop-App nur einmal starten); main auf beta gepusht, live unveraendert auf 1.5.2
@@ -0,0 +1,391 @@
---
phase: quick-260929-9wc
plan: 01
quick_id: 260929-9wc
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260929-9wc]
files_modified:
- 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
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 3
confidence: low
must_haves:
truths:
- "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)"
artifacts:
- path: "apps/api/prisma/migrations/20260929120000_custom_module/migration.sql"
provides: "Tabelle CustomModule mit tenantId, Index, RLS ENABLE/FORCE, tenant_isolation_policy ohne Benutzerdimension, ohne system_read_policy"
- path: "apps/api/src/custom-modules/custom-modules.controller.ts"
provides: "GET '' und GET ':id' (jeder Angemeldete), POST/PATCH ':id'/DELETE ':id' mit @Roles(ADMIN, SUPER_ADMIN); list vor getOne deklariert"
- path: "apps/api/src/custom-modules/custom-modules.service.ts"
provides: "list/getOne/create/update/remove, je Methode ein forTenant-Klient, Fremd-Mandant oder unbekannte id -> NotFoundException"
- path: "apps/api/src/custom-modules/dto/custom-module.dto.ts"
provides: "CreateCustomModuleDto/UpdateCustomModuleDto: Name 1-100 Zeichen, Adresse nur https ohne Zugangsdaten max 2048, Kategorie @IsIn(MODULE_CATEGORIES)"
- path: "packages/shared/src/index.ts"
provides: "MODULE_CATEGORIES = ['domain-tools','security-tools','fleet','infrastructure','procurement'] + Typ ModuleCategory"
- path: "apps/web/src/lib/custom-modules-api.ts"
provides: "CustomModule-Typ, listCustomModules/getCustomModule/createCustomModule/updateCustomModule/deleteCustomModule, checkCustomModuleUrl"
- path: "apps/web/src/components/modules/custom-module-view.tsx"
provides: "Rahmen-Ansicht mit Leiste (Name, Hinweis, „In neuem Tab öffnen“) und Vollflächen-iframe"
- path: "apps/web/src/app/(portal)/admin/custom-modules/page.tsx"
provides: "Verwaltungsseite: Liste, Anlegen/Bearbeiten (Formular-Dialog), Löschen (Bestätigung)"
key_links:
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "GET /custom-modules"
via: "listCustomModules() im selben Effekt wie /modules/active, ausgelöst durch sidebarRefreshKey"
pattern: "listCustomModules"
- from: "apps/web/src/app/(portal)/admin/custom-modules/page.tsx"
to: "apps/web/src/components/layout/sidebar.tsx"
via: "useMarketplaceStore bumpSidebarRefresh() nach jedem erfolgreichen Speichern/Löschen"
pattern: "bumpSidebarRefresh"
- from: "apps/web/src/components/modules/custom-module-view.tsx"
to: "apps/web/src/components/dashboard/widgets/xframe-config.ts"
via: "Import XFRAME_SANDBOX — ein Sandbox-Wert für XFrame und eigene Module"
pattern: "XFRAME_SANDBOX"
- from: "apps/api/src/custom-modules/custom-modules.service.ts"
to: "apps/api/src/prisma/prisma-tenant.extension.ts"
via: "const tenantPrisma = forTenant(this.prisma, tenantId)"
pattern: "const tenantPrisma = forTenant\\(this\\.prisma, tenantId\\)"
- from: "apps/api/src/app.module.ts"
to: "apps/api/src/custom-modules/custom-modules.module.ts"
via: "imports: [..., CustomModulesModule]"
pattern: "CustomModulesModule"
- from: "docs/mandantentrennung-zugriffsklassifikation.md"
to: "apps/api/src/prisma/rls-access-inventory.spec.ts"
via: "Bestandsaufnahme-Zeile custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden"
pattern: "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).
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Aufgabe 1 (Tracer): Ein eigenes Modul von der Datenbank bis in Seitenleiste und Rahmen-Seite</name>
<files>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</files>
<precondition>Der lokale Stack läuft: `docker compose ps --format '{{.Service}} {{.State}}'` zeigt db, api und web als running.</precondition>
<read_first>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</read_first>
<behavior>
- 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/<id>; eine Kategorie, die nur eigene Module hat, erscheint trotzdem; auf /modules/custom/<id> 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
</behavior>
<action>
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).
</action>
<verify>
<automated>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</automated>
</verify>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Verwaltungsseite „Eigene Module“ — Liste, Anlegen, Bearbeiten, Löschen</name>
<files>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</files>
<read_first>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)</read_first>
<behavior>
- 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/<id>), 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)
</behavior>
<action>
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).
</action>
<verify>
<automated>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"); }'</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Aufgabe 3: CHANGELOG, alle Tore, Stack neu bauen, Browser-Prüfung im Dunkelmodus</name>
<files>CHANGELOG.md</files>
<read_first>CHANGELOG.md (Zeilen 1-45)</read_first>
<action>
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.
2. 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.
3. 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.
4. 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.
5. 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.
</action>
<verify>
<automated>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)"</automated>
<human-check>Browser-Prüfung im Dunkelmodus nach den Schritten 1-9 in &lt;verification&gt; (Playwright MCP, lokaler Stack nach `docker compose up -d --build web api`).</human-check>
</verify>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
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.
</verification>
<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>
<output>
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).
</output>
@@ -0,0 +1,161 @@
---
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/<id>`, 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/<id>` 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.
@@ -0,0 +1,55 @@
---
quick_id: 260929-d37
type: quick
wave: 1
autonomous: true
files_modified:
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/Cargo.lock
- apps/desktop/src-tauri/src/lib.rs
- CHANGELOG.md
---
# Quick 260929-d37: Desktop-Client nur einmal starten (Single-Instance)
## Problem
User report (29.09.2026, Windows 11): at system start Tessera launches twice and two tray icons appear.
`apps/desktop/src-tauri/src/lib.rs` has no single-instance guard. Autostart via `tauri-plugin-autostart`
(HKCU Run key, only set when the user ticks "Mit Windows starten"); a second launch source (Windows 11
"restart restartable apps after sign-in", a stale Run/Startup entry from an older install, or a manual
double-click) starts a second full process with its own tray icon.
## Goal
Only one Tessera desktop process runs per user session. A second launch hands off to the running one
(show + unminimize + focus the main window) and exits immediately — no second tray icon.
## Task 1: Single-instance plugin
- files: apps/desktop/src-tauri/Cargo.toml, apps/desktop/src-tauri/Cargo.lock, apps/desktop/src-tauri/src/lib.rs
- action:
- Add `tauri-plugin-single-instance = "2"` to `[dependencies]` (resolve with cargo; lockfile updated).
- Register it as the FIRST plugin in `tauri::Builder` (plugin docs require it to be registered first):
`.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { focus main window }))`.
- Callback: reuse the exact show/unminimize/set_focus sequence already used by the tray "open" handler
(around lib.rs:820-835). If that sequence is duplicated 3x already, extract a small helper
`fn show_main_window(app: &AppHandle)` and use it in all places (keep behavior identical).
- Short German comment above the plugin line explaining why (double start at Windows sign-in, two tray icons).
- No capabilities/permissions change needed (plugin has no JS API); verify by building.
- verify: `cd apps/desktop/src-tauri && cargo build` succeeds; `cargo test` (existing unit tests) green; `cargo clippy` no new warnings if clippy is available.
- done: builds, tests green, commit `fix(desktop): nur eine Instanz — zweiter Start holt das Fenster nach vorne`.
## Task 2: CHANGELOG
- files: CHANGELOG.md
- action: under `## Unveröffentlicht` add a `### Behoben` section (after `### Neu`) with one plain-German bullet (app text uses "Sie"), e.g.:
"Desktop-App: Tessera startet nicht mehr doppelt. Wird die App ein zweites Mal gestartet – etwa beim Anmelden an Windows –, holt sie nur das vorhandene Fenster nach vorne; im Infobereich erscheint nur noch ein Symbol."
Follow existing CHANGELOG style; run the repo's changelog/umlaut checks if any exist (web tests touching CHANGELOG, e.g. `pnpm --filter web test -- changelog`).
- done: commit `docs(changelog): Desktop-App startet nicht mehr doppelt`.
## Constraints
- Commit locally only. NEVER `git push`.
- Do not touch the desktop version numbers (release process handles them).
- Real Windows verification is not possible from here; state in SUMMARY that the Windows check (VM 8233 or user's PC after next desktop release) is open.
@@ -0,0 +1,41 @@
---
quick_id: 260929-d37
status: complete
commits: 2
plan_head_before: bc26010
plan_head_after: 0751198
---
# Quick 260929-d37: Desktop-Client nur einmal starten (Single-Instance)
`tauri-plugin-single-instance` (2.4.5) ist als erstes Plugin im `tauri::Builder` registriert. Ein zweiter Start holt das Fenster der laufenden Instanz nach vorne und beendet sich, es entsteht kein zweites Tray-Symbol.
## Tasks
1. **Single-Instance-Plugin** — Commit `c0b145a` (`fix(desktop): nur eine Instanz — zweiter Start holt das Fenster nach vorne`)
- `Cargo.toml` und `Cargo.lock` um `tauri-plugin-single-instance = "2"` erweitert.
- Die Sequenz unminimize/show/set_focus stand dreimal in `lib.rs` (Tray "open", "change_server", Tray-Linksklick). Sie ist jetzt der Helper `show_main_window(&AppHandle)`, den auch der Single-Instance-Callback nutzt. Das Verhalten der Tray-Handler ist unverändert. Bei "change_server" läuft `navigate` weiterhin vor dem Anzeigen.
- Kurzer deutscher Kommentar über der Plugin-Zeile.
2. **CHANGELOG** — Commit `0751198` (`docs(changelog): Desktop-App startet nicht mehr doppelt`)
- Unter "Unveröffentlicht" neuer Abschnitt "Behoben" mit einem Eintrag.
## Verifikation
- `cargo build`: ok
- `cargo test`: 44 Tests grün
- `cargo clippy`: keine Warnungen
- Vitest `changelog.test.ts`, `release-notes.test.ts`, `changelog-page.test.tsx`: 33 Tests grün
## Deviations from Plan
None - plan executed exactly as written.
## Offen
Die Prüfung unter Windows steht aus, weil sie von hier aus nicht möglich ist. Sie kann in der Windows-Test-VM 8233 oder auf dem PC des Users nach dem nächsten Desktop-Release erfolgen: App zweimal starten, es darf nur ein Tray-Symbol erscheinen und das Fenster kommt nach vorne. Die Desktop-Versionsnummern sind unverändert.
## Known Stubs
None.
## Self-Check: PASSED
@@ -0,0 +1,61 @@
---
quick_id: 260929-dmx
type: quick
wave: 1
autonomous: true
---
# Quick 260929-dmx: Widget-Raster horizontal feiner (48 Spalten), Kalender schmaler
## User requests (29.09.2026)
1. "das kalender widget soll in der breite schmäler gemacht werden können."
2. "und mache das widget raster horizontal etwas feiner."
## Measurements (orchestrator, browser, lg breakpoint, grid width 1593 px, margin 12)
- Today: 24 cols → one width unit ≈ 66 px. Calendar minW 6 ≈ 383 px, default 8 ≈ 515 px.
- Calendar rendered at 251 px: fully usable (month grid, header "September 2026", event list truncates titles cleanly).
- Calendar at 185 px: header clipped, event titles reduced to one letter → too narrow.
- Target: calendar minimum ≈ 250 px.
## Decision (locked)
Double the HORIZONTAL resolution only: `COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }` in
`apps/web/src/components/dashboard/dashboard-grid.tsx`. Row height (20) and margin (12) unchanged.
Every existing widget keeps its exact on-screen size and position.
## Task 1: Grid version 3 (horizontal x2) with migration
- files: apps/web/src/lib/grid-layout-migration.ts (+ test), apps/web/src/components/dashboard/dashboard-grid.tsx (+ test), apps/web/src/lib/stores/dashboard-store.ts (only if needed)
- action:
- Bump `GRID_VERSION` to 3. Migration becomes stepwise and cumulative:
v1 → v2: existing behavior (x,y,w,h,minW,minH,maxW,maxH × 2).
v2 → v3: NEW, horizontal only: x, w, minW, maxW × 2 (y, h, minH, maxH unchanged).
So a v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Keep idempotence and marker semantics (marker only in persisted JSON). Update the file header comment (German, same style) to document v3.
- `COLS` as above. Check every other place that depends on column count or widget width units:
centering offset, `RESIZE_AXIS_FALLBACK`, `breakpointFor`, default positions when adding a widget
(`dashboard-grid.tsx` ~380: `defaultW ?? 4`, `minW ?? 4` fallbacks → 8), empty-dashboard suggestions,
any layout templates/seed data in apps/web or apps/api (grep `defaultW`, `w:` in dashboard code,
`layouts` defaults in apps/api/src/dashboard). Anything expressed in width units gets × 2.
- Tests: extend grid-layout-migration tests (v1→v3, v2→v3, v3 untouched, idempotence, marker 3 written), update dashboard-grid tests pinning cols.
- verify: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green.
## Task 2: Widget width constraints in new units
- files: apps/web/src/components/dashboard/widget-registry.tsx (+ test)
- action: In `WIDGET_CONSTRAINTS` double every `minW` and `defaultW` (same physical size as before),
EXCEPT calendar: `minW: 8` (≈ 251 px at lg — the measured usable minimum), `defaultW: 16` (unchanged size).
minH/defaultH unchanged. Update the comment above calendar (German): narrower on user request 29.09., 8 of 48 ≈ 250 px measured usable.
Existing layouts: the existing override logic in dashboard-grid (quick-260916-dyv: stored minW/minH replaced by constants in every breakpoint) must pick up the new calendar minW so existing calendars can be shrunk — verify that path with a test.
- verify: web tests green; `pnpm turbo run type-check lint --filter web` green; biome warnings for web not above baseline 55.
## Task 3: CHANGELOG, rebuild, commit
- CHANGELOG.md under `## Unveröffentlicht` → `### Geändert` (section exists) add plain-German bullets (app text uses "Sie"):
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
- Rebuild local stack: `docker compose up -d --build web` (plain `up` does not rebuild).
- Commits per task, messages end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
- NEVER git push (orchestrator pushes after browser check).
- Browser check is done by the orchestrator (resize calendar to minimum, existing layout unchanged after migration, marker 3 persisted).
@@ -0,0 +1,84 @@
---
quick_id: 260929-dmx
status: complete
commits: 3
plan_head_before: acd3c7a05f9f01e2f5cf8ebe9aed66d8c789a055
plan_head_after: 46ebb4e7ceafa1dc782514e487ac820166279f48
completed: 2026-09-29
actuals:
tasks: 3
commits: 3
---
# Quick 260929-dmx: Widget-Raster horizontal 48 Spalten, Kalender schmaler
Grid version 3: horizontal resolution doubled (COLS lg 48 / md 40 / sm 24 / xs 16 / xxs 4), row height 20 and margin 12 unchanged. Stored layouts are migrated stepwise, so every existing widget keeps its on-screen size and position. Calendar minimum is now 8 of 48 columns (about 250 px at lg).
## Commits
- 97744b5 feat: grid v3 with stepwise migration, COLS, fallbacks
- 9c9e142 feat: widget constraints in 48-column units, calendar minW 8
- 46ebb4e docs(changelog): two bullets under "Unveröffentlicht / Geändert"
## Every place where width units changed
1. `apps/web/src/lib/grid-layout-migration.ts`: `GRID_VERSION` 2 to 3. Migration is now a step table: v1 to v2 scales x, y, w, h, minW, minH, maxW, maxH by 2; v2 to v3 scales only x, w, minW, maxW by 2. A v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Marker semantics unchanged (marker only in the persisted JSON, stripped on load, re-added by `withGridVersion`). Header comment updated.
2. `apps/web/src/components/dashboard/dashboard-grid.tsx`:
- `COLS` is `{ lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }`.
- Fallback for a widget without a layout entry: `defaultW ?? 8` and `minW ?? 8` (was 4/4). The `?? 4` fallbacks for height stay.
- Comment block above BREAKPOINTS/COLS extended.
3. `apps/web/src/components/dashboard/widget-registry.tsx` (`WIDGET_CONSTRAINTS`, minW/defaultW doubled, heights untouched):
- clock 4/8
- search 12/24
- calendar minW 8, defaultW 16. Calendar is the only one that is not a plain doubling for minW: 8 instead of 12.
- note 8/12
- calculator 6/12
- favorites 2/12
- stopwatch 8/12
- picture-frame 8/16
- xframe 8/24
- proxmox 6/16
- Comments updated, German, including the calendar note (narrower on user request 29.09., 8 of 48 is about 250 px measured usable).
4. `dashboard-store.ts` needed no code change. `addWidget` reads `defaultW` from `WIDGET_CONSTRAINTS`, so new widgets are placed in the new units automatically. Load and save already route through `migrateGridLayouts` and `withGridVersion`.
5. `centeringOffset` needed no code change. It takes `cols` as a parameter and gets the new `COLS[breakpoint]`.
6. `breakpointFor` needed no change. It depends only on BREAKPOINTS, not on the column count.
The existing override in `applyConstraintMinima` (quick-260916-dyv) already replaces the stored minW/minH from the constants in every breakpoint, so existing calendars pick up minW 8 with no code change there. This is verified by a new test.
## Tests
- `grid-layout-migration.test.ts`: v1 to v3 (x/w/minW/maxW times 4, y/h/minH/maxH times 2), v2 to v3, v3 untouched, v1 result equals v2 result, idempotence for both paths with marker 3 written, future marker 4 untouched, string marker, foreign values.
- `dashboard-store.test.ts`: marker 3, new expected values, new-widget default of 8 wide.
- `dashboard-grid.test.tsx`: COLS pin, `centeringOffset` with 48 columns, data-grid fallbacks, minima overrides, new Test 9c (existing calendar with stored minW 12 gets minW 8 in every breakpoint, w 6 raised to 8, w 16 kept).
- `widget-registry.test.tsx`: constraints table.
- Verification: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green (513). The full web suite is green (995). Type-check for `@tessera/web` is green. Biome shows 55 warnings, equal to the baseline. Note: the turbo filter name is `@tessera/web`, not `web`.
## Rebuild
`docker compose up -d --build web` ran, and the web container is up. Browser check is left to the orchestrator (calendar at minimum, existing layout unchanged after migration, marker 3 persisted).
## Found but deliberately left
- `apps/api/src/dashboard/dashboard.service.spec.ts:834` stores `__gridVersion: 2` as a passthrough fixture. The API only passes the JSON through, so the value is arbitrary, and I did not touch it.
- `RESIZE_AXIS_FALLBACK` and its tests use abstract grid numbers and are unit-independent, so nothing was changed. Its resize logic has no column dependency.
- Historical comments that mention "24 Spalten" (the quick-260922-vdk explanation in `dashboard-grid.tsx`, the quick-260916-bwo test description, the bwo header text) describe past states and were left. The vdk comment's numbers (24 columns, 50 px) describe the old bug, not the current state.
- Widget internals (calendar, favorites, calculator) use pixel-based or container-based layout, not grid units, so no change was needed.
- md/sm/xs/xxs columns are doubled proportionally with lg. Only lg was measured, and I did not check the smaller breakpoints in a browser.
- API/`seed`: no default layouts or seed data with width units exist in apps/api (empty defaults `{ lg: [], ... }` only).
- A brand-new empty v2 layout is not re-saved with marker 3 on load (`migrated` stays false for empty layouts, existing behaviour). The marker gets written on the next save.
## Deviations from Plan
None. The plan was executed as written. The SUMMARY, STATE, PLAN and ROADMAP files were not committed, as instructed.
## Self-Check: PASSED
Commits 97744b5, 9c9e142, 46ebb4e exist. Changed files exist. Working tree contains only the untracked quick-task directory.
## Browser-Pruefung (Orchestrator, 29.09., dunkel, lg 1888 px)
- Bestehende Anordnung nach Migration pixelgenau gleich (6 Widgets, left/top/width/height vor und nach identisch); DB: `__gridVersion` 3, lg-w verdoppelt.
- Kalender im Bearbeitungsmodus nach links gezogen: stoppt bei 252 px (vorher Minimum 383 px), Monatsraster, Kopf und Terminliste sauber lesbar.
- Schrittweite beim Ziehen 33 px (284/317/350), vorher 66 px.
- Kalender danach wieder auf 515 px gezogen, Testzustand zurueckgesetzt.
- Nicht im Browser geprueft: kleinere Breakpoints (md/sm/xs/xxs).
+25
View File
@@ -4,6 +4,31 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht
### Neu
- Eigene Module: Als Administrator können Sie unter „Verwaltung“ im Punkt „Eigene Module“ andere Webseiten in die Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Alle Benutzer sehen die Einträge unter der gewählten Kategorie; ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“.
### Geändert
- Dashboard: Die Widgets bleiben beim Darüberfahren mit der Maus ruhig stehen, sie heben sich nicht mehr an.
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
### Behoben
- Desktop-App: Tessera startet nicht mehr doppelt. Wird die App ein zweites Mal gestartet – etwa beim Anmelden an Windows –, holt sie nur das vorhandene Fenster nach vorne; im Infobereich erscheint nur noch ein Symbol.
- Desktop-App: Die Suche im Such-Widget und Knöpfe wie „In neuem Tab öffnen“ funktionieren jetzt auch in der Desktop-App – die Seite öffnet sich in Ihrem normalen Browser. Bisher passierte dort beim Klick nichts.
## 1.5.2 – 2026-09-28
### Neu
- Dashboard: Den Titel eines Widgets können Sie jetzt ausblenden. Im Bearbeitungsmodus sitzt dafür oben in der Mitte jedes Widgets mit Titel ein kleines „T“; ein Klick blendet den Titel aus, ein zweiter wieder ein. Die Einstellung gilt je Widget und bleibt gespeichert. Im Bearbeitungsmodus sehen Sie den Titel weiterhin, damit Sie ihn ändern können; Knöpfe wie der Stift der Notiz bleiben auch ohne Titel oben rechts erreichbar.
### Geändert
- Seitenleiste: Die Kategorien (etwa „Fuhrpark“ oder „Infrastruktur“) sind etwas größer beschriftet, die Module darunter etwas kleiner – so ist die Gliederung auf einen Blick erkennbar.
## 1.5.1 – 2026-09-28
### Geändert
@@ -0,0 +1,47 @@
-- 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Eintraege.
--
-- Zweck: neue Tabelle "CustomModule". Der Administrator legt Eintraege an
-- (Name, https-Adresse, Kategorie), alle Benutzer des Mandanten sehen sie in
-- der Seitenleiste und ein Klick zeigt die Seite im Rahmen. Mehrere Zeilen je
-- Mandant, Vorbild "ProxmoxServer" (tenantId-Spalte, keine Relation zu
-- Tenant).
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — eigene
-- Module sind Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines
-- einzelnen Benutzers.
--
-- BEWUSST KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der
-- eigene Module ueber alle Mandanten lesen muesste; jeder Zugriff laeuft
-- mandantengebunden ueber `forTenant(prisma, tenantId)`.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "CustomModule" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"category" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomModule_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CustomModule_tenantId_idx" ON "CustomModule"("tenantId");
ALTER TABLE "CustomModule" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "CustomModule" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "CustomModule"
USING ("tenantId" = current_tenant_id());
+16
View File
@@ -716,3 +716,19 @@ model ProxmoxServerStatus {
@@index([tenantId])
}
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
// keine Relation zu Tenant).
model CustomModule {
id String @id @default(uuid())
tenantId String
name String
url String
category String // eine der MODULE_CATEGORIES aus @tessera/shared
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
+2
View File
@@ -27,6 +27,7 @@ import { TenantModule } from './tenant/tenant.module';
import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
@Module({
imports: [
@@ -53,6 +54,7 @@ import { ProxmoxModule } from './proxmox/proxmox.module';
TendersModule,
BugReportsModule,
ProxmoxModule,
CustomModulesModule,
],
providers: [
// Global JWT guard: all routes require auth unless @Public()
@@ -0,0 +1,81 @@
import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CustomModulesController } from './custom-modules.controller';
import { CreateCustomModuleDto } from './dto/custom-module.dto';
function makeService() {
return {
list: vi.fn(async (..._args: unknown[]) => []),
getOne: vi.fn(async (..._args: unknown[]) => ({})),
create: vi.fn(async (..._args: unknown[]) => ({})),
update: vi.fn(async (..._args: unknown[]) => ({})),
remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })),
};
}
const req = (tenantId?: string) => ({ tenantId }) as any;
const proto = CustomModulesController.prototype as any;
describe('CustomModulesController — Rollen (T-9WC-01)', () => {
it.each(['create', 'update', 'remove'])('%s ist nur fuer ADMIN und SUPER_ADMIN offen', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
});
it.each(['list', 'getOne'])('%s traegt keine Rollen (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
});
it('haengt an Pfad custom-modules', () => {
expect(Reflect.getMetadata('path', CustomModulesController)).toBe('custom-modules');
});
});
describe('CustomModulesController — Mandant', () => {
it('reicht req.tenantId an den Dienst weiter', async () => {
const service = makeService();
const controller = new CustomModulesController(service as any);
await controller.list(req('t1'));
await controller.getOne(req('t1'), 'x');
await controller.create(req('t1'), { name: 'a', url: 'https://a.de', category: 'fleet' });
await controller.update(req('t1'), 'x', { name: 'b' });
await controller.remove(req('t1'), 'x');
expect(service.list).toHaveBeenCalledWith('t1');
expect(service.getOne).toHaveBeenCalledWith('t1', 'x');
expect(service.create.mock.calls[0][0]).toBe('t1');
expect(service.update.mock.calls[0].slice(0, 2)).toEqual(['t1', 'x']);
expect(service.remove).toHaveBeenCalledWith('t1', 'x');
});
it('wirft ForbiddenException ohne req.tenantId', async () => {
const controller = new CustomModulesController(makeService() as any);
await expect(controller.list(req())).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getOne(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.create(req(), { name: 'a', url: 'https://a.de', category: 'fleet' }),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.remove(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
});
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{ name: 'a', url: 'https://a.de', category: 'fleet', tenantId: 'evil' },
{ type: 'body', metatype: CreateCustomModuleDto },
);
expect(out).not.toHaveProperty('tenantId');
});
});
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
it('deklariert list vor getOne', () => {
const methods = Object.getOwnPropertyNames(CustomModulesController.prototype);
const listIdx = methods.indexOf('list');
const idIdx = methods.indexOf('getOne');
expect(listIdx).toBeGreaterThanOrEqual(0);
expect(idIdx).toBeGreaterThanOrEqual(0);
expect(listIdx).toBeLessThan(idIdx);
});
});
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CustomModulesService } from './custom-modules.service';
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
/**
* Eigene Module (quick-260929-9wc). Lesen (`GET`, `GET :id`) steht jedem
* angemeldeten Benutzer offen — die Eintraege sind fuer alle sichtbar (D-01);
* Schreiben nur `@Roles(ADMIN, SUPER_ADMIN)` (T-9WC-01). Kein `@UseModule`:
* eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`).
*
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
* stehen, sonst faengt `:id` sie ab (404-Shadowing); der Controller-Test
* haelt die Reihenfolge von `list` vor `getOne` fest.
*/
@Controller('custom-modules')
export class CustomModulesController {
constructor(private readonly service: CustomModulesService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get()
async list(@Req() req: AuthenticatedRequest) {
return this.service.list(this.requireTenantId(req));
}
@Get(':id')
async getOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.getOne(this.requireTenantId(req), id);
}
@Post()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateCustomModuleDto) {
return this.service.create(this.requireTenantId(req), dto);
}
@Patch(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateCustomModuleDto,
) {
return this.service.update(this.requireTenantId(req), id, dto);
}
@Delete(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.remove(this.requireTenantId(req), id);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CustomModulesController } from './custom-modules.controller';
import { CustomModulesService } from './custom-modules.service';
/**
* Eigene Module (quick-260929-9wc). `PrismaModule` ist global (wie bei
* `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt).
*/
@Module({
controllers: [CustomModulesController],
providers: [CustomModulesService],
})
export class CustomModulesModule {}
@@ -0,0 +1,126 @@
import { NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
// rls-access-inventory.spec.ts; hier zaehlt, dass je Methode (prisma, tenantId)
// uebergeben wird.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CustomModulesService } from './custom-modules.service';
function makeFakePrisma() {
const rows = new Map<string, any>();
let seq = 0;
const customModule = {
create: vi.fn(async ({ data }: { data: any }) => {
const id = `cm-${++seq}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
rows.set(id, row);
return row;
}),
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
let list = [...rows.values()];
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name));
return list;
}),
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => rows.get(where.id) ?? null),
update: vi.fn(async ({ where, data }: { where: { id: string }; data: any }) => {
const row = { ...rows.get(where.id), ...data };
rows.set(where.id, row);
return row;
}),
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
rows.delete(where.id);
}),
};
return { customModule, rows };
}
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
describe('CustomModulesService', () => {
it('create speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
await service.create('t1', { ...dto, tenantId: 'evil' } as any);
expect(prisma.customModule.create).toHaveBeenCalledTimes(1);
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
});
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
await service.create('t1', { ...dto, name: 'Zebra' });
await service.create('t1', { ...dto, name: 'Anker' });
await service.create('t2', { ...dto, name: 'Fremd' });
const result = await service.list('t1');
expect(result.map((r: any) => r.name)).toEqual(['Anker', 'Zebra']);
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({ tenantId: 't1' });
});
it('getOne liefert die Zeile ohne tenantId', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t1', dto);
const row: any = await service.getOne('t1', created.id);
expect(row.name).toBe('Wiki');
expect(row).not.toHaveProperty('tenantId');
});
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
const service = new CustomModulesService(makeFakePrisma() as any);
await expect(service.getOne('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', 'nope', { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
});
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t2', dto);
await expect(service.getOne('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', created.id, { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.customModule.update).not.toHaveBeenCalled();
expect(prisma.customModule.delete).not.toHaveBeenCalled();
});
it('update aendert nur gesetzte Felder', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t1', dto);
await service.update('t1', created.id, { name: 'Neu' });
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
});
it('remove loescht und liefert { deleted: true }', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t1', dto);
await expect(service.remove('t1', created.id)).resolves.toEqual({ deleted: true });
expect(prisma.rows.size).toBe(0);
});
it('ruft forTenant je Methode mit (prisma, tenantId) auf', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
vi.mocked(forTenant).mockClear();
const created: any = await service.create('t1', dto);
await service.list('t1');
await service.getOne('t1', created.id);
await service.update('t1', created.id, { name: 'a' });
await service.remove('t1', created.id);
expect(forTenant).toHaveBeenCalledTimes(5);
for (const call of vi.mocked(forTenant).mock.calls) {
expect(call).toEqual([prisma, 't1']);
}
});
});
@@ -0,0 +1,85 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
const CUSTOM_MODULE_SELECT = {
id: true,
name: true,
url: true,
category: true,
createdAt: true,
updatedAt: true,
};
/**
* Eigene Module (quick-260929-9wc): vom Administrator angelegte
* Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt
* ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je
* Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen
* getOne/update/remove `row.tenantId` — zweites Netz, solange der
* RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte
* id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz.
*/
@Injectable()
export class CustomModulesService {
constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.customModule.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
select: CUSTOM_MODULE_SELECT,
});
}
async getOne(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.customModule.findUnique({
where: { id },
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
});
if (!row || row.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
const { tenantId: _omit, ...result } = row;
return result;
}
async create(tenantId: string, dto: CreateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.customModule.create({
data: { tenantId, name: dto.name, url: dto.url, category: dto.category },
select: CUSTOM_MODULE_SELECT,
});
}
async update(tenantId: string, id: string, dto: UpdateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
if (!existing || existing.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
const data: { name?: string; url?: string; category?: string } = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.url !== undefined) data.url = dto.url;
if (dto.category !== undefined) data.category = dto.category;
return tenantPrisma.customModule.update({
where: { id },
data,
select: CUSTOM_MODULE_SELECT,
});
}
async remove(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
if (!existing || existing.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
await tenantPrisma.customModule.delete({ where: { id } });
return { deleted: true };
}
}
@@ -0,0 +1,67 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, expect, it } from 'vitest';
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './custom-module.dto';
async function errorsFor<T extends object>(cls: new () => T, plain: Record<string, unknown>) {
const dto = plainToInstance(cls, plain);
const errors = await validate(dto as object);
return errors.map((e) => e.property);
}
const valid = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' };
describe('CreateCustomModuleDto', () => {
it('nimmt einen gueltigen Eintrag an', async () => {
expect(await errorsFor(CreateCustomModuleDto, valid)).toEqual([]);
});
it.each([
'http://example.com',
'javascript:alert(1)',
'data:text/html,x',
'ftp://x',
'kaputt',
'https://user:pw@example.com',
'https://user@example.com',
])('lehnt die Adresse %s ab', async (url) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
});
it('lehnt eine unbekannte Kategorie ab', async () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category: 'other' })).toContain(
'category',
);
});
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
});
it('trimmt den Namen', () => {
const dto = plainToInstance(CreateCustomModuleDto, { ...valid, name: ' Wiki ' });
expect(dto.name).toBe('Wiki');
});
it('lehnt zu lange Namen und Adressen ab', async () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name: 'a'.repeat(101) })).toContain(
'name',
);
const longUrl = `https://example.com/${'a'.repeat(2048)}`;
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url: longUrl })).toContain('url');
});
});
describe('UpdateCustomModuleDto', () => {
it('akzeptiert Teilmengen', async () => {
expect(await errorsFor(UpdateCustomModuleDto, { name: 'Neu' })).toEqual([]);
expect(await errorsFor(UpdateCustomModuleDto, {})).toEqual([]);
});
it('prueft jedes gesetzte Feld gleich', async () => {
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
});
});
@@ -0,0 +1,66 @@
import { PartialType } from '@nestjs/mapped-types';
import { MODULE_CATEGORIES } from '@tessera/shared';
import { Transform } from 'class-transformer';
import {
IsIn,
IsNotEmpty,
IsString,
MaxLength,
Validate,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
/**
* Adresse eines eigenen Moduls (T-9WC-03, T-9WC-06): gueltig nur, wenn der
* URL-Parser sie annimmt, das Schema `https:` ist, ein Rechnername da ist und
* weder Benutzername noch Kennwort in der Adresse stehen — sonst saehe jeder
* Benutzer die Zugangsdaten. `javascript:`, `data:`, `http:` und `ftp:` fallen
* damit heraus.
*/
@ValidatorConstraint({ name: 'nurHttpsOhneZugangsdaten', async: false })
class NurHttpsOhneZugangsdatenConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (typeof value !== 'string') return false;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (
parsed.protocol === 'https:' &&
parsed.hostname !== '' &&
parsed.username === '' &&
parsed.password === ''
);
}
defaultMessage(): string {
return 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.';
}
}
const trimString = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** DTO fuer das Anlegen eines eigenen Moduls. */
export class CreateCustomModuleDto {
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(100)
name!: string;
@Transform(trimString)
@IsString()
@MaxLength(2048)
@Validate(NurHttpsOhneZugangsdatenConstraint)
url!: string;
@IsIn([...MODULE_CATEGORIES])
category!: (typeof MODULE_CATEGORIES)[number];
}
/** Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. */
export class UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto) {}
+17
View File
@@ -4034,6 +4034,22 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tokio",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.3"
@@ -4246,6 +4262,7 @@ dependencies = [
"tauri-plugin-autostart",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
+1
View File
@@ -16,6 +16,7 @@ tauri-plugin-store = "2"
tauri-plugin-notification = "2"
tauri-plugin-autostart = "2"
tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+65 -16
View File
@@ -694,8 +694,34 @@ fn get_client_info() -> String {
client_info_label(env!("CARGO_PKG_VERSION"), env!("APP_COMMIT"))
}
/// Ziel fuer den System-Browser, wenn die Seite ein neues Fenster verlangt
/// (`window.open`, Links mit `target="_blank"`). Nur http/https -- andere
/// Schemata (file:, javascript:, eigene Protokolle) werden verworfen.
fn external_target(url: &tauri::Url) -> Option<String> {
match url.scheme() {
"http" | "https" => Some(url.to_string()),
_ => None,
}
}
/// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D)
/// wird zuerst wiederhergestellt.
fn show_main_window(app: &AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}
pub fn run() {
let app = tauri::Builder::default()
// Muss als erstes Plugin stehen. Verhindert den Doppelstart (z. B. beim
// Windows-Login) mit zwei Tray-Symbolen: Ein zweiter Start holt nur das
// Fenster der laufenden Instanz nach vorne und beendet sich sofort.
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
show_main_window(app);
}))
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
@@ -734,6 +760,20 @@ pub fn run() {
.center()
.decorations(true)
.resizable(true)
// Neue Fenster (Suche-Widget per `window.open`, „In neuem Tab
// öffnen“ bei XFrame/eigenen Modulen/Favoriten) verwarf die
// Webansicht still -- Nutzer, 29.09.2026: Suche im Client ohne
// Funktion. Jetzt gehen sie wie im Browser-Tab nach aussen, in den
// System-Browser; die App selbst oeffnet kein zweites Fenster.
.on_new_window({
let handle = app.handle().clone();
move |url, _features| {
if let Some(target) = external_target(&url) {
let _ = handle.opener().open_url(target, None::<&str>);
}
tauri::webview::NewWindowResponse::Deny
}
})
.on_download(|webview, event| {
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
let _ = webview
@@ -819,21 +859,12 @@ pub fn run() {
.tooltip(tray_labels(server_url.as_deref()).0)
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id().as_ref() {
"open" => {
if let Some(w) = app.get_webview_window("main") {
// Minimiertes Fenster (z. B. per Win+D) zuerst wiederherstellen
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}
"open" => show_main_window(app),
"change_server" => {
if let Some(w) = app.get_webview_window("main") {
let _ = w.navigate(setup_page_url(cfg!(windows)));
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
show_main_window(app);
}
"update" => {
// `take()` verhindert Doppelklick-Downloads, der
@@ -889,11 +920,7 @@ pub fn run() {
..
} = event
{
if let Some(w) = tray.app_handle().get_webview_window("main") {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
show_main_window(tray.app_handle());
}
})
.build(app)?;
@@ -1223,6 +1250,28 @@ mod tests {
);
}
#[test]
fn external_target_nur_http_und_https() {
let https = tauri::Url::parse("https://www.google.com/search?q=tessera").unwrap();
assert_eq!(
external_target(&https).as_deref(),
Some("https://www.google.com/search?q=tessera")
);
let http = tauri::Url::parse("http://intranet.local/").unwrap();
assert_eq!(
external_target(&http).as_deref(),
Some("http://intranet.local/")
);
for bad in [
"file:///C:/Windows/system32/calc.exe",
"javascript:alert(1)",
"ms-settings:privacy",
] {
let url = tauri::Url::parse(bad).unwrap();
assert_eq!(external_target(&url), None, "{bad}");
}
}
#[test]
fn parse_server_url_gueltige_adresse() {
let parsed = parse_server_url("https://tessera.ctl.de").unwrap();
@@ -0,0 +1,161 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { MODULE_CATEGORIES } from '@tessera/shared';
import {
type CustomModule,
checkCustomModuleUrl,
createCustomModule,
updateCustomModule,
} from '@/lib/custom-modules-api';
import { useCategoryLabel } from '@/lib/use-category-label';
interface CustomModuleFormModalProps {
/** `null` = neues Modul anlegen, sonst bearbeiten. */
mod: CustomModule | null;
onClose: () => void;
onSaved: () => void;
}
const NAME_MAX = 100;
const URL_MAX = 2048;
/**
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`).
* Die Adresse wird VOR dem Senden geprueft (`checkCustomModuleUrl`, dieselbe
* Regel wie die API): nur https, keine Zugangsdaten. `noValidate`, damit die
* eigenen, uebersetzten Meldungen erscheinen statt der des Browsers.
*/
export function CustomModuleFormModal({ mod, onClose, onSaved }: CustomModuleFormModalProps) {
const t = useTranslations('admin.customModules');
const tCommon = useTranslations('common');
const categoryLabel = useCategoryLabel();
const [name, setName] = useState(mod?.name ?? '');
const [url, setUrl] = useState(mod?.url ?? '');
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const trimmedName = name.trim();
if (trimmedName === '') {
setError(t('nameRequired'));
return;
}
const trimmedUrl = url.trim();
const check = checkCustomModuleUrl(trimmedUrl);
if (check === 'notHttps') {
setError(t('urlNotHttps'));
return;
}
if (check === 'credentials') {
setError(t('urlCredentials'));
return;
}
setSaving(true);
try {
const input = { name: trimmedName, url: trimmedUrl, category };
if (mod) {
await updateCustomModule(mod.id, input);
} else {
await createCustomModule(input);
}
onSaved();
onClose();
} catch {
setError(t('saveError'));
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-labelledby="custom-module-form-title"
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="custom-module-form-title" className="mb-4 text-lg font-semibold text-foreground">
{mod ? t('edit') : t('create')}
</h2>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="space-y-2">
<label htmlFor="custom-module-name" className="text-sm font-medium text-foreground">
{t('name')}
</label>
<input
id="custom-module-name"
type="text"
required
maxLength={NAME_MAX}
value={name}
onChange={(e) => setName(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label htmlFor="custom-module-url" className="text-sm font-medium text-foreground">
{t('url')}
</label>
<input
id="custom-module-url"
type="url"
required
maxLength={URL_MAX}
placeholder="https://…"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
<p className="text-xs text-muted-foreground">{t('urlHint')}</p>
</div>
<div className="space-y-2">
<label htmlFor="custom-module-category" className="text-sm font-medium text-foreground">
{t('category')}
</label>
<select
id="custom-module-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{MODULE_CATEGORIES.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)}
</option>
))}
</select>
</div>
{error && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="btn btn-secondary">
{tCommon('cancel')}
</button>
<button type="submit" disabled={saving} className="btn btn-primary">
{saving ? tCommon('loading') : tCommon('save')}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
interface DeleteCustomModuleDialogProps {
mod: CustomModule;
onClose: () => void;
onDeleted: () => void;
}
/**
* Rueckfrage vor dem Loeschen eines eigenen Moduls (quick-260929-9wc,
* Vorbild `DeleteGroupDialog`). Bleibt bei einem Fehler offen und zeigt ihn —
* der Eintrag verschwindet fuer ALLE Benutzer aus der Seitenleiste, ein
* stilles Scheitern wuerde ein Loeschen vortaeuschen.
*/
export function DeleteCustomModuleDialog({ mod, onClose, onDeleted }: DeleteCustomModuleDialogProps) {
const t = useTranslations('admin.customModules');
const tCommon = useTranslations('common');
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDelete = async () => {
setDeleting(true);
setError(null);
try {
await deleteCustomModule(mod.id);
onDeleted();
onClose();
} catch {
setError(t('deleteError'));
} finally {
setDeleting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-custom-module-title"
className="w-full max-w-sm rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
{t('deleteConfirm.title')}
</h2>
<p className="mb-4 text-sm text-foreground">{t('deleteConfirm.body', { name: mod.name })}</p>
{error && (
<div
role="alert"
className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex justify-end gap-3">
<button type="button" onClick={onClose} className="btn btn-secondary">
{tCommon('cancel')}
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
>
{deleting ? tCommon('loading') : tCommon('delete')}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,303 @@
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Namensraumfaehiger next-intl-Mock: Punktschluessel werden gegen den
// Namensraum aufgeloest, {param} wird eingesetzt (Muster groups-page.test.tsx).
const messages: Record<string, Record<string, unknown>> = {
'admin.customModules': {
title: 'Eigene Module',
description: 'Erklärung',
create: 'Eigenes Modul anlegen',
edit: 'Eigenes Modul bearbeiten',
name: 'Name',
url: 'Adresse',
urlHint: 'Hinweis https',
category: 'Kategorie',
actions: 'Aktionen',
noModules: 'Noch keine eigenen Module',
noModulesBody: 'Legen Sie Ihr erstes Modul an.',
nameRequired: 'Bitte geben Sie einen Namen ein.',
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
saveError: 'Das Modul konnte nicht gespeichert werden.',
deleteError: 'Das Modul konnte nicht gelöscht werden.',
loadError: 'Laden fehlgeschlagen.',
deleteConfirm: {
title: 'Eigenes Modul löschen',
body: 'Möchten Sie „{name}“ wirklich löschen?',
},
},
common: {
loading: 'Laden...',
cancel: 'Abbrechen',
save: 'Speichern',
delete: 'Löschen',
edit: 'Bearbeiten',
accessDenied: 'Zugriff verweigert',
},
};
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
let val: unknown = messages[ns];
for (const part of key.split('.')) {
val = (val as Record<string, unknown> | undefined)?.[part];
}
if (typeof val !== 'string') return key;
return params
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
: val;
},
}));
vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
<a href={href} className={className}>{children}</a>
),
}));
vi.mock('@/lib/use-category-label', () => ({
useCategoryLabel: () => (slug: string) =>
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
}));
const {
mockList,
mockCreate,
mockUpdate,
mockDelete,
mockBump,
} = vi.hoisted(() => ({
mockList: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockBump: vi.fn(),
}));
vi.mock('@/lib/custom-modules-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
'@/lib/custom-modules-api',
);
return {
...actual,
listCustomModules: mockList,
createCustomModule: mockCreate,
updateCustomModule: mockUpdate,
deleteCustomModule: mockDelete,
};
});
vi.mock('@/lib/stores/marketplace-store', () => ({
useMarketplaceStore: (selector: (s: { bumpSidebarRefresh: () => void }) => unknown) =>
selector({ bumpSidebarRefresh: mockBump }),
}));
let mockRole = 'ADMIN';
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (s: { user: { role: string } }) => unknown) =>
selector({ user: { role: mockRole } }),
}));
import AdminCustomModulesPage from './page';
const wiki = {
id: 'cm-1',
name: 'Wiki',
url: 'https://wiki.example.com',
category: 'infrastructure',
createdAt: '2026-09-29T00:00:00.000Z',
updatedAt: '2026-09-29T00:00:00.000Z',
};
beforeEach(() => {
mockRole = 'ADMIN';
mockList.mockReset().mockResolvedValue([]);
mockCreate.mockReset().mockResolvedValue(wiki);
mockUpdate.mockReset().mockResolvedValue(wiki);
mockDelete.mockReset().mockResolvedValue(undefined);
mockBump.mockReset();
});
afterEach(() => {
cleanup();
});
describe('AdminCustomModulesPage', () => {
it('zeigt ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
render(<AdminCustomModulesPage />);
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
expect(screen.getByText('Legen Sie Ihr erstes Modul an.')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(0);
});
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
mockList.mockResolvedValue([wiki]);
render(<AdminCustomModulesPage />);
const link = await screen.findByRole('link', { name: 'Wiki' });
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
expect(screen.getByText('https://wiki.example.com')).toBeInTheDocument();
expect(screen.getByText('Infrastruktur')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
});
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.')).toBeInTheDocument();
expect(mockCreate).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled();
});
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(
await screen.findByText('Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.'),
).toBeInTheDocument();
expect(mockCreate).not.toHaveBeenCalled();
});
it('Anlegen: leerer Name zeigt die Meldung', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), ' ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Bitte geben Sie einen Namen ein.')).toBeInTheDocument();
expect(mockCreate).not.toHaveBeenCalled();
});
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.selectOptions(screen.getByLabelText('Kategorie'), 'security-tools');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith({
name: 'Beispielseite',
url: 'https://example.com',
category: 'security-tools',
});
});
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
// Anfangsladen + Neuladen nach dem Speichern
expect(mockList).toHaveBeenCalledTimes(2);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure');
});
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
mockList.mockResolvedValue([wiki]);
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
const dialog = screen.getByRole('dialog');
expect(within(dialog).getByLabelText('Name')).toHaveValue('Wiki');
expect(within(dialog).getByLabelText('Adresse')).toHaveValue('https://wiki.example.com');
expect(within(dialog).getByLabelText('Kategorie')).toHaveValue('infrastructure');
const nameInput = within(dialog).getByLabelText('Name');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Wiki neu');
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith('cm-1', {
name: 'Wiki neu',
url: 'https://wiki.example.com',
category: 'infrastructure',
});
});
expect(mockCreate).not.toHaveBeenCalled();
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
});
it('Serverfehler beim Speichern bleibt im Dialog sichtbar, der Dialog bleibt offen', async () => {
mockCreate.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine eigenen Module');
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled();
});
it('Löschen: Rückfrage nennt den Namen, Bestätigen ruft delete, lädt neu und aktualisiert die Seitenleiste', async () => {
mockList.mockResolvedValue([wiki]);
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
const dialog = screen.getByRole('dialog');
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen?')).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
expect(mockList).toHaveBeenCalledTimes(2);
});
it('Löschen: Abbrechen ruft nichts auf', async () => {
mockList.mockResolvedValue([wiki]);
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }));
expect(mockDelete).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('Löschfehler bleibt im Dialog sichtbar', async () => {
mockList.mockResolvedValue([wiki]);
mockDelete.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }));
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled();
});
it('Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text', async () => {
mockRole = 'USER';
render(<AdminCustomModulesPage />);
expect(await screen.findByText('Zugriff verweigert')).toBeInTheDocument();
expect(mockList).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,174 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCategoryLabel } from '@/lib/use-category-label';
import { CustomModuleFormModal } from './components/CustomModuleFormModal';
import { DeleteCustomModuleDialog } from './components/DeleteCustomModuleDialog';
/**
* Verwaltung „Eigene Module“ (quick-260929-9wc, D-07, Vorbild
* `admin/groups`). ADMIN und SUPER_ADMIN; die Rollenpruefung hier ist nur
* Anzeige — durchgesetzt wird sie serverseitig durch `@Roles` an POST, PATCH
* und DELETE von `/custom-modules`. Nach jedem erfolgreichen Anlegen,
* Aendern oder Loeschen zieht die Seitenleiste ueber `bumpSidebarRefresh`
* ohne Neuladen nach (D-05).
*/
export default function AdminCustomModulesPage() {
const t = useTranslations('admin.customModules');
const tCommon = useTranslations('common');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
const [modules, setModules] = useState<CustomModule[]>([]);
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [showFormModal, setShowFormModal] = useState(false);
const [editing, setEditing] = useState<CustomModule | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CustomModule | null>(null);
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const fetchModules = useCallback(async () => {
try {
setModules(await listCustomModules());
setLoadFailed(false);
} catch {
setLoadFailed(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (hasAccess) {
fetchModules();
} else {
setLoading(false);
}
}, [hasAccess, fetchModules]);
const handleChanged = () => {
fetchModules();
bumpSidebarRefresh();
};
const openCreate = () => {
setEditing(null);
setShowFormModal(true);
};
const openEdit = (mod: CustomModule) => {
setEditing(mod);
setShowFormModal(true);
};
if (!hasAccess) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
<p className="text-sm text-muted-foreground">{t('description')}</p>
{loadFailed && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{t('loadError')}
</div>
)}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">{t('noModules')}</h2>
<p className="mb-6 text-sm text-muted-foreground">{t('noModulesBody')}</p>
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">{t('actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{modules.map((mod) => (
<tr key={mod.id} className="transition-colors hover:bg-muted/30">
<td className="px-4 py-3 font-medium text-foreground">
<Link href={`/modules/custom/${mod.id}`} className="link">
{mod.name}
</Link>
</td>
<td className="max-w-[24rem] px-4 py-3 text-muted-foreground">
<span className="block truncate" title={mod.url}>
{mod.url}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{categoryLabel(mod.category)}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openEdit(mod)}
className="rounded px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => setDeleteTarget(mod)}
className="rounded px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
>
{tCommon('delete')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showFormModal && (
<CustomModuleFormModal
mod={editing}
onClose={() => setShowFormModal(false)}
onSaved={handleChanged}
/>
)}
{deleteTarget && (
<DeleteCustomModuleDialog
mod={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDeleted={handleChanged}
/>
)}
</div>
);
}
@@ -0,0 +1,16 @@
import { CustomModuleView } from '@/components/modules/custom-module-view';
interface CustomModulePageProps {
params: Promise<{ id: string }>;
}
/**
* Eigenes Modul (quick-260929-9wc): externe https-Seite im Rahmen.
* Bewusst OHNE ModuleAccessGate — eigene Module sind fuer alle angemeldeten
* Benutzer sichtbar (D-01); der statische Ordner `custom` hat im App Router
* Vorrang vor `[category]/[moduleSlug]`.
*/
export default async function CustomModulePage({ params }: CustomModulePageProps) {
const { id } = await params;
return <CustomModuleView id={id} />;
}
@@ -21,6 +21,15 @@ import TenderRadarLayout from './tender-radar/layout';
const modulesDir = dirname(fileURLToPath(import.meta.url));
/**
* Ordner ohne ModuleAccessGate, bewusst (quick-260929-9wc): `custom/` traegt
* die Rahmen-Seiten der „Eigenen Module“. Das sind keine Zeilen der Tabelle
* `Module` und haengen an keiner Aktivierung oder Freigabe — sie sind fuer
* ALLE angemeldeten Benutzer sichtbar (D-01). Jeder weitere Eintrag hier ist
* eine bewusste Ausnahme von T-e8k-04 und gehoert begruendet.
*/
const DIRS_WITHOUT_GATE: readonly string[] = ['custom'];
const placeholderChild = <div data-testid="placeholder-child">child</div>;
describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)', () => {
@@ -42,6 +51,7 @@ describe('module directory coverage — every module has a layout (T-e8k-04)', (
const entries = readdirSync(modulesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) => !entry.name.startsWith('['))
.filter((entry) => !DIRS_WITHOUT_GATE.includes(entry.name))
.map((entry) => entry.name);
expect(entries.length).toBeGreaterThan(0);
+18 -12
View File
@@ -460,18 +460,24 @@ input[type="checkbox"], input[type="radio"] {
color: var(--primary-foreground);
}
/* Karte hebt sich beim Zeigen leicht an (nur Maus, nur Ansicht). */
@media (hover: hover) {
.widget-card {
transition: box-shadow 180ms ease-out, translate 180ms ease-out;
}
.widget-card:not(.widget-drag-handle):hover {
translate: 0 -4px;
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 14px 30px oklch(0 0 0 / 0.18);
}
.dark .widget-card:not(.widget-drag-handle):hover {
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.12), 0 16px 34px oklch(0 0 0 / 0.6);
}
/* Titel ausgeblendet (quick-260928-wtt, `hideTitle` in der Widget-
Konfiguration, nur in der Ansicht): die Titelzeile entfaellt. Traegt sie
Aktionen (Notiz-Stift, XFrame „In neuem Tab“), bleiben nur diese als
kleine Schwebeleiste oben rechts. */
[data-hide-title] .widget-head {
display: none;
}
[data-hide-title] .widget-head:has(.widget-action) {
display: flex;
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 5;
min-height: 0;
padding: 0;
}
[data-hide-title] .widget-head:has(.widget-action) > :not(.widget-action) {
display: none;
}
/* Beim Laden blenden die Kacheln gestaffelt ein (je 80 ms, --enter-delay). */
@@ -48,6 +48,18 @@ export function AdminSidebar() {
</svg>
),
},
{
label: t('admin.customModules'),
href: '/admin/custom-modules',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
),
},
{
label: t('admin.ldap'),
href: '/admin/ldap',
@@ -194,7 +194,7 @@ describe('DashboardGrid', () => {
expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument();
});
it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => {
it('quick-260916-bwo Test 4: Grid-Props — 48/40/24/16/4 Spalten (quick-260929-dmx), rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
render(
@@ -208,7 +208,7 @@ describe('DashboardGrid', () => {
);
expect(captured.props).not.toBeNull();
expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 });
expect(captured.props?.cols).toEqual({ lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 });
expect(captured.props?.rowHeight).toBe(20);
expect(captured.props?.margin).toEqual([12, 12]);
expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 });
@@ -233,8 +233,9 @@ describe('DashboardGrid', () => {
const first = children[0];
expect(isValidElement(first)).toBe(true);
const props = (first as React.ReactElement<Record<string, unknown>>).props;
// quick-260916-dyv: Uhr-Minimum 2x2 (vorher verdoppelt 4x4), Vorgabe 4x4 unveraendert.
expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 });
// quick-260916-dyv: Uhr-Minimum 2 Zeilen hoch; quick-260929-dmx: Breiten im
// 48er-Raster verdoppelt (Minimum 4, Vorgabe 8), Hoehen unveraendert.
expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 8, h: 4, minW: 4, minH: 2 });
});
it('quick-260916-dyv Test 9: gespeicherte minW/minH werden in JEDEM Breakpoint aus WIDGET_CONSTRAINTS ueberschrieben, x/y/w/h bleiben, unbekannte Typen und Schluessel unveraendert', async () => {
@@ -265,9 +266,9 @@ describe('DashboardGrid', () => {
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(Object.keys(passed)).toEqual(Object.keys(layouts));
expect(passed.lg[0]).toEqual({ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 2, minH: 2 });
expect(passed.md[0]).toEqual({ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 });
expect(passed.md[0].minW).toBe(2);
expect(passed.lg[0]).toEqual({ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 4, minH: 2 });
expect(passed.md[0]).toEqual({ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 2 });
expect(passed.md[0].minW).toBe(4);
// Unbekannter Typ: Eintrag bleibt woertlich erhalten (kein Absturz, T-DYV-01).
expect(passed.lg[1]).toEqual({ i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 });
expect(passed.sm).toEqual([]);
@@ -278,9 +279,9 @@ describe('DashboardGrid', () => {
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
lg: [
// Rechner: gespeichert mit h 8 (bwo-Minimum), neues Minimum 3x9 -> h wird 9, w 6 bleibt.
// Rechner: gespeichert mit h 8 (bwo-Minimum), neues Minimum 6x10 -> h wird 10, w 6 bleibt.
{ i: 'calc-1', x: 0, y: 0, w: 6, h: 8, minW: 4, minH: 8 },
// Suche: gespeichert mit w 4 (unter minW 6) -> w wird 6, h 4 bleibt.
// Suche: gespeichert mit w 4 (unter minW 12) -> w wird 12, h 4 bleibt.
{ i: 'srch-1', x: 6, y: 0, w: 4, h: 4, minW: 6, minH: 4 },
],
md: [{ i: 'calc-1', x: 0, y: 0, w: 3, h: 7, minW: 4, minH: 8 }],
@@ -302,15 +303,53 @@ describe('DashboardGrid', () => {
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.lg[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 10, minW: 3, minH: 10 });
expect(passed.lg[1]).toEqual({ i: 'srch-1', x: 6, y: 0, w: 6, h: 4, minW: 6, minH: 2 });
// Jeder Breakpoint: md-Rechner 3x7 -> 3x10 (w schon = minW, h angehoben).
expect(passed.md[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 3, h: 10, minW: 3, minH: 10 });
expect(passed.lg[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 10, minW: 6, minH: 10 });
expect(passed.lg[1]).toEqual({ i: 'srch-1', x: 6, y: 0, w: 12, h: 4, minW: 12, minH: 2 });
// Jeder Breakpoint: md-Rechner 3x7 -> 6x10 (w und h auf das Minimum angehoben).
expect(passed.md[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 10, minW: 6, minH: 10 });
// Eingabe unveraendert (keine Mutation des Store-Objekts).
expect(layouts.lg[0].h).toBe(8);
expect(layouts.lg[0].minH).toBe(8);
});
it('quick-260929-dmx Test 9c: bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 8 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 8 angehoben', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
// So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus.
const layouts = {
lg: [
{ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 12, minH: 8 },
{ i: 'cal-2', x: 24, y: 0, w: 6, h: 16, minW: 12, minH: 8 },
],
md: [{ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 12, minH: 8 }],
sm: [],
xs: [],
xxs: [],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'cal-1', widgetType: 'calendar', config: {} },
{ id: 'cal-2', widgetType: 'calendar', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
// Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt.
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 8, h: 16, minW: 8, minH: 8 });
// Auch der data-grid-Wert der Kinder traegt das neue Minimum.
const children = Children.toArray(captured.props?.children as React.ReactNode);
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props['data-grid'] as Record<string, unknown>;
expect(grid.minW).toBe(8);
});
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
@@ -503,9 +542,9 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
describe('centeringOffset (Design „Mosaik“, Runde 3)', () => {
it('stellt belegte Spalten mittig und laesst eine volle Anordnung stehen', () => {
// 24 Spalten auf 1212 px: Spalte 38 px + 12 px Abstand = 50 px je Spalte.
expect(centeringOffset([{ x: 0, w: 12 }], 24, 1212)).toBe(300);
expect(centeringOffset([{ x: 12, w: 12 }], 24, 1212)).toBe(0);
expect(centeringOffset([], 24, 1212)).toBe(0);
// 48 Spalten auf 1212 px: Spalte 13 px + 12 px Abstand = 25 px je Spalte.
expect(centeringOffset([{ x: 0, w: 24 }], 48, 1212)).toBe(300);
expect(centeringOffset([{ x: 24, w: 24 }], 48, 1212)).toBe(0);
expect(centeringOffset([], 48, 1212)).toBe(0);
});
});
@@ -14,9 +14,16 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
// quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten,
// 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen
// in alten Einheiten werden in `@/lib/grid-layout-migration` einmalig
// umgerechnet und mit `__gridVersion: 2` markiert. BREAKPOINTS unveraendert.
// umgerechnet und mit `__gridVersion` markiert. BREAKPOINTS unveraendert.
//
// quick-260929-dmx (Version 3): Raster in der BREITE noch einmal doppelt so
// fein (48 statt 24 Spalten am lg-Breakpoint, uebrige Breakpoints ebenso
// verdoppelt). Zeilenhoehe (20 px) und Abstand (12 px) bleiben. Alle
// Breitenwerte (x, w, minW, maxW, WIDGET_CONSTRAINTS, Rueckfallwerte) sind
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
// quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
@@ -379,10 +386,10 @@ export function DashboardGrid({
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
x: 0,
y: 0,
w: constraints?.defaultW ?? 4,
w: constraints?.defaultW ?? 8,
h: constraints?.defaultH ?? 4,
}),
minW: constraints?.minW ?? 4,
minW: constraints?.minW ?? 8,
minH: constraints?.minH ?? 4,
}}
>
@@ -67,21 +67,21 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
});
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu, zehn Typen)', () => {
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
// 3 und 9 sind ungerade — die bwo-Pruefung "jeder Wert ist gerade" entfaellt.
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8
// (rund 250 px, gemessene kleinste benutzbare Breite; vorher 6 von 24).
expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 },
clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
stopwatch: { minW: 8, minH: 3, defaultW: 12, defaultH: 6 },
'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 },
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
});
let counted = 0;
@@ -40,45 +40,55 @@ export const WIDGET_CONSTRAINTS: Record<
{ minW: number; minH: number; defaultW: number; defaultH: number }
> = {
// quick-260916-dyv: minW/minH = kleinste noch bedienbare Kachel je Typ im
// 24-Spalten/20-px-Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 +
// Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 +
// Eingabe + Knopf; Rechner: Anzeige 40 + Speicherzeile 28 + 5 Tastenreihen 28 =
// 268 px -> 10 Zeilen; Stoppuhr: kompakte Bedienleiste). defaultW/defaultH =
// altes 12-Spalten-Mass x2, unveraendert. Gespeicherte minW/minH werden in
// dashboard-grid.tsx aus dieser Tabelle ueberschrieben.
// quick-260929-dmx: Raster in der Breite doppelt so fein (48 Spalten am
// lg-Breakpoint). Alle Breitenwerte (minW, defaultW) sind gegenueber dem
// 24-Spalten-Raster verdoppelt, die Kacheln behalten ihre Bildschirmbreite;
// Hoehen (minH, defaultH) unveraendert. Einzige Ausnahme: Kalender-minW.
// Existing widgets
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin.
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar — minW 8 von
// 48 Spalten = rund 250 px am lg-Breakpoint (gemessen: Monatsraster,
// Ueberschrift und Terminliste sind dort noch benutzbar; bei rund 185 px
// wird die Ueberschrift abgeschnitten). defaultW 16 = unveraenderte Breite.
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
// Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
// SECHS Tastenreihen (%/CE/C/Loeschen, 1/x/x2/Wurzel/Division, 7-9, 4-6, 1-3, 0/Komma/=),
// Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px.
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
// Favoriten: minW 1 statt 3 (Nutzerwunsch 23.09.) — bei kurzen Linknamen
// blieb rechts viel Leerraum. Der Titel kuerzt mit Auslassungszeichen,
// in der schmalsten Stufe bleibt das Symbol als Leiste stehen.
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
// quick-260921-pi9: ein Bild braucht Flaeche — 8x12 entspricht der
// Kalender-Vorgabe (rund 530x330 px, Querformat wie ein Foto); 8x8 war im
// Browser-Rundgang mit 216 px Hoehe zu flach. 4x4 ist die kleinste Kachel,
// in der Bild und Unterschrift-Streifen noch erkennbar sind.
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
// quick-260921-qd3: eine Webseite braucht Breite UND Hoehe — 12x12 = halbe
// Rasterbreite bei rund 330 px Hoehe (8 Zeilen waeren nur 216 px, zu flach
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein
// Rahmen noch Sinn hat.
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
// Favoriten: minW 1 (im 24er-Raster, jetzt 2 von 48) statt 3 (Nutzerwunsch
// 23.09.) — bei kurzen Linknamen blieb rechts viel Leerraum. Der Titel
// kuerzt mit Auslassungszeichen, in der schmalsten Stufe bleibt das Symbol
// als Leiste stehen.
favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
stopwatch: { minW: 8, minH: 3, defaultW: 12, defaultH: 6 },
// quick-260921-pi9: ein Bild braucht Flaeche — 16x12 (im 48er-Raster)
// entspricht der Kalender-Vorgabe (rund 530x330 px, Querformat wie ein
// Foto); Hoehe 8 war im Browser-Rundgang mit 216 px zu flach. 8x4 ist die
// kleinste Kachel, in der Bild und Unterschrift-Streifen noch erkennbar sind.
'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 },
// quick-260921-qd3: eine Webseite braucht Breite UND Hoehe — 24x12 im
// 48er-Raster = halbe Rasterbreite bei rund 330 px Hoehe (8 Zeilen waeren
// nur 216 px, zu flach fuer eine Seite; vgl. Bilderrahmen). 8x4 kleinste
// Kachel, in der ein Rahmen noch Sinn hat.
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
// quick-260924-i8v: 4 Zeilen = 104 px reichen genau fuer Balken und
// Zusammenfassung (die Serverliste blendet sich darunter per Container-
// Query aus); 8x8 = rund 456x216 px bei 1400 px Breite zeigt etwa sechs
// Serverzeilen; 3 Spalten = rund 166 px zeigen nur Punkte und Namen.
proxmox: { minW: 3, minH: 4, defaultW: 8, defaultH: 8 },
// Query aus); 16x8 (im 48er-Raster) = rund 456x216 px bei 1400 px Breite
// zeigt etwa sechs Serverzeilen; 6 Spalten = rund 166 px zeigen nur Punkte
// und Namen.
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
};
/**
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
@@ -14,6 +14,12 @@ vi.mock('next-intl', () => ({
: key,
}));
const setWidgetConfig = vi.fn();
vi.mock('@/lib/stores/dashboard-store', () => ({
useDashboardStore: (selector: (state: { setWidgetConfig: typeof setWidgetConfig }) => unknown) =>
selector({ setWidgetConfig }),
}));
import { WidgetWrapper } from './widget-wrapper';
describe('WidgetWrapper', () => {
@@ -54,4 +60,53 @@ describe('WidgetWrapper', () => {
),
).toBeNull();
});
// quick-260928-wtt: Titel je Widget ein-/ausblenden
it('Bearbeitungsmodus: Schalter blendet den Titel aus und speichert hideTitle', () => {
setWidgetConfig.mockClear();
render(
<WidgetWrapper
widget={{ id: 'w-uhr', widgetType: 'clock', config: {} }}
isEditMode={true}
onRemove={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' }));
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
// im Bearbeitungsmodus bleibt der Titel sichtbar
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
});
it('ausgeblendeter Titel: Schalter blendet wieder ein, Ansicht markiert die Karte', () => {
setWidgetConfig.mockClear();
const { rerender } = render(
<WidgetWrapper
widget={{ id: 'w-uhr', widgetType: 'clock', config: { hideTitle: true } }}
isEditMode={true}
onRemove={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'showTitle' }));
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
rerender(
<WidgetWrapper
widget={{ id: 'w-uhr', widgetType: 'clock', config: { hideTitle: true } }}
isEditMode={false}
onRemove={vi.fn()}
/>,
);
expect(screen.getByRole('article')).toHaveAttribute('data-hide-title');
});
it('Widgets ohne Titelzeile (Suche) bekommen keinen Schalter', () => {
render(
<WidgetWrapper
widget={{ id: 'w-suche', widgetType: 'search', config: {} }}
isEditMode={true}
onRemove={vi.fn()}
/>,
);
expect(screen.queryByRole('button', { name: 'hideTitle' })).toBeNull();
});
});
@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl';
import type { CSSProperties } from 'react';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
import { WidgetIcon } from './widget-icon';
@@ -14,6 +15,14 @@ import { WidgetIcon } from './widget-icon';
*/
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
/**
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den
* Schalter „Titel ausblenden“ (quick-260928-wtt). Die Wahl steht als
* `hideTitle: true` in der Widget-Konfiguration und gilt nur in der Ansicht;
* im Bearbeitungsmodus bleibt der Titel sichtbar (und editierbar).
*/
const TITLE_TYPES = new Set([...FRAME_HEADER_TYPES, 'note', 'favorites', 'proxmox', 'xframe']);
interface WidgetWrapperProps {
widget: {
id: string;
@@ -42,6 +51,9 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component;
const setWidgetConfig = useDashboardStore((state) => state.setWidgetConfig);
const hasTitle = TITLE_TYPES.has(widget.widgetType);
const titleHidden = widget.config?.hideTitle === true;
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
@@ -54,6 +66,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
className={cardClassName}
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 80}ms` } as CSSProperties}
data-widget-id={widget.id}
data-hide-title={!isEditMode && hasTitle && titleHidden ? '' : undefined}
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
>
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
@@ -81,6 +94,39 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
<circle cx="12" cy="15" r="1.5" />
<circle cx="19" cy="15" r="1.5" />
</svg>
{hasTitle && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
}}
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
aria-label={titleHidden ? t('showTitle') : t('hideTitle')}
title={titleHidden ? t('showTitle') : t('hideTitle')}
aria-pressed={titleHidden}
data-no-drag=""
>
{/* „T“ mit Querstrich, solange der Titel ausgeblendet ist */}
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="4" x2="12" y2="20" />
{titleHidden && <line x1="3" y1="3" x2="21" y2="21" />}
</svg>
</button>
)}
<button
type="button"
onClick={(e) => {
@@ -74,6 +74,20 @@ vi.mock('@/components/layout/app-version-badge', () => ({
AppVersionBadge: () => <div data-testid="app-version-badge" />,
}));
// Eigene Module (quick-260929-9wc): eigener Client, Standard = leere Liste,
// damit die Abruf-Zaehler-Tests auf `fetch` unveraendert gelten.
let mockCustomModules: {
id: string;
name: string;
url: string;
category: string;
createdAt: string;
updatedAt: string;
}[] = [];
vi.mock('@/lib/custom-modules-api', () => ({
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
}));
const mockActiveModules = [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
@@ -93,8 +107,21 @@ afterEach(() => {
vi.restoreAllMocks();
mockPathname = '/';
mockRefreshKey = 0;
mockCustomModules = [];
});
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
return {
id: 'cm-1',
name: 'Wiki',
url: 'https://wiki.example.com',
category: 'infrastructure',
createdAt: '2026-09-29T00:00:00.000Z',
updatedAt: '2026-09-29T00:00:00.000Z',
...overrides,
};
}
async function importSidebar() {
const mod = await import('./sidebar');
return mod.Sidebar;
@@ -243,6 +270,85 @@ describe('Sidebar', () => {
});
});
it('zeigt ein eigenes Modul unter seiner Kategorie als Link auf /modules/custom/<id>', async () => {
mockCustomModules = [customModule()];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
const link = screen.getByText('Wiki').closest('a');
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
expect(link?.querySelector('[data-module-tile="custom"]')).not.toBeNull();
// Kategorie-Kopf "infrastructure" (Test-Attrappe ohne Uebersetzung -> Kennung)
const header = screen.getByText('infrastructure').closest('li');
expect(header?.contains(link)).toBe(true);
});
it('zeigt eine Kategorie, die nur eigene Module hat', async () => {
mockCustomModules = [customModule({ category: 'fleet' })];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('fleet')).toBeInTheDocument();
});
expect(screen.getByText('Wiki')).toBeInTheDocument();
// Die eingebauten Kategorien bleiben daneben stehen.
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
});
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Aaa Eigenes')).toBeInTheDocument();
});
const items = screen
.getByText('Domain-Tools')
.closest('li')
?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
});
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
mockCustomModules = [customModule(), customModule({ id: 'cm-2', name: 'Anderes' })];
mockPathname = '/modules/custom/cm-1';
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
expect(
screen.getByText('Wiki').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).not.toBeNull();
expect(
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull();
expect(
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull();
});
it('die Suche findet ein eigenes Modul', async () => {
mockCustomModules = [customModule()];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
await userEvent.type(screen.getByPlaceholderText('Module suchen...'), 'wik');
await waitFor(() => {
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
});
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
it('renders the version badge below the navigation', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
+106 -29
View File
@@ -13,6 +13,7 @@ import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store';
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() {
@@ -34,11 +35,50 @@ interface SidebarModule {
category: string;
}
/**
* Ein Eintrag der Seitenleiste: eingebautes Modul ODER eigenes Modul
* (quick-260929-9wc). Beide laufen durch dieselbe Gruppierung, Suche und
* Kachelliste; sie unterscheiden sich nur in Ziel und Kachelsymbol.
*/
interface SidebarEntry {
key: string;
name: string;
category: string;
href: string;
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
tileSlug: string;
/** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */
extraActiveHref?: string;
}
function moduleEntry(mod: SidebarModule): SidebarEntry {
const category = mod.category || 'other';
return {
key: mod.id,
name: mod.name,
category,
href: `/modules/${category}/${mod.slug}`,
tileSlug: mod.slug,
extraActiveHref: `/modules/${mod.slug}`,
};
}
function customEntry(mod: CustomModule): SidebarEntry {
return {
key: `custom-${mod.id}`,
name: mod.name,
category: mod.category || 'other',
href: `/modules/custom/${mod.id}`,
tileSlug: 'custom',
};
}
export function Sidebar() {
const t = useTranslations('sidebar');
const tCommon = useTranslations('common');
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
const [customModules, setCustomModules] = useState<CustomModule[]>([]);
const [searchQuery, setSearchQuery] = useState('');
// Kategorien sind anfangs aufgeklappt (Design „Mosaik“): die Modul-Kacheln
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
@@ -67,20 +107,60 @@ export function Sidebar() {
const linkClass = (href: string) => itemClass(isActive(href));
const isEntryActive = (entry: SidebarEntry) =>
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
// die 20-px-Modulkachel passt weiter hinein.
const moduleItemClass = (active: boolean) =>
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
const fetchActiveModules = useCallback(async () => {
const loadBuiltIn = async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (res.ok) {
const modules: SidebarModule[] = await res.json();
setActiveModules(modules);
setNavModules(modules);
setActiveModules(await res.json());
}
} catch {
// silently fail
}
}, [setNavModules]);
};
// Eigene Module (quick-260929-9wc) im selben Auffrisch-Takt; ein Fehler
// laesst die Leiste wie beim Modulabruf still beim bisherigen Stand.
const loadCustom = async () => {
try {
setCustomModules(await listCustomModules());
} catch {
// silently fail
}
};
await Promise.all([loadBuiltIn(), loadCustom()]);
}, []);
// Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
// Pfadsegment /modules/custom/<id> ohne Aenderung findet.
useEffect(() => {
setNavModules([
...activeModules,
...customModules.map((m) => ({
id: m.id,
slug: m.id,
name: m.name,
category: m.category,
})),
]);
}, [activeModules, customModules, setNavModules]);
// Eingebaute Module stehen innerhalb einer Kategorie vor eigenen.
const entries = useMemo<SidebarEntry[]>(
() => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)],
[activeModules, customModules],
);
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
@@ -101,17 +181,16 @@ export function Sidebar() {
};
const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarModule[]>();
for (const mod of activeModules) {
const cat = mod.category || 'other';
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat)!.push(mod);
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
if (!categories.has(entry.category)) categories.set(entry.category, []);
categories.get(entry.category)!.push(entry);
}
if (!searchQuery) return Array.from(categories.entries());
const q = searchQuery.toLowerCase();
const result: [string, SidebarModule[]][] = [];
const result: [string, SidebarEntry[]][] = [];
for (const [cat, mods] of categories.entries()) {
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
result.push([cat, mods]);
@@ -121,7 +200,7 @@ export function Sidebar() {
}
}
return result;
}, [activeModules, searchQuery, categoryLabel]);
}, [entries, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
@@ -162,16 +241,15 @@ export function Sidebar() {
</ul>
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
{isCollapsed && activeModules.length > 0 && (
{isCollapsed && entries.length > 0 && (
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3">
{activeModules.map((mod) => {
const href = `/modules/${mod.category || 'other'}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
{entries.map((entry) => {
const active = isEntryActive(entry);
return (
<li key={mod.id}>
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
<li key={entry.key}>
<Link href={entry.href} className={itemClass(active)} aria-label={entry.name} title={entry.name}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
</Link>
</li>
);
@@ -186,7 +264,7 @@ export function Sidebar() {
</div>
<div className="mt-1" aria-label={t('categories.label')} role="group">
{activeModules.length === 0 ? (
{entries.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
@@ -196,7 +274,7 @@ export function Sidebar() {
</div>
) : (
<ul className="flex flex-col gap-2">
{filteredCategories.map(([category, mods]) => {
{filteredCategories.map(([category, categoryEntries]) => {
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
const open = searchQuery !== '' || !closedCategories.has(category);
return (
@@ -205,24 +283,23 @@ export function Sidebar() {
type="button"
onClick={() => toggleCategory(category)}
aria-expanded={open}
className="group flex h-7 w-full items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="truncate">{categoryLabel(category)}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{open && (
<ul className="mt-0.5 flex flex-col gap-0.5">
{mods.map((mod) => {
const href = `/modules/${category}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
{categoryEntries.map((entry) => {
const active = isEntryActive(entry);
return (
<li key={mod.id}>
<Link href={href} className={itemClass(active)}>
<li key={entry.key}>
<Link href={entry.href} className={moduleItemClass(active)}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
<span className="truncate">{mod.name}</span>
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
<span className="truncate">{entry.name}</span>
</Link>
</li>
);
@@ -0,0 +1,96 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const map: Record<string, string> = {
openInNewTab: 'In neuem Tab öffnen',
embedHint: 'Manche Seiten lassen sich nicht einbetten.',
notFound: 'Dieses Modul gibt es nicht mehr.',
invalidUrl: 'Ungültige Adresse',
loading: 'Wird geladen',
};
return map[key] ?? key;
},
}));
const { mockGetCustomModule } = vi.hoisted(() => ({ mockGetCustomModule: vi.fn() }));
vi.mock('@/lib/custom-modules-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
'@/lib/custom-modules-api',
);
return { ...actual, getCustomModule: mockGetCustomModule };
});
import { CustomModuleView } from './custom-module-view';
function mod(url: string) {
return {
id: 'abc',
name: 'Wiki',
url,
category: 'infrastructure',
createdAt: '2026-09-29T00:00:00.000Z',
updatedAt: '2026-09-29T00:00:00.000Z',
};
}
beforeEach(() => {
mockGetCustomModule.mockReset();
});
afterEach(() => {
cleanup();
});
describe('CustomModuleView', () => {
it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />);
const frame = await screen.findByTitle('Wiki');
expect(frame.tagName).toBe('IFRAME');
expect(frame).toHaveAttribute('src', 'https://example.com/wiki');
expect(frame.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
expect(frame.getAttribute('sandbox')).not.toContain('top-navigation');
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer');
expect(frame.getAttribute('allow')).toBe('');
expect(mockGetCustomModule).toHaveBeenCalledWith('abc');
});
it('zeigt Namen und immer sichtbar den Link „In neuem Tab öffnen“', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />);
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
expect(link).toHaveAttribute('href', 'https://example.com/wiki');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(screen.getByRole('heading', { name: 'Wiki' })).toBeInTheDocument();
});
it.each([
'http://example.com',
'javascript:alert(1)',
'data:text/html,x',
'https://user:pw@example.com',
])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => {
mockGetCustomModule.mockResolvedValue(mod(url));
const { container } = render(<CustomModuleView id="abc" />);
await screen.findByText('Ungültige Adresse');
expect(container.querySelector('iframe')).toBeNull();
expect(container.querySelector('a')).toBeNull();
});
it('zeigt bei 404 den Nicht-gefunden-Text', async () => {
mockGetCustomModule.mockResolvedValue(null);
const { container } = render(<CustomModuleView id="weg" />);
await waitFor(() => {
expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
});
expect(container.querySelector('iframe')).toBeNull();
});
});
@@ -0,0 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule };
/**
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): schmale
* Leiste mit Name, Hinweis und dem immer sichtbaren Knopf „In neuem Tab
* oeffnen“, darunter der flaechenfuellende Rahmen. Sandbox-Wert und
* Attribute wie im XFrame-Widget (`XFRAME_SANDBOX`, kein `allow`, kein
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
*/
export function CustomModuleView({ id }: { id: string }) {
const t = useTranslations('customModules');
const [state, setState] = useState<LoadState>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
setState({ status: 'loading' });
getCustomModule(id)
.then((mod) => {
if (cancelled) return;
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
})
.catch(() => {
if (!cancelled) setState({ status: 'missing' });
});
return () => {
cancelled = true;
};
}, [id]);
if (state.status === 'loading') {
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
}
if (state.status === 'missing') {
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
}
const { mod } = state;
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
return (
<div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-base font-semibold text-foreground">{mod.name}</h1>
{urlOk && <p className="truncate text-xs text-muted-foreground">{t('embedHint')}</p>}
</div>
{urlOk && (
<a
href={mod.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary shrink-0"
>
{t('openInNewTab')}
</a>
)}
</div>
{urlOk ? (
<iframe
src={mod.url}
title={mod.name}
sandbox={XFRAME_SANDBOX}
allow=""
referrerPolicy="no-referrer"
className="w-full flex-1 rounded-lg border-0 bg-background"
data-testid="custom-module-frame"
/>
) : (
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
{t('invalidUrl')}
</div>
)}
</div>
);
}
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CustomModuleRequestError,
checkCustomModuleUrl,
createCustomModule,
deleteCustomModule,
getCustomModule,
listCustomModules,
updateCustomModule,
} from './custom-modules-api';
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
beforeEach(() => {
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('checkCustomModuleUrl', () => {
it('nimmt eine https-Adresse an', () => {
expect(checkCustomModuleUrl('https://a.de')).toBe('ok');
});
it('lehnt http und Unparsbares als notHttps ab', () => {
expect(checkCustomModuleUrl('http://a.de')).toBe('notHttps');
expect(checkCustomModuleUrl('kaputt')).toBe('notHttps');
expect(checkCustomModuleUrl('javascript:alert(1)')).toBe('notHttps');
expect(checkCustomModuleUrl('')).toBe('notHttps');
});
it('erkennt Zugangsdaten in der Adresse', () => {
expect(checkCustomModuleUrl('https://u:p@a.de')).toBe('credentials');
expect(checkCustomModuleUrl('https://u@a.de')).toBe('credentials');
});
});
describe('custom-modules-api', () => {
it('listCustomModules ruft GET /custom-modules mit credentials include', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify([{ id: 'a' }]), { status: 200 }));
const list = await listCustomModules();
expect(list).toEqual([{ id: 'a' }]);
const [url, init] = mockFetch.mock.calls[0];
expect(String(url)).toMatch(/\/custom-modules$/);
expect(init.credentials).toBe('include');
});
it('getCustomModule liefert null bei 404', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 404 }));
await expect(getCustomModule('x')).resolves.toBeNull();
});
it('getCustomModule liefert die Zeile bei 200', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ id: 'x' }), { status: 200 }));
await expect(getCustomModule('x')).resolves.toEqual({ id: 'x' });
});
it('createCustomModule schickt POST mit JSON und wirft bei Fehler mit Servermeldung', async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ message: ['Nur https'] }), { status: 400 }),
);
const input = { name: 'a', url: 'http://a.de', category: 'fleet' };
const err = await createCustomModule(input).catch((e) => e);
expect(err).toBeInstanceOf(CustomModuleRequestError);
expect(err.status).toBe(400);
expect(err.message).toBe('Nur https');
const [, init] = mockFetch.mock.calls[0];
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual(input);
});
it('updateCustomModule schickt PATCH, deleteCustomModule DELETE', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
await updateCustomModule('x', { name: 'n' });
expect(mockFetch.mock.calls[0][1].method).toBe('PATCH');
expect(String(mockFetch.mock.calls[0][0])).toMatch(/\/custom-modules\/x$/);
await deleteCustomModule('x');
expect(mockFetch.mock.calls[1][1].method).toBe('DELETE');
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Eigene Module — API-Client (quick-260929-9wc). Konsumiert `/custom-modules`.
* Muster `favorites-api.ts`/`proxmox-api.ts`: `credentials: 'include'` fuer
* Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
*/
import { isHttpsUrl } from '@/components/dashboard/widgets/xframe-config';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export interface CustomModule {
id: string;
name: string;
url: string;
category: string;
createdAt: string;
updatedAt: string;
}
export interface CustomModuleInput {
name: string;
url: string;
category: string;
}
/** Fehler mit HTTP-Status und Servermeldung (falls vorhanden). */
export class CustomModuleRequestError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
this.name = 'CustomModuleRequestError';
}
}
/** Ergebnis der Adresspruefung im Web — dieselbe Regel wie die API. */
export type CustomModuleUrlCheck = 'ok' | 'notHttps' | 'credentials';
/**
* Gueltig ist nur eine https-Adresse ohne Zugangsdaten. Die https-Regel ist
* EINE im ganzen Web (`isHttpsUrl` aus `xframe-config.ts`); Zugangsdaten
* erkennt der URL-Parser an `username`/`password`.
*/
export function checkCustomModuleUrl(value: string): CustomModuleUrlCheck {
if (!isHttpsUrl(value)) return 'notHttps';
try {
const parsed = new URL(value);
if (parsed.username !== '' || parsed.password !== '') return 'credentials';
if (parsed.hostname === '') return 'notHttps';
} catch {
return 'notHttps';
}
return 'ok';
}
async function failure(res: Response): Promise<CustomModuleRequestError> {
let message = `Request failed (${res.status})`;
try {
const body = await res.json();
const raw = body?.message;
if (Array.isArray(raw)) message = raw.join(' ');
else if (typeof raw === 'string') message = raw;
} catch {
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
}
return new CustomModuleRequestError(res.status, message);
}
export async function listCustomModules(): Promise<CustomModule[]> {
const res = await fetch(`${API_URL}/custom-modules`, { credentials: 'include' });
if (!res.ok) throw await failure(res);
return res.json();
}
/** `null` bei 404 (Eintrag geloescht oder fremd). */
export async function getCustomModule(id: string): Promise<CustomModule | null> {
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
credentials: 'include',
});
if (res.status === 404) return null;
if (!res.ok) throw await failure(res);
return res.json();
}
export async function createCustomModule(input: CustomModuleInput): Promise<CustomModule> {
const res = await fetch(`${API_URL}/custom-modules`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw await failure(res);
return res.json();
}
export async function updateCustomModule(
id: string,
input: Partial<CustomModuleInput>,
): Promise<CustomModule> {
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw await failure(res);
return res.json();
}
export async function deleteCustomModule(id: string): Promise<void> {
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw await failure(res);
}
+55 -17
View File
@@ -8,13 +8,14 @@ import {
} from './grid-layout-migration';
/**
* grid-layout-migration.test — NEU (quick-260916-bwo, feineres Dashboard-Raster).
* grid-layout-migration.test (quick-260916-bwo, erweitert quick-260929-dmx).
*
* Sieben Tests fuer die einmalige Umrechnung gespeicherter Anordnungen von
* den alten Einheiten (12 Spalten / 40 px) in die neuen (24 Spalten / 20 px).
* Kern ist T-BWO-02: die Verdopplung darf GENAU EINMAL geschehen. Der Marker
* `__gridVersion` steht dafuer im gespeicherten JSON, nie im Zustand — Test 4
* (Idempotenz) wird rot, sobald jemand die Marker-Pruefung entfernt.
* Tests fuer die stufenweise Umrechnung gespeicherter Anordnungen:
* v1 (12 Spalten / 40 px) -> v2 (24 / 20 px, alles x2) -> v3 (48 Spalten,
* nur Breite x2). Kern ist T-BWO-02: jede Stufe darf GENAU EINMAL geschehen.
* Der Marker `__gridVersion` steht dafuer im gespeicherten JSON, nie im
* Zustand — der Idempotenz-Test wird rot, sobald jemand die Marker-Pruefung
* entfernt.
*/
function altLayouts() {
@@ -31,14 +32,14 @@ function altLayouts() {
}
describe('grid-layout-migration (quick-260916-bwo)', () => {
it('Test 1: alte Anordnung ohne Marker -> alle Positionen und Groessen x2, migrated true, kein Marker im Ergebnis', () => {
it('Test 1: v1-Anordnung ohne Marker -> beide Stufen: x/w/minW/maxW x4, y/h/minH/maxH x2, migrated true, kein Marker im Ergebnis', () => {
const { layouts, migrated } = migrateGridLayouts(altLayouts());
expect(layouts.lg[0]).toEqual({
i: 'a', x: 2, y: 4, w: 4, h: 6, minW: 4, minH: 4, moved: false, static: false,
i: 'a', x: 4, y: 4, w: 8, h: 6, minW: 8, minH: 4, moved: false, static: false,
});
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 4, maxW: 24, maxH: 16 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 4 });
expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 });
expect(layouts.sm).toEqual([]);
expect(layouts.xs).toEqual([]);
expect(layouts.xxs).toEqual([]);
@@ -47,15 +48,44 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(GRID_SCALE_FACTOR).toBe(2);
});
it('Test 2: markierte Anordnung (__gridVersion 2) bleibt unveraendert, migrated false', () => {
it('Test 2: v2-Anordnung (__gridVersion 2) -> nur die zweite Stufe: x/w/minW/maxW x2, y/h/minH/maxH unveraendert, migrated true', () => {
const alt = altLayouts();
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 });
expect(layouts.lg[0]).toEqual({
i: 'a', x: 2, y: 2, w: 4, h: 3, minW: 4, minH: 2, moved: false, static: false,
});
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 });
expect(migrated).toBe(true);
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
});
it('Test 2b: v3-Anordnung (__gridVersion 3) bleibt unveraendert, migrated false', () => {
const alt = altLayouts();
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 3 });
expect(layouts).toEqual(alt);
expect(migrated).toBe(false);
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
});
it('Test 2c: v1 und v2 -> v3 ergeben dieselbe Anordnung, wenn die v2-Eingabe die v1-Eingabe x2 ist', () => {
const v1 = altLayouts();
const v2 = {
lg: [
{ i: 'a', x: 2, y: 4, w: 4, h: 6, minW: 4, minH: 4, moved: false, static: false },
{ i: 'b', x: 4, y: 0, w: 12, h: 4, maxW: 24, maxH: 16 },
],
md: [{ i: 'a', x: 0, y: 0, w: 4, h: 4 }],
sm: [],
xs: [],
xxs: [],
__gridVersion: 2,
};
expect(migrateGridLayouts(v2).layouts).toEqual(migrateGridLayouts(v1).layouts);
});
it('Test 3: leere Anordnung bleibt leer, migrated false (kein Speichern noetig)', () => {
const empty = { lg: [], md: [], sm: [], xs: [], xxs: [] };
const r1 = migrateGridLayouts(empty);
@@ -73,6 +103,14 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(twice.layouts).toEqual(once.layouts);
expect(twice.migrated).toBe(false);
// Auch die einstufige v2 -> v3 ist idempotent, und der Marker 3 wird geschrieben.
const onceV2 = migrateGridLayouts({ ...altLayouts(), __gridVersion: 2 });
const marked = withGridVersion(onceV2.layouts);
expect(marked[GRID_VERSION_KEY]).toBe(3);
const twiceV2 = migrateGridLayouts(marked);
expect(twiceV2.layouts).toEqual(onceV2.layouts);
expect(twiceV2.migrated).toBe(false);
});
it('Test 5: withGridVersion haengt den Marker an, ohne die Eingabe zu veraendern', () => {
@@ -80,30 +118,30 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const marked = withGridVersion(layouts);
expect(marked[GRID_VERSION_KEY]).toBe(GRID_VERSION);
expect(GRID_VERSION).toBe(2);
expect(GRID_VERSION).toBe(3);
expect(marked.lg).toBe(layouts.lg);
expect(marked.md).toBe(layouts.md);
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
expect((layouts as Record<string, unknown>)[GRID_VERSION_KEY]).toBeUndefined();
});
it('Test 6: Zukunft und Robustheit — Marker 3 bleibt, Zeichenketten-Marker zaehlt nicht, nicht-numerische Felder bleiben', () => {
it('Test 6: Zukunft und Robustheit — Marker 4 bleibt, Zeichenketten-Marker zaehlt nicht, nicht-numerische Felder bleiben', () => {
const alt = altLayouts();
const future = migrateGridLayouts({ ...alt, __gridVersion: 3 });
const future = migrateGridLayouts({ ...alt, __gridVersion: 4 });
expect(future.layouts).toEqual(alt);
expect(future.migrated).toBe(false);
const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' });
expect(stringMarker.migrated).toBe(true);
expect(stringMarker.layouts.lg[0].x).toBe(2);
expect(stringMarker.layouts.lg[0].x).toBe(4);
expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY);
const odd = migrateGridLayouts({
lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }],
});
expect(odd.layouts.lg[0]).toEqual({
i: 'c', x: 'kaputt', y: 2, w: 4, h: 4, resizeHandles: ['se'], moved: true, static: false,
i: 'c', x: 'kaputt', y: 2, w: 8, h: 4, resizeHandles: ['se'], moved: true, static: false,
});
expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false);
expect(odd.migrated).toBe(true);
@@ -112,7 +150,7 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => {
const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] });
expect(Object.keys(r.layouts)).toEqual(['sm']);
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 2, y: 2, w: 2, h: 2 });
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
expect(r.migrated).toBe(true);
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false });
+42 -21
View File
@@ -1,23 +1,35 @@
/**
* grid-layout-migration — einmalige Umrechnung gespeicherter Dashboard-
* Anordnungen in die feineren Raster-Einheiten (quick-260916-bwo).
* grid-layout-migration — einmalige, stufenweise Umrechnung gespeicherter
* Dashboard-Anordnungen in die feineren Raster-Einheiten (quick-260916-bwo,
* quick-260929-dmx).
*
* Warum: Das Raster wurde von 12 Spalten / 40 px Zeilenhoehe auf 24 Spalten /
* 20 px verdoppelt (`dashboard-grid.tsx`). Eine in ALTEN Einheiten gespeicherte
* Anordnung wuerde im neuen Raster halb so gross und an der halben Position
* erscheinen. Deshalb werden `x, y, w, h` (und, falls vorhanden, `minW, minH,
* maxW, maxH`) jedes Elements in jedem Breakpoint GENAU EINMAL mit 2
* multipliziert.
* Warum: Das Raster wurde zweimal feiner. Eine in ALTEN Einheiten gespeicherte
* Anordnung wuerde im neuen Raster kleiner und an anderer Position erscheinen.
* Deshalb wird jedes Element in jedem Breakpoint stufenweise umgerechnet,
* jede Stufe GENAU EINMAL:
*
* Marker: Damit die Verdopplung nur einmal geschieht, traegt das gespeicherte
* JSON den Schluessel `__gridVersion: 2`. Der Marker lebt NUR im persistierten
* JSON (Spalte `DashboardLayout.layouts`, Json, kein Schema noetig) — nie im
* Zustand des Stores, der mit `Object.keys` ueber die Breakpoints iteriert und
* `.filter` auf jedem Wert aufruft (ein Zahlwert wuerde dort abstuerzen).
* `migrateGridLayouts` entfernt den Marker beim Laden, `withGridVersion`
* haengt ihn beim Speichern wieder an. Fehlt der Marker beim Speichern, wird
* beim naechsten Laden ERNEUT verdoppelt — deshalb muss JEDER Speichervorgang
* `withGridVersion` benutzen (T-BWO-02, Store-Tests pinnen das).
* - Version 1 -> 2 (quick-260916-bwo): 12 Spalten / 40 px Zeilenhoehe wurden
* 24 Spalten / 20 px. `x, y, w, h` (und, falls vorhanden, `minW, minH, maxW,
* maxH`) werden mit 2 multipliziert.
* - Version 2 -> 3 (quick-260929-dmx): NUR die Breite wurde noch einmal
* verdoppelt (24 -> 48 Spalten am lg-Breakpoint, Zeilenhoehe und Abstand
* unveraendert). Nur `x, w, minW, maxW` werden mit 2 multipliziert; `y, h,
* minH, maxH` bleiben.
*
* Die Stufen sind kumulativ: eine Version-1-Anordnung durchlaeuft beide
* (x/w/minW/maxW also x4, y/h/minH/maxH x2), eine Version-2-Anordnung nur die
* zweite, eine Version-3-Anordnung keine.
*
* Marker: Damit jede Stufe nur einmal geschieht, traegt das gespeicherte JSON
* den Schluessel `__gridVersion` (aktuell 3). Der Marker lebt NUR im
* persistierten JSON (Spalte `DashboardLayout.layouts`, Json, kein Schema
* noetig) — nie im Zustand des Stores, der mit `Object.keys` ueber die
* Breakpoints iteriert und `.filter` auf jedem Wert aufruft (ein Zahlwert
* wuerde dort abstuerzen). `migrateGridLayouts` entfernt den Marker beim
* Laden, `withGridVersion` haengt ihn beim Speichern wieder an. Fehlt der
* Marker beim Speichern, wird beim naechsten Laden ERNEUT umgerechnet —
* deshalb muss JEDER Speichervorgang `withGridVersion` benutzen (T-BWO-02,
* Store-Tests pinnen das).
*
* Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))`
* liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`.
@@ -28,7 +40,7 @@
* React- oder Store-Abhaengigkeit.
*/
export const GRID_VERSION = 2;
export const GRID_VERSION = 3;
export const GRID_VERSION_KEY = '__gridVersion';
export const GRID_SCALE_FACTOR = 2;
@@ -43,7 +55,13 @@ export interface GridLayoutItem {
export type GridLayouts = Record<string, GridLayoutItem[]>;
const SCALED_FIELDS = ['x', 'y', 'w', 'h', 'minW', 'minH', 'maxW', 'maxH'] as const;
/** Felder je Umrechnungsstufe; `from` ist die Version, von der die Stufe ausgeht. */
const MIGRATION_STEPS: ReadonlyArray<{ from: number; fields: readonly string[] }> = [
// 1 -> 2 (quick-260916-bwo): Spalten UND Zeilen verdoppelt.
{ from: 1, fields: ['x', 'y', 'w', 'h', 'minW', 'minH', 'maxW', 'maxH'] },
// 2 -> 3 (quick-260929-dmx): nur die Breite verdoppelt.
{ from: 2, fields: ['x', 'w', 'minW', 'maxW'] },
];
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -62,7 +80,8 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
const markerValue = raw[GRID_VERSION_KEY];
// Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt.
const version = typeof markerValue === 'number' ? markerValue : 1;
const needsScaling = version < GRID_VERSION;
const steps = MIGRATION_STEPS.filter((step) => version <= step.from);
const needsScaling = steps.length > 0;
const layouts: GridLayouts = {};
let migrated = false;
@@ -75,12 +94,14 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
layouts[key] = value.map((item) => {
const copy = { ...(item as GridLayoutItem) };
if (needsScaling) {
for (const field of SCALED_FIELDS) {
for (const step of steps) {
for (const field of step.fields) {
const n = copy[field];
if (typeof n === 'number') {
copy[field] = n * GRID_SCALE_FACTOR;
}
}
}
migrated = true;
}
return copy;
@@ -74,12 +74,12 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
await useDashboardStore.getState().loadDashboard();
const state = useDashboardStore.getState();
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 2, y: 2, w: 4, h: 4 });
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 4, y: 2, w: 8, h: 4 });
expect(Object.keys(state.layouts)).not.toContain('__gridVersion');
expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1',
expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }),
expect.objectContaining({ __gridVersion: 3, lg: [{ i: 'a', x: 4, y: 2, w: 8, h: 4 }] }),
);
expect(state.isDirty).toBe(false);
expect(state.isLoading).toBe(false);
@@ -88,7 +88,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2,
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3,
});
await useDashboardStore.getState().loadDashboard();
@@ -121,7 +121,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1',
expect.objectContaining({ __gridVersion: 2, ...layouts }),
expect.objectContaining({ __gridVersion: 3, ...layouts }),
);
expect(useDashboardStore.getState().isDirty).toBe(false);
expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion');
@@ -137,13 +137,13 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
await expect(useDashboardStore.getState().loadDashboard()).resolves.toBeUndefined();
const state = useDashboardStore.getState();
expect(state.layouts.lg[0].x).toBe(2);
expect(state.layouts.lg[0].x).toBe(4);
expect(state.error).toBeNull();
expect(state.isLoading).toBe(false);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen fuer den aktiven Reiter angelegt', async () => {
it('Test 6: neues Widget wird in den Vorgabegroessen des 48er-Rasters (quick-260929-dmx) fuer den aktiven Reiter angelegt', async () => {
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} });
@@ -151,7 +151,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
const state = useDashboardStore.getState();
expect(api.addWidget).toHaveBeenCalledWith('dash-1', 'clock');
expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 4, h: 4 });
expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 8, h: 4 });
expect(state.isDirty).toBe(true);
});
});
@@ -213,7 +213,7 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2,
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3,
});
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
@@ -26,6 +26,8 @@ interface DashboardState {
updateLayouts: (layouts: Record<string, unknown>) => void;
addWidget: (widgetType: WidgetType) => Promise<void>;
removeWidget: (id: string) => Promise<void>;
/** Teil-Konfiguration eines Widgets setzen — sofort sichtbar, bei Fehler zurueck. */
setWidgetConfig: (id: string, partial: Record<string, unknown>) => Promise<void>;
loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>;
selectDashboard: (id: string) => Promise<void>;
@@ -159,6 +161,22 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
}
},
setWidgetConfig: async (id: string, partial: Record<string, unknown>) => {
const before = get().widgets.find((w) => w.id === id)?.config;
if (!before) return;
const patch = (config: Record<string, unknown>) =>
set((state) => ({
widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)),
}));
patch({ ...before, ...partial });
try {
await api.updateWidgetConfig(id, partial);
} catch (err) {
console.error('Failed to update widget config:', err);
patch(before);
}
},
loadDashboard: async () => {
set({ isLoading: true, error: null });
try {
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { resolvePageTitle } from './nav-store';
describe('resolvePageTitle — eigene Module', () => {
it('liefert den Namen des eigenen Moduls (slug = id)', () => {
const modules = [{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' }];
expect(resolvePageTitle('/modules/custom/abc', modules)).toEqual({ text: 'Wiki' });
});
it('findet eingebaute Module weiterhin ueber ihren slug', () => {
const modules = [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'domain-tools' },
{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' },
];
expect(resolvePageTitle('/modules/domain-tools/domaincheck', modules)).toEqual({
text: 'Domaincheck',
});
});
});
+33
View File
@@ -85,6 +85,7 @@
"users": "Benutzer",
"tenants": "Mandanten",
"modules": "Module",
"customModules": "Eigene Module",
"ldap": "LDAP",
"smtp": "SMTP",
"groups": "Gruppen"
@@ -236,6 +237,8 @@
"catalogTitle": "Widget hinzufügen",
"catalogClose": "Dialog schließen",
"deleteTooltip": "Widget entfernen",
"hideTitle": "Titel ausblenden",
"showTitle": "Titel einblenden",
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
"editMode": "Dashboard bearbeiten",
"saveChanges": "Änderungen speichern",
@@ -666,6 +669,29 @@
"grants": {
"matrixCheckboxLabel": "{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}"
}
},
"customModules": {
"title": "Eigene Module",
"description": "Nehmen Sie andere Webseiten als Einträge in die Seitenleiste auf. Alle Benutzer sehen diese Einträge unter der gewählten Kategorie, ein Klick zeigt die Seite direkt in Tessera.",
"create": "Eigenes Modul anlegen",
"edit": "Eigenes Modul bearbeiten",
"name": "Name",
"url": "Adresse",
"urlHint": "Die Adresse muss mit https:// beginnen.",
"category": "Kategorie",
"actions": "Aktionen",
"noModules": "Noch keine eigenen Module",
"noModulesBody": "Legen Sie Ihr erstes Modul an, um eine Webseite in der Seitenleiste zu zeigen.",
"nameRequired": "Bitte geben Sie einen Namen ein.",
"urlNotHttps": "Bitte geben Sie eine Adresse ein, die mit https:// beginnt.",
"urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.",
"saveError": "Das Modul konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"loadError": "Die eigenen Module konnten nicht geladen werden.",
"deleteConfirm": {
"title": "Eigenes Modul löschen",
"body": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste."
}
}
},
"adminModules": {
@@ -1344,6 +1370,13 @@
"close": "Fenster schließen",
"contentLabel": "Änderungen"
},
"customModules": {
"openInNewTab": "In neuem Tab öffnen",
"embedHint": "Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.",
"notFound": "Dieses Modul gibt es nicht mehr.",
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
"loading": "Wird geladen …"
},
"moduleCategories": {
"domain-tools": "Domains",
"security-tools": "Sicherheit",
+33
View File
@@ -85,6 +85,7 @@
"users": "Users",
"tenants": "Tenants",
"modules": "Modules",
"customModules": "Custom modules",
"ldap": "LDAP",
"smtp": "SMTP",
"groups": "Groups"
@@ -236,6 +237,8 @@
"catalogTitle": "Add widget",
"catalogClose": "Close dialog",
"deleteTooltip": "Remove widget",
"hideTitle": "Hide title",
"showTitle": "Show title",
"dragHint": "Drag the tile to move it",
"editMode": "Edit dashboard",
"saveChanges": "Save changes",
@@ -666,6 +669,29 @@
"grants": {
"matrixCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} for group {group}"
}
},
"customModules": {
"title": "Custom modules",
"description": "Add other websites as entries in the sidebar. All users see these entries under the chosen category, and a click shows the page right inside Tessera.",
"create": "Add custom module",
"edit": "Edit custom module",
"name": "Name",
"url": "Address",
"urlHint": "The address must start with https://.",
"category": "Category",
"actions": "Actions",
"noModules": "No custom modules yet",
"noModulesBody": "Add your first module to show a website in the sidebar.",
"nameRequired": "Please enter a name.",
"urlNotHttps": "Please enter an address that starts with https://.",
"urlCredentials": "The address must not contain a user name or a password.",
"saveError": "The module could not be saved. Please try again.",
"deleteError": "The module could not be deleted. Please try again.",
"loadError": "The custom modules could not be loaded.",
"deleteConfirm": {
"title": "Delete custom module",
"body": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users."
}
}
},
"adminModules": {
@@ -1344,6 +1370,13 @@
"close": "Close window",
"contentLabel": "Changes"
},
"customModules": {
"openInNewTab": "Open in new tab",
"embedHint": "Some pages cannot be embedded. If this one stays blank, open it in a new tab.",
"notFound": "This module no longer exists.",
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
"loading": "Loading …"
},
"moduleCategories": {
"domain-tools": "Domains",
"security-tools": "Security",
@@ -0,0 +1,22 @@
import { MODULE_CATEGORIES } from '@tessera/shared';
import { describe, expect, it } from 'vitest';
import de from './de.json';
import en from './en.json';
/**
* Gleichlauf der Modulkategorien (quick-260929-9wc): jede Kennung aus
* `MODULE_CATEGORIES` (Auswahl im Formular „Eigene Module“, Pruefung in der
* API) braucht einen Anzeigenamen in `moduleCategories` beider Sprachen —
* sonst zeigte die Seitenleiste die rohe Kennung.
*/
describe('MODULE_CATEGORIES', () => {
it.each([
['de', de],
['en', en],
] as const)('hat je Kennung einen Schluessel in moduleCategories (%s)', (_lang, messages) => {
const labels = messages.moduleCategories as Record<string, string>;
for (const category of MODULE_CATEGORIES) {
expect(labels[category], `moduleCategories.${category}`).toBeTruthy();
}
});
});
+1 -1
View File
@@ -58,7 +58,7 @@ Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Name
## Dashboard
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, das mittig auf der Seite steht. Jedes Widget trägt oben ein gelbes Symbol-Feld, hebt sich leicht an, wenn Sie mit der Maus darüberfahren, und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, das mittig auf der Seite steht. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
@@ -166,7 +166,7 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| groups | 0 | 31 | 0 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) |
| ldap | 1 | 27 | 2 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer waren bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04). **260914-eym:** die beiden Leser in `ldap-config.service.ts` laufen über `forSystem()` (4→1 ungebunden, 2 System), die Schreibzeile der Nachverschlüsselung über `forTenant()` (26→27 gebunden); der eine verbleibende ungebundene Rohtreffer ist `resolveEmailForWrite` |
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
| user | 8 | 17 | 0 | **quick-260925-bow:** +3 gebunden in `user.controller.ts`, „Was ist neu“-Fenster, `GET me/release-notice` (ein `findUnique`) und `POST me/release-seen` (`findUnique` + `update`), beide über `forTenant()` mit `where: { id: currentUser.id }`, nachgemessen mit der Gate-Schleife: 8/17/0. Vorher: **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| user | 8 | 18 | 0 | **Nachgemessen quick-260929-9wc:** 8/18/0 — die Zeile nannte 17 gebunden, gemessen sind 18 (Drift aus quick-260928-ujj, Hintergrund pro Benutzer, nachgeholt). Vorher: **quick-260925-bow:** +3 gebunden in `user.controller.ts`, „Was ist neu“-Fenster, `GET me/release-notice` (ein `findUnique`) und `POST me/release-seen` (`findUnique` + `update`), beide über `forTenant()` mit `where: { id: currentUser.id }`, nachgemessen mit der Gate-Schleife: 8/17/0. Vorher: **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
| dashboard | 1 | 29 | 0 | **quick-260924-m4n (Stufe 2 der Bilderrahmen-Umstellung):** nachgemessen mit der Gate-Schleife 1/29/0 — die Zeile nannte zuletzt 1/28/1, gemessen waren vor dieser Änderung aber schon 1/31/1: quick-260923-lrr hatte in `dashboard.service.ts` zwei gebundene `tenantPrisma.favoriteLink.`-Rohtreffer (Aufräumen hochgeladener Favoriten-Symbole) hinzugefügt, ohne diese Zeile nachzuziehen, und die ad9-Zählung lag um eins zu niedrig. Diese Änderung selbst: −2 gebunden und −1 System in `dashboard-images.service.ts` — der Bootstrap-Umzug ist entfernt (sein `systemPrisma.dashboardImage.findMany` und sein je Zeile gebundenes `update`), und der Upload legt die Zeile gleich MIT `storagePath` an (UUID vom Dienst), das nachträgliche `update` entfällt. Übrig in `dashboard-images.service.ts`: 7 gebundene Rohtreffer (`findMany`, `count`, `create`, `delete` beim Zurücknehmen, zweimal `findUnique`, `delete`). Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
@@ -176,9 +176,10 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
| **Summe** | **61** | **216** | **6** | **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
| custom-modules | 0 | 7 | 0 | **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. |
| **Summe** | **61** | **224** | **6** | **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 77 Paare)
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 79 Paare)
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
@@ -343,16 +344,24 @@ entnommen (30 Zusicherungen, darunter der Wachhund
| Klasse | Anzahl Paare |
|---|---|
| muss-mandantengebunden | 40 |
| muss-mandantengebunden | 42 |
| keine-mandantengebundene-tabelle | 21 |
| beides | 14 |
| bewusst-uebergreifend | 2 |
| **Summe** | **77** |
| **Summe** | **79** |
quick-260923-dhh (Aufgabe 1): +2 `muss-mandantengebunden` (`proxmox.service.ts`/`proxmoxServer`
und `/proxmoxServerStatus`, beide `gebunden`) — nachgerechnet mit der Gate-Schleife, nicht
abgeschrieben.
quick-260929-9wc: nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme:
Vorher standen 78 Zeilen (41 `muss-mandantengebunden`) im Dokument, Ueberschrift und Tabelle
nannten aber noch 77 Paare/40 `muss-mandantengebunden` — Drift, eine Zeile war nach der
Tabelle hinzugekommen, ohne sie fortzuschreiben. Jetzt +1 `muss-mandantengebunden`
(`custom-modules.service.ts`/`customModule`, `gebunden`): 79 Paare, davon 42
`muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 14 `beides`,
2 `bewusst-uebergreifend`.
## Der Hintergrunddienst als Falle — sechs Fälle
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
@@ -765,6 +774,7 @@ werden.
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). |
## Was diese Etappe NICHT entscheidet
+18
View File
@@ -256,3 +256,21 @@ export function parseDashboardBackground(value: unknown): DashboardBackground |
}
return null;
}
/**
* Die fuenf Seitenleisten-Kategorien der Module (quick-260929-9wc). Die
* Liste entspricht den Kategorien in den Seeds der eingebauten Module und
* den Schluesseln `moduleCategories` in den Uebersetzungen (de.json/en.json);
* `apps/web/src/messages/module-categories.spec.ts` haelt den Gleichlauf.
* Die API prueft damit die Kategorie eigener Module, das Formular baut die
* Auswahl daraus.
*/
export const MODULE_CATEGORIES = [
"domain-tools",
"security-tools",
"fleet",
"infrastructure",
"procurement",
] as const;
export type ModuleCategory = (typeof MODULE_CATEGORIES)[number];