Compare commits
48 Commits
acfffa3097
..
v1.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d0e649baa1 | |||
| cbc89d9810 | |||
| 3cb43d6cc0 | |||
| 4ff9a239fd | |||
| 00c2cfe2c9 | |||
| cd4b5b56ee | |||
| b15a43f3d3 | |||
| 8f41bd26bd | |||
| ee97b4ed9f | |||
| bc4c0119de | |||
| c703d87a1c | |||
| 76a923450f | |||
| a435a30c34 | |||
| 46ebb4e7ce | |||
| 9c9e1420fe | |||
| 97744b59cd | |||
| acd3c7a05f | |||
| b9c05791b2 | |||
| 0751198822 | |||
| c0b145a9b0 | |||
| bc260100f6 | |||
| e48c0de238 | |||
| e7fc4de430 | |||
| b9d87be360 | |||
| 643b1a2caa | |||
| 12eea333ba | |||
| bdaa3c8db5 | |||
| 03026d616f | |||
| 105b66ed8d | |||
| cb45d2663a | |||
| 69d17302e1 | |||
| 0aaa15240d | |||
| 76f6d87973 | |||
| 9fa0a3f498 | |||
| 76d17fe3c9 | |||
| a8a910fd29 | |||
| 8440db8c6e | |||
| 8532b63097 | |||
| b06a2d1066 | |||
| 228112014c | |||
| 76520b2010 | |||
| 30e4bc3a4f | |||
| 219038b3ee | |||
| 522cdfd368 | |||
| b80db49c5e | |||
| 7192be223a | |||
| 3c62a1a29c | |||
| 3fc33e3507 |
+8
-3
@@ -7,7 +7,7 @@ status: verified
|
||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||
last_updated: "2026-09-23T15:30:00.000Z"
|
||||
last_activity: 2026-09-23
|
||||
last_activity_desc: Quick 260925-bow — Was-ist-neu-Fenster nach Versionswechsel; 1.4.0 am 25.09. freigegeben und live; Stirling-PDF-Modul in Diskussion (Fragen an Nutzer offen)
|
||||
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
total_phases: 18
|
||||
@@ -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-22 - Quick 260922-ge2: XFrame-Ausschnitt waehlen und einpassen, Zoom, Nur anzeigen (Browser-Befund Rahmenhoehe behoben); davor Desktop: Download-Knoepfe in der App oeffnen jetzt den System-Browser (fast, 747a4d4); davor Quick 260922-frg: Tray-Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung (HTTP 401 durch Passwortschutz am Proxy vor alpha), Klick prueft erneut, Pruefung alle 4 h; davor Kosmetik am Bilderrahmen (fast, 8b45a28): „1 Stunde“ statt „60 Minuten“, Bildanzahl in der Einstellungs-Kopfzeile; am 21.09. davor Quick 260921-pi9 und 260921-qd3: die zwei bestellten Dashboard-Widgets „Bilderrahmen“ und „XFrame“ gebaut, im Browser nachgewiesen, gepusht
|
||||
Last activity: 2026-09-29 - Quicks 260929-9wc/d37/dmx/dzu + Fixes; Freigabe 1.6.0
|
||||
|
||||
Progress: [██████████] 99%
|
||||
|
||||
@@ -476,6 +476,11 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260924-i8v | **Proxmox-Kachel fuers Dashboard.** Modul-Kachel ueber den Weg aus 260922-m1h (Typ `proxmox` in packages/shared + Modulbindung, API-Freigabeliste, Registry, Katalog), nur fuer Benutzer mit Modulzugriff. Kompakter Gesundheitsbalken + Zusammenfassung in Worten, Serverliste nach Dringlichkeit mit je einer Kennzahl (Gaeste/Auslastung, aelteste Sicherung, eingehende Mails, unbekannt nie 0), Links auf /modules/proxmox (nicht im Bearbeitungsmodus), liest jede Minute den Zwischenstand (pausiert bei verborgenem Tab, loest NIE eine Abfrage aus), Titel + Serverauswahl an der Kachel und unter Einstellungen > Dashboard, Groessenstufen per Container-Query. Gemeinsame Teile nach `components/proxmox/` verschoben. Browser: Katalog, Kachel hell/dunkel, schmale Stufe (nur Punkte+Namen). web 864, api 1370 gruen. | 2026-09-24 | a906c67,92bf130,a217d60,377b6e3,586da44,602a45c | [260924-i8v-proxmox-kachel-fuers-dashboard](./quick/260924-i8v-proxmox-kachel-fuers-dashboard/) |
|
||||
| 260924-m4n | **Flackernden Test entschaerft, alte Bildspalte entfernt.** (1) `tenant-selector.test.tsx`: Ursache war das Laden der Bausteine INNERHALB des ersten Tests (zaehlte in dessen 5-s-Grenze) -> Import vorab, Doppelfall getrennt, dasselbe in zwei weiteren Marktplatz-Tests; langsamster Web-Test jetzt < 2 s (mit 2 Kernen 1,3 s); act()-Warnungen der Proxmox-Kachel weg. (2) DashboardImage Stufe 2: Migration `20260924120000_dashboard_image_drop_data` mit Schutz (bricht ab, wenn noch Zeilen ohne `storagePath`; Zeilenschutz fuer die Pruefung abgeschaltet, sonst saehe sie still 0), `storagePath` NOT NULL, `data` weg, `system_read_policy` weg, Bootstrap-Umzug + `forSystem()` entfernt, Upload legt Zeile gleich mit Pfad an. Vorbedingung alpha geprueft (0 von 3 ohne Pfad); Live nicht pruefbar. Rueckweg bei Abbruch in `docs/anleitung-betrieb.md` Kap. 4. Browser/API: Bilder laden, Upload+Anzeige+Loeschen ok. api 1364, web 865 gruen. | 2026-09-24 | b10734f,dd54ec5 | [260924-m4n-flackernden-test-entschaerfen-und-dashbo](./quick/260924-m4n-flackernden-test-entschaerfen-und-dashbo/) |
|
||||
| 260925-bow | **Was-ist-neu-Fenster nach Versionswechsel.** Spalte `User.lastSeenReleaseVersion` (Migration 20260925120000), Versionsnummer allein aus der API (`GET /users/me/release-notice`, Semver-Funktionen in packages/shared), Fenster im Portal-Rahmen einmal nach Versionswechsel, gemerkt erst beim Schliessen (`POST`), nur freigegebene Versionen (`dev` nie), hoechstens 3 Versionen + Hinweis auf aeltere + Link /changelog; neue Konten bekommen die laufende Version eingetragen; vorhandene ohne Stand sehen nur die aktuelle. Changelog-Text bleibt serverseitig. Browser: 1.3.0 -> Fenster 1.4.0, Verstanden merkt 1.4.0, kein zweites Mal; 1.0.0 -> 1.4.0/1.3.1/1.3.0 + „2 aelteren Versionen“; Link-Kontrast nachgebessert. api 1435, web 924 gruen. | 2026-09-25 | 59db32a,187fb76,5ae9aaa,b3b7b5d | [260925-bow-was-ist-neu-fenster-beim-ersten-anmelden](./quick/260925-bow-was-ist-neu-fenster-beim-ersten-anmelden/) |
|
||||
| 260928-ujj | **Design Mosaik uebernommen + Hintergrund pro Benutzer.** Merge design/mosaik (76d17fe, inkl. RESIZE_AXIS_FALLBACK), Spalte `User.dashboardBackground` JSONB (Migration 20260928120000), `PATCH /users/me/dashboard-background` mit `parseDashboardBackground` aus packages/shared (Preset-Liste, imageId nur UUID), Web liest aus Sitzung, alte localStorage-Wahl einmalig uebernommen. Browser: Duenen gewaehlt, DB-Zeile gesetzt, nach localStorage-Loeschen weiter sichtbar. api 1462, web 952 gruen; Freigabe als 1.5.0. | 2026-09-28 | 9fa0a3f,0aaa152,cb45d26 | [260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-](./quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/) |
|
||||
| 260929-9wc | **Eigene Module (nur lokal, nicht gepusht).** Modell `CustomModule` + Migration 20260929120000 mit RLS (Muster ProxmoxServer), `/custom-modules` (GET alle Angemeldeten, POST/PATCH/DELETE Admin, nur https ohne Zugangsdaten), `MODULE_CATEGORIES` in packages/shared, Seitenleisten-Eintrag unter gewaehlter Kategorie, Rahmen-Seite `/modules/custom/[id]` mit XFRAME_SANDBOX + no-referrer + „In neuem Tab öffnen“, Verwaltung `/admin/custom-modules`, Zugriffsklassifikation 61/224/6. Gruppen-Beschraenkung zurueckgestellt (ModuleGrant haengt an Module). api 1495, web 992 gruen; Browser dunkel 9 Schritte bestanden. | 2026-09-29 | b9d87be,e7fc4de,e48c0de | [260929-9wc-eigene-module-admin-legt-seitenleisten-e](./quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/) |
|
||||
| 260929-d37 | **Desktop-App nur einmal starten.** User-Meldung Windows 11: beim Systemstart zwei Instanzen/zwei Tray-Symbole. `tauri-plugin-single-instance` 2.4.5 als erstes Plugin, zweiter Start ruft `show_main_window` (neuer Helper, ersetzt 3 Kopien) und beendet sich. cargo build/test (44)/clippy gruen. Windows-Pruefung offen (VM 8233 oder User-PC nach naechster Desktop-Version). | 2026-09-29 | c0b145a,0751198 | [260929-d37-desktop-client-nur-einmal-starten-single](./quick/260929-d37-desktop-client-nur-einmal-starten-single/) |
|
||||
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
|
||||
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -521,4 +526,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-22 - Quick 260922-hk4: Bilderrahmen-Bilder im Dateibereich, Selbstheilung aus der alten Spalte
|
||||
Last activity: 2026-09-29 - Quicks 260929-9wc/d37/dmx/dzu + Fixes; Freigabe 1.6.0
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
---
|
||||
phase: quick-260928-ujj
|
||||
plan: 01
|
||||
quick_id: 260928-ujj
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260928-ujj]
|
||||
files_modified:
|
||||
- apps/web/** (Merge design/mosaik, 115 Dateien, nur apps/web)
|
||||
- packages/shared/src/index.ts
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql (neu)
|
||||
- apps/api/src/auth/auth.service.ts
|
||||
- apps/api/src/auth/auth.service.spec.ts
|
||||
- apps/api/src/user/user.controller.ts
|
||||
- apps/api/src/user/user.controller.spec.ts
|
||||
- apps/web/src/lib/auth-actions.ts
|
||||
- apps/web/src/lib/stores/auth-store.ts
|
||||
- apps/web/src/components/layout/header.tsx
|
||||
- apps/web/src/components/layout/header.test.tsx
|
||||
- apps/web/src/lib/dashboard-background.ts
|
||||
- apps/web/src/lib/dashboard-background.test.ts
|
||||
- apps/web/src/components/dashboard/dashboard-background.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-background.test.tsx (neu)
|
||||
- "apps/web/src/app/(portal)/page.tsx"
|
||||
- "apps/web/src/app/(portal)/page.test.tsx"
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-anwender.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 95000
|
||||
raw_tokens: 95000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "main enthaelt das freigegebene Design Mosaik als Merge-Commit mit design/mosaik (76d17fe) als zweitem Elternteil; apps/web-Tests sind gruen"
|
||||
- "Widgets lassen sich schmaler ziehen, auch wenn die Maus dabei leicht wackelt (RESIZE_AXIS_FALLBACK in dashboard-grid.tsx, Test in dashboard-grid.test.tsx gruen)"
|
||||
- "Der gewaehlte Dashboard-Hintergrund steht pro Benutzer in der Datenbank (User.dashboardBackground) und kommt ueber dieselbe Anmelde-/Sitzungsantwort zurueck wie accentColor — im zweiten Browser erscheint derselbe Hintergrund"
|
||||
- "PATCH /users/me/dashboard-background nimmt nur 'none', bekannte Preset-Kennungen oder eine UUID-Bildkennung an; alles andere ergibt 400 und schreibt nichts"
|
||||
- "Eine bereits im localStorage gespeicherte Wahl wird einmalig in die Datenbank uebernommen und der alte Schluessel entfernt"
|
||||
- "CHANGELOG.md 'Unveroeffentlicht' beschreibt das neue Aussehen (Neu/Geaendert) und den Resize-Fehler (Behoben); die Anwender-Anleitung beschreibt die Hintergrundwahl"
|
||||
artifacts:
|
||||
- path: apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
|
||||
provides: "Spalte User.dashboardBackground (JSONB, nullable)"
|
||||
contains: "dashboardBackground"
|
||||
- path: packages/shared/src/index.ts
|
||||
provides: "DASHBOARD_BACKGROUND_PRESET_IDS, Typ DashboardBackground, parseDashboardBackground() — eine Pruefregel fuer API und Web"
|
||||
contains: "parseDashboardBackground"
|
||||
- path: apps/api/src/user/user.controller.ts
|
||||
provides: "PATCH me/dashboard-background"
|
||||
contains: "me/dashboard-background"
|
||||
- path: apps/web/src/components/dashboard/dashboard-background.tsx
|
||||
provides: "useDashboardBackground liest aus dem Auth-Store und speichert ueber die Server-Aktion"
|
||||
key_links:
|
||||
- from: apps/web/src/components/dashboard/dashboard-background.tsx
|
||||
to: "PATCH /users/me/dashboard-background"
|
||||
via: "updateDashboardBackgroundAction in apps/web/src/lib/auth-actions.ts"
|
||||
pattern: "updateDashboardBackgroundAction"
|
||||
- from: apps/api/src/auth/auth.service.ts
|
||||
to: "User.dashboardBackground"
|
||||
via: "select neben accentColor, Ausgabe durch parseDashboardBackground normalisiert"
|
||||
pattern: "dashboardBackground: true"
|
||||
- from: apps/web/src/components/layout/header.tsx
|
||||
to: apps/web/src/lib/stores/auth-store.ts
|
||||
via: "setUser-Abbildung uebernimmt dashboardBackground aus der Sitzung"
|
||||
pattern: "dashboardBackground"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Das vom Nutzer abgenommene Design „Mosaik“ (Zweig design/mosaik, nur apps/web) in main uebernehmen, die Hintergrundwahl des Dashboards von localStorage auf ein Datenbankfeld pro Benutzer umstellen (Muster accentColor) und CHANGELOG sowie Anleitungen fuer Version 1.5.0 vorbereiten.
|
||||
|
||||
Purpose: Das neue Aussehen samt Resize-Fix soll als 1.5.0 ausgeliefert werden; der Hintergrund soll dem Benutzer auf jedem Geraet folgen statt an einem Browser zu kleben.
|
||||
Output: Merge-Commit, Migration + API-Weg + Web-Anbindung mit Tests, CHANGELOG-/Doku-Eintraege. Release (Tag, live-Zweig, Push) ist NICHT Teil dieses Plans — nicht pushen.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@.planning/HANDOFF.json
|
||||
|
||||
Fakten (nicht neu herleiten):
|
||||
- Zweig design/mosaik liegt lokal (Spitze 76d17fe, 15 Commits auf 3fc33e3), aendert nur apps/web (115 Dateien, keine package.json/Lockfile). Merge ist konfliktfrei. Die nicht committete Aenderung an .planning/HANDOFF.json beruehrt der Merge nicht — NICHT stagen, NICHT verwerfen.
|
||||
- Hintergrund-Datentyp heute in apps/web/src/lib/dashboard-background.ts (Stand design/mosaik): kind 'none' | 'preset' (id aus mist, pebble, bloom, dunes, mosaic) | 'image' (imageId = Kennung eines Bilderrahmen-Bildes, DashboardImage.id ist uuid()). Speicherung per localStorage-Schluessel tessera.dashboardBackground.<userId>.
|
||||
- Vorbild accentColor: schema.prisma Zeile ~45; Auswahl in apps/api/src/auth/auth.service.ts (~Zeile 320-345, select mit accentColor, speist die Sitzungsantwort); PATCH me/accent-color in apps/api/src/user/user.controller.ts (~Zeile 463-484, forTenant + where id currentUser.id, Inline-Body-Typ); Web: AuthUser in auth-actions.ts und stores/auth-store.ts, updateAccentColorAction in auth-actions.ts (~Zeile 220), setUser-Abbildung in components/layout/header.tsx (~Zeile 47-55).
|
||||
- Vorbild Migration: apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql (deutscher Kopfkommentar, Hinweis auf auth_lookup_*-Funktionen mit fester Spaltenliste).
|
||||
- NestJS-Routenreihenfolge: @Patch(':id') steht bei Zeile ~273; zweisegmentige Pfade wie me/accent-color werden davon nicht verschattet — der neue Pfad me/dashboard-background ist ebenfalls zweisegmentig.
|
||||
- GET /dashboard/images/:id prueft den Besitz (dashboard-images.service.ts) — eine fremde Bildkennung liefert nur 404, deshalb reicht serverseitig die Formatpruefung.
|
||||
- RLS-Inventar-Test apps/api/src/prisma/rls-access-inventory.spec.ts vergleicht Paare (Datei, Modell) gegen docs/mandantentrennung-zugriffsklassifikation.md; user.controller.ts + user existiert schon gebunden, also kein neues Paar — nur den Zeilentext fortschreiben.
|
||||
- Lokale DB hat keinen Host-Port: Prisma vom Host ueber die Container-IP (172.19.x, docker inspect) mit tessera:tessera_dev.
|
||||
- CHANGELOG.md wird vom Was-ist-neu-Fenster geparst: Ueberschriftenformat „## Unveröffentlicht“ / „### Neu|Geändert|Behoben“ exakt beibehalten.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Design Mosaik in main mergen</name>
|
||||
<files>apps/web/** (aus design/mosaik)</files>
|
||||
<action>Auf main (HEAD a8a910f) pruefen, dass design/mosaik auf 76d17fe steht und `git diff --name-only 3fc33e3 design/mosaik` ausschliesslich apps/web-Pfade zeigt. Dann `git merge --no-ff design/mosaik -m "feat(260928-ujj): Design Mosaik uebernehmen"` ausfuehren (Nachricht kurz, deutsch ohne Umlaute; im Rumpf eine Zeile, dass der Merge den Resize-Achsen-Fallback fuer schmaler gezogene Widgets mitbringt). .planning/HANDOFF.json bleibt unangetastet und ungestaged. Kein pnpm install noetig (keine Abhaengigkeitsaenderung). Danach Web-Tests und Typpruefung laufen lassen; schlaegt etwas fehl, das im Klon gruen war, Ursache beheben und als eigener Commit fix(260928-ujj) nachziehen — nicht in den Merge-Commit falten.</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && git merge-base --is-ancestor 76d17fe HEAD && grep -q RESIZE_AXIS_FALLBACK apps/web/src/components/dashboard/dashboard-grid.tsx && pnpm --filter web test && pnpm --filter web type-check</automated>
|
||||
</verify>
|
||||
<done>Merge-Commit auf main mit 76d17fe als Elternteil; dashboard-grid.tsx enthaelt RESIZE_AXIS_FALLBACK; `pnpm --filter web test` und `pnpm --filter web type-check` gruen; HANDOFF.json weiterhin nur als lokale Aenderung vorhanden.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Dashboard-Hintergrund pro Benutzer in der Datenbank</name>
|
||||
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql, apps/api/src/auth/auth.service.ts, apps/api/src/auth/auth.service.spec.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.controller.spec.ts, apps/web/src/lib/auth-actions.ts, apps/web/src/lib/stores/auth-store.ts, apps/web/src/components/layout/header.tsx, apps/web/src/components/layout/header.test.tsx, apps/web/src/lib/dashboard-background.ts, apps/web/src/lib/dashboard-background.test.ts, apps/web/src/components/dashboard/dashboard-background.tsx, apps/web/src/components/dashboard/dashboard-background.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<behavior>
|
||||
- API: PATCH me/dashboard-background mit background {kind:'none'} / {kind:'preset', id:'dunes'} / {kind:'image', imageId:<uuid>} schreibt genau das normalisierte Objekt (Zusatzschluessel entfernt) per forTenant mit where id = currentUser.id und liefert {success:true, dashboardBackground}
|
||||
- API: unbekannte Preset-Kennung, unbekanntes kind, imageId kein UUID (z. B. 'img-1', mit Anfuehrungszeichen/Klammern, laenger als 36), background null/fehlend/kein Objekt/Array ergibt BadRequestException und kein update-Aufruf
|
||||
- API: auth.service liefert dashboardBackground neben accentColor; gespeichertes gueltiges Objekt kommt normalisiert zurueck, NULL oder ungueltiger Inhalt kommt als null zurueck
|
||||
- Web-Lib: takeLegacyDashboardBackground(userId) liefert eine gueltige alte localStorage-Wahl und entfernt den Schluessel; ungueltiger Wert ergibt null und entfernt ebenfalls; gesperrter Speicher ergibt null ohne Ausnahme
|
||||
- Web-Lib: Preset-Kennungen in BACKGROUND_PRESETS sind deckungsgleich mit DASHBOARD_BACKGROUND_PRESET_IDS aus @tessera/shared
|
||||
- Web-Hook: background kommt aus user.dashboardBackground im Auth-Store (null ergibt 'none'); choose() setzt den Store sofort und ruft updateDashboardBackgroundAction; bei Fehlschlag wird der vorige Wert zurueckgesetzt
|
||||
- Web-Hook: ist der Server-Wert null und liegt eine alte localStorage-Wahl vor, wird sie genau einmal gespeichert; ist der Server-Wert gesetzt, passiert keine Uebernahme
|
||||
</behavior>
|
||||
<action>Zuerst die Tests aus dem behavior-Block schreiben (rot), dann umsetzen.
|
||||
|
||||
Gemeinsame Pruefregel: In packages/shared/src/index.ts DASHBOARD_BACKGROUND_PRESET_IDS (mist, pebble, bloom, dunes, mosaic als const-Tupel), Typ DashboardBackgroundPresetId, Typ DashboardBackground (drei Faelle wie im Web heute) und parseDashboardBackground(value: unknown): DashboardBackground | null ergaenzen. Die Funktion baut immer ein frisches Objekt nur aus den erlaubten Feldern; imageId muss eine UUID (8-4-4-4-12 Hex, Gross/Klein egal) sein — das haelt auch jede CSS-Einschleusung in den spaeteren url("...")-Stil fern. Deutscher Kommentar mit Verweis quick-260928-ujj im Stil der Datei.
|
||||
|
||||
API: In schema.prisma am User nach lastSeenReleaseVersion das Feld dashboardBackground Json? mit Kommentar (quick-260928-ujj, null = nie gewaehlt, sonst normalisiertes Objekt inkl. kind none). Migration 20260928120000_user_dashboard_background/migration.sql von Hand im Stil der Vorlage: deutscher Kopfkommentar (Zweck, NULL-Bedeutung, kein Backfill, auth_lookup_* unberuehrt) und ALTER TABLE "User" ADD COLUMN "dashboardBackground" JSONB. Danach `pnpm --filter api exec prisma generate`. Laeuft der lokale Stack, die Migration zusaetzlich per prisma migrate deploy gegen die Container-IP der db einspielen (tessera:tessera_dev, DB-Name aus .env/Compose) — kein Gate. In auth.service.ts im select neben accentColor dashboardBackground aufnehmen und in der Rueckgabe durch parseDashboardBackground normalisieren; auth.service.spec.ts Fixture/Erwartungen (~Zeile 500-540) ergaenzen. In user.controller.ts direkt nach updateAccentColor eine Methode updateDashboardBackground mit @Patch('me/dashboard-background'), Inline-Body-Typ mit background: unknown (wie accent-color, bewusst keine DTO-Klasse — die globale ValidationPipe mit whitelist wuerde verschachtelte Felder sonst nicht pruefen), parseDashboardBackground, bei null BadRequestException('Invalid dashboard background.'), sonst forTenant(...).user.update mit where id currentUser.id und data dashboardBackground; JSDoc mit Bedrohungsverweis T-ujj-01/02. Tests als neuer describe-Block „Dashboard-Hintergrund (quick-260928-ujj)“ in user.controller.spec.ts nach dem Muster des Was-ist-neu-Blocks. In docs/mandantentrennung-zugriffsklassifikation.md die Zeile zu apps/api/src/user/user.controller.ts fortschreiben: seit quick-260928-ujj schreibt der Selbstbedienungsweg PATCH me/dashboard-background dashboardBackground, ebenfalls forTenant mit where id currentUser.id, ohne Kennungsparameter.
|
||||
|
||||
Web: AuthUser in auth-actions.ts und stores/auth-store.ts um dashboardBackground?: DashboardBackground | null (Typ aus @tessera/shared) erweitern; updateDashboardBackgroundAction(background) als Server-Aktion nach dem Muster updateAccentColorAction (PATCH /users/me/dashboard-background, Body mit background). header.tsx setUser-Abbildung um dashboardBackground erweitern, header.test.tsx-Fixture nachziehen. apps/web/src/lib/dashboard-background.ts: Typ und Preset-Kennungen aus @tessera/shared beziehen (BackgroundPresetId als Alias behalten, falls genutzt), BACKGROUND_PRESETS/presetBackground bleiben; loadDashboardBackground und saveDashboardBackground ersetzen durch takeLegacyDashboardBackground(userId) (liest, prueft mit parseDashboardBackground, entfernt Schluessel); Kopfkommentar aktualisieren (Speicherung jetzt in der Datenbank, „Prototyp“ entfernen). components/dashboard/dashboard-background.tsx: useDashboardBackground liest user aus useAuthStore statt userId-Parameter; choose() wie im behavior-Block (optimistisch, bei Fehlschlag zuruecksetzen); einmalige Uebernahme der alten Wahl per useRef je Benutzerkennung. Aufrufstelle in app/(portal)/page.tsx anpassen (Kommentar „Prototyp mit localStorage“ ersetzen), page.test.tsx bei Bedarf nachziehen. Hook-Tests in neuer Datei components/dashboard/dashboard-background.test.tsx mit gemocktem @/lib/auth-actions. Hinweistext dashboard.background.hint in de.json auf „Gilt nur für Sie – auf jedem Gerät, auf dem Sie sich anmelden.“ und in en.json sinngemaess („Applies only to you – on every device you sign in on.“) aendern; bestehende Tests, die den alten Text pruefen, anpassen.
|
||||
|
||||
Commit(s): test(260928-ujj) fuer die roten Tests, feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank — deutsch ohne Umlaute.</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/shared type-check && pnpm --filter api type-check && pnpm --filter api test && pnpm --filter web type-check && pnpm --filter web test && grep -q '"dashboardBackground" JSONB' apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql && ! grep -v '^\s*//' apps/web/src/lib/dashboard-background.ts | grep -q 'setItem'</automated>
|
||||
</verify>
|
||||
<done>Migration und Schemafeld vorhanden, Prisma-Client generiert; PATCH me/dashboard-background prueft und speichert, Sitzungsantwort liefert dashboardBackground; Web liest aus dem Store und speichert ueber die API, alte localStorage-Wahl wird einmalig uebernommen; api- und web-Tests inkl. rls-access-inventory.spec.ts gruen, Typpruefung aller drei Pakete sauber.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: CHANGELOG, Anleitungen, Abschlusspruefung</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md</files>
|
||||
<action>Vorher `git log --format='%h %s%n%b' 3fc33e3..design/mosaik` lesen, um alle sichtbaren Aenderungen zu erfassen. CHANGELOG.md, Abschnitt „## Unveröffentlicht“ (bestehenden Neu-Eintrag zum Was-ist-neu-Fenster behalten), in Alltagssprache, Sie-Form, ganze Saetze, Stil der bisherigen Eintraege, keine Fachbegriffe:
|
||||
- „### Neu“: ein Eintrag zum waehlbaren Dashboard-Hintergrund (Knopf „Hintergrund“: keiner, ruhige Flaechen und Motive, eigenes Bild aus den Bilderrahmen-Bildern oder neu hochgeladen; gilt nur fuer Sie und folgt Ihnen auf jedes Geraet und in die Desktop-App; eine bisher im Browser gemerkte Wahl wird automatisch uebernommen).
|
||||
- „### Geändert“ (neu anlegen, zwischen Neu und Behoben): drei bis vier Eintraege — (1) neues Aussehen: dunkle App-Leiste, neu gestaltete Seitenleiste mit Modul-Kacheln, deutschen Kategorienamen und der Begruessung unten, Akzentfarbe nur beim Modul im Fokus; (2) neue Anmeldeseite, geteilt mit dunklem Markenbereich und Farbmosaik; (3) Dashboard: Kacheln mittig ausgerichtet, Widgets mit gelbem Symbol-Feld, heben sich beim Darueberfahren leicht an und blenden beim Laden sanft ein, Begruessung/Befehlsleiste ueber den Kacheln, ruhigere Kalender-, Favoriten- und Notiz-Kacheln; (4) Kalender: Terminliste einzeilig mit „Heute“/„Morgen“ statt Datum. Eintraege aus den Commit-Rumpfen ergaenzen, die fuer Anwender sichtbar sind (z. B. mobile Schublade), nichts Internes.
|
||||
- „### Behoben“ (neu anlegen): Widgets liessen sich manchmal nicht schmaler ziehen, wenn die Maus dabei leicht wackelte — jetzt klappt das zuverlaessig.
|
||||
docs/anleitung-anwender.md knapp nachziehen: Abschnitt „Aufbau der Oberfläche“ (dunkle App-Leiste, Seitenleiste mit Modul-Kacheln und Begruessung unten — nur was sich wirklich geaendert hat, gegen den gemergten Code pruefen), Abschnitt „Anmeldung“ falls die Seite beschrieben ist, Abschnitt „Dashboard“ um einen Absatz **Hintergrund** (Knopf, Auswahl, pro Benutzer gespeichert, im dunklen Erscheinungsbild werden eigene Bilder abgedunkelt und „Blüte“ durch „Nebel“ ersetzt), Kalender-Zeile der Widget-Tabelle (einzeilige Terminliste, „Heute“/„Morgen“). docs/anleitung-entwicklung.md: kurzer Absatz neben der Stelle zu User.lastSeenReleaseVersion (~Zeile 669) zu User.dashboardBackground, PATCH /users/me/dashboard-background und parseDashboardBackground in @tessera/shared als einzige Pruefregel.
|
||||
Abschlusspruefung: web- und api-Build, Biome auf allen in dieser Aufgabe und im Merge geaenderten ts/tsx-Dateien ohne Fehler (Fehler in unveraenderten Dateien sind ausser Umfang; Biome-Fehler in gemergten Dateien beheben als fix(260928-ujj)). Laeuft der lokale Stack: api und web mit --build neu starten und per Playwright MCP pruefen — Hintergrund waehlen, Seite neu laden, in einem zweiten Browserkontext (gleicher Benutzer) erscheint derselbe Hintergrund; nie per fetch aus der Seite messen. Kein Push, kein Tag. Commit docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik.</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && awk '/^## Unver/{f=1;next} /^## [0-9]/{f=0} f' CHANGELOG.md | grep -c '^### \(Neu\|Geändert\|Behoben\)$' | grep -q '^3$' && grep -q 'Hintergrund' docs/anleitung-anwender.md && grep -q 'dashboardBackground' docs/anleitung-entwicklung.md && pnpm exec biome check $(git diff --name-only --diff-filter=AM 3fc33e3 HEAD -- '*.ts' '*.tsx') && pnpm --filter api build && pnpm --filter web build</automated>
|
||||
</verify>
|
||||
<done>CHANGELOG „Unveröffentlicht“ hat Neu, Geändert und Behoben mit den beschriebenen Eintraegen; Anwender- und Entwickler-Anleitung beschreiben Hintergrundwahl und neues Aussehen; Biome ohne Fehler auf den geaenderten Dateien; api- und web-Build erfolgreich; alles committet, nichts gepusht.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser -> API PATCH /users/me/dashboard-background | Unvertrauter JSON-Body wird gespeichert und spaeter als CSS-Stil (url("...")) gerendert |
|
||||
| DB -> Web (Sitzungsantwort) | Gespeicherter JSON-Wert fliesst in style-Attribut des Dashboard-Hintergrunds |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-ujj-01 | Tampering | user.controller.ts updateDashboardBackground / parseDashboardBackground | medium | mitigate | Allowlist fuer kind und Preset-Kennungen, imageId nur als UUID, frisch aufgebautes Objekt ohne Zusatzschluessel; ungueltig ergibt 400 ohne Schreibzugriff; Ausgabe in auth.service erneut durch parseDashboardBackground |
|
||||
| T-ujj-02 | Elevation of Privilege | PATCH me/dashboard-background | medium | mitigate | Kein Kennungsparameter; forTenant(prisma, currentUser.tenantId).user.update mit where id = currentUser.id |
|
||||
| T-ujj-03 | Information Disclosure | imageId eines fremden Bildes | low | accept | GET /dashboard/images/:id prueft Besitz; fremde Kennung ergibt nur ein fehlendes Bild beim eigenen Benutzer |
|
||||
| T-ujj-04 | Denial of Service | uebergrosser Body | low | accept | Express-JSON-Grenze greift; gespeichert wird nur das normalisierte Kleinobjekt |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `git merge-base --is-ancestor 76d17fe main` ist erfolgreich (Merge-Commit mit 76d17fe als zweitem Elternteil)
|
||||
- `pnpm --filter web test`, `pnpm --filter api test` gruen; type-check fuer shared, api, web sauber
|
||||
- Biome ohne Fehler auf geaenderten ts/tsx-Dateien; `pnpm --filter api build` und `pnpm --filter web build` erfolgreich
|
||||
- Nichts gepusht, kein Tag; .planning/HANDOFF.json unveraendert als lokale Aenderung
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
main traegt das Design Mosaik inklusive Resize-Fix, der Dashboard-Hintergrund wird pro Benutzer in der Datenbank gespeichert und geprueft, CHANGELOG und Anleitungen sind fuer 1.5.0 vorbereitet — bereit fuer die Freigabe durch den Orchestrator.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/260928-ujj-SUMMARY.md` when done
|
||||
</output>
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
---
|
||||
phase: quick-260928-ujj
|
||||
plan: 01
|
||||
quick_id: 260928-ujj
|
||||
status: complete
|
||||
subsystem: web, api, shared, docs
|
||||
tags: [design-mosaik, dashboard-background, prisma-migration, changelog, 1.5.0]
|
||||
requires:
|
||||
- design/mosaik (76d17fe)
|
||||
provides:
|
||||
- Design Mosaik auf main (Merge 9fa0a3f)
|
||||
- User.dashboardBackground (JSONB) + PATCH /users/me/dashboard-background
|
||||
- parseDashboardBackground / DASHBOARD_BACKGROUND_PRESET_IDS in @tessera/shared
|
||||
- CHANGELOG "Unveröffentlicht" mit Neu/Geändert/Behoben fuer 1.5.0
|
||||
affects:
|
||||
- apps/web (115 Dateien aus dem Merge)
|
||||
- apps/api user/auth
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Gemeinsame Pruefregel in @tessera/shared, angewendet beim Schreiben (Controller) und Lesen (getMe)"
|
||||
- "Hook liest Benutzerwahl aus dem Auth-Store und speichert optimistisch ueber Server-Aktion mit Ruecksetzen"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
|
||||
- apps/web/src/components/dashboard/dashboard-background.test.tsx
|
||||
modified:
|
||||
- packages/shared/src/index.ts
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/auth/auth.service.ts
|
||||
- apps/api/src/auth/auth.service.spec.ts
|
||||
- apps/api/src/user/user.controller.ts
|
||||
- apps/api/src/user/user.controller.spec.ts
|
||||
- apps/web/src/lib/auth-actions.ts
|
||||
- apps/web/src/lib/stores/auth-store.ts
|
||||
- apps/web/src/components/layout/header.tsx
|
||||
- apps/web/src/components/layout/header.test.tsx
|
||||
- apps/web/src/lib/dashboard-background.ts
|
||||
- apps/web/src/lib/dashboard-background.test.ts
|
||||
- apps/web/src/components/dashboard/dashboard-background.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-anwender.md
|
||||
- docs/anleitung-entwicklung.md
|
||||
- CHANGELOG.md
|
||||
decisions:
|
||||
- "Dashboard-Hintergrund als JSONB-Spalte User.dashboardBackground; null = nie gewaehlt, { kind: 'none' } = bewusst kein Hintergrund"
|
||||
- "parseDashboardBackground in @tessera/shared ist die einzige Pruefregel (Allowlist kind/Preset, imageId nur UUID) fuer API-Schreiben, API-Lesen und Web-Altdatenuebernahme"
|
||||
- "Alter localStorage-Schluessel wird immer einmal gelesen und entfernt, uebernommen nur bei Server-Wert null"
|
||||
- "Biome: nur vom Merge neu eingebrachte Befunde behoben; vorbestehende Format-/Importbefunde (158 an der Basis) bleiben ausser Umfang"
|
||||
metrics:
|
||||
duration: 17min
|
||||
completed: 2026-09-28
|
||||
estimate:
|
||||
tokens: 95000
|
||||
tasks: 3
|
||||
actuals:
|
||||
tokens: 95700
|
||||
tasks: 3
|
||||
commits: 17
|
||||
plan_head_before: a8a910fd29cf6f2e4c3b228e2a71b2af226f39da
|
||||
plan_head_after: cb45d2663ac65954463e8c5a6bf859f73a555e86
|
||||
---
|
||||
|
||||
# Quick 260928-ujj Plan 01: Design Mosaik uebernehmen und fuer 1.5.0 vorbereiten — Summary
|
||||
|
||||
Design „Mosaik“ per `--no-ff`-Merge (zweiter Elternteil 76d17fe) auf main übernommen. Der Dashboard-Hintergrund wird jetzt pro Benutzer in `User.dashboardBackground` (JSONB) gespeichert: `PATCH /users/me/dashboard-background` prüft mit dem gemeinsamen `parseDashboardBackground`, und der Wert kommt zusammen mit `accentColor` über `getMe` zurück. Die alte Wahl aus dem localStorage wird einmal übernommen. CHANGELOG und beide Anleitungen sind für 1.5.0 vorbereitet.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Beschreibung |
|
||||
|------|--------|--------------|
|
||||
| 1 | 9fa0a3f | feat(260928-ujj): Design Mosaik uebernehmen (Merge, Eltern a8a910f + 76d17fe; bringt 12 Commits aus design/mosaik mit) |
|
||||
| 2 (RED) | 76f6d87 | test(260928-ujj): rote Tests fuer Dashboard-Hintergrund in der Datenbank |
|
||||
| 2 (GREEN) | 0aaa152 | feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank |
|
||||
| 3 (Biome) | 69d1730 | fix(260928-ujj): Biome-Formatierung der mit Design Mosaik eingebrachten Dateien |
|
||||
| 3 | cb45d26 | docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik |
|
||||
|
||||
`commits: 17` wurde gemessen mit `git rev-list --count a8a910f..HEAD`. Die Zahl enthält die 12 Commits aus design/mosaik, die der Merge mitbringt. Auf der ersten Elternlinie stehen 5 eigene Commits.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- `git merge-base --is-ancestor 76d17fe HEAD`: erfolgreich. `RESIZE_AXIS_FALLBACK` steht in `dashboard-grid.tsx`.
|
||||
- Web-Tests (vitest 4.1.9): 97 Dateien, **952 Tests grün**. Direkt nach dem Merge waren es 96 Dateien und 940 Tests.
|
||||
- API-Tests (vitest 3.2.6): 85 Dateien, **1462 Tests grün**, einschließlich `rls-access-inventory.spec.ts`.
|
||||
- tsc: shared, api und web sind sauber.
|
||||
- Builds: `pnpm --filter api build` und `pnpm --filter web build` sind erfolgreich.
|
||||
- Biome bringt **keine neuen Fehler**. In den seit 3fc33e3 geänderten ts/tsx-Dateien standen an der Basis 158 Fehler, jetzt sind es 156. Alle verbleibenden Befunde bestanden schon vorher (84 format, 72 organizeImports in 93 Dateien).
|
||||
- Die Migration ist lokal eingespielt (`prisma migrate deploy` gegen 172.19.0.2). Die Spalte `dashboardBackground` hat den Typ jsonb.
|
||||
- Lokaler Stack neu gebaut mit `docker compose up -d --build api web`. Die API meldet die Route `Mapped {/users/me/dashboard-background, PATCH}`. Ein Aufruf ohne Anmeldung ergibt 401.
|
||||
- Nichts gepusht, kein Tag gesetzt, den live-Zweig nicht angefasst. `.planning/HANDOFF.json` ist weiter nur eine lokale Änderung und wurde nicht gestaged.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Das Biome-Gate des Plans kann auf den geänderten Dateien nicht fehlerfrei werden**
|
||||
- **Found during:** Task 2 und Task 3
|
||||
- **Issue:** Die Verify-Zeile `biome check $(git diff --name-only --diff-filter=AM 3fc33e3 HEAD ...)` endet mit Exit 1. Die 93 betroffenen Dateien hatten schon an der Basis 3fc33e3 zusammen 158 Format- und Importbefunde.
|
||||
- **Fix:** Ich habe nur die Befunde behoben, die der Merge neu eingebracht hat (12 Dateien, nur Formatierung und Importreihenfolge, eigener Commit 69d1730), dazu alle Befunde in den von mir geschriebenen Zeilen und neuen Dateien. Den Rest ganzer Dateien habe ich nicht umformatiert. Die Vorgabe des Auftraggebers („no new errors“) ist erfüllt: 158 → 156.
|
||||
- **Commit:** 69d1730
|
||||
|
||||
**2. [Rule 2 - Missing critical] Der alte localStorage-Schlüssel wird auch bei gesetztem Server-Wert aufgeräumt**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** Nach Plan hätte bei gesetztem Server-Wert keine Übernahme stattgefunden, der alte Schlüssel wäre dann aber für immer liegen geblieben.
|
||||
- **Fix:** `takeLegacyDashboardBackground` wird je Benutzer einmal aufgerufen und entfernt den Schlüssel immer. Übernommen wird die alte Wahl nur, wenn der Server-Wert `null` ist. Ein Test deckt das ab.
|
||||
|
||||
**3. [Rule 1 - Doc] Die Anwender-Anleitung war schon vor dem Merge an drei Stellen veraltet**
|
||||
- Die Seitenleiste zeigte bereits vorher keine Sprachumschaltung und keine Name/Rolle-Zeile mehr. Die Reiter standen seit 1.4.0 in der Kopfzeile. Der Stift-Schalter ist mit Mosaik zu „Bearbeiten“/„Fertig“ oben rechts geworden. Diese Stellen habe ich beim Nachziehen gegen den gemergten Code korrigiert.
|
||||
|
||||
### Nicht ausgeführt
|
||||
|
||||
- **Browser-Prüfung per Playwright MCP** (Hintergrund wählen, neu laden, zweiter Browserkontext): In dieser Ausführungsumgebung gab es kein Playwright-MCP-Werkzeug. Ersatzweise habe ich den lokalen Stack neu gebaut und geprüft, dass die Route gemappt ist, ohne Anmeldung 401 liefert und die Spalte in der DB existiert. Die Prüfung über zwei Geräte im Browser steht noch aus.
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Eine Übernahme der alten Wahl, die fehlschlägt (z. B. wegen eines API-Ausfalls genau in dem Moment), geht verloren, weil der Schlüssel schon beim Lesen entfernt wird. Der Benutzer wählt dann einfach neu. Das ist bewusst so, damit die Übernahme garantiert nur einmal passiert.
|
||||
- Alte Prototyp-Werte mit einer Bildkennung, die keine UUID ist, werden nicht übernommen. Echte Bilderrahmen-Kennungen sind immer UUIDs.
|
||||
- Das Web liest `@tessera/shared` jetzt auch in `dashboard-background.ts` zur Laufzeit. `parseDashboardBackground` enthält nur löschbare Syntax (Regel aus dem Warnkommentar über `WIDGET_TYPES`).
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine über das Threat-Register hinaus. T-ujj-01 und T-ujj-02 sind wie geplant umgesetzt: Allowlist und UUID-Prüfung beim Schreiben und Lesen, `forTenant` mit `where id = currentUser.id`, kein Kennungsparameter.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
|
||||
- FOUND: apps/web/src/components/dashboard/dashboard-background.test.tsx
|
||||
- FOUND commits: 9fa0a3f, 76f6d87, 0aaa152, 69d1730, cb45d26
|
||||
+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
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
---
|
||||
quick_id: 260929-dmx
|
||||
type: quick
|
||||
wave: 1
|
||||
autonomous: true
|
||||
---
|
||||
|
||||
# Quick 260929-dmx: Widget-Raster horizontal feiner (48 Spalten), Kalender schmaler
|
||||
|
||||
## User requests (29.09.2026)
|
||||
|
||||
1. "das kalender widget soll in der breite schmäler gemacht werden können."
|
||||
2. "und mache das widget raster horizontal etwas feiner."
|
||||
|
||||
## Measurements (orchestrator, browser, lg breakpoint, grid width 1593 px, margin 12)
|
||||
|
||||
- Today: 24 cols → one width unit ≈ 66 px. Calendar minW 6 ≈ 383 px, default 8 ≈ 515 px.
|
||||
- Calendar rendered at 251 px: fully usable (month grid, header "September 2026", event list truncates titles cleanly).
|
||||
- Calendar at 185 px: header clipped, event titles reduced to one letter → too narrow.
|
||||
- Target: calendar minimum ≈ 250 px.
|
||||
|
||||
## Decision (locked)
|
||||
|
||||
Double the HORIZONTAL resolution only: `COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }` in
|
||||
`apps/web/src/components/dashboard/dashboard-grid.tsx`. Row height (20) and margin (12) unchanged.
|
||||
Every existing widget keeps its exact on-screen size and position.
|
||||
|
||||
## Task 1: Grid version 3 (horizontal x2) with migration
|
||||
|
||||
- files: apps/web/src/lib/grid-layout-migration.ts (+ test), apps/web/src/components/dashboard/dashboard-grid.tsx (+ test), apps/web/src/lib/stores/dashboard-store.ts (only if needed)
|
||||
- action:
|
||||
- Bump `GRID_VERSION` to 3. Migration becomes stepwise and cumulative:
|
||||
v1 → v2: existing behavior (x,y,w,h,minW,minH,maxW,maxH × 2).
|
||||
v2 → v3: NEW, horizontal only: x, w, minW, maxW × 2 (y, h, minH, maxH unchanged).
|
||||
So a v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Keep idempotence and marker semantics (marker only in persisted JSON). Update the file header comment (German, same style) to document v3.
|
||||
- `COLS` as above. Check every other place that depends on column count or widget width units:
|
||||
centering offset, `RESIZE_AXIS_FALLBACK`, `breakpointFor`, default positions when adding a widget
|
||||
(`dashboard-grid.tsx` ~380: `defaultW ?? 4`, `minW ?? 4` fallbacks → 8), empty-dashboard suggestions,
|
||||
any layout templates/seed data in apps/web or apps/api (grep `defaultW`, `w:` in dashboard code,
|
||||
`layouts` defaults in apps/api/src/dashboard). Anything expressed in width units gets × 2.
|
||||
- Tests: extend grid-layout-migration tests (v1→v3, v2→v3, v3 untouched, idempotence, marker 3 written), update dashboard-grid tests pinning cols.
|
||||
- verify: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green.
|
||||
|
||||
## Task 2: Widget width constraints in new units
|
||||
|
||||
- files: apps/web/src/components/dashboard/widget-registry.tsx (+ test)
|
||||
- action: In `WIDGET_CONSTRAINTS` double every `minW` and `defaultW` (same physical size as before),
|
||||
EXCEPT calendar: `minW: 8` (≈ 251 px at lg — the measured usable minimum), `defaultW: 16` (unchanged size).
|
||||
minH/defaultH unchanged. Update the comment above calendar (German): narrower on user request 29.09., 8 of 48 ≈ 250 px measured usable.
|
||||
Existing layouts: the existing override logic in dashboard-grid (quick-260916-dyv: stored minW/minH replaced by constants in every breakpoint) must pick up the new calendar minW so existing calendars can be shrunk — verify that path with a test.
|
||||
- verify: web tests green; `pnpm turbo run type-check lint --filter web` green; biome warnings for web not above baseline 55.
|
||||
|
||||
## Task 3: CHANGELOG, rebuild, commit
|
||||
|
||||
- CHANGELOG.md under `## Unveröffentlicht` → `### Geändert` (section exists) add plain-German bullets (app text uses "Sie"):
|
||||
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
|
||||
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
|
||||
- Rebuild local stack: `docker compose up -d --build web` (plain `up` does not rebuild).
|
||||
- Commits per task, messages end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
|
||||
- NEVER git push (orchestrator pushes after browser check).
|
||||
- Browser check is done by the orchestrator (resize calendar to minimum, existing layout unchanged after migration, marker 3 persisted).
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
---
|
||||
quick_id: 260929-dmx
|
||||
status: complete
|
||||
commits: 3
|
||||
plan_head_before: acd3c7a05f9f01e2f5cf8ebe9aed66d8c789a055
|
||||
plan_head_after: 46ebb4e7ceafa1dc782514e487ac820166279f48
|
||||
completed: 2026-09-29
|
||||
actuals:
|
||||
tasks: 3
|
||||
commits: 3
|
||||
---
|
||||
|
||||
# Quick 260929-dmx: Widget-Raster horizontal 48 Spalten, Kalender schmaler
|
||||
|
||||
Grid version 3: horizontal resolution doubled (COLS lg 48 / md 40 / sm 24 / xs 16 / xxs 4), row height 20 and margin 12 unchanged. Stored layouts are migrated stepwise, so every existing widget keeps its on-screen size and position. Calendar minimum is now 8 of 48 columns (about 250 px at lg).
|
||||
|
||||
## Commits
|
||||
|
||||
- 97744b5 feat: grid v3 with stepwise migration, COLS, fallbacks
|
||||
- 9c9e142 feat: widget constraints in 48-column units, calendar minW 8
|
||||
- 46ebb4e docs(changelog): two bullets under "Unveröffentlicht / Geändert"
|
||||
|
||||
## Every place where width units changed
|
||||
|
||||
1. `apps/web/src/lib/grid-layout-migration.ts`: `GRID_VERSION` 2 to 3. Migration is now a step table: v1 to v2 scales x, y, w, h, minW, minH, maxW, maxH by 2; v2 to v3 scales only x, w, minW, maxW by 2. A v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Marker semantics unchanged (marker only in the persisted JSON, stripped on load, re-added by `withGridVersion`). Header comment updated.
|
||||
2. `apps/web/src/components/dashboard/dashboard-grid.tsx`:
|
||||
- `COLS` is `{ lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }`.
|
||||
- Fallback for a widget without a layout entry: `defaultW ?? 8` and `minW ?? 8` (was 4/4). The `?? 4` fallbacks for height stay.
|
||||
- Comment block above BREAKPOINTS/COLS extended.
|
||||
3. `apps/web/src/components/dashboard/widget-registry.tsx` (`WIDGET_CONSTRAINTS`, minW/defaultW doubled, heights untouched):
|
||||
- clock 4/8
|
||||
- search 12/24
|
||||
- calendar minW 8, defaultW 16. Calendar is the only one that is not a plain doubling for minW: 8 instead of 12.
|
||||
- note 8/12
|
||||
- calculator 6/12
|
||||
- favorites 2/12
|
||||
- stopwatch 8/12
|
||||
- picture-frame 8/16
|
||||
- xframe 8/24
|
||||
- proxmox 6/16
|
||||
- Comments updated, German, including the calendar note (narrower on user request 29.09., 8 of 48 is about 250 px measured usable).
|
||||
4. `dashboard-store.ts` needed no code change. `addWidget` reads `defaultW` from `WIDGET_CONSTRAINTS`, so new widgets are placed in the new units automatically. Load and save already route through `migrateGridLayouts` and `withGridVersion`.
|
||||
5. `centeringOffset` needed no code change. It takes `cols` as a parameter and gets the new `COLS[breakpoint]`.
|
||||
6. `breakpointFor` needed no change. It depends only on BREAKPOINTS, not on the column count.
|
||||
|
||||
The existing override in `applyConstraintMinima` (quick-260916-dyv) already replaces the stored minW/minH from the constants in every breakpoint, so existing calendars pick up minW 8 with no code change there. This is verified by a new test.
|
||||
|
||||
## Tests
|
||||
|
||||
- `grid-layout-migration.test.ts`: v1 to v3 (x/w/minW/maxW times 4, y/h/minH/maxH times 2), v2 to v3, v3 untouched, v1 result equals v2 result, idempotence for both paths with marker 3 written, future marker 4 untouched, string marker, foreign values.
|
||||
- `dashboard-store.test.ts`: marker 3, new expected values, new-widget default of 8 wide.
|
||||
- `dashboard-grid.test.tsx`: COLS pin, `centeringOffset` with 48 columns, data-grid fallbacks, minima overrides, new Test 9c (existing calendar with stored minW 12 gets minW 8 in every breakpoint, w 6 raised to 8, w 16 kept).
|
||||
- `widget-registry.test.tsx`: constraints table.
|
||||
- Verification: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green (513). The full web suite is green (995). Type-check for `@tessera/web` is green. Biome shows 55 warnings, equal to the baseline. Note: the turbo filter name is `@tessera/web`, not `web`.
|
||||
|
||||
## Rebuild
|
||||
|
||||
`docker compose up -d --build web` ran, and the web container is up. Browser check is left to the orchestrator (calendar at minimum, existing layout unchanged after migration, marker 3 persisted).
|
||||
|
||||
## Found but deliberately left
|
||||
|
||||
- `apps/api/src/dashboard/dashboard.service.spec.ts:834` stores `__gridVersion: 2` as a passthrough fixture. The API only passes the JSON through, so the value is arbitrary, and I did not touch it.
|
||||
- `RESIZE_AXIS_FALLBACK` and its tests use abstract grid numbers and are unit-independent, so nothing was changed. Its resize logic has no column dependency.
|
||||
- Historical comments that mention "24 Spalten" (the quick-260922-vdk explanation in `dashboard-grid.tsx`, the quick-260916-bwo test description, the bwo header text) describe past states and were left. The vdk comment's numbers (24 columns, 50 px) describe the old bug, not the current state.
|
||||
- Widget internals (calendar, favorites, calculator) use pixel-based or container-based layout, not grid units, so no change was needed.
|
||||
- md/sm/xs/xxs columns are doubled proportionally with lg. Only lg was measured, and I did not check the smaller breakpoints in a browser.
|
||||
- API/`seed`: no default layouts or seed data with width units exist in apps/api (empty defaults `{ lg: [], ... }` only).
|
||||
- A brand-new empty v2 layout is not re-saved with marker 3 on load (`migrated` stays false for empty layouts, existing behaviour). The marker gets written on the next save.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan was executed as written. The SUMMARY, STATE, PLAN and ROADMAP files were not committed, as instructed.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Commits 97744b5, 9c9e142, 46ebb4e exist. Changed files exist. Working tree contains only the untracked quick-task directory.
|
||||
|
||||
## Browser-Pruefung (Orchestrator, 29.09., dunkel, lg 1888 px)
|
||||
|
||||
- Bestehende Anordnung nach Migration pixelgenau gleich (6 Widgets, left/top/width/height vor und nach identisch); DB: `__gridVersion` 3, lg-w verdoppelt.
|
||||
- Kalender im Bearbeitungsmodus nach links gezogen: stoppt bei 252 px (vorher Minimum 383 px), Monatsraster, Kopf und Terminliste sauber lesbar.
|
||||
- Schrittweite beim Ziehen 33 px (284/317/350), vorher 66 px.
|
||||
- Kalender danach wieder auf 515 px gezogen, Testzustand zurueckgesetzt.
|
||||
- Nicht im Browser geprueft: kleinere Breakpoints (md/sm/xs/xxs).
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
quick_id: 260929-dzu
|
||||
type: quick
|
||||
wave: 1
|
||||
autonomous: true
|
||||
---
|
||||
|
||||
# Quick 260929-dzu: Eigene Module für jeden Benutzer (persönlich)
|
||||
|
||||
## User request (29.09.2026)
|
||||
|
||||
"Jeder User soll eigene Module anlegen können. nicht nur admins."
|
||||
Decision (AskUserQuestion, locked): **"Nur er selbst"** — a normal user's entries are visible ONLY to that user.
|
||||
Admins keep creating shared entries (visible to everyone) on /admin/custom-modules as today.
|
||||
No user can put anything into another user's sidebar.
|
||||
|
||||
## Existing state (quick 260929-9wc, commits b9d87be, e7fc4de)
|
||||
|
||||
- Prisma `CustomModule { id, tenantId, name, url, category, createdAt, updatedAt }`, migration
|
||||
`20260929120000_custom_module` with RLS (tenant only, pattern ProxmoxServer).
|
||||
- API `apps/api/src/custom-modules/*`: GET list/one for any authenticated user; POST/PATCH/DELETE admin only;
|
||||
https-only, no credentials in URL.
|
||||
- Web: sidebar loads `listCustomModules()`, frame page `/modules/custom/[id]`, admin page `/admin/custom-modules`
|
||||
with `CustomModuleFormModal` + `DeleteCustomModuleDialog`, `bumpSidebarRefresh` after changes.
|
||||
|
||||
## Task 1: Model + API (tests first)
|
||||
|
||||
- Add nullable `ownerUserId String?` (+ relation to User with onDelete: Cascade, index `[tenantId, ownerUserId]`)
|
||||
via NEW migration (e.g. `20260929130000_custom_module_owner`). `null` = shared (admin-made), set = personal.
|
||||
- RLS: extend the existing policy the way user-scoped tables already do it (find the pattern used by e.g.
|
||||
DashboardImage / Favorite / other tables with a user dimension). Personal rows must only be readable/writable by
|
||||
their owner; shared rows readable by the whole tenant. If the project's RLS pattern handles the user dimension
|
||||
in the service layer instead, follow that pattern and document it. Update the RLS inventory test and
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md` (re-measure totals as last time).
|
||||
- Service/controller:
|
||||
- `GET /custom-modules` → shared rows + rows owned by the caller. Response carries `personal: boolean` (or `ownerUserId === me`).
|
||||
- `GET /custom-modules/:id` → 404 unless shared or owned by caller.
|
||||
- `POST /custom-modules` → any authenticated user; body flag `shared?: boolean`. `shared: true` only allowed for admins
|
||||
(403 otherwise); default personal (ownerUserId = caller). The admin page sends `shared: true`.
|
||||
- `PATCH` / `DELETE` → personal rows: only the owner (404 for others, do not leak existence); shared rows: admin only (403 for non-admin).
|
||||
Ownership/shared-ness cannot be changed via PATCH.
|
||||
- Keep URL validation. Keep static routes before `:id`.
|
||||
- Admin page list: `GET /custom-modules?scope=shared` (admin) or filter client-side — pick the simplest; the admin page shows only shared entries; the settings page only the caller's personal ones.
|
||||
- Tests: service + controller specs for all permission cases (user A cannot see/edit/delete user B's entry; non-admin cannot create/edit/delete shared; admin personal vs shared).
|
||||
- verify: `pnpm --filter @tessera/api exec vitest run src/custom-modules` + RLS inventory test green; migrate local DB (db container IP 172.19.x, tessera/tessera_dev), rebuild api, curl check.
|
||||
|
||||
## Task 2: Web — settings section
|
||||
|
||||
- Settings: new section/page "Eigene Module" in the user settings (`apps/web/src/app/(portal)/settings/`, follow how
|
||||
`general` / `dashboard` sub-pages and their nav are built). Reuse `CustomModuleFormModal` and
|
||||
`DeleteCustomModuleDialog` (move to a shared location if needed, e.g. `components/custom-modules/`) — one form, two callers.
|
||||
Intro text (Sie-Form): e.g. "Nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie."
|
||||
- Admin page: shows only shared entries; intro text states they are visible for all users.
|
||||
- Sidebar: unchanged behavior, shows shared + own personal entries (API already filters). `bumpSidebarRefresh` after changes on the settings page too.
|
||||
- de + en texts; umlaut dictionary if needed.
|
||||
- Tests: component tests for the settings page (create/edit/delete, list only personal), admin page still passes `shared: true`.
|
||||
- verify: `pnpm --filter @tessera/web exec vitest run` green; `pnpm turbo run type-check lint` green; biome web ≤ 55, api ≤ 82.
|
||||
|
||||
## Task 3: CHANGELOG + rebuild
|
||||
|
||||
- CHANGELOG `## Unveröffentlicht` → adjust the existing "Eigene Module" bullet under "Neu" (not released yet, so rewrite it):
|
||||
every user can add own entries under "Einstellungen → Eigene Module", visible only to them; administrators can additionally add entries for everyone under "Verwaltung → Eigene Module". Plain German, Sie-Form.
|
||||
- Update `docs/anleitung-anwender.md` (and admin guide if it mentions custom modules) accordingly.
|
||||
- `docker compose up -d --build web api`.
|
||||
- Commits per task, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. NEVER git push.
|
||||
- Browser check is done by the orchestrator (normal user + admin, dark mode).
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
---
|
||||
phase: quick-260929-dzu
|
||||
plan: 01
|
||||
quick_id: 260929-dzu
|
||||
subsystem: api, web, prisma
|
||||
tags: [custom-modules, personal, rls, settings]
|
||||
status: complete
|
||||
requires: [260929-9wc]
|
||||
provides:
|
||||
- Spalte CustomModule.ownerUserId (NULL = gemeinsam, gesetzt = persoenlich), Migration 20260929130000
|
||||
- Zeilenschutz mit Benutzerdimension nach Muster SearchProvider
|
||||
- API /custom-modules mit persoenlichen und gemeinsamen Eintraegen (Antwortfeld personal)
|
||||
- Einstellungen > Eigene Module (/settings/custom-modules) fuer jeden Benutzer
|
||||
- gemeinsame Oberflaeche CustomModuleManager (Formular, Loeschdialog, Liste) fuer Verwaltung und Einstellungen
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql
|
||||
- apps/web/src/components/custom-modules/custom-module-manager.tsx
|
||||
- apps/web/src/app/(portal)/settings/custom-modules/page.tsx
|
||||
- apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx
|
||||
modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/custom-modules/ (Dienst, Controller, DTO, Specs)
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx (verschoben aus admin/custom-modules/components)
|
||||
- apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx (verschoben)
|
||||
- apps/web/src/app/(portal)/admin/custom-modules/page.tsx (+ Test)
|
||||
- apps/web/src/components/settings/settings-sidebar.tsx
|
||||
- apps/web/src/lib/custom-modules-api.ts
|
||||
- apps/web/src/messages/de.json, en.json
|
||||
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
|
||||
decisions:
|
||||
- "RLS: Muster SearchProvider (nullable Besitzerspalte, vier Regeln je Befehl), nicht die einfache Muster DashboardImage (Pflicht-userId)"
|
||||
- "Gemeinsame Eintraege werden ohne Benutzerkontext geschrieben (forTenant ohne userId), persoenliche mit Benutzer"
|
||||
- "Rollenpruefung fuer gemeinsame Eintraege im Dienst statt per @Roles, weil sie vom Eintrag abhaengt"
|
||||
- "Filter fuer Verwaltung/Einstellungen im Web ueber personal, kein scope-Parameter in der API"
|
||||
- "Texte von Formular und Loeschdialog in eigenen Namensraum customModules.form, Umzug aus admin.customModules"
|
||||
completed: 2026-09-29
|
||||
commits: 3
|
||||
plan_head_before: 76a923450fd2f492ec046d5945a6965c8e94b707
|
||||
plan_head_after: 8f41bd26bd3eddee1979485281cb375ade52949d
|
||||
actuals:
|
||||
tokens: 42000
|
||||
tasks: 3
|
||||
commits: 3
|
||||
---
|
||||
|
||||
# Phase quick-260929-dzu Plan 01: Eigene Module fuer jeden Benutzer Summary
|
||||
|
||||
Jeder angemeldete Benutzer legt unter Einstellungen > Eigene Module persoenliche Seitenleisten-Eintraege an, die nur er sieht; Administratoren pflegen weiter gemeinsame Eintraege unter Verwaltung > Eigene Module (Senden von `shared: true`). Niemand kann etwas in die Seitenleiste eines anderen Benutzers legen.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Aufgabe 1, Commit c703d87 (Modell + API, Tests zuerst angepasst)**
|
||||
- Schema: `ownerUserId String?` mit Relation zu `User` (`onDelete: Cascade`), Index `[tenantId, ownerUserId]`; Gegenfeld `customModules` am `User`.
|
||||
- Migration `20260929130000_custom_module_owner` (von Hand, mit Kopfkommentar): Spalte, Index, Fremdschluessel, alte Regel ersetzt durch vier Regeln.
|
||||
- Dienst/Controller: `GET /custom-modules` liefert gemeinsame plus eigene Zeilen mit `personal: boolean` (ownerUserId wird nicht ausgeliefert); `GET :id` 404 bei fremdem persoenlichem Eintrag (auch fuer Administratoren); `POST` fuer jeden Angemeldeten, `shared: true` nur fuer ADMIN/SUPER_ADMIN (sonst 403), Standard persoenlich; `PATCH`/`DELETE`: persoenlich nur Besitzer (fremd: 404), gemeinsam nur Administrator (sonst 403). `shared`/`ownerUserId` sind per PATCH nicht aenderbar (`OmitType` im DTO plus `whitelist`).
|
||||
- Routen: `list` steht weiter vor `getOne`; kein `@Roles` mehr an den Schreibrouten, die Rollenpruefung sitzt im Dienst.
|
||||
- Specs: Dienst (22 Faelle: A sieht/aendert/loescht B nicht, Nicht-Admin nicht shared, Admin persoenlich vs. gemeinsam, RLS-Bindung mit/ohne Benutzer), Controller, DTO-Pipe-Faelle.
|
||||
- Zugriffsklassifikation nachgemessen (siehe unten).
|
||||
|
||||
**Aufgabe 2, Commit ee97b4e (Web)**
|
||||
- Neue Seite `/settings/custom-modules` und Nav-Eintrag „Eigene Module“ unter „Allgemein“.
|
||||
- Gemeinsame Komponenten unter `components/custom-modules/`: `CustomModuleFormModal` und `DeleteCustomModuleDialog` (verschoben, Parameter `shared`) plus neu `CustomModuleManager` (Liste, Anlegen/Bearbeiten/Loeschen, `bumpSidebarRefresh`), aufgerufen mit `scope="shared"` (Verwaltung) oder `scope="personal"` (Einstellungen). Filter ueber `personal` im Web.
|
||||
- Verwaltung sendet beim Anlegen `shared: true`, zeigt nur gemeinsame Eintraege, Einleitung nennt „alle Benutzer“; Einstellungen senden kein `shared`, Einleitung: „Diese Einträge sehen nur Sie.“
|
||||
- Texte de/en (Namensraeume `customModules.form`, `customModules.manage`, `settings.customModules`), Umlaut-Waechter gruen.
|
||||
- Tests: neuer Settings-Test (7), Admin-Test angepasst (`shared: true`, Filter; 14).
|
||||
|
||||
**Aufgabe 3, Commit 8f41bd2 (Doku) + Neubau**
|
||||
- CHANGELOG-Punkt „Eigene Module“ umgeschrieben (Einstellungen fuer jeden, Verwaltung zusaetzlich fuer alle), `docs/anleitung-anwender.md` (Abschnitt „Allgemein > Eigene Module“) und `docs/anleitung-administration.md` (Unterabschnitt bei 5.).
|
||||
- `docker compose up -d --build web api`: web :3000/login 200, api /health ok, `GET /custom-modules` anonym 401, `/settings/custom-modules` ohne Anmeldung 307 (Umleitung auf Login).
|
||||
|
||||
## RLS-Muster und Begruendung
|
||||
|
||||
Gefolgt bin ich dem Muster **SearchProvider** aus `20260911120000_rls_user_dimension_personal_tables`: nullable Besitzerspalte, vier nach Befehl getrennte Regeln.
|
||||
- SELECT: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId IS NULL` ODER `ownerUserId = current_user_id()`).
|
||||
- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId = current_user_id()`).
|
||||
|
||||
Warum nicht das einfachere Muster DashboardImage/Favorite (Pflicht-`userId`, eine Regel): eigene Module haben gemeinsame Zeilen (`NULL`), die jeder lesen, aber nur ein Administrator schreiben darf. Eine einzelne Regel, die die gemeinsame Zeile zum Lesen freigibt, wuerde sie auch zum Aendern/Loeschen freigeben (Praezedenz 260910-jab (3)), deshalb getrennte Befehle. Folge: ein Benutzerkontext kann gemeinsame Zeilen nicht schreiben; der Dienst bindet Schreibzugriffe auf gemeinsame Eintraege deshalb bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), nachdem er die Administrator-Rolle geprueft hat. Persoenliche Zugriffe binden mit Benutzer. Wie bei allen RLS-Regeln wirkt der Schutz erst mit dem Datenbankrollen-Schalter (heute AUS); bis dahin tragen die Anwendungspruefungen (`row.tenantId`, `ownerUserId`) den Schutz.
|
||||
|
||||
## Curl-Pruefung (lokal, echte API :3001)
|
||||
|
||||
Benutzer: admin (SUPER_ADMIN), testuser (USER), curltmp (USER, nur fuer die Pruefung angelegt und danach geloescht).
|
||||
|
||||
| Fall | Ergebnis |
|
||||
|------|----------|
|
||||
| USER legt Eintrag ohne shared an | 200, `personal: true` |
|
||||
| USER `shared: true` | 403 „Gemeinsame Einträge dürfen nur Administratoren anlegen“ |
|
||||
| Admin `shared: true` | 200, `personal: false` |
|
||||
| Admin ohne shared | 200, `personal: true` |
|
||||
| Liste USER | gemeinsam + eigener |
|
||||
| Liste zweiter USER | nur gemeinsam |
|
||||
| Liste Admin | nur gemeinsam (persoenliche Eintraege anderer nicht) |
|
||||
| zweiter USER: GET / PATCH / DELETE auf fremden persoenlichen Eintrag | 404 / 404 / 404 |
|
||||
| Admin: GET / DELETE auf persoenlichen Eintrag eines Benutzers | 404 / 404 |
|
||||
| USER GET gemeinsam | 200 |
|
||||
| USER PATCH / DELETE gemeinsam | 403 / 403 |
|
||||
| Admin PATCH gemeinsam (mit eingeschmuggeltem `shared:false`) | 200, bleibt gemeinsam |
|
||||
| USER PATCH eigenen mit `shared:true`, `ownerUserId:null` | 200, bleibt persoenlich |
|
||||
| http-Adresse | 400 |
|
||||
| anonym | 401 |
|
||||
| Benutzer loeschen -> seine persoenlichen Eintraege | Cascade, 0 Zeilen |
|
||||
|
||||
Alle Testeintraege sind geloescht, `CustomModule` ist leer.
|
||||
|
||||
## Tore (gemessen)
|
||||
|
||||
| Tor | Ergebnis |
|
||||
|-----|----------|
|
||||
| API-Tests vollstaendig | 88 Dateien, 1511 Tests gruen |
|
||||
| Web-Tests vollstaendig | 103 Dateien, 1003 Tests gruen |
|
||||
| `pnpm turbo run type-check lint --force` | 9/9 erfolgreich |
|
||||
| Biome-Warnungen Web / API | 55 (Grundlinie 55) / 82 (Grundlinie 82) |
|
||||
| rls-coverage / rls-access-inventory | gruen |
|
||||
| `prisma migrate deploy` lokal (Container-IP 172.19.0.2) | Migration angewendet, `migrate diff` danach leer |
|
||||
| Zugriffsklassifikation | Gate-Schleife 61/223/6 (vorher 61/224/6); `custom-modules` 0/6/0 |
|
||||
|
||||
## Testbenutzer fuer die Browser-Pruefung des Orchestrators
|
||||
|
||||
Es gab lokal schon die Nicht-Admin-Konten `nutzer1` und `nutzer2`, deren Passwoerter aber nicht bekannt sind. Deshalb habe ich per Admin-API angelegt: Login **testuser**, Passwort **Test1234!test** (Rolle USER, `mustChangePassword` auf false gesetzt, damit die Anmeldung nicht auf die Passwort-Seite umleitet). Der Administrator ist wie gehabt admin / admin123.
|
||||
|
||||
Vorschlag fuer die Browser-Pruefung (dunkel): als testuser unter Einstellungen > Allgemein > Eigene Module einen Eintrag anlegen (Seitenleiste zieht ohne Neuladen nach), als admin unter Verwaltung > Eigene Module einen gemeinsamen Eintrag anlegen (testuser sieht ihn in der Seitenleiste, kann ihn unter Einstellungen aber nicht bearbeiten), als admin pruefen, dass der persoenliche Eintrag von testuser weder in Seitenleiste noch Verwaltung erscheint. Danach die Testeintraege loeschen.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Festplatte voll (0 Byte frei) mitten in der Arbeit**
|
||||
- **Found during:** Aufgabe 2 (Biome meldete „No space left on device“)
|
||||
- **Issue:** die Docker-Build-Cache-Ablagen der Neubauten fuellten die Platte.
|
||||
- **Fix:** `docker builder prune -f` (nur Build-Cache, 17,97 GB, keine Images, Container oder Volumes); danach type-check/lint/Tests frisch und vollstaendig wiederholt, alle gruen.
|
||||
- **Commit:** kein Code betroffen.
|
||||
|
||||
**2. [Rule 1 - Bug] Detektor-Vorgaben fuer `forTenant`**
|
||||
- **Found during:** Aufgabe 1 (rls-access-inventory schlug zweimal fehl)
|
||||
- **Issue:** eine Ternary-Bindung (`shared ? forTenant(..) : forTenant(..)`) und ein `client.customModule.create` in einer Hilfsfunktion werden vom Detektor nicht als Zuweisungsform/Modellaufruf erkannt.
|
||||
- **Fix:** je Zweig `const tenantPrisma = forTenant(...)` mit direktem Modellaufruf; Ausnahmeliste unveraendert leer.
|
||||
- **Files modified:** `apps/api/src/custom-modules/custom-modules.service.ts`
|
||||
- **Commit:** c703d87
|
||||
|
||||
**3. Plan-Feinheit:** Kein API-Parameter `scope`; die Verwaltung filtert im Web ueber `personal` (Plan liess beides zu, „das Einfachste“). Nebenwirkung: die Verwaltungsseite laedt auch die eigenen persoenlichen Eintraege des Administrators und blendet sie aus.
|
||||
|
||||
**4. Plan-Feinheit:** Formular-/Dialog-Texte aus `admin.customModules` in den neuen Namensraum `customModules.form` umgezogen (beide Aufrufer teilen sie); Admin-Test entsprechend angepasst. Die Anleitung des Anwenders hatte den Punkt „Eigene Module“ vorher nicht, er ist jetzt neu beschrieben (der Plan sprach von „aktualisieren“).
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Zwischen c703d87 und ee97b4e liegt ein fremder Commit `bc4c011` (fix(web) Widgets nicht mehr zur Mitte versetzen), nicht von diesem Plan; er beruehrt CHANGELOG.md und `docs/anleitung-anwender.md` an anderen Stellen. Die 3 Commits dieses Plans sind c703d87, ee97b4e, 8f41bd2 (`git rev-list` ab dem Vorgaenger von c703d87 zaehlt 4 inklusive des fremden). Der Ledger nach Protokoll 0c wurde nicht vor dem ersten Commit angelegt, `plan_head_before` ist deshalb der Vorgaenger von c703d87.
|
||||
- Die Verwaltungs-Nav zeigt weiterhin „Eigene Module“; sie fuehrt jetzt auf die gemeinsamen Eintraege, die Einleitung nennt das.
|
||||
- Nichts gepusht.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Angriffsflaeche ausserhalb des bestehenden Modells: `ownerUserId` kommt nie aus dem Body (Whitelist, im Test belegt), `shared` ist per PATCH nicht setzbar, fremde persoenliche Eintraege sind ununterscheidbar 404.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Dateien vorhanden: Migration `20260929130000_custom_module_owner`, `custom-module-manager.tsx`, `settings/custom-modules/page.tsx`, Settings-Test.
|
||||
- Commits vorhanden: c703d87, ee97b4e, 8f41bd2 (`git log`); nichts gepusht (`git branch -r --contains HEAD` leer).
|
||||
|
||||
## Browser-Pruefung (Orchestrator, 29.09.)
|
||||
|
||||
- testuser: Einstellungen > Eigene Module vorhanden; „Meine Seite“ angelegt -> sofort in eigener Seitenleiste (Infrastruktur).
|
||||
- admin: sieht „Meine Seite“ weder in Seitenleiste noch Verwaltung; Direktlink zeigt „Dieses Modul gibt es nicht mehr.“; Verwaltung heisst „Gemeinsamen Eintrag anlegen“.
|
||||
- admin legt „Firmenseite“ (Sicherheit) an -> testuser sieht sie in der Seitenleiste, nicht in seinen Einstellungen; DELETE als testuser -> 403.
|
||||
- Nebenbei: Widgets nicht mehr zentriert (bc4c011) — alle linken Kanten am Raster (272 px bei Rasterbeginn 260 + 12 Rand).
|
||||
- Testeintraege geloescht, CustomModule leer.
|
||||
@@ -4,9 +4,69 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
## 1.7.0 – 2026-09-29
|
||||
|
||||
### Neu
|
||||
|
||||
- Eigene Module: Als Kategorie steht jetzt auch „Eigene Module“ zur Auswahl. Einträge dort erscheinen gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste; die Gruppe ist nur zu sehen, solange ein Eintrag darin liegt. Bestehende Einträge verschieben Sie über „Bearbeiten“ dorthin.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Seitenleiste: Ist sie eingeklappt, sind die Symbole etwas größer und stehen etwas enger beieinander.
|
||||
- Dashboard: Im Such-Widget haben die Auswahl der Suchmaschine und das Suchfeld keine helle Linie an der Unterkante mehr.
|
||||
|
||||
## 1.6.0 – 2026-09-29
|
||||
|
||||
### Neu
|
||||
|
||||
- Eigene Module: Jeder Benutzer kann unter „Einstellungen → Eigene Module“ Webseiten, die er oft braucht, als eigene Einträge in seine Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Diese Einträge sieht nur der Benutzer selbst. Ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“. Administratoren können zusätzlich unter „Verwaltung → Eigene Module“ Einträge für alle Benutzer anlegen; die sehen dann alle unter der gewählten Kategorie.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Dashboard: Die Widgets bleiben beim Darüberfahren mit der Maus ruhig stehen, sie heben sich nicht mehr an.
|
||||
- Dashboard: Die Widgets stehen in der Ansicht genau dort, wo Sie sie beim Bearbeiten platziert haben. Bisher rückte Tessera sie nach dem Bearbeiten zur Seitenmitte, sodass etwa ein einzelnes Widget oben links plötzlich in die Mitte sprang.
|
||||
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
|
||||
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Desktop-App: Tessera startet nicht mehr doppelt. Wird die App ein zweites Mal gestartet – etwa beim Anmelden an Windows –, holt sie nur das vorhandene Fenster nach vorne; im Infobereich erscheint nur noch ein Symbol.
|
||||
- Desktop-App: Die Suche im Such-Widget und Knöpfe wie „In neuem Tab öffnen“ funktionieren jetzt auch in der Desktop-App – die Seite öffnet sich in Ihrem normalen Browser. Bisher passierte dort beim Klick nichts.
|
||||
|
||||
## 1.5.2 – 2026-09-28
|
||||
|
||||
### Neu
|
||||
|
||||
- Dashboard: Den Titel eines Widgets können Sie jetzt ausblenden. Im Bearbeitungsmodus sitzt dafür oben in der Mitte jedes Widgets mit Titel ein kleines „T“; ein Klick blendet den Titel aus, ein zweiter wieder ein. Die Einstellung gilt je Widget und bleibt gespeichert. Im Bearbeitungsmodus sehen Sie den Titel weiterhin, damit Sie ihn ändern können; Knöpfe wie der Stift der Notiz bleiben auch ohne Titel oben rechts erreichbar.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Seitenleiste: Die Kategorien (etwa „Fuhrpark“ oder „Infrastruktur“) sind etwas größer beschriftet, die Module darunter etwas kleiner – so ist die Gliederung auf einen Blick erkennbar.
|
||||
|
||||
## 1.5.1 – 2026-09-28
|
||||
|
||||
### Geändert
|
||||
|
||||
- Dashboard: Der Knopf „Bearbeiten“ sitzt jetzt als Stift-Symbol rechts in der dunklen Leiste oben und nimmt über den Widgets keinen Platz mehr weg. Im Bearbeitungsmodus erscheinen dort auch „Hintergrund“, „Widget hinzufügen“ und „Fertig“.
|
||||
|
||||
## 1.5.0 – 2026-09-28
|
||||
|
||||
### Neu
|
||||
|
||||
- Nach einem Versionswechsel zeigt Tessera bei Ihrer ersten Anmeldung ein Fenster mit den wichtigsten Änderungen der neuen Version – neue Funktionen, Verbesserungen und behobene Fehler. Haben Sie mehrere Versionen verpasst, erscheinen die drei neuesten. „Verstanden“ schließt das Fenster; es erscheint erst mit der nächsten Version wieder, im Browser wie in der Desktop-App. Die vollständige Liste finden Sie weiterhin unter „Was ist neu“.
|
||||
- Dashboard: Sie können jetzt einen Hintergrund wählen. Im Bearbeitungsmodus öffnet der Knopf „Hintergrund“ eine Auswahl – keiner, ruhige Flächen und Motive in Gelb, Grau und Graphit, oder ein eigenes Bild aus Ihren Bilderrahmen-Bildern bzw. ein neu hochgeladenes. Der Hintergrund gilt nur für Sie und folgt Ihnen auf jedes Gerät, auf dem Sie sich anmelden, auch in die Desktop-App. Eine bisher nur in Ihrem Browser gemerkte Wahl wird dabei automatisch übernommen.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Neues Aussehen: Tessera hat eine dunkle App-Leiste oben und eine neu gestaltete Seitenleiste. Jedes Modul erscheint dort mit einer eigenen kleinen Kachel, die Kategorien tragen deutsche Namen und sind anfangs aufgeklappt, und unten in der Seitenleiste begrüßt Sie Tessera mit Ihrem Namen und dem heutigen Datum. Die persönliche Akzentfarbe hebt nur noch das Modul hervor, in dem Sie gerade arbeiten. Auch die übrigen Seiten – Marktplatz, Module, Einstellungen und Verwaltung – folgen diesem ruhigeren Stil.
|
||||
- Neue Anmeldeseite: Auf großen Bildschirmen ist sie geteilt, links ein dunkler Bereich mit dem Tessera-Zeichen und einem Farbmosaik, rechts das Anmeldeformular.
|
||||
- Dashboard: Die Kacheln stehen mittig auf der Seite. Jedes Widget trägt oben ein gelbes Symbol-Feld, hebt sich beim Darüberfahren leicht an und blendet beim Laden sanft ein. Die Knöpfe „Bearbeiten“, „Widget hinzufügen“ und „Hintergrund“ sitzen jetzt als Leiste oben rechts über den Kacheln statt unten rechts. Kalender, Favoriten und Notizen sind ruhiger gestaltet, und ein leeres Dashboard schlägt Ihnen passende erste Kacheln vor.
|
||||
- Kalender-Widget: Die nächsten Termine stehen jetzt in einer kompakten, einzeiligen Liste, in der „Heute“ und „Morgen“ statt des Datums erscheinen – so passen auch in eine kleine Kachel mehrere Termine. Den Ort eines Termins sehen Sie, wenn Sie mit der Maus darüberfahren.
|
||||
- Auf dem Handy öffnet sich die Seitenleiste als Schublade über der ganzen Seite, samt App-Leiste.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Dashboard: Widgets ließen sich manchmal nicht schmaler ziehen, wenn die Maus dabei leicht nach oben oder unten wackelte. Jetzt klappt das zuverlässig.
|
||||
|
||||
## 1.4.0 – 2026-09-25
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- quick-260928-ujj: Dashboard-Hintergrund pro Benutzer in der Datenbank.
|
||||
--
|
||||
-- Bisher lag die Wahl des Dashboard-Hintergrunds (Design "Mosaik") im
|
||||
-- localStorage des Browsers und folgte dem Benutzer nicht auf ein anderes
|
||||
-- Geraet oder in die Desktop-App. Jetzt steht sie hier, geschrieben nur ueber
|
||||
-- PATCH /users/me/dashboard-background und dort wie beim Lesen durch
|
||||
-- parseDashboardBackground (@tessera/shared) geprueft und normalisiert.
|
||||
-- NULL = nie gewaehlt (das Web uebernimmt dann einmalig eine alte
|
||||
-- localStorage-Wahl); sonst ein Objekt { kind: 'none' | 'preset' | 'image', ... }.
|
||||
-- Bewusst kein Standardwert und kein Backfill.
|
||||
--
|
||||
-- Die Anmelde-Funktionen auth_lookup_* liefern eine feste Spaltenliste
|
||||
-- (RETURNS TABLE) und bleiben von der neuen Spalte unberuehrt.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "dashboardBackground" JSONB;
|
||||
@@ -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());
|
||||
@@ -0,0 +1,77 @@
|
||||
-- 260929-dzu — Eigene Module fuer jeden Benutzer: persoenliche Eintraege.
|
||||
--
|
||||
-- Zweck: jeder Benutzer darf eigene Seitenleisten-Eintraege anlegen, die nur
|
||||
-- er selbst sieht. Die Spalte "ownerUserId" unterscheidet: NULL = gemeinsamer
|
||||
-- Eintrag (vom Administrator, fuer alle sichtbar, bisheriges Verhalten),
|
||||
-- gesetzt = persoenlicher Eintrag dieses Benutzers. Faellt der Benutzer weg,
|
||||
-- fallen seine Eintraege mit (ON DELETE CASCADE). Bestehende Zeilen bleiben
|
||||
-- gemeinsam (NULL).
|
||||
--
|
||||
-- Zeilenschutz: Muster "SearchProvider" (20260911120000_rls_user_dimension_
|
||||
-- personal_tables) — Spalte mit NULL = gemeinsame Zeile. Die eine Regel
|
||||
-- "tenant_isolation_policy" (aus 20260929120000, ohne Benutzerdimension) wird
|
||||
-- durch vier nach Befehl getrennte Regeln ersetzt (Praezedenz 260910-jab (3)):
|
||||
-- ein einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst,
|
||||
-- wuerde sie sonst auch zum Aendern/Entfernen freigeben.
|
||||
-- SELECT: Mandant UND (kein Benutzer gesetzt ODER gemeinsame Zeile ODER
|
||||
-- eigene Zeile).
|
||||
-- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER eigene
|
||||
-- Zeile). Ein Benutzerkontext kann gemeinsame Zeilen also NICHT
|
||||
-- schreiben; der Administrator-Weg fuer gemeinsame Eintraege bindet
|
||||
-- deshalb ohne Benutzer (`forTenant(prisma, tenantId)`), die
|
||||
-- Rollenpruefung liegt im Controller/Dienst.
|
||||
-- Die Regelnamen sind neu (vier statt eine), rls-coverage.spec.ts fordert nur
|
||||
-- mindestens eine Regel je Tabelle mit eingeschaltetem RLS.
|
||||
--
|
||||
-- Rechte fuer tessera_app kommen ueber ALTER DEFAULT PRIVILEGES aus
|
||||
-- 20260909130000_rls_app_role — hier nichts zu tun.
|
||||
--
|
||||
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
|
||||
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
|
||||
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
|
||||
-- Anwendungspruefungen im Dienst den Schutz allein.
|
||||
|
||||
ALTER TABLE "CustomModule" ADD COLUMN "ownerUserId" TEXT;
|
||||
|
||||
CREATE INDEX "CustomModule_tenantId_ownerUserId_idx" ON "CustomModule"("tenantId", "ownerUserId");
|
||||
|
||||
ALTER TABLE "CustomModule" ADD CONSTRAINT "CustomModule_ownerUserId_fkey"
|
||||
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
DROP POLICY tenant_isolation_policy ON "CustomModule";
|
||||
|
||||
CREATE POLICY tenant_user_read_policy ON "CustomModule"
|
||||
FOR SELECT
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (
|
||||
current_user_id() IS NULL
|
||||
OR "ownerUserId" IS NULL
|
||||
OR "ownerUserId" = current_user_id()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_user_insert_policy ON "CustomModule"
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_user_update_policy ON "CustomModule"
|
||||
FOR UPDATE
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||
)
|
||||
WITH CHECK (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||
);
|
||||
|
||||
CREATE POLICY tenant_user_delete_policy ON "CustomModule"
|
||||
FOR DELETE
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||
);
|
||||
@@ -46,9 +46,14 @@ model User {
|
||||
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
|
||||
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
|
||||
lastSeenReleaseVersion String?
|
||||
// quick-260928-ujj: gewaehlter Dashboard-Hintergrund; null = nie gewaehlt,
|
||||
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
||||
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
||||
dashboardBackground Json?
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
groupMemberships GroupMembership[]
|
||||
moduleGrants ModuleGrant[]
|
||||
customModules CustomModule[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([username])
|
||||
@@ -712,3 +717,25 @@ 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
|
||||
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
|
||||
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
|
||||
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
|
||||
ownerUserId String?
|
||||
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, ownerUserId])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -49,6 +49,7 @@ interface FakeUserRow {
|
||||
mustChangePassword: boolean;
|
||||
avatarPath?: string | null;
|
||||
accentColor?: string | null;
|
||||
dashboardBackground?: unknown;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
@@ -501,6 +502,8 @@ describe('AuthService.getMe', () => {
|
||||
mustChangePassword: false,
|
||||
avatarPath: 'avatars/u1.png',
|
||||
accentColor: '#3b82f6',
|
||||
// quick-260928-ujj: gespeicherter Zusatzschluessel wird bei der Ausgabe verworfen.
|
||||
dashboardBackground: { kind: 'preset', id: 'dunes', extra: 'weg' },
|
||||
};
|
||||
|
||||
const ldapUserRow: FakeUserRow = {
|
||||
@@ -515,6 +518,7 @@ describe('AuthService.getMe', () => {
|
||||
mustChangePassword: false,
|
||||
avatarPath: null,
|
||||
accentColor: null,
|
||||
dashboardBackground: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -535,6 +539,7 @@ describe('AuthService.getMe', () => {
|
||||
tenantId: 't1',
|
||||
mustChangePassword: false,
|
||||
accentColor: '#3b82f6',
|
||||
dashboardBackground: { kind: 'preset', id: 'dunes' },
|
||||
isLocalUser: true,
|
||||
hasAvatar: true,
|
||||
});
|
||||
@@ -549,6 +554,30 @@ describe('AuthService.getMe', () => {
|
||||
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
|
||||
});
|
||||
|
||||
it('quick-260928-ujj: dashboardBackground NULL (nie gewaehlt) kommt als null zurueck', async () => {
|
||||
const result = await service.getMe('t1', 'u2');
|
||||
|
||||
expect(result).toHaveProperty('dashboardBackground', null);
|
||||
});
|
||||
|
||||
it('quick-260928-ujj: ungueltiger gespeicherter Hintergrund kommt als null zurueck (T-ujj-01)', async () => {
|
||||
prisma.__users.set('u1', {
|
||||
...prisma.__users.get('u1'),
|
||||
dashboardBackground: { kind: 'image', imageId: '") ; background: url("x' },
|
||||
});
|
||||
|
||||
const result = await service.getMe('t1', 'u1');
|
||||
|
||||
expect(result).toHaveProperty('dashboardBackground', null);
|
||||
});
|
||||
|
||||
it('quick-260928-ujj: dashboardBackground steht im select neben accentColor', async () => {
|
||||
await service.getMe('t1', 'u1');
|
||||
|
||||
const call = prisma.__boundCallLog.find((c: any) => c.method === 'findUnique');
|
||||
expect(call.args.select).toMatchObject({ accentColor: true, dashboardBackground: true });
|
||||
});
|
||||
|
||||
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
|
||||
const result = await service.getMe('t2', 'u1');
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Role } from '@prisma/client';
|
||||
import { parseDashboardBackground } from '@tessera/shared';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Response } from 'express';
|
||||
@@ -331,6 +332,7 @@ export class AuthService {
|
||||
ldapDn: true,
|
||||
avatarPath: true,
|
||||
accentColor: true,
|
||||
dashboardBackground: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -338,10 +340,13 @@ export class AuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { passwordHash, ldapDn, avatarPath, ...publicFields } = user;
|
||||
const { passwordHash, ldapDn, avatarPath, dashboardBackground, ...publicFields } = user;
|
||||
|
||||
return {
|
||||
...publicFields,
|
||||
// quick-260928-ujj (T-ujj-01): auch beim Lesen durch die gemeinsame
|
||||
// Pruefregel — NULL oder ein ungueltiger Inhalt ergibt null.
|
||||
dashboardBackground: parseDashboardBackground(dashboardBackground),
|
||||
isLocalUser: !!passwordHash && !ldapDn,
|
||||
hasAvatar: !!avatarPath,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'reflect-metadata';
|
||||
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { CustomModulesController } from './custom-modules.controller';
|
||||
import { CreateCustomModuleDto, UpdateCustomModuleDto } 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 user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
|
||||
const proto = CustomModulesController.prototype as any;
|
||||
|
||||
describe('CustomModulesController — Rollen (quick-260929-dzu)', () => {
|
||||
// Jeder Angemeldete darf persoenliche Eintraege anlegen/aendern/loeschen; die
|
||||
// Administrator-Pflicht fuer gemeinsame Eintraege prueft der Dienst (hangt
|
||||
// vom Eintrag ab, nicht von der Route) — siehe custom-modules.service.spec.ts.
|
||||
it.each([
|
||||
'list',
|
||||
'getOne',
|
||||
'create',
|
||||
'update',
|
||||
'remove',
|
||||
])('%s traegt keine Routen-Rolle (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'), user);
|
||||
await controller.getOne(req('t1'), user, 'x');
|
||||
await controller.create(req('t1'), user, { name: 'a', url: 'https://a.de', category: 'fleet' });
|
||||
await controller.update(req('t1'), user, 'x', { name: 'b' });
|
||||
await controller.remove(req('t1'), user, 'x');
|
||||
expect(service.list).toHaveBeenCalledWith('t1', user);
|
||||
expect(service.getOne).toHaveBeenCalledWith('t1', user, 'x');
|
||||
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', user]);
|
||||
expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', user, 'x']);
|
||||
expect(service.remove).toHaveBeenCalledWith('t1', user, 'x');
|
||||
});
|
||||
|
||||
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||
const controller = new CustomModulesController(makeService() as any);
|
||||
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.getOne(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
controller.create(req(), user, { name: 'a', url: 'https://a.de', category: 'fleet' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.remove(req(), user, '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');
|
||||
});
|
||||
|
||||
it('die globale Pipe verwirft ownerUserId, laesst shared beim Anlegen durch', async () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const out: any = await pipe.transform(
|
||||
{ name: 'a', url: 'https://a.de', category: 'fleet', ownerUserId: 'evil', shared: true },
|
||||
{ type: 'body', metatype: CreateCustomModuleDto },
|
||||
);
|
||||
expect(out).not.toHaveProperty('ownerUserId');
|
||||
expect(out.shared).toBe(true);
|
||||
});
|
||||
|
||||
it('die globale Pipe verwirft shared und ownerUserId beim Aendern', async () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const out: any = await pipe.transform(
|
||||
{ name: 'b', shared: true, ownerUserId: 'evil' },
|
||||
{ type: 'body', metatype: UpdateCustomModuleDto },
|
||||
);
|
||||
expect(out).not.toHaveProperty('shared');
|
||||
expect(out).not.toHaveProperty('ownerUserId');
|
||||
expect(out.name).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
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,86 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
||||
import { CustomModulesService } from './custom-modules.service';
|
||||
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||
|
||||
/**
|
||||
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder
|
||||
* angemeldete Benutzer darf lesen und eigene (persoenliche) Eintraege anlegen,
|
||||
* aendern und loeschen; gemeinsame Eintraege (`shared: true`) darf nur ein
|
||||
* Administrator anlegen, aendern und loeschen — diese Rollenentscheidung trifft
|
||||
* der Dienst, weil sie vom Eintrag abhaengt (gemeinsam oder persoenlich), nicht
|
||||
* von der Route. Deshalb tragen die Routen kein `@Roles`. Kein `@UseModule`:
|
||||
* eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt
|
||||
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), der Benutzer
|
||||
* aus dem Token.
|
||||
*
|
||||
* 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, @CurrentUser() user: AuthUser) {
|
||||
return this.service.list(this.requireTenantId(req), user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getOne(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.service.getOne(this.requireTenantId(req), user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: CreateCustomModuleDto,
|
||||
) {
|
||||
return this.service.create(this.requireTenantId(req), user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCustomModuleDto,
|
||||
) {
|
||||
return this.service.update(this.requireTenantId(req), user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.service.remove(this.requireTenantId(req), user, 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,279 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
|
||||
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten je Methode
|
||||
// gebunden wird (mit oder ohne Benutzer).
|
||||
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 (where?.OR) {
|
||||
list = list.filter((r) =>
|
||||
where.OR.some((c: { ownerUserId: string | null }) => r.ownerUserId === c.ownerUserId),
|
||||
);
|
||||
}
|
||||
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 };
|
||||
const admin = { id: 'admin1', role: Role.ADMIN };
|
||||
const userA = { id: 'ua', role: Role.USER };
|
||||
const userB = { id: 'ub', role: Role.USER };
|
||||
|
||||
function setup() {
|
||||
const prisma = makeFakePrisma();
|
||||
return { prisma, service: new CustomModulesService(prisma as any) };
|
||||
}
|
||||
|
||||
describe('CustomModulesService — anlegen', () => {
|
||||
it('speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
|
||||
const { prisma, service } = setup();
|
||||
await service.create('t1', userA, { ...dto, tenantId: 'evil' } as any);
|
||||
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
|
||||
});
|
||||
|
||||
it('ohne shared-Angabe ist der Eintrag persoenlich (ownerUserId = Aufrufer)', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const res: any = await service.create('t1', userA, dto);
|
||||
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
|
||||
expect(res.personal).toBe(true);
|
||||
expect(res).not.toHaveProperty('ownerUserId');
|
||||
});
|
||||
|
||||
it('auch ein Administrator legt ohne shared persoenlich an', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const res: any = await service.create('t1', admin, dto);
|
||||
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('admin1');
|
||||
expect(res.personal).toBe(true);
|
||||
});
|
||||
|
||||
it('shared: true durch einen Administrator legt einen gemeinsamen Eintrag an', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const res: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBeNull();
|
||||
expect(res.personal).toBe(false);
|
||||
});
|
||||
|
||||
it('shared: true durch einen normalen Benutzer -> ForbiddenException, nichts gespeichert', async () => {
|
||||
const { prisma, service } = setup();
|
||||
await expect(service.create('t1', userA, { ...dto, shared: true })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(prisma.customModule.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shared: false durch einen normalen Benutzer bleibt persoenlich', async () => {
|
||||
const { prisma, service } = setup();
|
||||
await service.create('t1', userA, { ...dto, shared: false });
|
||||
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
|
||||
});
|
||||
|
||||
it('das Feld shared landet nie in den gespeicherten Daten', async () => {
|
||||
const { prisma, service } = setup();
|
||||
await service.create('t1', admin, { ...dto, shared: true });
|
||||
expect(prisma.customModule.create.mock.calls[0][0].data).not.toHaveProperty('shared');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CustomModulesService — lesen', () => {
|
||||
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
|
||||
const { service } = setup();
|
||||
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
|
||||
await service.create('t1', userA, { ...dto, name: 'A-privat' });
|
||||
await service.create('t1', userB, { ...dto, name: 'B-privat' });
|
||||
const resA: any[] = await service.list('t1', userA);
|
||||
expect(resA.map((r) => [r.name, r.personal])).toEqual([
|
||||
['A-privat', true],
|
||||
['Gemeinsam', false],
|
||||
]);
|
||||
const resB: any[] = await service.list('t1', userB);
|
||||
expect(resB.map((r) => r.name)).toEqual(['B-privat', 'Gemeinsam']);
|
||||
});
|
||||
|
||||
it('list sieht auch als Administrator keine persoenlichen Eintraege anderer', async () => {
|
||||
const { service } = setup();
|
||||
await service.create('t1', userA, { ...dto, name: 'A-privat' });
|
||||
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
|
||||
const res: any[] = await service.list('t1', admin);
|
||||
expect(res.map((r) => r.name)).toEqual(['Gemeinsam']);
|
||||
});
|
||||
|
||||
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
|
||||
const { prisma, service } = setup();
|
||||
await service.create('t1', admin, { ...dto, name: 'Zebra', shared: true });
|
||||
await service.create('t1', admin, { ...dto, name: 'Anker', shared: true });
|
||||
await service.create('t2', admin, { ...dto, name: 'Fremd', shared: true });
|
||||
const result: any[] = await service.list('t1', userA);
|
||||
expect(result.map((r) => r.name)).toEqual(['Anker', 'Zebra']);
|
||||
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({
|
||||
tenantId: 't1',
|
||||
OR: [{ ownerUserId: null }, { ownerUserId: 'ua' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('getOne liefert einen gemeinsamen Eintrag jedem, ohne tenantId und ownerUserId', async () => {
|
||||
const { service } = setup();
|
||||
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||
const row: any = await service.getOne('t1', userA, created.id);
|
||||
expect(row.name).toBe('Wiki');
|
||||
expect(row.personal).toBe(false);
|
||||
expect(row).not.toHaveProperty('tenantId');
|
||||
expect(row).not.toHaveProperty('ownerUserId');
|
||||
});
|
||||
|
||||
it('getOne liefert einen eigenen persoenlichen Eintrag', async () => {
|
||||
const { service } = setup();
|
||||
const created: any = await service.create('t1', userA, dto);
|
||||
const row: any = await service.getOne('t1', userA, created.id);
|
||||
expect(row.personal).toBe(true);
|
||||
});
|
||||
|
||||
it('getOne auf den persoenlichen Eintrag eines anderen -> NotFoundException (auch fuer Administratoren)', async () => {
|
||||
const { service } = setup();
|
||||
const created: any = await service.create('t1', userA, dto);
|
||||
await expect(service.getOne('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
|
||||
const { service } = setup();
|
||||
await expect(service.getOne('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.update('t1', userA, 'nope', { name: 'x' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.remove('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t2', admin, { ...dto, shared: true });
|
||||
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CustomModulesService — aendern und loeschen', () => {
|
||||
it('der Besitzer aendert und loescht seinen persoenlichen Eintrag', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t1', userA, dto);
|
||||
const updated: any = await service.update('t1', userA, created.id, { name: 'Neu' });
|
||||
expect(updated.name).toBe('Neu');
|
||||
expect(updated.personal).toBe(true);
|
||||
await expect(service.remove('t1', userA, created.id)).resolves.toEqual({ deleted: true });
|
||||
expect(prisma.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it('ein anderer Benutzer kann den persoenlichen Eintrag weder aendern noch loeschen (404)', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t1', userA, dto);
|
||||
await expect(service.update('t1', userB, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.remove('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auch ein Administrator kann den persoenlichen Eintrag eines Benutzers nicht aendern (404)', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t1', userA, dto);
|
||||
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.rows.size).toBe(1);
|
||||
});
|
||||
|
||||
it('ein normaler Benutzer kann einen gemeinsamen Eintrag weder aendern noch loeschen (403)', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||
await expect(service.update('t1', userA, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
await expect(service.remove('t1', userA, created.id)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein Administrator aendert und loescht einen gemeinsamen Eintrag', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||
const updated: any = await service.update('t1', admin, created.id, { name: 'Neu' });
|
||||
expect(updated.name).toBe('Neu');
|
||||
expect(updated.personal).toBe(false);
|
||||
await expect(service.remove('t1', admin, created.id)).resolves.toEqual({ deleted: true });
|
||||
expect(prisma.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it('update aendert nur gesetzte Felder und nie Besitz oder Gemeinsamkeit', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t1', userA, dto);
|
||||
await service.update('t1', userA, created.id, {
|
||||
name: 'Neu',
|
||||
shared: true,
|
||||
ownerUserId: 'ub',
|
||||
} as any);
|
||||
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CustomModulesService — RLS-Bindung', () => {
|
||||
it('bindet persoenliche Zugriffe mit Benutzer, gemeinsame Schreibzugriffe ohne', async () => {
|
||||
const { prisma, service } = setup();
|
||||
vi.mocked(forTenant).mockClear();
|
||||
const shared: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||
const mine: any = await service.create('t1', userA, dto);
|
||||
await service.list('t1', userA);
|
||||
await service.getOne('t1', userA, mine.id);
|
||||
await service.update('t1', userA, mine.id, { name: 'a' });
|
||||
await service.update('t1', admin, shared.id, { name: 'b' });
|
||||
const calls = vi.mocked(forTenant).mock.calls;
|
||||
// create shared: ohne Benutzer
|
||||
expect(calls[0]).toEqual([prisma, 't1']);
|
||||
// create personal + list + getOne + (update personal: Laden + Schreiben)
|
||||
expect(calls[1]).toEqual([prisma, 't1', 'ua']);
|
||||
expect(calls[2]).toEqual([prisma, 't1', 'ua']);
|
||||
expect(calls[3]).toEqual([prisma, 't1', 'ua']);
|
||||
// update shared als Admin: Laden mit Benutzer, Schreiben ohne
|
||||
expect(calls[calls.length - 2]).toEqual([prisma, 't1', 'admin1']);
|
||||
expect(calls[calls.length - 1]).toEqual([prisma, 't1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
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,
|
||||
ownerUserId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
/** Der Aufrufer, wie ihn der Controller aus dem Anmelde-Token liest. */
|
||||
export interface CustomModuleCaller {
|
||||
id: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
function isAdmin(caller: CustomModuleCaller): boolean {
|
||||
return caller.role === Role.ADMIN || caller.role === Role.SUPER_ADMIN;
|
||||
}
|
||||
|
||||
/** Aus der Zeile die Antwort machen: `personal` statt der Besitzer-Kennung. */
|
||||
function toResponse<T extends { ownerUserId: string | null }>(row: T) {
|
||||
const { ownerUserId, ...rest } = row;
|
||||
return { ...rest, personal: ownerUserId !== null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu): Seitenleisten-
|
||||
* Eintraege mit externer https-Adresse. Zwei Arten:
|
||||
*
|
||||
* - gemeinsam (`ownerUserId` null): vom Administrator angelegt, fuer alle
|
||||
* Benutzer des Mandanten sichtbar; Schreiben nur fuer Administratoren.
|
||||
* - persoenlich (`ownerUserId` = Benutzer): nur der Besitzer sieht, aendert und
|
||||
* loescht ihn. Ein anderer Benutzer bekommt fuer die id immer 404 — nie einen
|
||||
* Hinweis, dass es sie gibt.
|
||||
*
|
||||
* `tenantId` kommt ausschliesslich als Argument (aus `req.tenantId`), nie aus
|
||||
* dem DTO. Je Methode ein eigener `forTenant`-Klient.
|
||||
*
|
||||
* RLS-BINDUNG (Muster SearchProvider, siehe Migration 20260929130000): Lesen
|
||||
* und Schreiben PERSOENLICHER Eintraege laeuft mit dem Benutzer als drittem
|
||||
* Argument (`forTenant(prisma, tenantId, user.id)`); die Regel laesst dann nur
|
||||
* gemeinsame und eigene Zeilen zu. Schreiben GEMEINSAMER Eintraege laeuft
|
||||
* bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), weil die Regel einem
|
||||
* Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — die
|
||||
* Rollenpruefung (Administrator) sitzt vorher im Dienst. Zusaetzlich pruefen
|
||||
* alle Methoden `row.tenantId` und `row.ownerUserId` in der Anwendung, solange
|
||||
* der RLS-Schalter aus ist.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomModulesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
|
||||
async list(tenantId: string, caller: CustomModuleCaller) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||
const rows = await tenantPrisma.customModule.findMany({
|
||||
where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] },
|
||||
orderBy: { name: 'asc' },
|
||||
select: CUSTOM_MODULE_SELECT,
|
||||
});
|
||||
return rows.map(toResponse);
|
||||
}
|
||||
|
||||
async getOne(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||
const row = await this.loadVisible(tenantPrisma, tenantId, caller, id);
|
||||
const { tenantId: _omit, ...result } = row;
|
||||
return toResponse(result);
|
||||
}
|
||||
|
||||
async create(tenantId: string, caller: CustomModuleCaller, dto: CreateCustomModuleDto) {
|
||||
const shared = dto.shared === true;
|
||||
if (shared && !isAdmin(caller)) {
|
||||
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
|
||||
}
|
||||
const data = {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
url: dto.url,
|
||||
category: dto.category,
|
||||
ownerUserId: shared ? null : caller.id,
|
||||
};
|
||||
if (shared) {
|
||||
// Gemeinsam: ohne Benutzerkontext (die Regel verwehrt ihn dort).
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
|
||||
return toResponse(row);
|
||||
}
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
|
||||
return toResponse(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
caller: CustomModuleCaller,
|
||||
id: string,
|
||||
dto: UpdateCustomModuleDto,
|
||||
) {
|
||||
const tenantPrisma = await this.writableClient(tenantId, caller, id);
|
||||
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;
|
||||
// Besitz und Gemeinsamkeit stehen nie in `data` — sie aendern sich nicht.
|
||||
const row = await tenantPrisma.customModule.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: CUSTOM_MODULE_SELECT,
|
||||
});
|
||||
return toResponse(row);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||
const tenantPrisma = await this.writableClient(tenantId, caller, id);
|
||||
await tenantPrisma.customModule.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeile laden, die der Aufrufer sehen darf: gleicher Mandant UND (gemeinsam
|
||||
* ODER eigene). Alles andere — unbekannt, fremder Mandant, fremder
|
||||
* persoenlicher Eintrag — ist ununterscheidbar 404.
|
||||
*/
|
||||
private async loadVisible(
|
||||
tenantPrisma: ReturnType<typeof forTenant>,
|
||||
tenantId: string,
|
||||
caller: CustomModuleCaller,
|
||||
id: string,
|
||||
) {
|
||||
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');
|
||||
}
|
||||
if (row.ownerUserId !== null && row.ownerUserId !== caller.id) {
|
||||
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Klient fuer Aendern/Loeschen: persoenlicher Eintrag -> mit Benutzer (nur der
|
||||
* Besitzer kommt bis hierher); gemeinsamer Eintrag -> nur Administrator (403
|
||||
* sonst, der Eintrag ist fuer alle sichtbar, sein Bestehen ist kein
|
||||
* Geheimnis), dann ohne Benutzerkontext.
|
||||
*/
|
||||
private async writableClient(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||
const userClient = forTenant(this.prisma, tenantId, caller.id);
|
||||
const row = await this.loadVisible(userClient, tenantId, caller, id);
|
||||
if (row.ownerUserId === caller.id) {
|
||||
return userClient;
|
||||
}
|
||||
if (!isAdmin(caller)) {
|
||||
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren ändern');
|
||||
}
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma;
|
||||
}
|
||||
}
|
||||
@@ -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,84 @@
|
||||
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
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([...CUSTOM_MODULE_CATEGORIES])
|
||||
category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
|
||||
* an (nur Administratoren, sonst 403 im Dienst). Fehlt das Feld oder ist es
|
||||
* `false`, ist der Eintrag persoenlich und gehoert dem Aufrufer.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
|
||||
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
|
||||
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
|
||||
* dank `whitelist: true`).
|
||||
*/
|
||||
export class UpdateCustomModuleDto extends PartialType(
|
||||
OmitType(CreateCustomModuleDto, ['shared'] as const),
|
||||
) {}
|
||||
@@ -580,4 +580,87 @@ describe('UserController', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260928-ujj: Dashboard-Hintergrund pro Benutzer in der Datenbank.
|
||||
* Der Selbstbedienungsweg schreibt ausschliesslich die Zeile des
|
||||
* angemeldeten Benutzers ueber den an dessen Mandanten gebundenen Klienten
|
||||
* (T-ujj-02) und nimmt nur die erlaubten Formen an (T-ujj-01): gespeichert
|
||||
* wird immer ein frisch aufgebautes Objekt ohne Zusatzschluessel.
|
||||
*/
|
||||
describe('Dashboard-Hintergrund (quick-260928-ujj)', () => {
|
||||
const me = { role: Role.USER, tenantId: 't1', id: 'u-me', username: 'me', mustChangePassword: false };
|
||||
const IMAGE_ID = '3f2b8c1e-9a4d-4e7f-8b21-0c5d6e7f8a9b';
|
||||
|
||||
function seedMe(tenantId = 't1', id = 'u-me') {
|
||||
prisma.__seedUser({ id, username: id, tenantId, dashboardBackground: null });
|
||||
}
|
||||
|
||||
async function storedFor(tenantId: string, id: string) {
|
||||
const row = await prisma.__makeBoundClient(tenantId).user.findUnique({ where: { id } });
|
||||
return row.dashboardBackground;
|
||||
}
|
||||
|
||||
it.each([
|
||||
[{ kind: 'none' }, { kind: 'none' }],
|
||||
[{ kind: 'preset', id: 'dunes' }, { kind: 'preset', id: 'dunes' }],
|
||||
[{ kind: 'preset', id: 'mosaic', extra: 'weg' }, { kind: 'preset', id: 'mosaic' }],
|
||||
[{ kind: 'image', imageId: IMAGE_ID }, { kind: 'image', imageId: IMAGE_ID }],
|
||||
[{ kind: 'image', imageId: IMAGE_ID.toUpperCase(), id: 'mist' }, { kind: 'image', imageId: IMAGE_ID.toUpperCase() }],
|
||||
[{ kind: 'none', id: 'dunes', imageId: IMAGE_ID }, { kind: 'none' }],
|
||||
])('%j wird normalisiert als %j gespeichert, gebunden an den eigenen Mandanten', async (input, expected) => {
|
||||
seedMe();
|
||||
|
||||
const result = await controller.updateDashboardBackground({ background: input }, me);
|
||||
|
||||
expect(result).toEqual({ success: true, dashboardBackground: expected });
|
||||
expect(await storedFor('t1', 'u-me')).toEqual(expected);
|
||||
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||
expect(prisma.__boundCallLog.filter((c: any) => c.method === 'update').every((c: any) => c.tenantId === 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ kind: 'preset', id: 'regenbogen' }],
|
||||
[{ kind: 'preset' }],
|
||||
[{ kind: 'preset', id: 42 }],
|
||||
[{ kind: 'gradient', id: 'mist' }],
|
||||
[{ kind: 'image', imageId: 'img-1' }],
|
||||
[{ kind: 'image', imageId: '") ; background: url("https://boese.invalid/x' }],
|
||||
[{ kind: 'image', imageId: `${IMAGE_ID})` }],
|
||||
[{ kind: 'image', imageId: `${IMAGE_ID}0` }],
|
||||
[{ kind: 'image', imageId: '' }],
|
||||
[{ kind: 'image' }],
|
||||
[{}],
|
||||
[null],
|
||||
[undefined],
|
||||
['none'],
|
||||
[42],
|
||||
[[{ kind: 'none' }]],
|
||||
])('ungueltige Wahl %j → BadRequestException, nichts geschrieben', async (background) => {
|
||||
seedMe();
|
||||
|
||||
await expect(controller.updateDashboardBackground({ background }, me)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
||||
expect(await storedFor('t1', 'u-me')).toBeNull();
|
||||
});
|
||||
|
||||
it('fehlender Rumpf → BadRequestException', async () => {
|
||||
seedMe();
|
||||
|
||||
await expect(
|
||||
controller.updateDashboardBackground(undefined as unknown as { background: unknown }, me),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
||||
});
|
||||
|
||||
it('zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden aendert sich', async () => {
|
||||
seedMe('t1', 'u-me');
|
||||
seedMe('t2', 'u-other');
|
||||
|
||||
await controller.updateDashboardBackground({ background: { kind: 'preset', id: 'mist' } }, me);
|
||||
|
||||
expect(await storedFor('t1', 'u-me')).toEqual({ kind: 'preset', id: 'mist' });
|
||||
expect(await storedFor('t2', 'u-other')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Role } from '@prisma/client';
|
||||
import {
|
||||
compareReleaseVersions,
|
||||
type DashboardBackground,
|
||||
parseDashboardBackground,
|
||||
parseReleaseVersion,
|
||||
type ReleaseNoticeResponse,
|
||||
} from '@tessera/shared';
|
||||
@@ -62,6 +64,10 @@ function resolveAvatarsDir(): string {
|
||||
* Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen`
|
||||
* ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit
|
||||
* `where: { id: currentUser.id }`.
|
||||
*
|
||||
* quick-260928-ujj: dazu kommt ein gebundener Zugriff des
|
||||
* Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls
|
||||
* `forTenant()` mit `where: { id: currentUser.id }`.
|
||||
*/
|
||||
@Controller('users')
|
||||
@UseGuards(RolesGuard)
|
||||
@@ -483,6 +489,43 @@ export class UserController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /users/me/dashboard-background (quick-260928-ujj)
|
||||
*
|
||||
* Speichert den gewaehlten Dashboard-Hintergrund des angemeldeten
|
||||
* Benutzers. Jeder angemeldete Benutzer, kein `@Roles`.
|
||||
*
|
||||
* T-ujj-01 (Tampering): der Wert wird spaeter als CSS-Hintergrund
|
||||
* gerendert. `parseDashboardBackground` (@tessera/shared) laesst nur
|
||||
* `kind` none/preset/image, bekannte Preset-Kennungen und eine UUID als
|
||||
* Bildkennung zu und baut ein frisches Objekt ohne Zusatzschluessel;
|
||||
* alles andere ergibt 400 ohne Schreibzugriff. Bewusst Inline-Body-Typ
|
||||
* statt DTO-Klasse (wie `me/accent-color`): die globale ValidationPipe mit
|
||||
* `whitelist` wuerde das verschachtelte Objekt sonst nicht pruefen.
|
||||
*
|
||||
* T-ujj-02 (Elevation of Privilege): kein Kennungsparameter; geschrieben
|
||||
* wird ausschliesslich die eigene Zeile (`where: { id: currentUser.id }`)
|
||||
* ueber `forTenant(this.prisma, currentUser.tenantId)`.
|
||||
*/
|
||||
@Patch('me/dashboard-background')
|
||||
async updateDashboardBackground(
|
||||
@Body() body: { background: unknown },
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
): Promise<{ success: true; dashboardBackground: DashboardBackground }> {
|
||||
const background = parseDashboardBackground(body?.background);
|
||||
if (background === null) {
|
||||
throw new BadRequestException('Invalid dashboard background.');
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||
await tenantPrisma.user.update({
|
||||
where: { id: currentUser.id },
|
||||
data: { dashboardBackground: background },
|
||||
});
|
||||
|
||||
return { success: true, dashboardBackground: background };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/me/avatar
|
||||
* Stream the current user's avatar image.
|
||||
|
||||
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,34 @@ fn get_client_info() -> String {
|
||||
client_info_label(env!("CARGO_PKG_VERSION"), env!("APP_COMMIT"))
|
||||
}
|
||||
|
||||
/// Ziel fuer den System-Browser, wenn die Seite ein neues Fenster verlangt
|
||||
/// (`window.open`, Links mit `target="_blank"`). Nur http/https -- andere
|
||||
/// Schemata (file:, javascript:, eigene Protokolle) werden verworfen.
|
||||
fn external_target(url: &tauri::Url) -> Option<String> {
|
||||
match url.scheme() {
|
||||
"http" | "https" => Some(url.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D)
|
||||
/// wird zuerst wiederhergestellt.
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let app = tauri::Builder::default()
|
||||
// Muss als erstes Plugin stehen. Verhindert den Doppelstart (z. B. beim
|
||||
// Windows-Login) mit zwei Tray-Symbolen: Ein zweiter Start holt nur das
|
||||
// Fenster der laufenden Instanz nach vorne und beendet sich sofort.
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
show_main_window(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
@@ -734,6 +760,20 @@ pub fn run() {
|
||||
.center()
|
||||
.decorations(true)
|
||||
.resizable(true)
|
||||
// Neue Fenster (Suche-Widget per `window.open`, „In neuem Tab
|
||||
// öffnen“ bei XFrame/eigenen Modulen/Favoriten) verwarf die
|
||||
// Webansicht still -- Nutzer, 29.09.2026: Suche im Client ohne
|
||||
// Funktion. Jetzt gehen sie wie im Browser-Tab nach aussen, in den
|
||||
// System-Browser; die App selbst oeffnet kein zweites Fenster.
|
||||
.on_new_window({
|
||||
let handle = app.handle().clone();
|
||||
move |url, _features| {
|
||||
if let Some(target) = external_target(&url) {
|
||||
let _ = handle.opener().open_url(target, None::<&str>);
|
||||
}
|
||||
tauri::webview::NewWindowResponse::Deny
|
||||
}
|
||||
})
|
||||
.on_download(|webview, event| {
|
||||
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
|
||||
let _ = webview
|
||||
@@ -819,21 +859,12 @@ pub fn run() {
|
||||
.tooltip(tray_labels(server_url.as_deref()).0)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(move |app, event| match event.id().as_ref() {
|
||||
"open" => {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
// Minimiertes Fenster (z. B. per Win+D) zuerst wiederherstellen
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
"open" => show_main_window(app),
|
||||
"change_server" => {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.navigate(setup_page_url(cfg!(windows)));
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
show_main_window(app);
|
||||
}
|
||||
"update" => {
|
||||
// `take()` verhindert Doppelklick-Downloads, der
|
||||
@@ -889,11 +920,7 @@ pub fn run() {
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if let Some(w) = tray.app_handle().get_webview_window("main") {
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
@@ -1223,6 +1250,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_target_nur_http_und_https() {
|
||||
let https = tauri::Url::parse("https://www.google.com/search?q=tessera").unwrap();
|
||||
assert_eq!(
|
||||
external_target(&https).as_deref(),
|
||||
Some("https://www.google.com/search?q=tessera")
|
||||
);
|
||||
let http = tauri::Url::parse("http://intranet.local/").unwrap();
|
||||
assert_eq!(
|
||||
external_target(&http).as_deref(),
|
||||
Some("http://intranet.local/")
|
||||
);
|
||||
for bad in [
|
||||
"file:///C:/Windows/system32/calc.exe",
|
||||
"javascript:alert(1)",
|
||||
"ms-settings:privacy",
|
||||
] {
|
||||
let url = tauri::Url::parse(bad).unwrap();
|
||||
assert_eq!(external_target(&url), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_server_url_gueltige_adresse() {
|
||||
let parsed = parse_server_url("https://tessera.ctl.de").unwrap();
|
||||
|
||||
@@ -5,14 +5,13 @@ import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { login } from '@/lib/auth-actions';
|
||||
import { sanitizeNextPath } from '@/lib/safe-next';
|
||||
import { BRAND_YELLOW } from '@/components/brand/brand';
|
||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
|
||||
|
||||
/**
|
||||
* Split-screen login page (D-01).
|
||||
* Left: Tessera branding on the brand yellow background (BRAND_YELLOW).
|
||||
* Right: Login form on neutral background.
|
||||
* Split-screen login page (D-01), Design „Mosaik“.
|
||||
* Left (ab lg): dunkles Markenpanel mit Bildmarke, Claim und Farbmosaik.
|
||||
* Right: Anmeldekarte auf der Arbeitsflaeche; unter lg nur die Karte.
|
||||
* No sidebar or header (D-04, handled by (auth) layout).
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
@@ -42,41 +41,52 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left: Branding panel (hidden on mobile, visible on md+) */}
|
||||
<div
|
||||
className="hidden md:flex md:flex-1 items-center justify-center"
|
||||
style={{ backgroundColor: BRAND_YELLOW }}
|
||||
>
|
||||
<div className="px-8 text-gray-900">
|
||||
<TesseraLogo
|
||||
variant="horizontal"
|
||||
size={104}
|
||||
plateOutline={false}
|
||||
tagline={t('branding.tagline')}
|
||||
className="gap-6"
|
||||
wordmarkClassName="text-5xl"
|
||||
taglineClassName="text-lg text-gray-800 max-w-xs"
|
||||
/>
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* Links (ab lg): dunkles Markenpanel im Ton der App-Leiste mit
|
||||
grosser Bildmarke, Produktname, einem ruhigen Satz und einem
|
||||
stillen Mosaik in Grau und Gelb — kein Verlauf, keine Animation. */}
|
||||
<div className="relative hidden w-[45%] shrink-0 flex-col overflow-hidden bg-appbar px-14 py-12 text-appbar-foreground lg:flex">
|
||||
<TesseraLogo
|
||||
variant="horizontal"
|
||||
size={40}
|
||||
plateOutline="always"
|
||||
className="gap-3"
|
||||
wordmarkClassName="text-xl font-semibold tracking-normal text-appbar-foreground"
|
||||
/>
|
||||
|
||||
<div className="my-auto max-w-[30rem]">
|
||||
<LoginMosaic />
|
||||
<p className="mt-12 text-[32px] font-semibold leading-10 tracking-tight">
|
||||
{t('branding.claim')}
|
||||
</p>
|
||||
<p className="mt-3 text-base leading-6 text-appbar-muted">
|
||||
{t('branding.tagline')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Login form */}
|
||||
<div className="flex flex-1 items-center justify-center bg-background px-4 py-8">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
{/* Mobile-only branding */}
|
||||
<div className="text-center md:hidden">
|
||||
<TesseraLogo variant="horizontal" size={32} />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t('branding.tagline')}
|
||||
</p>
|
||||
{/* Rechts: Anmeldekarte zentriert auf der Arbeitsflaeche */}
|
||||
<div className="flex flex-1 items-center justify-center px-4 py-10">
|
||||
<div className="w-full max-w-[400px]">
|
||||
{/* Unter lg: Bildmarke ueber der Karte */}
|
||||
<div className="mb-6 flex justify-center lg:hidden">
|
||||
<TesseraLogo
|
||||
variant="horizontal"
|
||||
size={32}
|
||||
className="gap-2.5"
|
||||
wordmarkClassName="text-lg font-semibold tracking-normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="surface space-y-6 p-6 sm:p-8">
|
||||
{/* Login heading */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{t('login')}
|
||||
</h2>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('loginSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
@@ -87,12 +97,12 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
{/* Login form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Username */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="text-sm font-medium text-foreground"
|
||||
className="block text-sm font-semibold text-foreground"
|
||||
>
|
||||
{t('username')}
|
||||
</label>
|
||||
@@ -102,16 +112,15 @@ export default function LoginPage() {
|
||||
type="text"
|
||||
required
|
||||
autoComplete="username"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
placeholder={t('username')}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-foreground"
|
||||
className="block text-sm font-semibold text-foreground"
|
||||
>
|
||||
{t('password')}
|
||||
</label>
|
||||
@@ -121,33 +130,30 @@ export default function LoginPage() {
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
placeholder={t('password')}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remember me (D-02) */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Angemeldet bleiben (D-02) und Passwort vergessen (D-03) in einer Zeile */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="rememberMe"
|
||||
name="rememberMe"
|
||||
type="checkbox"
|
||||
defaultChecked
|
||||
className="h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="text-sm text-muted-foreground"
|
||||
className="whitespace-nowrap text-sm text-muted-foreground"
|
||||
>
|
||||
{t('rememberMe')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Forgot password link (D-03) */}
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="whitespace-nowrap text-sm link"
|
||||
>
|
||||
{t('forgotPassword')}
|
||||
</Link>
|
||||
@@ -157,7 +163,7 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<svg
|
||||
@@ -186,11 +192,49 @@ export default function LoginPage() {
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */}
|
||||
<DesktopDownloadLinks />
|
||||
<div className="mt-6">
|
||||
<DesktopDownloadLinks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stilles Mosaik (Design „Mosaik“): acht Kacheln in einem lockeren
|
||||
* 4 x 3-Raster in Grautoenen, eine davon in der Akzentfarbe und — wie die
|
||||
* Signalkachel der Bildmarke — leicht gedreht. Rein dekorativ.
|
||||
*/
|
||||
function LoginMosaic() {
|
||||
// Runde 2: nur Gelb und Graphit-/Grautoene des dunklen Panels.
|
||||
const tiles: { col: number; row: number; color: string; rotate?: boolean }[] = [
|
||||
{ col: 1, row: 1, color: 'oklch(1 0 0 / 0.14)' },
|
||||
{ col: 2, row: 1, color: 'oklch(1 0 0 / 0.08)' },
|
||||
{ col: 4, row: 1, color: 'oklch(1 0 0 / 0.1)' },
|
||||
{ col: 2, row: 2, color: 'oklch(1 0 0 / 0.18)' },
|
||||
{ col: 3, row: 2, color: 'var(--primary)', rotate: true },
|
||||
{ col: 4, row: 2, color: 'oklch(1 0 0 / 0.06)' },
|
||||
{ col: 1, row: 3, color: 'oklch(1 0 0 / 0.1)' },
|
||||
{ col: 3, row: 3, color: 'oklch(1 0 0 / 0.05)' },
|
||||
];
|
||||
return (
|
||||
<div aria-hidden="true" className="grid w-max grid-cols-4 grid-rows-3 gap-3">
|
||||
{tiles.map((tile) => (
|
||||
<span
|
||||
key={`${tile.col}-${tile.row}`}
|
||||
className="h-14 w-14 rounded-xl"
|
||||
style={{
|
||||
gridColumn: tile.col,
|
||||
gridRow: tile.row,
|
||||
backgroundColor: tile.color,
|
||||
transform: tile.rotate ? 'rotate(12deg)' : undefined,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function ResetPasswordTokenPage() {
|
||||
{/* Heading */}
|
||||
<div className="text-center">
|
||||
<TesseraLogo variant="horizontal" size={32} />
|
||||
<h2 className="mt-4 text-2xl font-bold text-foreground">
|
||||
<h2 className="mt-4 text-2xl font-semibold text-foreground">
|
||||
{t('resetPassword.newPasswordTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -99,7 +99,7 @@ export default function ResetPasswordTokenPage() {
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{t('resetPassword.backToLogin')}
|
||||
</Link>
|
||||
@@ -164,7 +164,7 @@ export default function ResetPasswordTokenPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<svg
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function ResetPasswordPage() {
|
||||
{/* Heading */}
|
||||
<div className="text-center">
|
||||
<TesseraLogo variant="horizontal" size={32} />
|
||||
<h2 className="mt-4 text-2xl font-bold text-foreground">
|
||||
<h2 className="mt-4 text-2xl font-semibold text-foreground">
|
||||
{t('resetPassword.title')}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ export default function ResetPasswordPage() {
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{t('resetPassword.backToLogin')}
|
||||
</Link>
|
||||
@@ -104,7 +104,7 @@ export default function ResetPasswordPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
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 gemeinsam',
|
||||
create: 'Gemeinsamen Eintrag anlegen',
|
||||
noModules: 'Noch keine gemeinsamen Einträge',
|
||||
noModulesBody: 'Legen Sie Ihren ersten gemeinsamen Eintrag an.',
|
||||
},
|
||||
'customModules.manage': {
|
||||
actions: 'Aktionen',
|
||||
loadError: 'Laden fehlgeschlagen.',
|
||||
},
|
||||
'customModules.form': {
|
||||
create: 'Eigenes Modul anlegen',
|
||||
edit: 'Eigenes Modul bearbeiten',
|
||||
name: 'Name',
|
||||
url: 'Adresse',
|
||||
urlHint: 'Hinweis https',
|
||||
category: 'Kategorie',
|
||||
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.',
|
||||
deleteConfirm: {
|
||||
title: 'Eigenes Modul löschen',
|
||||
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
||||
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
|
||||
},
|
||||
},
|
||||
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',
|
||||
personal: false,
|
||||
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 gemeinsamen Einträge')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag 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 gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag 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 gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag 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 gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag 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 gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag 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',
|
||||
shared: true,
|
||||
});
|
||||
});
|
||||
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 gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag 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 gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag 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? Für alle Benutzer.')).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('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
wiki,
|
||||
{ ...wiki, id: 'cm-2', name: 'Mein Privates', personal: true },
|
||||
]);
|
||||
render(<AdminCustomModulesPage />);
|
||||
expect(await screen.findByRole('link', { name: 'Wiki' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mein Privates')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('nur persönliche Einträge vorhanden: Leer-Zustand der Verwaltung', async () => {
|
||||
mockList.mockResolvedValue([{ ...wiki, personal: true }]);
|
||||
render(<AdminCustomModulesPage />);
|
||||
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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,42 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
/**
|
||||
* Verwaltung „Eigene Module“ (quick-260929-9wc, D-07, Vorbild
|
||||
* `admin/groups`; quick-260929-dzu: nur die GEMEINSAMEN Eintraege). ADMIN und
|
||||
* SUPER_ADMIN; die Rollenpruefung hier ist nur Anzeige — durchgesetzt wird sie
|
||||
* serverseitig: die API verlangt fuer gemeinsame Eintraege (`shared: true`,
|
||||
* Aendern, Loeschen) die Administrator-Rolle. Persoenliche Eintraege eines
|
||||
* Administrators stehen wie bei jedem Benutzer unter Einstellungen.
|
||||
*/
|
||||
export default function AdminCustomModulesPage() {
|
||||
const t = useTranslations('admin.customModules');
|
||||
const tCommon = useTranslations('common');
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
|
||||
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 (
|
||||
<CustomModuleManager
|
||||
scope="shared"
|
||||
texts={{
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
create: t('create'),
|
||||
noModules: t('noModules'),
|
||||
noModulesBody: t('noModulesBody'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">{t('deleteConfirm.title')}</h2>
|
||||
|
||||
{loadingImpact ? (
|
||||
@@ -113,11 +113,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -77,7 +77,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
{group ? t('edit') : t('create')}
|
||||
</h2>
|
||||
@@ -101,7 +101,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
|
||||
{!group && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('createLdapHint')}{' '}
|
||||
<Link href="/admin/ldap" className="text-primary hover:underline">
|
||||
<Link href="/admin/ldap" className="link">
|
||||
{t('goToLdap')}
|
||||
</Link>
|
||||
</p>
|
||||
@@ -139,14 +139,14 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving ? tCommon('loading') : tCommon('save')}
|
||||
</button>
|
||||
|
||||
@@ -158,7 +158,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
{t('members.title')} — {group.name}
|
||||
</h2>
|
||||
@@ -249,18 +249,14 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
{tCommon('close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={adding || selectedUserIds.length === 0}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{adding ? tCommon('loading') : `${t('members.addTitle')} (${selectedUserIds.length})`}
|
||||
</button>
|
||||
|
||||
@@ -124,11 +124,11 @@ export default function AdminGroupsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('create')}
|
||||
</button>
|
||||
@@ -149,7 +149,7 @@ export default function AdminGroupsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('create')}
|
||||
</button>
|
||||
|
||||
@@ -12,9 +12,9 @@ export default function AdminLayout({
|
||||
const t = useTranslations('settings');
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex h-full flex-col md:flex-row">
|
||||
<AdminSidebar />
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
|
||||
@@ -547,7 +547,7 @@ export default function AdminLdapPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
|
||||
{/* Section 1: Connection Settings */}
|
||||
<section className="rounded-lg border border-border p-6">
|
||||
@@ -646,7 +646,7 @@ export default function AdminLdapPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving ? tCommon('loading') : t('save')}
|
||||
</button>
|
||||
@@ -654,7 +654,7 @@ export default function AdminLdapPage() {
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={!config && !formData.serverUrl}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('testConnection')}
|
||||
</button>
|
||||
@@ -816,7 +816,7 @@ export default function AdminLdapPage() {
|
||||
type="button"
|
||||
onClick={handleDiscoverGroups}
|
||||
disabled={discovering}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{discovering ? tCommon('loading') : t('groupFilter.discover')}
|
||||
</button>
|
||||
@@ -919,7 +919,7 @@ export default function AdminLdapPage() {
|
||||
type="button"
|
||||
onClick={handleSaveGroupFilter}
|
||||
disabled={savingFilter}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{savingFilter ? tCommon('loading') : t('groupFilter.save')}
|
||||
</button>
|
||||
@@ -941,7 +941,7 @@ export default function AdminLdapPage() {
|
||||
type="button"
|
||||
onClick={handleDiscoverGroupsToImport}
|
||||
disabled={discoveringGroupImport}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{discoveringGroupImport
|
||||
? tCommon('loading')
|
||||
@@ -1015,7 +1015,7 @@ export default function AdminLdapPage() {
|
||||
type="button"
|
||||
onClick={handleImportGroups}
|
||||
disabled={importingGroups || selectedImportDns.length === 0}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{importingGroups
|
||||
? tCommon('loading')
|
||||
@@ -1142,7 +1142,7 @@ export default function AdminLdapPage() {
|
||||
type="button"
|
||||
onClick={() => handleImportUsers(selectedUserDns)}
|
||||
disabled={importingUsers || selectedUserDns.length === 0}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{importingUsers
|
||||
? tCommon('loading')
|
||||
@@ -1234,7 +1234,7 @@ export default function AdminLdapPage() {
|
||||
type="button"
|
||||
onClick={handleSaveExcludeList}
|
||||
disabled={savingExclude}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{savingExclude ? tCommon('loading') : t('userExclude.save')}
|
||||
</button>
|
||||
@@ -1297,7 +1297,7 @@ export default function AdminLdapPage() {
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('save')}
|
||||
</button>
|
||||
@@ -1319,7 +1319,7 @@ export default function AdminLdapPage() {
|
||||
type="button"
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{syncing ? t('sync.syncing') : t('sync.trigger')}
|
||||
</button>
|
||||
|
||||
@@ -142,7 +142,7 @@ export function ActivateModuleDialog({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<h2 className="mb-2 break-words text-lg font-semibold text-foreground">
|
||||
{t('title', { module: moduleName })}
|
||||
</h2>
|
||||
@@ -157,7 +157,7 @@ export function ActivateModuleDialog({
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
@@ -165,7 +165,7 @@ export function ActivateModuleDialog({
|
||||
type="button"
|
||||
onClick={handleConfigureLater}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('configureLater')}
|
||||
</button>
|
||||
@@ -173,7 +173,7 @@ export function ActivateModuleDialog({
|
||||
type="button"
|
||||
onClick={handleGrantNow}
|
||||
disabled={grantNowDisabled}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('grantNow')}
|
||||
</button>
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function AdminModuleGrantsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -205,7 +205,7 @@ export default function AdminModuleGrantsPage() {
|
||||
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
|
||||
<Link
|
||||
href="/admin/modules"
|
||||
className="mt-2 inline-block text-sm font-medium text-primary hover:underline"
|
||||
className="mt-2 inline-block text-sm font-medium link"
|
||||
>
|
||||
{t('emptyModulesLink')}
|
||||
</Link>
|
||||
|
||||
@@ -134,12 +134,12 @@ export default function AdminModulesPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/modules/grants"
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('grantsLink')}
|
||||
</Link>
|
||||
|
||||
@@ -146,11 +146,11 @@ export default function AdminTenantsPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('create')}
|
||||
</button>
|
||||
@@ -244,7 +244,7 @@ export default function AdminTenantsPage() {
|
||||
{/* Create/Edit modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<h2 className="text-lg font-bold text-foreground mb-4">
|
||||
{editingTenant ? t('edit') : t('create')}
|
||||
</h2>
|
||||
@@ -287,13 +287,13 @@ export default function AdminTenantsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{tCommon('save')}
|
||||
</button>
|
||||
@@ -306,7 +306,7 @@ export default function AdminTenantsPage() {
|
||||
{/* Delete confirmation modal */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<p className="text-sm text-foreground mb-4">
|
||||
{t('deleteConfirm')}
|
||||
</p>
|
||||
@@ -314,7 +314,7 @@ export default function AdminTenantsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2>
|
||||
<button
|
||||
|
||||
@@ -227,11 +227,11 @@ export default function AdminUsersPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('create')}
|
||||
</button>
|
||||
@@ -349,7 +349,7 @@ export default function AdminUsersPage() {
|
||||
{/* Create/Edit modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<h2 className="text-lg font-bold text-foreground mb-4">
|
||||
{editingUser ? t('edit') : t('create')}
|
||||
</h2>
|
||||
@@ -455,13 +455,13 @@ export default function AdminUsersPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{tCommon('save')}
|
||||
</button>
|
||||
@@ -474,7 +474,7 @@ export default function AdminUsersPage() {
|
||||
{/* Delete confirmation modal */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<p className="text-sm text-foreground mb-4">
|
||||
{t('deleteConfirm')}
|
||||
</p>
|
||||
@@ -490,7 +490,7 @@ export default function AdminUsersPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function ChangePasswordPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md py-8 px-4">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">
|
||||
<h1 className="text-2xl font-semibold text-foreground mb-6">
|
||||
{t('changePassword.title')}
|
||||
</h1>
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function ChangePasswordPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { ChangelogView } from '@/components/changelog/changelog-view';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { appVersion } from '@/lib/app-version';
|
||||
import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog';
|
||||
|
||||
@@ -22,14 +23,13 @@ export default async function ChangelogPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl py-8 px-4">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">{t('title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">{t('intro')}</p>
|
||||
<div className="mx-auto max-w-3xl p-3 sm:p-6">
|
||||
<PageHeader title={t('title')} description={t('intro')} className="mb-6" />
|
||||
|
||||
{hasUnreleased && (
|
||||
<div
|
||||
data-testid="changelog-unreleased-hint"
|
||||
className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 px-4 py-3 text-sm text-yellow-800 dark:text-yellow-200 mb-6"
|
||||
className="mb-6 rounded-md border-l-4 border-status-warn bg-status-warn/10 px-4 py-3 text-sm text-foreground"
|
||||
>
|
||||
{t('unreleasedHint')}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useToastStore } from '../components/Toast';
|
||||
@@ -32,6 +34,7 @@ interface CatalogModule {
|
||||
export default function ModuleDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const t = useTranslations('marketplace');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
@@ -148,7 +151,7 @@ export default function ModuleDetailPage() {
|
||||
const isActive = module.isActiveForTenant;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
|
||||
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<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="m15 18-6-6 6-6" />
|
||||
@@ -157,23 +160,11 @@ export default function ModuleDetailPage() {
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-muted p-4 shrink-0">
|
||||
{module.icon === 'Globe' ? (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<ModuleTile slug={module.slug} size={56} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{module.name}</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{module.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{module.category}</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{categoryLabel(module.category)}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('detailVersion', { version: module.version })}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,11 +181,7 @@ export default function ModuleDetailPage() {
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
disabled={toggling}
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
|
||||
isActive
|
||||
? 'border border-border text-foreground hover:bg-muted'
|
||||
: 'bg-primary text-primary-foreground'
|
||||
}`}
|
||||
className={`btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
|
||||
>
|
||||
{toggling ? (
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true">
|
||||
<div className="max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div className="max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">{t('deactivateDialogTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('deactivateDialogBody', { moduleName })}
|
||||
@@ -55,7 +55,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
|
||||
type="button"
|
||||
ref={cancelRef}
|
||||
onClick={onCancel}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('deactivateDialogCancel')}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
interface CategoryFilterProps {
|
||||
categories: string[];
|
||||
@@ -10,17 +11,23 @@ interface CategoryFilterProps {
|
||||
|
||||
export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) {
|
||||
const t = useTranslations('marketplace');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
// Dezente Pillen (Fluent „subtle“): gewaehlt = dunkle Fuellung wie bei
|
||||
// GitHub-Filtern, nicht die gelbe Akzentflaeche.
|
||||
const chip = (selected: boolean) =>
|
||||
`inline-flex h-7 items-center rounded-full border px-3 text-xs font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
selected
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border bg-card text-foreground hover:bg-muted'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
<div className="flex gap-2 overflow-x-auto p-0.5 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('all')}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
value === 'all'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
aria-pressed={value === 'all'}
|
||||
className={chip(value === 'all')}
|
||||
>
|
||||
{t('categoryAll')}
|
||||
</button>
|
||||
@@ -29,13 +36,10 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => onChange(cat)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
value === cat
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
aria-pressed={value === cat}
|
||||
className={chip(value === cat)}
|
||||
>
|
||||
{cat}
|
||||
{categoryLabel(cat)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
/**
|
||||
* Props for the MarketplaceCard component.
|
||||
@@ -25,53 +27,6 @@ export interface MarketplaceCardProps {
|
||||
onLockedClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default icon mapping for known module icons.
|
||||
* Renders inline SVG to avoid external icon library dependency.
|
||||
*/
|
||||
function ModuleIcon({ icon }: { icon?: string }) {
|
||||
if (icon === 'Globe') {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Default puzzle-piece icon for generic modules
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary"
|
||||
>
|
||||
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketplace card showing a module with activation controls.
|
||||
*
|
||||
@@ -84,7 +39,6 @@ export function MarketplaceCard({
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
icon,
|
||||
category,
|
||||
isActive,
|
||||
hasAccess,
|
||||
@@ -95,6 +49,7 @@ export function MarketplaceCard({
|
||||
onLockedClick,
|
||||
}: MarketplaceCardProps) {
|
||||
const t = useTranslations('marketplace');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
|
||||
const localizedDescription = description[locale] || description.en || description.de || '';
|
||||
|
||||
@@ -114,10 +69,9 @@ export function MarketplaceCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
|
||||
locked ? 'opacity-60' : 'hover:shadow-md hover:border-primary/30'
|
||||
className={`group relative flex min-h-[176px] flex-col rounded-lg bg-card p-4 shadow-sm transition-shadow dark:border dark:border-border ${
|
||||
locked ? 'opacity-60' : isActive ? 'hover:shadow-md' : ''
|
||||
}`}
|
||||
style={{ minHeight: '180px' }}
|
||||
>
|
||||
{/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte
|
||||
Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */}
|
||||
@@ -126,54 +80,47 @@ export function MarketplaceCard({
|
||||
type="button"
|
||||
onClick={handleCardClick}
|
||||
aria-label={t('openDetail', { name })}
|
||||
className={`absolute inset-0 rounded-lg ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
className={`absolute inset-0 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header: icon + name + badges */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="rounded-md bg-muted p-2.5 shrink-0">
|
||||
<ModuleIcon icon={icon} />
|
||||
</div>
|
||||
{/* Kopf: App-Kachel + Name + Kategorie */}
|
||||
<div className="flex items-start gap-3">
|
||||
<ModuleTile slug={slug} size={40} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-base leading-tight truncate">{name}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{category}
|
||||
</span>
|
||||
<span
|
||||
role="status"
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
isActive
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isActive ? t('statusActive') : t('statusAvailable')}
|
||||
</span>
|
||||
{locked && (
|
||||
<span
|
||||
className={[
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
'bg-amber-100 text-amber-700',
|
||||
'dark:bg-amber-900/30 dark:text-amber-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{t('statusNoAccess')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="truncate text-base font-semibold leading-snug text-foreground">{name}</h3>
|
||||
<p className="truncate text-xs text-muted-foreground">{categoryLabel(category)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
|
||||
{/* Beschreibung */}
|
||||
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
|
||||
{localizedDescription}
|
||||
</p>
|
||||
|
||||
{/* Footer: action button — only rendered for users who may activate/deactivate */}
|
||||
{canManage && (
|
||||
<div className="relative flex items-center justify-end">
|
||||
{/* Fuss: Status links, Aktion rechts */}
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
role="status"
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
isActive
|
||||
? 'bg-status-ok/12 text-status-ok-fg'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isActive && <span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-status-ok" />}
|
||||
{isActive ? t('statusActive') : t('statusAvailable')}
|
||||
</span>
|
||||
{locked && (
|
||||
<span className="rounded-full bg-status-warn/15 px-2 py-0.5 text-xs font-medium text-status-warn-fg">
|
||||
{t('statusNoAccess')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Aktion — nur fuer Benutzer, die aktivieren/deaktivieren duerfen */}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -181,11 +128,7 @@ export function MarketplaceCard({
|
||||
onToggle(id, isActive);
|
||||
}}
|
||||
disabled={isToggling}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
|
||||
isActive
|
||||
? 'border border-border text-foreground hover:bg-muted'
|
||||
: 'bg-primary text-primary-foreground'
|
||||
}`}
|
||||
className={`relative btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
|
||||
>
|
||||
{isToggling ? (
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
@@ -195,8 +138,8 @@ export function MarketplaceCard({
|
||||
t('activate')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,13 +23,30 @@ export function MarketplaceSearch({ onChange }: MarketplaceSearchProps) {
|
||||
}, [value, onChange]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative max-w-md">
|
||||
<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"
|
||||
className="pointer-events-none absolute left-2.5 top-2 text-muted-foreground"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t('searchPlaceholder')}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-card pl-8 pr-8 text-sm placeholder:text-muted-foreground"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
|
||||
@@ -21,25 +21,37 @@ export function StatusFilter({ value, counts, onChange }: StatusFilterProps) {
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 border-b border-border" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === tab.key}
|
||||
onClick={() => onChange(tab.key)}
|
||||
className={`px-3 py-2 text-sm font-medium transition-colors ${
|
||||
value === tab.key
|
||||
? 'text-foreground border-b-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(tab.label)}
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
{counts[tab.key]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{tabs.map((tab) => {
|
||||
const selected = value === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onChange(tab.key)}
|
||||
className={`group relative -mb-px inline-flex h-10 items-center px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
|
||||
selected ? 'font-semibold text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(tab.label)}
|
||||
<span
|
||||
className={`ml-2 rounded-full px-1.5 text-xs font-medium ${
|
||||
selected ? 'bg-foreground/10 text-foreground' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{counts[tab.key]}
|
||||
</span>
|
||||
{/* Fluent-Reiter: 2 px Akzentlinie unten, beim Zeigen eine graue */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-x-3 bottom-0 h-[2px] rounded-full ${
|
||||
selected ? 'bg-primary-strong' : 'bg-transparent group-hover:bg-border'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ export function TenantContextSelector() {
|
||||
if (!isSuperAdmin) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-lg p-3 mb-4 border border-border">
|
||||
<label htmlFor="tenant-context-select" className="block text-xs font-semibold text-muted-foreground mb-1.5">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg bg-card px-4 py-3 shadow-sm dark:border dark:border-border">
|
||||
<label htmlFor="tenant-context-select" className="text-sm font-semibold text-foreground">
|
||||
{t('tenantContextLabel')}
|
||||
</label>
|
||||
{loading ? (
|
||||
@@ -48,7 +48,7 @@ export function TenantContextSelector() {
|
||||
id="tenant-context-select"
|
||||
onChange={(e) => setSelectedTenantId(e.target.value || null)}
|
||||
defaultValue=""
|
||||
className="bg-card border border-border rounded-md px-3 py-1.5 text-sm min-w-[200px]"
|
||||
className="h-8 min-w-[200px] rounded-md border border-input bg-card px-2.5 text-sm"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{tenants.map((tenant) => (
|
||||
|
||||
@@ -39,7 +39,7 @@ export function ToastContainer() {
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-card p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]"
|
||||
className="flex items-center gap-3 rounded-lg bg-card dark:border dark:border-border p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]"
|
||||
>
|
||||
{toast.type === 'success' ? (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400 shrink-0">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MarketplaceSearch } from './components/MarketplaceSearch';
|
||||
import { StatusFilter, type StatusValue } from './components/StatusFilter';
|
||||
import { CategoryFilter } from './components/CategoryFilter';
|
||||
import { ToastContainer, useToastStore } from './components/Toast';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { TenantContextSelector } from './components/TenantContextSelector';
|
||||
import { ActivationDialog } from './components/ActivationDialog';
|
||||
|
||||
@@ -179,13 +180,10 @@ export default function MarketplacePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TenantContextSelector />
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader title={t('title')} description={t('subtitle')} />
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
|
||||
</div>
|
||||
<TenantContextSelector />
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
@@ -199,29 +197,30 @@ export default function MarketplacePage() {
|
||||
</div>
|
||||
) : modules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
|
||||
<span className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-muted-foreground/40 mb-4"
|
||||
>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||
<path d="M12 22V12" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('emptyTitle')}</h2>
|
||||
</span>
|
||||
<h2 className="text-base font-semibold text-foreground">{t('emptyTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<MarketplaceSearch onChange={setSearchQuery} />
|
||||
<StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} />
|
||||
<CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} />
|
||||
@@ -229,7 +228,13 @@ export default function MarketplacePage() {
|
||||
|
||||
{filteredModules.length === 0 && hasFiltersApplied ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[30vh] text-center">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('filteredEmptyTitle')}</h2>
|
||||
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<h2 className="text-base font-semibold text-foreground">{t('filteredEmptyTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('filteredEmptyBody')}</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -38,16 +38,16 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="rounded-lg bg-muted p-4 mb-4">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-muted-foreground"
|
||||
@@ -57,13 +57,13 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
|
||||
<path d="m9 9 6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">{t('notFound')}</h2>
|
||||
<h2 className="mb-1 text-base font-semibold">{t('notFound')}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{t('notFoundDescription')}
|
||||
</p>
|
||||
<Link
|
||||
href={`/modules/${category}`}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('backToCategory')}
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import Link from 'next/link';
|
||||
|
||||
/**
|
||||
@@ -14,54 +15,6 @@ export interface ModuleCardProps {
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default icon mapping for known module icons.
|
||||
* Renders inline SVG to avoid external icon library dependency.
|
||||
*/
|
||||
function ModuleIcon({ icon }: { icon?: string }) {
|
||||
// Globe icon for domain-related modules
|
||||
if (icon === 'Globe') {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Default puzzle-piece icon for generic modules
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary"
|
||||
>
|
||||
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Module card for the category grid view.
|
||||
*
|
||||
@@ -69,7 +22,7 @@ function ModuleIcon({ icon }: { icon?: string }) {
|
||||
* to the expanded module view. Per D-05a: uniform card appearance,
|
||||
* compact, looks good even with a single module.
|
||||
*/
|
||||
export function ModuleCard({ name, slug, description, icon, category }: ModuleCardProps) {
|
||||
export function ModuleCard({ name, slug, description, category }: ModuleCardProps) {
|
||||
const t = useTranslations('modules');
|
||||
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
|
||||
const localizedDescription = description[locale] || description.en || description.de || '';
|
||||
@@ -79,14 +32,12 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
|
||||
href={`/modules/${category}/${slug}`}
|
||||
className="group block"
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-card p-5 shadow-sm transition-all hover:shadow-md hover:border-primary/30 group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2">
|
||||
<div className="rounded-lg bg-card p-4 shadow-sm transition-shadow hover:shadow-md dark:border dark:border-border group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2">
|
||||
{/* Header: icon + name */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="rounded-md bg-muted p-2.5 shrink-0">
|
||||
<ModuleIcon icon={icon} />
|
||||
</div>
|
||||
<ModuleTile slug={slug} size={40} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-base leading-tight truncate">
|
||||
<h3 className="truncate pt-2 text-base font-semibold leading-snug">
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -99,7 +50,7 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
|
||||
|
||||
{/* Footer: open link */}
|
||||
<div className="flex justify-end">
|
||||
<span className="text-sm font-medium text-primary group-hover:underline">
|
||||
<span className="text-sm font-medium text-link group-hover:underline">
|
||||
{t('openModule')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function CategoryPage() {
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t('categoryTitle', { category: categoryTitle })}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function ConvertTab({ file, pemText, password, onTargetFormatChange }: Co
|
||||
type="button"
|
||||
onClick={handleConvert}
|
||||
disabled={loading || !canConvert}
|
||||
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.convert')}
|
||||
</button>
|
||||
|
||||
@@ -46,7 +46,7 @@ export function InspectTab({ file, pemText, password }: InspectTabProps) {
|
||||
type="button"
|
||||
onClick={handleInspect}
|
||||
disabled={loading || !canInspect}
|
||||
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.inspect')}
|
||||
</button>
|
||||
|
||||
@@ -139,7 +139,7 @@ export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
|
||||
data-testid="merge-action-btn"
|
||||
onClick={handleMerge}
|
||||
disabled={loading || !canMerge}
|
||||
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.merge')}
|
||||
</button>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
|
||||
type="button"
|
||||
onClick={handleSplit}
|
||||
disabled={loading || !file}
|
||||
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.split')}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { DropZone } from './components/DropZone';
|
||||
@@ -98,15 +99,11 @@ export default function CertManagerPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
||||
|
||||
{/* Shared input card */}
|
||||
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
||||
{/* DropZone */}
|
||||
<DropZone
|
||||
onFile={handleFile}
|
||||
@@ -148,7 +145,7 @@ export default function CertManagerPage() {
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={`pb-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-primary text-foreground'
|
||||
? 'border-b-2 border-primary-strong font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
@@ -158,7 +155,7 @@ export default function CertManagerPage() {
|
||||
</nav>
|
||||
|
||||
{/* Tab content card */}
|
||||
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
|
||||
{renderActiveTab()}
|
||||
</div>
|
||||
</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} />;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function ExportFileList({ historyItems }: ExportFileListProps) {
|
||||
<a
|
||||
href={exportFileUrl(filename)}
|
||||
download
|
||||
className="text-sm text-primary hover:underline"
|
||||
className="text-sm link"
|
||||
>
|
||||
{filename}
|
||||
</a>
|
||||
|
||||
@@ -89,7 +89,7 @@ export function InvoiceHistoryTable({
|
||||
const totalPages = Math.ceil(total / 25);
|
||||
|
||||
const theadCls =
|
||||
'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
|
||||
'px-4 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border overflow-hidden">
|
||||
@@ -170,7 +170,7 @@ export function InvoiceHistoryTable({
|
||||
<a
|
||||
href={exportFileUrl(row.exportFilename)}
|
||||
download
|
||||
className="inline-flex items-center gap-1 text-primary text-sm hover:underline"
|
||||
className="inline-flex items-center gap-1 link text-sm"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{row.exportFilename}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api';
|
||||
import { InvoiceHistoryTable } from './components/InvoiceHistoryTable';
|
||||
import { ExportFileList } from './components/ExportFileList';
|
||||
@@ -75,51 +76,47 @@ export default function DkvFleetPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
{/* Page Header Row */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{lastChecked
|
||||
? t('lastChecked', { time: formatLastChecked(lastChecked) })
|
||||
: t('neverChecked')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Einstellungen-Link */}
|
||||
<Link
|
||||
href={`${basePath}/settings`}
|
||||
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={t('settingsTitle')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
|
||||
{/* Seitenkopf (Design „Mosaik“): App-Kachel, Titel, Stand; Aktionen rechts */}
|
||||
<PageHeader
|
||||
moduleSlug="dkv-fleet"
|
||||
title={t('pageTitle')}
|
||||
description={
|
||||
lastChecked
|
||||
? t('lastChecked', { time: formatLastChecked(lastChecked) })
|
||||
: t('neverChecked')
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
href={`${basePath}/settings`}
|
||||
className="btn btn-secondary btn-icon"
|
||||
title={t('settingsTitle')}
|
||||
aria-label={t('settingsTitle')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
{/* "Jetzt prüfen" button per 07-UI-SPEC button states */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCheckNow}
|
||||
disabled={isChecking}
|
||||
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
|
||||
isChecking ? ' opacity-80 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{isChecking ? (
|
||||
<>
|
||||
<SpinnerIcon />
|
||||
{t('checking')}
|
||||
</>
|
||||
) : (
|
||||
t('checkNow')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCheckNow}
|
||||
disabled={isChecking}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{isChecking ? (
|
||||
<>
|
||||
<SpinnerIcon />
|
||||
{t('checking')}
|
||||
</>
|
||||
) : (
|
||||
t('checkNow')
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error banner — conditional per UI-SPEC */}
|
||||
{pollError && (
|
||||
|
||||
@@ -103,14 +103,14 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleButtonClick}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('importCsv')}
|
||||
</button>
|
||||
|
||||
{/* Import mode inline dialog (below button, per UI-SPEC) */}
|
||||
{showDialog && selectedFile && (
|
||||
<div className="absolute right-0 top-10 z-30 w-80 rounded border border-border bg-card p-4 shadow-md">
|
||||
<div className="absolute right-0 top-10 z-30 w-80 rounded bg-card dark:border dark:border-border p-4 shadow-md">
|
||||
<div className="mb-3 rounded bg-muted/50 px-3 py-2 text-xs text-muted-foreground space-y-0.5">
|
||||
<p>{t('csvFormatHelp')}</p>
|
||||
<p className="font-mono">{t('csvFormatExample')}</p>
|
||||
@@ -185,7 +185,7 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('form.cancelDialog')}
|
||||
</button>
|
||||
|
||||
@@ -525,7 +525,7 @@ export function InboxConfigForm() {
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting || isSaving}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{isTesting ? t('form.testTesting') : t('form.testConnection')}
|
||||
</button>
|
||||
@@ -533,7 +533,7 @@ export function InboxConfigForm() {
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || isTesting}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('form.save')}
|
||||
</button>
|
||||
@@ -542,7 +542,7 @@ export function InboxConfigForm() {
|
||||
type="button"
|
||||
onClick={handleCheckNow}
|
||||
disabled={isChecking || isSaving || isTesting}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{isChecking ? 'Prüfe...' : 'Jetzt prüfen'}
|
||||
</button>
|
||||
|
||||
@@ -77,7 +77,7 @@ const vehicleToEdit = (v: DkvVehicle): EditState => ({
|
||||
|
||||
// --- Shared classes ---
|
||||
const theadCls =
|
||||
'px-3 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
|
||||
'px-3 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
|
||||
const iconBtnCls =
|
||||
'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors';
|
||||
const editInputCls =
|
||||
@@ -95,7 +95,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
|
||||
const t = useTranslations('dkvFleet');
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="rounded border border-border bg-card px-6 py-5 shadow-lg max-w-sm w-full">
|
||||
<div className="rounded bg-card dark:border dark:border-border px-6 py-5 shadow-lg max-w-sm w-full">
|
||||
<h3 className="text-base font-semibold text-foreground mb-2">
|
||||
{t('deleteVehicle')}
|
||||
</h3>
|
||||
@@ -107,7 +107,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('form.cancelDialog')}
|
||||
</button>
|
||||
@@ -132,7 +132,7 @@ interface ToastProps {
|
||||
|
||||
function Toast({ message }: ToastProps) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 rounded border border-border bg-card shadow-md px-4 py-3 text-sm">
|
||||
<div className="fixed bottom-4 right-4 z-50 rounded bg-card dark:border dark:border-border shadow-md px-4 py-3 text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
@@ -317,7 +317,7 @@ export function VehicleTable() {
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
disabled={isAddingNew}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('addVehicle')}
|
||||
</button>
|
||||
@@ -408,7 +408,7 @@ export function VehicleTable() {
|
||||
aria-label={t('form.saveRow')}
|
||||
onClick={() => saveEdit(v.id)}
|
||||
disabled={isSavingRow}
|
||||
className={`${iconBtnCls} hover:text-primary`}
|
||||
className={`${iconBtnCls} hover:text-foreground`}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
@@ -515,7 +515,7 @@ export function VehicleTable() {
|
||||
aria-label={t('form.saveRow')}
|
||||
onClick={saveNew}
|
||||
disabled={isSavingNew}
|
||||
className={`${iconBtnCls} hover:text-primary`}
|
||||
className={`${iconBtnCls} hover:text-foreground`}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function DkvFleetSettingsPage() {
|
||||
const tabCls = (tab: ActiveTab) =>
|
||||
`px-4 py-2 text-sm transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-primary text-foreground font-medium'
|
||||
? 'border-b-2 border-primary-strong text-foreground font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`;
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function DomainInput({ onSubmit, isLoading }: DomainInputProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !domain.trim()}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:pointer-events-none disabled:opacity-50"
|
||||
className="btn btn-primary disabled:pointer-events-none"
|
||||
>
|
||||
{isLoading ? t('checking') : t('checkButton')}
|
||||
</button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ResultList({ results }: ResultListProps) {
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t('suggestions')}
|
||||
</p>
|
||||
{suggestions.map((result) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { type DomainCheckResult, checkDomainAction } from './actions';
|
||||
@@ -37,17 +38,11 @@ export default function DomaincheckPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader moduleSlug="domaincheck" title={t('title')} description={t('description')} />
|
||||
|
||||
{/* Card panel (per D-05a) */}
|
||||
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
||||
{/* Domain input */}
|
||||
<DomainInput onSubmit={handleSubmit} isLoading={isLoading} />
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -443,8 +443,8 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
|
||||
data-testid="server-card"
|
||||
data-health={health}
|
||||
aria-label={`${server.name}: ${t(`health.${health}`)}`}
|
||||
className={`relative flex h-full flex-col overflow-hidden rounded-xl border bg-card ${
|
||||
isOrphan ? 'border-dashed border-border' : 'border-border'
|
||||
className={`relative flex h-full flex-col overflow-hidden rounded-lg bg-card ${
|
||||
isOrphan ? 'border border-dashed border-border' : 'dark:border dark:border-border'
|
||||
} ${style.shadow}`}
|
||||
>
|
||||
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import { HealthBar } from '@/components/proxmox/HealthBar';
|
||||
@@ -36,7 +37,7 @@ function RefreshIcon({ spinning }: { spinning: boolean }) {
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 pl-5 shadow-[0_1px_2px_oklch(0_0_0/0.06)]">
|
||||
<div className="rounded-xl bg-card p-4 pl-5 shadow-sm dark:border dark:border-border">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
@@ -106,33 +107,31 @@ export default function ProxmoxPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/modules/proxmox/settings"
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{t('card.settingsNav')}
|
||||
</Link>
|
||||
{servers !== null && servers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<RefreshIcon spinning={isRefreshing} />
|
||||
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PageHeader
|
||||
moduleSlug="proxmox"
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
actions={
|
||||
isAdmin ? (
|
||||
<>
|
||||
<Link href="/modules/proxmox/settings" className="btn btn-subtle">
|
||||
{t('card.settingsNav')}
|
||||
</Link>
|
||||
{servers !== null && servers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<RefreshIcon spinning={isRefreshing} />
|
||||
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
|
||||
|
||||
@@ -179,7 +179,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<div className="space-y-4 rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm">
|
||||
<div>
|
||||
<label htmlFor="proxmox-name" className={labelCls}>
|
||||
{t('settings.nameLabel')}
|
||||
@@ -368,7 +368,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !form.name || !form.baseUrl}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{isSaving ? t('settings.saving') : t('settings.save')}
|
||||
</button>
|
||||
@@ -383,7 +383,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={isTesting || !form.baseUrl}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{isTesting ? t('settings.testTesting') : t('settings.testConnection')}
|
||||
</button>
|
||||
|
||||
@@ -17,7 +17,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
|
||||
const t = useTranslations('proxmox');
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="w-full max-w-sm rounded border border-border bg-card px-6 py-5 shadow-lg">
|
||||
<div className="w-full max-w-sm rounded bg-card dark:border dark:border-border px-6 py-5 shadow-lg">
|
||||
<h3 className="mb-2 text-base font-semibold text-foreground">
|
||||
{t('settings.deleteConfirmTitle')}
|
||||
</h3>
|
||||
@@ -29,7 +29,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('settings.deleteCancelButton')}
|
||||
</button>
|
||||
@@ -116,11 +116,7 @@ export default function ProxmoxSettingsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
|
||||
{editingId === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId('new')}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<button type="button" onClick={() => setEditingId('new')} className="btn btn-primary">
|
||||
{t('settings.addServer')}
|
||||
</button>
|
||||
)}
|
||||
@@ -155,7 +151,7 @@ export default function ProxmoxSettingsPage() {
|
||||
) : (
|
||||
<li
|
||||
key={server.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 shadow-sm"
|
||||
className="flex items-center justify-between rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{server.name}</div>
|
||||
@@ -167,7 +163,7 @@ export default function ProxmoxSettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(server.id)}
|
||||
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('settings.edit')}
|
||||
</button>
|
||||
|
||||
@@ -157,7 +157,7 @@ export function FilterPanel() {
|
||||
const labelCls = 'mb-1 block text-sm text-foreground';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-4">
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<label htmlFor="tr-filter-q" className={labelCls}>
|
||||
@@ -297,7 +297,7 @@ export function FilterPanel() {
|
||||
autoComplete="off"
|
||||
/>
|
||||
{cpvMatches.length > 0 && (
|
||||
<ul className="absolute z-10 mt-1 w-full max-h-56 overflow-auto rounded border border-border bg-card shadow-md">
|
||||
<ul className="absolute z-10 mt-1 w-full max-h-56 overflow-auto rounded bg-card dark:border dark:border-border shadow-md">
|
||||
{cpvMatches.map((d) => (
|
||||
<li key={d.code}>
|
||||
<button
|
||||
|
||||
@@ -65,7 +65,7 @@ export function PollNowButton({ onPolled }: PollNowButtonProps) {
|
||||
onClick={handlePoll}
|
||||
disabled={isPolling}
|
||||
title={t('page.pollNowTitle')}
|
||||
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
|
||||
className={`btn btn-primary flex items-center${
|
||||
isPolling ? ' opacity-80 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -210,7 +210,7 @@ export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
const thStaticCls =
|
||||
'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
|
||||
'px-4 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
|
||||
const thSortCls = `${thStaticCls} cursor-pointer select-none hover:text-foreground transition-colors`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -219,7 +219,7 @@ export function SavedSearchBar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-3">
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm space-y-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('savedSearch.title')}</h2>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
@@ -276,7 +276,7 @@ export function SavedSearchBar() {
|
||||
type="button"
|
||||
aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })}
|
||||
onClick={() => confirmRename(profile.id)}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
className="text-xs font-medium link"
|
||||
>
|
||||
{t('savedSearch.save')}
|
||||
</button>
|
||||
|
||||
@@ -67,7 +67,7 @@ function formatPublished(value: string): string {
|
||||
}
|
||||
|
||||
function fieldLabelCls() {
|
||||
return 'text-xs font-semibold uppercase tracking-wider text-muted-foreground';
|
||||
return 'text-xs font-semibold text-muted-foreground';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +234,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
href={source.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
className="inline-block text-sm font-medium link"
|
||||
>
|
||||
{t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })}
|
||||
</a>
|
||||
@@ -251,7 +251,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
href={tender.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
className="mt-2 inline-block text-sm font-medium link"
|
||||
>
|
||||
{t('detail.sourceLinkFallback')}
|
||||
</a>
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function TenderRadarMySourcesPage() {
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<Link
|
||||
href="/modules/tender-radar/settings"
|
||||
className="text-sm text-primary hover:underline"
|
||||
className="text-sm link"
|
||||
>
|
||||
{t('page.settingsTitle')} →
|
||||
</Link>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Suspense, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { CoverageBanner } from './components/CoverageBanner';
|
||||
import { FilterPanel } from './components/FilterPanel';
|
||||
import { PollNowButton } from './components/PollNowButton';
|
||||
@@ -59,42 +60,41 @@ function TenderRadarContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t('page.title')}
|
||||
</h1>
|
||||
|
||||
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/*
|
||||
* Zahnrad-Link. Absolute literal href (NOT the dynamic
|
||||
* [category]/[moduleSlug] basePath): tender-radar routes only
|
||||
* exist as literal paths — the dynamic settings route returns
|
||||
* "Modul nicht gefunden" for this module, so the gear must point
|
||||
* at the literal path directly.
|
||||
*
|
||||
* Phase 17 Plan 03 (D-03): points at "Meine Quellen", not the
|
||||
* admin-only settings page — that's the page every module user
|
||||
* (not just administrators) is allowed to configure something
|
||||
* on; administrators reach the platform-wide settings from there
|
||||
* with one more click.
|
||||
*/}
|
||||
<Link
|
||||
href="/modules/tender-radar/my-sources"
|
||||
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={t('page.mySourcesTitle')}
|
||||
aria-label={t('page.mySourcesTitle')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader
|
||||
moduleSlug="tender-radar"
|
||||
title={t('page.title')}
|
||||
actions={
|
||||
<>
|
||||
{/*
|
||||
* Zahnrad-Link. Absolute literal href (NOT the dynamic
|
||||
* [category]/[moduleSlug] basePath): tender-radar routes only
|
||||
* exist as literal paths — the dynamic settings route returns
|
||||
* "Modul nicht gefunden" for this module, so the gear must point
|
||||
* at the literal path directly.
|
||||
*
|
||||
* Phase 17 Plan 03 (D-03): points at "Meine Quellen", not the
|
||||
* admin-only settings page — that's the page every module user
|
||||
* (not just administrators) is allowed to configure something
|
||||
* on; administrators reach the platform-wide settings from there
|
||||
* with one more click.
|
||||
*/}
|
||||
<Link
|
||||
href="/modules/tender-radar/my-sources"
|
||||
className="btn btn-secondary btn-icon"
|
||||
title={t('page.mySourcesTitle')}
|
||||
aria-label={t('page.mySourcesTitle')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</Link>
|
||||
|
||||
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
|
||||
</div>
|
||||
</div>
|
||||
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<CoverageBanner />
|
||||
<SavedSearchBar />
|
||||
|
||||
+2
-2
@@ -449,7 +449,7 @@ export function EmailAlertConfigForm() {
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting || isSaving}
|
||||
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{isTesting ? t('emailAlerts.testTesting') : t('emailAlerts.testConnection')}
|
||||
</button>
|
||||
@@ -457,7 +457,7 @@ export function EmailAlertConfigForm() {
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || isTesting}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
|
||||
</button>
|
||||
|
||||
+1
-1
@@ -230,7 +230,7 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={isAdding}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{isAdding ? t('rssFeeds.adding') : t('rssFeeds.add')}
|
||||
</button>
|
||||
|
||||
+1
-1
@@ -216,7 +216,7 @@ export function SourceConfigForm() {
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{isSaving ? t('sourceConfig.saving') : t('sourceConfig.save')}
|
||||
</button>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function TenderRadarSettingsPage() {
|
||||
</p>
|
||||
<Link
|
||||
href="/modules/tender-radar/my-sources"
|
||||
className="text-sm text-primary hover:underline"
|
||||
className="text-sm link"
|
||||
>
|
||||
{t('mySources.title')} →
|
||||
</Link>
|
||||
|
||||
@@ -11,12 +11,16 @@ const messages: Record<string, Record<string, string>> = {
|
||||
editMode: 'Dashboard bearbeiten',
|
||||
saveChanges: 'Änderungen speichern',
|
||||
addWidget: 'Widget hinzufügen',
|
||||
editShort: 'Bearbeiten',
|
||||
done: 'Fertig',
|
||||
toolbarLabel: 'Dashboard-Aktionen',
|
||||
layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key,
|
||||
useLocale: () => 'de',
|
||||
}));
|
||||
|
||||
const mockStore = {
|
||||
@@ -106,45 +110,47 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('DashboardPage (quick-260916-dyv)', () => {
|
||||
it('Test 1: Ansichtsmodus — Stift in fester Leiste unten rechts, kein "Widget hinzufuegen", kein mt-8/top-2, Grid direkt im Container p-2', async () => {
|
||||
it('Test 1: Ansichtsmodus — Befehlsleiste rechts oben mit „Bearbeiten“, kein "Widget hinzufuegen", Begruessung, Grid direkt im Container p-2 (Design „Mosaik“)', async () => {
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Dashboard bearbeiten' });
|
||||
const bar = toggle.parentElement as HTMLElement;
|
||||
expect(bar.classList.contains('fixed')).toBe(true);
|
||||
expect(bar.classList.contains('bottom-6')).toBe(true);
|
||||
expect(bar.classList.contains('right-6')).toBe(true);
|
||||
expect(bar.classList.contains('z-20')).toBe(true);
|
||||
const toolbar = screen.getByRole('toolbar', { name: 'Dashboard-Aktionen' });
|
||||
const toggle = screen.getByRole('button', { name: 'Bearbeiten' });
|
||||
expect(toggle.parentElement).toBe(toolbar);
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
// kein schwebender Knopf mehr unten rechts
|
||||
expect(toolbar.classList.contains('fixed')).toBe(false);
|
||||
expect(document.querySelector('.bottom-6')).toBeNull();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull();
|
||||
expect(document.querySelector('.mt-8')).toBeNull();
|
||||
expect(document.querySelector('.top-2')).toBeNull();
|
||||
// Runde 3: die Begruessung steht in der Seitenleiste, nicht mehr hier.
|
||||
expect(screen.queryByTestId('dashboard-greeting')).toBeNull();
|
||||
|
||||
const grid = screen.getByTestId('dashboard-grid');
|
||||
expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true);
|
||||
expect(grid.getAttribute('data-edit')).toBe('false');
|
||||
});
|
||||
|
||||
it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben dem Haekchen in derselben Leiste, Grid im Bearbeitungsmodus', async () => {
|
||||
it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben „Fertig“ in derselben Leiste, Grid im Bearbeitungsmodus', async () => {
|
||||
mockStore.isEditMode = true;
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Änderungen speichern' });
|
||||
const toggle = screen.getByRole('button', { name: 'Fertig' });
|
||||
const add = screen.getByRole('button', { name: 'Widget hinzufügen' });
|
||||
expect(add.parentElement).toBe(toggle.parentElement);
|
||||
// "Widget hinzufuegen" steht im DOM VOR dem Umschalter (links daneben).
|
||||
expect(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true');
|
||||
});
|
||||
|
||||
it('Test 3: Umschalten — Klick auf "Dashboard bearbeiten" ruft setEditMode(true) genau einmal', async () => {
|
||||
it('Test 3: Umschalten — Klick auf "Bearbeiten" ruft setEditMode(true) genau einmal', async () => {
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard bearbeiten' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bearbeiten' }));
|
||||
expect(mockStore.setEditMode).toHaveBeenCalledTimes(1);
|
||||
expect(mockStore.setEditMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
|
||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||
import { registerWidget } from '@/components/dashboard/widget-registry';
|
||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||
@@ -45,6 +48,17 @@ interface ActiveModule {
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('widgets');
|
||||
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||
// Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung
|
||||
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
|
||||
// Einhaengepunkt der Kopfzeile fuer die Dashboard-Aktionen (quick-260928-vxe)
|
||||
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
|
||||
}, []);
|
||||
// Hintergrund je Benutzer (Design „Mosaik“), seit quick-260928-ujj in der
|
||||
// Datenbank gespeichert und mit der Sitzung geladen.
|
||||
const { background, choose: chooseBackground } = useDashboardBackground();
|
||||
const hasBackground = background.kind !== 'none';
|
||||
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
|
||||
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
|
||||
// "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed.
|
||||
@@ -114,8 +128,42 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<div role="toolbar" aria-label={t('toolbarLabel')} className="flex items-center gap-1">
|
||||
{isEditMode && <BackgroundPicker value={background} onChange={chooseBackground} />}
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatalogOpen(true)}
|
||||
aria-label={t('addWidget')}
|
||||
title={t('addWidget')}
|
||||
className="btn btn-appbar"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
<span className="hidden lg:inline">{t('addWidget')}</span>
|
||||
</button>
|
||||
)}
|
||||
<EditModeToggle isEditMode={isEditMode} onToggle={() => setEditMode(!isEditMode)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative p-2">
|
||||
<div className={`relative p-2 ${hasBackground ? 'dashboard-has-bg' : ''}`}>
|
||||
<DashboardBackdrop background={background} />
|
||||
{/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal
|
||||
in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
|
||||
waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
|
||||
@@ -131,6 +179,11 @@ export default function DashboardPage() {
|
||||
onReorder={reorderDashboards}
|
||||
/>
|
||||
|
||||
{/* Dashboard-Aktionen (quick-260928-vxe): per Portal rechts in der
|
||||
App-Leiste statt als eigene Zeile ueber dem Raster. Fehlt der
|
||||
Einhaengepunkt (etwa in Tests ohne Kopfzeile), stehen sie hier. */}
|
||||
{actionsSlot ? createPortal(actions, actionsSlot) : actions}
|
||||
|
||||
{isSwitchingDashboard ? (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
|
||||
@@ -143,44 +196,11 @@ export default function DashboardPage() {
|
||||
isEditMode={isEditMode}
|
||||
onLayoutChange={updateLayouts}
|
||||
onRemoveWidget={removeWidget}
|
||||
onOpenCatalog={() => setCatalogOpen(true)}
|
||||
onQuickAdd={(type) => addWidget(type)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feste Aktionsleiste unten rechts.
|
||||
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
|
||||
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
|
||||
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
|
||||
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatalogOpen(true)}
|
||||
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
{t('addWidget')}
|
||||
</button>
|
||||
)}
|
||||
<EditModeToggle
|
||||
isEditMode={isEditMode}
|
||||
onToggle={() => setEditMode(!isEditMode)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Widget catalog modal */}
|
||||
<WidgetCatalogModal
|
||||
isOpen={catalogOpen}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
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 (Muster admin/custom-modules-page.test.tsx).
|
||||
const messages: Record<string, Record<string, unknown>> = {
|
||||
'settings.customModules': {
|
||||
title: 'Eigene Module',
|
||||
description: 'Diese Einträge sehen nur Sie.',
|
||||
create: 'Eigenes Modul anlegen',
|
||||
noModules: 'Noch keine eigenen Module',
|
||||
noModulesBody: 'Legen Sie Ihr erstes Modul an.',
|
||||
},
|
||||
'customModules.manage': { actions: 'Aktionen', loadError: 'Laden fehlgeschlagen.' },
|
||||
'customModules.form': {
|
||||
create: 'Eigenes Modul anlegen',
|
||||
edit: 'Eigenes Modul bearbeiten',
|
||||
name: 'Name',
|
||||
url: 'Adresse',
|
||||
urlHint: 'Hinweis https',
|
||||
category: 'Kategorie',
|
||||
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.',
|
||||
deleteConfirm: {
|
||||
title: 'Eigenes Modul löschen',
|
||||
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
||||
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
loading: 'Laden...',
|
||||
cancel: 'Abbrechen',
|
||||
save: 'Speichern',
|
||||
delete: 'Löschen',
|
||||
edit: 'Bearbeiten',
|
||||
},
|
||||
};
|
||||
|
||||
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 }),
|
||||
}));
|
||||
|
||||
import CustomModulesSettingsPage from './page';
|
||||
|
||||
const mine = {
|
||||
id: 'cm-1',
|
||||
name: 'Mein Wiki',
|
||||
url: 'https://wiki.example.com',
|
||||
category: 'infrastructure',
|
||||
personal: true,
|
||||
createdAt: '2026-09-29T00:00:00.000Z',
|
||||
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||
};
|
||||
const shared = { ...mine, id: 'cm-2', name: 'Firmenseite', personal: false };
|
||||
|
||||
beforeEach(() => {
|
||||
mockList.mockReset().mockResolvedValue([]);
|
||||
mockCreate.mockReset().mockResolvedValue(mine);
|
||||
mockUpdate.mockReset().mockResolvedValue(mine);
|
||||
mockDelete.mockReset().mockResolvedValue(undefined);
|
||||
mockBump.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('CustomModulesSettingsPage', () => {
|
||||
it('zeigt Einleitung und ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
|
||||
render(<CustomModulesSettingsPage />);
|
||||
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
|
||||
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
|
||||
mockList.mockResolvedValue([shared, mine]);
|
||||
render(<CustomModulesSettingsPage />);
|
||||
expect(await screen.findByRole('link', { name: 'Mein Wiki' })).toHaveAttribute(
|
||||
'href',
|
||||
'/modules/custom/cm-1',
|
||||
);
|
||||
expect(screen.queryByText('Firmenseite')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'Bearbeiten' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Anlegen sendet KEIN shared (persönlicher Eintrag), lädt neu und aktualisiert die Seitenleiste', async () => {
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), ' Mein Wiki ');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'Mein Wiki',
|
||||
url: 'https://example.com',
|
||||
category: 'infrastructure',
|
||||
});
|
||||
});
|
||||
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
|
||||
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||
expect(mockList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Anlegen: http-Adresse wird vor dem Senden abgelehnt', async () => {
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'X');
|
||||
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();
|
||||
});
|
||||
|
||||
it('Bearbeiten: ruft update ohne shared und aktualisiert die Seitenleiste', async () => {
|
||||
mockList.mockResolvedValue([mine]);
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const nameInput = within(dialog).getByLabelText('Name');
|
||||
expect(nameInput).toHaveValue('Mein Wiki');
|
||||
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('Löschen: Rückfrage nennt den persönlichen Text, Bestätigen ruft delete und aktualisiert die Seitenleiste', async () => {
|
||||
mockList.mockResolvedValue([mine]);
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(
|
||||
within(dialog).getByText('Möchten Sie „Mein Wiki“ wirklich löschen? Nur bei Ihnen.'),
|
||||
).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('Serverfehler beim Speichern bleibt im Dialog sichtbar', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('500'));
|
||||
render(<CustomModulesSettingsPage />);
|
||||
await screen.findByText('Noch keine eigenen Module');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]);
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'X');
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
|
||||
|
||||
/**
|
||||
* Einstellungen > Eigene Module (quick-260929-dzu) — /settings/custom-modules.
|
||||
* Jeder angemeldete Benutzer pflegt hier seine PERSOENLICHEN Seitenleisten-
|
||||
* Eintraege; nur er selbst sieht sie. Die Trennung von den gemeinsamen
|
||||
* Eintraegen der Verwaltung erzwingt die API, nicht diese Seite.
|
||||
*/
|
||||
export default function CustomModulesSettingsPage() {
|
||||
const t = useTranslations('settings.customModules');
|
||||
|
||||
return (
|
||||
<CustomModuleManager
|
||||
scope="personal"
|
||||
texts={{
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
create: t('create'),
|
||||
noModules: t('noModules'),
|
||||
noModulesBody: t('noModulesBody'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,9 +17,9 @@ export default function SettingsLayout({
|
||||
const t = useTranslations('settings');
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex h-full flex-col md:flex-row">
|
||||
<SettingsSidebar />
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
{/* Back to Dashboard link */}
|
||||
<Link
|
||||
href="/"
|
||||
|
||||
+475
-42
@@ -57,45 +57,90 @@
|
||||
--color-status-idle-fg: var(--status-idle-fg);
|
||||
--color-status-orphan-fg: var(--status-orphan-fg);
|
||||
--color-well: var(--well);
|
||||
--color-link: var(--link);
|
||||
--color-tile: var(--tile);
|
||||
--color-tile-foreground: var(--tile-foreground);
|
||||
--color-primary-strong: var(--primary-strong);
|
||||
--color-input-strong: var(--input-strong);
|
||||
--color-appbar: var(--appbar);
|
||||
--color-appbar-foreground: var(--appbar-foreground);
|
||||
--color-appbar-muted: var(--appbar-muted);
|
||||
--color-appbar-hover: var(--appbar-hover);
|
||||
--color-sidebar-hover: var(--sidebar-hover);
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
}
|
||||
|
||||
/* Fluent-2-Hoehenstufen: shadow-sm = shadow2 (ruhende Karte), shadow-md =
|
||||
shadow4 (angehobene/Hover-Karte), shadow-lg = shadow16 (Menues, Dialoge).
|
||||
Im dunklen Erscheinungsbild tragen Haarlinien statt Schatten (--border). */
|
||||
@theme {
|
||||
--shadow-sm: 0 0 2px oklch(0 0 0 / 0.12), 0 1px 2px oklch(0 0 0 / 0.14);
|
||||
--shadow-md: 0 0 2px oklch(0 0 0 / 0.12), 0 4px 8px oklch(0 0 0 / 0.14);
|
||||
--shadow-lg: 0 0 2px oklch(0 0 0 / 0.12), 0 8px 16px oklch(0 0 0 / 0.14);
|
||||
--shadow-xl: 0 0 8px oklch(0 0 0 / 0.12), 0 14px 28px oklch(0 0 0 / 0.14);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||
/* Design „Mosaik“ (Fluent 2 / GitHub als Vorbild): 6 px fuer Bedien-
|
||||
elemente (rounded-md), 8 px fuer Karten (rounded-lg), 12 px fuer grosse
|
||||
Flaechen (rounded-xl). */
|
||||
--radius: 0.375rem;
|
||||
/* Segoe UI Variable zuerst: unter Windows (Desktop-Client) wirkt die
|
||||
Oberflaeche damit wie eine native Anwendung; sonst Inter bzw. Systemschrift. */
|
||||
--font-sans: "Segoe UI Variable Text", "Segoe UI", Inter, system-ui, -apple-system, sans-serif;
|
||||
|
||||
/* Layout dimensions */
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
--header-height: 60px;
|
||||
--header-height: 48px;
|
||||
|
||||
/* Light mode colors (OKLCH) */
|
||||
--background: oklch(0.99 0 0);
|
||||
--foreground: oklch(0.15 0 0);
|
||||
/* Light mode colors (OKLCH) — Arbeitsflaeche leicht kuehlgrau, Karten weiss */
|
||||
--background: oklch(0.972 0.003 260);
|
||||
--foreground: oklch(0.2 0.006 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.15 0 0);
|
||||
--card-foreground: oklch(0.2 0.006 260);
|
||||
--primary: oklch(0.91 0.19 102);
|
||||
--primary-foreground: oklch(0.20 0.02 90);
|
||||
--secondary: oklch(0.96 0 0);
|
||||
--secondary-foreground: oklch(0.15 0 0);
|
||||
--muted: oklch(0.96 0 0);
|
||||
--muted-foreground: oklch(0.55 0 0);
|
||||
--accent: oklch(0.96 0 0);
|
||||
--accent-foreground: oklch(0.15 0 0);
|
||||
--secondary: oklch(0.955 0.003 260);
|
||||
--secondary-foreground: oklch(0.2 0.006 260);
|
||||
--muted: oklch(0.955 0.003 260);
|
||||
--muted-foreground: oklch(0.49 0.01 260);
|
||||
--accent: oklch(0.955 0.003 260);
|
||||
--accent-foreground: oklch(0.2 0.006 260);
|
||||
--destructive: oklch(0.55 0.2 27);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--border: oklch(0.90 0 0);
|
||||
--input: oklch(0.90 0 0);
|
||||
--ring: oklch(0.91 0.19 102);
|
||||
--border: oklch(0.915 0.004 260);
|
||||
--input: oklch(0.87 0 0);
|
||||
/* Untere Kante von Eingabefeldern (Fluent-Eingabefeld). */
|
||||
--input-strong: oklch(0.58 0.005 260);
|
||||
/* Tastaturfokus: dunkler Ring statt Gelb — Gelb auf Weiss erreicht nur
|
||||
1,3:1 und waere als Fokusanzeige kaum sichtbar. */
|
||||
--ring: oklch(0.3 0.01 260);
|
||||
/* Verweise: nie gelbe Schrift auf Weiss. 5,9:1 auf --card. */
|
||||
--link: oklch(0.50 0.15 255);
|
||||
/* Neutrale Modul-Kachel (Runde 2: einfarbig statt bunt). */
|
||||
--tile: oklch(0.93 0.004 260);
|
||||
--tile-foreground: oklch(0.35 0.01 260);
|
||||
/* Akzent fuer duenne Markierungen (Auswahlpille, Reiter-Unterstrich) auf
|
||||
hellem Grund: die Akzentfarbe um knapp ein Drittel abgedunkelt, damit
|
||||
auch Gelb neben Hellgrau noch erkennbar bleibt. */
|
||||
--primary-strong: color-mix(in oklab, var(--primary) 70%, oklch(0.3 0.03 90));
|
||||
|
||||
/* Kopfzeile (dunkle App-Leiste wie GitHub / Microsoft 365) */
|
||||
--appbar: oklch(0.24 0.012 260);
|
||||
--appbar-foreground: oklch(0.985 0 0);
|
||||
--appbar-muted: oklch(0.985 0 0 / 0.72);
|
||||
--appbar-hover: oklch(1 0 0 / 0.08);
|
||||
|
||||
/* Sidebar tokens */
|
||||
--sidebar: oklch(0.97 0 0);
|
||||
--sidebar-foreground: oklch(0.15 0 0);
|
||||
--sidebar-accent: oklch(0.93 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.25 0 0);
|
||||
--sidebar-border: oklch(0.90 0 0);
|
||||
--sidebar: oklch(0.975 0.003 260);
|
||||
--sidebar-foreground: oklch(0.2 0.006 260);
|
||||
--sidebar-accent: oklch(0.925 0.005 260);
|
||||
--sidebar-accent-foreground: oklch(0.16 0.006 260);
|
||||
--sidebar-hover: oklch(0.945 0.004 260);
|
||||
--sidebar-border: oklch(0.915 0.004 260);
|
||||
|
||||
/* Statusfarben (quick-260924-h7x). Die Flaechentoene stammen aus dem
|
||||
Design-Plan; als Schrift auf Weiss erreichen sie aber nur 2,6:1 bis
|
||||
@@ -113,34 +158,46 @@
|
||||
--status-idle-fg: oklch(0.50 0.02 260);
|
||||
--status-orphan-fg: oklch(0.50 0.01 260);
|
||||
/* Eingelassene Messfelder. */
|
||||
--well: oklch(0.975 0.003 260);
|
||||
--well: oklch(0.972 0.003 260);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.23 0.01 260);
|
||||
--foreground: oklch(0.95 0 0);
|
||||
--card: oklch(0.27 0.01 260);
|
||||
--card-foreground: oklch(0.95 0 0);
|
||||
--background: oklch(0.2 0.01 260);
|
||||
--foreground: oklch(0.95 0.003 260);
|
||||
--card: oklch(0.245 0.01 260);
|
||||
--card-foreground: oklch(0.95 0.003 260);
|
||||
--primary: oklch(0.91 0.19 102);
|
||||
--primary-foreground: oklch(0.20 0.02 90);
|
||||
--secondary: oklch(0.30 0.01 260);
|
||||
--secondary-foreground: oklch(0.95 0 0);
|
||||
--muted: oklch(0.30 0.01 260);
|
||||
--muted-foreground: oklch(0.65 0 0);
|
||||
--accent: oklch(0.30 0.01 260);
|
||||
--accent-foreground: oklch(0.95 0 0);
|
||||
--destructive: oklch(0.55 0.2 27);
|
||||
--secondary: oklch(0.28 0.01 260);
|
||||
--secondary-foreground: oklch(0.95 0.003 260);
|
||||
--muted: oklch(0.28 0.01 260);
|
||||
--muted-foreground: oklch(0.72 0.01 260);
|
||||
--accent: oklch(0.28 0.01 260);
|
||||
--accent-foreground: oklch(0.95 0.003 260);
|
||||
--destructive: oklch(0.6 0.2 27);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--border: oklch(0.30 0.01 260);
|
||||
--input: oklch(0.30 0.01 260);
|
||||
--ring: oklch(0.91 0.19 102);
|
||||
/* Dunkel: feine helle Haarlinie statt Schatten. */
|
||||
--border: oklch(1 0 0 / 0.09);
|
||||
--input: oklch(1 0 0 / 0.16);
|
||||
--input-strong: oklch(1 0 0 / 0.42);
|
||||
--ring: oklch(0.92 0.005 260);
|
||||
--link: oklch(0.78 0.11 250);
|
||||
--tile: oklch(0.32 0.01 260);
|
||||
--tile-foreground: oklch(0.88 0.005 260);
|
||||
--primary-strong: var(--primary);
|
||||
|
||||
--appbar: oklch(0.18 0.01 260);
|
||||
--appbar-foreground: oklch(0.985 0 0);
|
||||
--appbar-muted: oklch(0.985 0 0 / 0.7);
|
||||
--appbar-hover: oklch(1 0 0 / 0.08);
|
||||
|
||||
/* Sidebar tokens (dark) */
|
||||
--sidebar: oklch(0.20 0.01 260);
|
||||
--sidebar-foreground: oklch(0.95 0 0);
|
||||
--sidebar-accent: oklch(0.91 0.19 102 / 0.15);
|
||||
--sidebar-accent-foreground: oklch(0.91 0.19 102);
|
||||
--sidebar-border: oklch(0.30 0.01 260);
|
||||
--sidebar: oklch(0.21 0.01 260);
|
||||
--sidebar-foreground: oklch(0.93 0.003 260);
|
||||
--sidebar-accent: oklch(1 0 0 / 0.08);
|
||||
--sidebar-accent-foreground: oklch(0.98 0 0);
|
||||
--sidebar-hover: oklch(1 0 0 / 0.05);
|
||||
--sidebar-border: oklch(1 0 0 / 0.08);
|
||||
|
||||
/* Statusfarben (dark). Textvariante auf --card gemessen: ok 7,4:1,
|
||||
warn 8,5:1, down 6,1:1, idle 6,1:1, orphan 5,6:1. */
|
||||
@@ -154,11 +211,13 @@
|
||||
--status-down-fg: oklch(0.74 0.16 27);
|
||||
--status-idle-fg: oklch(0.72 0.02 260);
|
||||
--status-orphan-fg: oklch(0.70 0.01 260);
|
||||
--well: oklch(0.235 0.01 260);
|
||||
--well: oklch(0.215 0.01 260);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -181,3 +240,377 @@ body {
|
||||
.wmde-markdown ul ul { list-style: circle; }
|
||||
.wmde-markdown ol { list-style: decimal; }
|
||||
.wmde-markdown ul.contains-task-list, .wmde-markdown li.task-list-item { list-style: none; }
|
||||
|
||||
/* Tastaturfokus sichtbar ueberall, auch wo ein Bauteil keinen eigenen
|
||||
Fokusring setzt (im Basis-Layer, damit `outline-none` + eigener Ring
|
||||
eines Bauteils Vorrang behalten). Mausklicks loesen :focus-visible nicht aus. */
|
||||
@layer base {
|
||||
:where(a, button, [role="tab"], [role="button"], summary, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fluent-Eingabefeld: untere Kante eine Stufe dunkler; Fokus = dunklerer
|
||||
Rahmen plus 2 px Akzentlinie unten. Gilt fuer Textfelder und Auswahllisten
|
||||
mit dem ueblichen `border-input`/`border-border`-Rahmen; Fehlerzustaende
|
||||
(aria-invalid, border-destructive) behalten ihre Farbe. Bewusst ausserhalb
|
||||
eines @layer, damit die Regel die Tailwind-Utilities schlaegt.
|
||||
`.field-plain` nimmt ein Feld aus (Such-Widget, Nutzerwunsch 29.09.: die
|
||||
helle Unterkante wirkte im Dunkelmodus wie eine weisse Linie). */
|
||||
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive, .field-plain) {
|
||||
border-bottom-color: var(--input-strong);
|
||||
}
|
||||
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive, .field-plain):focus {
|
||||
outline: none;
|
||||
border-color: var(--input-strong);
|
||||
border-bottom-color: var(--primary);
|
||||
box-shadow: inset 0 -1px 0 var(--primary);
|
||||
}
|
||||
|
||||
/* Karteneffekt in einer Klasse fuer neue Flaechen: hell Schatten, dunkel Haarlinie. */
|
||||
.surface {
|
||||
background: var(--card);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.dark .surface {
|
||||
box-shadow: none;
|
||||
outline: 1px solid var(--border);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Knoepfe (Design „Mosaik“, Fluent 2): 32 px hoch, 6 px Radius, 14 px
|
||||
halbfett. Primaer = Akzentflaeche mit dunkler Schrift, Sekundaer = weisse
|
||||
Flaeche mit Rahmen, Subtil = ohne Flaeche. Im Komponenten-Layer, damit
|
||||
einzelne Utilities (etwa `w-full`) weiter ueberschreiben koennen. */
|
||||
@layer components {
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid transparent;
|
||||
padding-inline: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background-color 120ms, border-color 120ms, color 120ms;
|
||||
}
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* Fluent: deaktiviert = graue Flaeche, graue Schrift (nicht verblasstes Gelb). */
|
||||
.btn-primary:disabled {
|
||||
background-color: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-secondary:disabled, .btn-subtle:disabled {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: color-mix(in oklab, var(--primary) 88%, black);
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: var(--card);
|
||||
color: var(--foreground);
|
||||
border-color: oklch(0.87 0 0);
|
||||
box-shadow: 0 1px 2px oklch(0 0 0 / 0.06);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: oklch(0.965 0 0);
|
||||
}
|
||||
.dark .btn-secondary {
|
||||
border-color: var(--input);
|
||||
box-shadow: none;
|
||||
}
|
||||
.dark .btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
.btn-subtle {
|
||||
background-color: transparent;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.btn-subtle:hover:not(:disabled) {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
.btn-icon {
|
||||
width: 2rem;
|
||||
padding-inline: 0;
|
||||
}
|
||||
/* Knopf auf der dunklen App-Leiste (Dashboard-Aktionen, quick-260928-vxe). */
|
||||
.btn-appbar {
|
||||
background-color: transparent;
|
||||
color: var(--appbar-muted);
|
||||
}
|
||||
.btn-appbar:hover:not(:disabled) {
|
||||
background-color: var(--appbar-hover);
|
||||
color: var(--appbar-foreground);
|
||||
}
|
||||
.btn-appbar:focus-visible {
|
||||
outline-color: var(--appbar-foreground);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Verweis: dunkle bzw. blaue Schrift, nie gelb auf Weiss. */
|
||||
.link {
|
||||
color: var(--link);
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: color-mix(in oklab, var(--link) 40%, transparent);
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown (Notiz-Kachel, „Was ist neu“): Fluent-Schriftgroessen statt der
|
||||
GitHub-Vorgaben (16 px Grundschrift, 2em-Ueberschriften), Hintergrund
|
||||
transparent, Farben aus den Tokens. */
|
||||
:root .wmde-markdown {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
background-color: transparent !important;
|
||||
color: var(--foreground) !important;
|
||||
--color-border-default: var(--border);
|
||||
--color-border-muted: var(--border);
|
||||
--color-accent-fg: var(--link);
|
||||
--color-fg-default: var(--foreground);
|
||||
--color-fg-muted: var(--muted-foreground);
|
||||
}
|
||||
:root .wmde-markdown h1 { font-size: 1.25rem; line-height: 1.75rem; font-weight: 600; }
|
||||
:root .wmde-markdown h2 { font-size: 1rem; line-height: 1.375rem; font-weight: 600; }
|
||||
:root .wmde-markdown h3, :root .wmde-markdown h4 { font-size: 0.875rem; line-height: 1.25rem; font-weight: 600; }
|
||||
:root .w-md-editor {
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Navigationseintrag mit Fluent-Auswahlpille, gesteuert ueber aria-current
|
||||
(Einstellungs- und Verwaltungsnavigation). */
|
||||
.nav-item {
|
||||
position: relative;
|
||||
}
|
||||
.nav-item[aria-current="page"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 1rem;
|
||||
margin-top: -0.5rem;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--primary-strong);
|
||||
}
|
||||
|
||||
/* Checkboxen und Optionsfelder in der Link-Farbe statt Browser-Blau bzw.
|
||||
Gelb (Gelb mit weissem Haken waere kaum lesbar). */
|
||||
input[type="checkbox"], input[type="radio"] {
|
||||
accent-color: var(--link);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Dashboard-Kacheln (Design „Mosaik“)
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
/* Einheitliche Kachel-Kopfzeile: 40 px, 12 px Innenabstand, 14 px halbfett. */
|
||||
.widget-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Runde 3 — mehr Leben in den Kacheln, ohne bunt zu werden.
|
||||
Symbol-Chip: volle Akzentfarbe als Flaeche, Symbol in der passenden Vordergrundfarbe. */
|
||||
.widget-icon-chip {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border-radius: 0.4375rem;
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
animation: widget-enter 520ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||
animation-delay: var(--enter-delay, 0ms);
|
||||
}
|
||||
}
|
||||
@keyframes widget-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Aktionen der Kachel erscheinen erst beim Zeigen oder mit Tastaturfokus —
|
||||
nur auf Geraeten mit Maus; auf Touch bleiben sie sichtbar. */
|
||||
@media (hover: hover) {
|
||||
[data-widget-id]:not(:hover):not(:focus-within) .widget-action:not(.widget-action-pinned) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.widget-action {
|
||||
transition: opacity 120ms;
|
||||
}
|
||||
|
||||
/* Notiz: ruhige Schrift, Innenabstand wie die Kopfzeile. */
|
||||
:root .widget-note-body .wmde-markdown {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
:root .widget-note-body .w-md-editor-preview,
|
||||
:root .widget-note-body .wmde-markdown {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
:root .widget-note-body .wmde-markdown h1,
|
||||
:root .widget-note-body .wmde-markdown h2 {
|
||||
border-bottom: 0;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.375rem;
|
||||
}
|
||||
|
||||
/* Bearbeitungsmodus: gestrichelte Akzentkontur und sichtbarer Groessengriff. */
|
||||
.react-grid-item .widget-drag-handle {
|
||||
outline: 2px dashed var(--primary-strong);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.react-grid-item > .react-resizable-handle {
|
||||
z-index: 20;
|
||||
}
|
||||
.react-grid-item > .react-resizable-handle.react-resizable-handle-se {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
background-image: none;
|
||||
border-right: 2px solid var(--primary-strong);
|
||||
border-bottom: 2px solid var(--primary-strong);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.react-grid-item > .react-resizable-handle.react-resizable-handle-se::after {
|
||||
display: none;
|
||||
}
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
background: var(--primary);
|
||||
opacity: 0.18;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mit Hintergrund: Kacheln als Windows-11-„Mica“ — halbtransparent mit
|
||||
Unschaerfe. Die einzige Stelle mit Transparenz. Deckkraft so gewaehlt,
|
||||
dass Text auch vor einem rein schwarzen (hell) bzw. rein weissen, dann auf
|
||||
55 % abgedunkelten (dunkel) Bild 4,5:1 haelt. Ohne backdrop-filter oder
|
||||
bei „weniger Transparenz“ bleibt die Kachel deckend. */
|
||||
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
@media not (prefers-reduced-transparency: reduce) {
|
||||
.dashboard-has-bg [data-widget-id] {
|
||||
background-color: oklch(1 0 0 / 0.86);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
}
|
||||
.dark .dashboard-has-bg [data-widget-id] {
|
||||
background-color: oklch(0.24 0.01 260 / 0.88);
|
||||
border-color: oklch(1 0 0 / 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.dashboard-has-bg [data-widget-id] {
|
||||
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 2px 6px oklch(0 0 0 / 0.16);
|
||||
}
|
||||
|
||||
/* Begruessung, Befehlsleiste und leerer Zustand: ohne Hintergrund frei auf
|
||||
der Arbeitsflaeche, mit Hintergrund auf derselben Kachelflaeche wie die
|
||||
Widgets (deckend als Rueckfall, durchscheinend wo moeglich). */
|
||||
.dashboard-has-bg .dashboard-chrome {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background-color: var(--card);
|
||||
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 2px 6px oklch(0 0 0 / 0.16);
|
||||
}
|
||||
.dashboard-has-bg [role="toolbar"].dashboard-chrome {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
@media not (prefers-reduced-transparency: reduce) {
|
||||
.dashboard-has-bg .dashboard-chrome {
|
||||
background-color: oklch(1 0 0 / 0.86);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
}
|
||||
.dark .dashboard-has-bg .dashboard-chrome {
|
||||
background-color: oklch(0.24 0.01 260 / 0.88);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Notiz-Checklisten: Kaestchen in der Akzentfarbe; der Browser waehlt den
|
||||
Haken selbst hell oder dunkel passend zur Akzentfarbe. */
|
||||
:root .widget-note-body input[type="checkbox"] {
|
||||
accent-color: var(--primary);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0 0.5rem 0 0;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
:root .widget-note-body .task-list-item {
|
||||
padding-block: 0.125rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user