Compare commits
9 Commits
v1.5.1
...
b9c05791b2
| Author | SHA1 | Date | |
|---|---|---|---|
| b9c05791b2 | |||
| 0751198822 | |||
| c0b145a9b0 | |||
| bc260100f6 | |||
| e48c0de238 | |||
| e7fc4de430 | |||
| b9d87be360 | |||
| 643b1a2caa | |||
| 12eea333ba |
+4
-2
@@ -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,8 @@ 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/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -522,4 +524,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
|
||||
|
||||
+391
@@ -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 <verification> (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>
|
||||
+161
@@ -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.
|
||||
+55
@@ -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.
|
||||
+41
@@ -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
|
||||
@@ -4,6 +4,24 @@ 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“.
|
||||
|
||||
### 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.
|
||||
|
||||
## 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());
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -694,8 +694,24 @@ fn get_client_info() -> String {
|
||||
client_info_label(env!("CARGO_PKG_VERSION"), env!("APP_COMMIT"))
|
||||
}
|
||||
|
||||
/// 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())
|
||||
@@ -819,21 +835,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 +896,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)?;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+77
@@ -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);
|
||||
|
||||
@@ -474,6 +474,26 @@ input[type="checkbox"], input[type="radio"] {
|
||||
}
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.widget-card {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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 () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/modules/active`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
const modules: SidebarModule[] = await res.json();
|
||||
setActiveModules(modules);
|
||||
setNavModules(modules);
|
||||
const loadBuiltIn = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/modules/active`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
setActiveModules(await res.json());
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
} 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user