Compare commits
10 Commits
design/mosaik
...
v1.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 12eea333ba | |||
| bdaa3c8db5 | |||
| 03026d616f | |||
| 105b66ed8d | |||
| cb45d2663a | |||
| 69d17302e1 | |||
| 0aaa15240d | |||
| 76f6d87973 | |||
| 9fa0a3f498 | |||
| a8a910fd29 |
+4
-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."
|
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_updated: "2026-09-23T15:30:00.000Z"
|
||||||
last_activity: 2026-09-23
|
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
|
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||||
progress:
|
progress:
|
||||||
total_phases: 18
|
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)
|
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||||
Plan: 6 of 6
|
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
|
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-28 - Quick 260928-ujj: Design Mosaik aus dem Klon uebernommen (inkl. Resize-Fix), Dashboard-Hintergrund pro Benutzer in der Datenbank; Freigabe als 1.5.0
|
||||||
|
|
||||||
Progress: [██████████] 99%
|
Progress: [██████████] 99%
|
||||||
|
|
||||||
@@ -476,6 +476,7 @@ 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-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/) |
|
| 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/) |
|
| 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-/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
@@ -521,4 +522,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.
|
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.
|
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
|
Resume file: None
|
||||||
Last activity: 2026-09-22 - Quick 260922-hk4: Bilderrahmen-Bilder im Dateibereich, Selbstheilung aus der alten Spalte
|
Last activity: 2026-09-28 - Quick 260928-ujj: Design Mosaik aus dem Klon uebernommen (inkl. Resize-Fix), Dashboard-Hintergrund pro Benutzer in der Datenbank; Freigabe als 1.5.0
|
||||||
|
|||||||
+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
|
||||||
@@ -4,9 +4,40 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
## Unveröffentlicht
|
## Unveröffentlicht
|
||||||
|
|
||||||
|
## 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
|
### 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“.
|
- 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
|
## 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;
|
||||||
@@ -46,6 +46,10 @@ model User {
|
|||||||
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
|
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
|
||||||
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
|
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
|
||||||
lastSeenReleaseVersion String?
|
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[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
groupMemberships GroupMembership[]
|
groupMemberships GroupMembership[]
|
||||||
moduleGrants ModuleGrant[]
|
moduleGrants ModuleGrant[]
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface FakeUserRow {
|
|||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
avatarPath?: string | null;
|
avatarPath?: string | null;
|
||||||
accentColor?: string | null;
|
accentColor?: string | null;
|
||||||
|
dashboardBackground?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BoundCall {
|
interface BoundCall {
|
||||||
@@ -501,6 +502,8 @@ describe('AuthService.getMe', () => {
|
|||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
avatarPath: 'avatars/u1.png',
|
avatarPath: 'avatars/u1.png',
|
||||||
accentColor: '#3b82f6',
|
accentColor: '#3b82f6',
|
||||||
|
// quick-260928-ujj: gespeicherter Zusatzschluessel wird bei der Ausgabe verworfen.
|
||||||
|
dashboardBackground: { kind: 'preset', id: 'dunes', extra: 'weg' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ldapUserRow: FakeUserRow = {
|
const ldapUserRow: FakeUserRow = {
|
||||||
@@ -515,6 +518,7 @@ describe('AuthService.getMe', () => {
|
|||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
avatarPath: null,
|
avatarPath: null,
|
||||||
accentColor: null,
|
accentColor: null,
|
||||||
|
dashboardBackground: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -535,6 +539,7 @@ describe('AuthService.getMe', () => {
|
|||||||
tenantId: 't1',
|
tenantId: 't1',
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
accentColor: '#3b82f6',
|
accentColor: '#3b82f6',
|
||||||
|
dashboardBackground: { kind: 'preset', id: 'dunes' },
|
||||||
isLocalUser: true,
|
isLocalUser: true,
|
||||||
hasAvatar: true,
|
hasAvatar: true,
|
||||||
});
|
});
|
||||||
@@ -549,6 +554,30 @@ describe('AuthService.getMe', () => {
|
|||||||
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
|
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 () => {
|
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
|
||||||
const result = await service.getMe('t2', 'u1');
|
const result = await service.getMe('t2', 'u1');
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
|
import { parseDashboardBackground } from '@tessera/shared';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
@@ -331,6 +332,7 @@ export class AuthService {
|
|||||||
ldapDn: true,
|
ldapDn: true,
|
||||||
avatarPath: true,
|
avatarPath: true,
|
||||||
accentColor: true,
|
accentColor: true,
|
||||||
|
dashboardBackground: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,10 +340,13 @@ export class AuthService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash, ldapDn, avatarPath, ...publicFields } = user;
|
const { passwordHash, ldapDn, avatarPath, dashboardBackground, ...publicFields } = user;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...publicFields,
|
...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,
|
isLocalUser: !!passwordHash && !ldapDn,
|
||||||
hasAvatar: !!avatarPath,
|
hasAvatar: !!avatarPath,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { Role } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
compareReleaseVersions,
|
compareReleaseVersions,
|
||||||
|
type DashboardBackground,
|
||||||
|
parseDashboardBackground,
|
||||||
parseReleaseVersion,
|
parseReleaseVersion,
|
||||||
type ReleaseNoticeResponse,
|
type ReleaseNoticeResponse,
|
||||||
} from '@tessera/shared';
|
} from '@tessera/shared';
|
||||||
@@ -62,6 +64,10 @@ function resolveAvatarsDir(): string {
|
|||||||
* Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen`
|
* Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen`
|
||||||
* ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit
|
* ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit
|
||||||
* `where: { id: currentUser.id }`.
|
* `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')
|
@Controller('users')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@@ -483,6 +489,43 @@ export class UserController {
|
|||||||
return { success: true };
|
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
|
* GET /users/me/avatar
|
||||||
* Stream the current user's avatar image.
|
* Stream the current user's avatar image.
|
||||||
|
|||||||
@@ -113,11 +113,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
{tCommon('cancel')}
|
{tCommon('cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -249,11 +249,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between items-center gap-3">
|
<div className="flex justify-between items-center gap-3">
|
||||||
<button
|
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
{tCommon('close')}
|
{tCommon('close')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
|
||||||
import { ChangelogView } from '@/components/changelog/changelog-view';
|
import { ChangelogView } from '@/components/changelog/changelog-view';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { appVersion } from '@/lib/app-version';
|
import { appVersion } from '@/lib/app-version';
|
||||||
import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog';
|
import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog';
|
||||||
|
|
||||||
|
|||||||
@@ -116,11 +116,7 @@ export default function ProxmoxSettingsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
|
||||||
{editingId === null && (
|
{editingId === null && (
|
||||||
<button
|
<button type="button" onClick={() => setEditingId('new')} className="btn btn-primary">
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingId('new')}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
{t('settings.addServer')}
|
{t('settings.addServer')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||||
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||||
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
|
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
|
||||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||||
import { registerWidget } from '@/components/dashboard/widget-registry';
|
import { registerWidget } from '@/components/dashboard/widget-registry';
|
||||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||||
@@ -49,10 +50,14 @@ export default function DashboardPage() {
|
|||||||
const [catalogOpen, setCatalogOpen] = useState(false);
|
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||||
// Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung
|
// Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung
|
||||||
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
|
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
|
||||||
const [gridInset, setGridInset] = useState(0);
|
// Einhaengepunkt der Kopfzeile fuer die Dashboard-Aktionen (quick-260928-vxe)
|
||||||
// Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage).
|
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
useEffect(() => {
|
||||||
const { background, choose: chooseBackground } = useDashboardBackground(userId);
|
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';
|
const hasBackground = background.kind !== 'none';
|
||||||
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
|
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
|
||||||
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
|
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
|
||||||
@@ -123,6 +128,39 @@ 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 (
|
return (
|
||||||
<div className={`relative p-2 ${hasBackground ? 'dashboard-has-bg' : ''}`}>
|
<div className={`relative p-2 ${hasBackground ? 'dashboard-has-bg' : ''}`}>
|
||||||
<DashboardBackdrop background={background} />
|
<DashboardBackdrop background={background} />
|
||||||
@@ -141,50 +179,10 @@ export default function DashboardPage() {
|
|||||||
onReorder={reorderDashboards}
|
onReorder={reorderDashboards}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Befehlsleiste rechts ueber dem Raster (Design „Mosaik“, Fluent
|
{/* Dashboard-Aktionen (quick-260928-vxe): per Portal rechts in der
|
||||||
„command bar“). Die Begruessung sitzt seit Runde 3 unten in der
|
App-Leiste statt als eigene Zeile ueber dem Raster. Fehlt der
|
||||||
Seitenleiste; hier bleibt nur die Leiste. Mit Hintergrund liegt sie
|
Einhaengepunkt (etwa in Tests ohne Kopfzeile), stehen sie hier. */}
|
||||||
auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */}
|
{actionsSlot ? createPortal(actions, actionsSlot) : actions}
|
||||||
<div
|
|
||||||
className="flex justify-end px-3 pt-1 transition-[padding] duration-200 ease-out motion-reduce:transition-none"
|
|
||||||
style={gridInset > 0 ? { paddingInline: 12 + gridInset } : undefined}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
aria-label={t('toolbarLabel')}
|
|
||||||
className="dashboard-chrome flex items-center gap-1 rounded-lg"
|
|
||||||
>
|
|
||||||
{isEditMode && <BackgroundPicker value={background} onChange={chooseBackground} />}
|
|
||||||
{isEditMode && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCatalogOpen(true)}
|
|
||||||
className="btn btn-subtle"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSwitchingDashboard ? (
|
{isSwitchingDashboard ? (
|
||||||
<div className="flex min-h-[40vh] items-center justify-center">
|
<div className="flex min-h-[40vh] items-center justify-center">
|
||||||
@@ -200,7 +198,6 @@ export default function DashboardPage() {
|
|||||||
onRemoveWidget={removeWidget}
|
onRemoveWidget={removeWidget}
|
||||||
onOpenCatalog={() => setCatalogOpen(true)}
|
onOpenCatalog={() => setCatalogOpen(true)}
|
||||||
onQuickAdd={(type) => addWidget(type)}
|
onQuickAdd={(type) => addWidget(type)}
|
||||||
onInsetChange={setGridInset}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,19 @@ body {
|
|||||||
width: 2rem;
|
width: 2rem;
|
||||||
padding-inline: 0;
|
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. */
|
/* Verweis: dunkle bzw. blaue Schrift, nie gelb auf Weiss. */
|
||||||
.link {
|
.link {
|
||||||
@@ -461,6 +474,26 @@ input[type="checkbox"], input[type="radio"] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Titel ausgeblendet (quick-260928-wtt, `hideTitle` in der Widget-
|
||||||
|
Konfiguration, nur in der Ansicht): die Titelzeile entfaellt. Traegt sie
|
||||||
|
Aktionen (Notiz-Stift, XFrame „In neuem Tab“), bleiben nur diese als
|
||||||
|
kleine Schwebeleiste oben rechts. */
|
||||||
|
[data-hide-title] .widget-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-hide-title] .widget-head:has(.widget-action) {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
z-index: 5;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
[data-hide-title] .widget-head:has(.widget-action) > :not(.widget-action) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Beim Laden blenden die Kacheln gestaffelt ein (je 80 ms, --enter-delay). */
|
/* Beim Laden blenden die Kacheln gestaffelt ein (je 80 ms, --enter-delay). */
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.widget-card {
|
.widget-card {
|
||||||
|
|||||||
@@ -112,11 +112,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
|
|||||||
<>
|
<>
|
||||||
<p className="text-sm text-foreground mb-4">{t('sent')}</p>
|
<p className="text-sm text-foreground mb-4">{t('sent')}</p>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button type="button" onClick={onClose} className="btn btn-primary">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
{t('close')}
|
{t('close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,12 +175,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end gap-3">
|
<div className="mt-4 flex justify-end gap-3">
|
||||||
<button
|
<button type="button" onClick={onClose} disabled={busy} className="btn btn-secondary">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={busy}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
|
||||||
|
// quick-260928-ujj: der Dashboard-Hintergrund steht pro Benutzer in der
|
||||||
|
// Datenbank. Der Hook liest ihn aus dem Auth-Store (Sitzungsantwort) und
|
||||||
|
// speichert ueber die Server-Aktion; eine alte localStorage-Wahl wird
|
||||||
|
// einmalig uebernommen.
|
||||||
|
|
||||||
|
const updateDashboardBackgroundAction = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-actions', () => ({
|
||||||
|
updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const LEGACY_KEY = 'tessera.dashboardBackground.u1';
|
||||||
|
|
||||||
|
function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
|
||||||
|
return {
|
||||||
|
id: 'u1',
|
||||||
|
username: 'schalli',
|
||||||
|
displayName: 'Schalli',
|
||||||
|
role: 'USER',
|
||||||
|
tenantId: 't1',
|
||||||
|
accentColor: null,
|
||||||
|
dashboardBackground: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderBackgroundHook() {
|
||||||
|
const { useDashboardBackground } = await import('./dashboard-background');
|
||||||
|
return renderHook(() => useDashboardBackground());
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
updateDashboardBackgroundAction.mockReset();
|
||||||
|
updateDashboardBackgroundAction.mockResolvedValue({ success: true });
|
||||||
|
useAuthStore.setState({ user: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDashboardBackground (quick-260928-ujj)', () => {
|
||||||
|
it('ohne angemeldeten Benutzer: kein Hintergrund, kein Speichern', async () => {
|
||||||
|
const { result } = await renderBackgroundHook();
|
||||||
|
|
||||||
|
expect(result.current.background).toEqual({ kind: 'none' });
|
||||||
|
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest die Wahl aus dem Auth-Store; null ergibt „kein Hintergrund“', async () => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }),
|
||||||
|
});
|
||||||
|
const { result } = await renderBackgroundHook();
|
||||||
|
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
|
||||||
|
|
||||||
|
act(() => useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) }));
|
||||||
|
expect(result.current.background).toEqual({ kind: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('choose() setzt den Store sofort und speichert ueber die Server-Aktion', async () => {
|
||||||
|
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
|
||||||
|
const { result } = await renderBackgroundHook();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.choose({ kind: 'preset', id: 'mosaic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
|
||||||
|
kind: 'preset',
|
||||||
|
id: 'mosaic',
|
||||||
|
});
|
||||||
|
expect(result.current.background).toEqual({ kind: 'preset', id: 'mosaic' });
|
||||||
|
expect(updateDashboardBackgroundAction).toHaveBeenCalledWith({ kind: 'preset', id: 'mosaic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'Fehlerantwort',
|
||||||
|
() =>
|
||||||
|
updateDashboardBackgroundAction.mockResolvedValue({ success: false, error: 'saveError' }),
|
||||||
|
],
|
||||||
|
['Ausnahme', () => updateDashboardBackgroundAction.mockRejectedValue(new Error('offline'))],
|
||||||
|
])('Speichern schlaegt fehl (%s): der vorige Wert wird zurueckgesetzt', async (_label, arrange) => {
|
||||||
|
arrange();
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: makeUser({ dashboardBackground: { kind: 'preset', id: 'dunes' } }),
|
||||||
|
});
|
||||||
|
const { result } = await renderBackgroundHook();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.choose({ kind: 'preset', id: 'mist' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' }));
|
||||||
|
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
|
||||||
|
kind: 'preset',
|
||||||
|
id: 'dunes',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Server-Wert null und alte localStorage-Wahl: wird genau einmal gespeichert, Schluessel entfernt', async () => {
|
||||||
|
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ kind: 'preset', id: 'pebble' }));
|
||||||
|
useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) });
|
||||||
|
|
||||||
|
const { result, rerender } = await renderBackgroundHook();
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateDashboardBackgroundAction).toHaveBeenCalledTimes(1));
|
||||||
|
expect(updateDashboardBackgroundAction).toHaveBeenCalledWith({ kind: 'preset', id: 'pebble' });
|
||||||
|
expect(result.current.background).toEqual({ kind: 'preset', id: 'pebble' });
|
||||||
|
expect(window.localStorage.getItem(LEGACY_KEY)).toBeNull();
|
||||||
|
|
||||||
|
// erneutes Rendern und ein neues Benutzerobjekt desselben Benutzers loesen nichts mehr aus
|
||||||
|
rerender();
|
||||||
|
act(() => useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) }));
|
||||||
|
rerender();
|
||||||
|
expect(updateDashboardBackgroundAction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Server-Wert gesetzt: keine Uebernahme, der alte Schluessel wird nur aufgeraeumt', async () => {
|
||||||
|
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ kind: 'preset', id: 'pebble' }));
|
||||||
|
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
|
||||||
|
|
||||||
|
const { result } = await renderBackgroundHook();
|
||||||
|
|
||||||
|
await waitFor(() => expect(window.localStorage.getItem(LEGACY_KEY)).toBeNull());
|
||||||
|
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.background).toEqual({ kind: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Server-Wert null ohne alte Wahl: nichts wird gespeichert', async () => {
|
||||||
|
useAuthStore.setState({ user: makeUser({ dashboardBackground: null }) });
|
||||||
|
|
||||||
|
const { result } = await renderBackgroundHook();
|
||||||
|
|
||||||
|
expect(result.current.background).toEqual({ kind: 'none' });
|
||||||
|
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,13 +3,13 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { updateDashboardBackgroundAction } from '@/lib/auth-actions';
|
||||||
import {
|
import {
|
||||||
BACKGROUND_PRESETS,
|
BACKGROUND_PRESETS,
|
||||||
type DashboardBackground,
|
type DashboardBackground,
|
||||||
loadDashboardBackground,
|
|
||||||
NO_BACKGROUND,
|
NO_BACKGROUND,
|
||||||
presetBackground,
|
presetBackground,
|
||||||
saveDashboardBackground,
|
takeLegacyDashboardBackground,
|
||||||
} from '@/lib/dashboard-background';
|
} from '@/lib/dashboard-background';
|
||||||
import {
|
import {
|
||||||
type DashboardImageMeta,
|
type DashboardImageMeta,
|
||||||
@@ -17,23 +17,61 @@ import {
|
|||||||
fetchDashboardImages,
|
fetchDashboardImages,
|
||||||
uploadDashboardImage,
|
uploadDashboardImage,
|
||||||
} from '@/lib/dashboard-images-api';
|
} from '@/lib/dashboard-images-api';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
|
||||||
/** Wahl des Hintergrunds fuer den angemeldeten Benutzer (localStorage, Prototyp). */
|
/** Schreibt die Wahl in den Auth-Store, sofern noch derselbe Benutzer angemeldet ist. */
|
||||||
export function useDashboardBackground(userId: string | null | undefined) {
|
function setStoredBackground(userId: string, value: DashboardBackground | null) {
|
||||||
const [background, setBackground] = useState<DashboardBackground>(NO_BACKGROUND);
|
const current = useAuthStore.getState().user;
|
||||||
|
if (!current || current.id !== userId) return;
|
||||||
|
useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
/**
|
||||||
if (userId) setBackground(loadDashboardBackground(userId));
|
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
|
||||||
}, [userId]);
|
*
|
||||||
|
* Gelesen aus dem Auth-Store (Sitzungsantwort, `null` = nie gewaehlt =
|
||||||
|
* kein Hintergrund). `choose()` setzt den Store sofort und speichert ueber
|
||||||
|
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
|
||||||
|
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
|
||||||
|
* wird sie genau einmal je Benutzerkennung uebernommen.
|
||||||
|
*/
|
||||||
|
export function useDashboardBackground() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const userId = user?.id ?? null;
|
||||||
|
const stored = user?.dashboardBackground ?? null;
|
||||||
|
const background = stored ?? NO_BACKGROUND;
|
||||||
|
const migratedFor = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
|
||||||
|
setStoredBackground(id, value);
|
||||||
|
const revert = () => setStoredBackground(id, previous);
|
||||||
|
updateDashboardBackgroundAction(value)
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.success) revert();
|
||||||
|
})
|
||||||
|
.catch(revert);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const choose = useCallback(
|
const choose = useCallback(
|
||||||
(value: DashboardBackground) => {
|
(value: DashboardBackground) => {
|
||||||
setBackground(value);
|
if (!userId) return;
|
||||||
if (userId) saveDashboardBackground(userId, value);
|
save(userId, value, stored);
|
||||||
},
|
},
|
||||||
[userId],
|
[userId, stored, save],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId || migratedFor.current === userId) return;
|
||||||
|
migratedFor.current = userId;
|
||||||
|
// Auch bei gesetztem Server-Wert auslesen: der alte Schluessel wird so
|
||||||
|
// aufgeraeumt, uebernommen wird aber nur, wenn noch nie gewaehlt wurde.
|
||||||
|
const legacy = takeLegacyDashboardBackground(userId);
|
||||||
|
if (legacy && stored === null) save(userId, legacy, null);
|
||||||
|
}, [userId, stored, save]);
|
||||||
|
|
||||||
return { background, choose };
|
return { background, choose };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,13 +202,17 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`flex h-12 items-center justify-center overflow-hidden rounded-md border text-muted-foreground transition-shadow group-focus-visible:ring-2 group-focus-visible:ring-ring ${
|
className={`flex h-12 items-center justify-center overflow-hidden rounded-md border text-muted-foreground transition-shadow group-focus-visible:ring-2 group-focus-visible:ring-ring ${
|
||||||
selected ? 'border-transparent ring-2 ring-foreground' : 'border-border group-hover:border-input-strong'
|
selected
|
||||||
|
? 'border-transparent ring-2 ring-foreground'
|
||||||
|
: 'border-border group-hover:border-input-strong'
|
||||||
}`}
|
}`}
|
||||||
style={preview}
|
style={preview}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</span>
|
</span>
|
||||||
<span className={`truncate text-xs ${selected ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}>
|
<span
|
||||||
|
className={`truncate text-xs ${selected ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -185,14 +227,28 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
|||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
className="btn btn-subtle"
|
aria-label={t('button')}
|
||||||
|
title={t('button')}
|
||||||
|
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">
|
<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"
|
||||||
|
>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
<circle cx="9" cy="9" r="2" />
|
<circle cx="9" cy="9" r="2" />
|
||||||
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
|
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
|
||||||
</svg>
|
</svg>
|
||||||
{t('button')}
|
{/* In der App-Leiste ist der Platz knapp: Beschriftung erst ab lg. */}
|
||||||
|
<span className="hidden lg:inline">{t('button')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
@@ -206,8 +262,25 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
|||||||
|
|
||||||
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('builtIn')}</h3>
|
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('builtIn')}</h3>
|
||||||
<ul className="mt-2 grid grid-cols-3 gap-3">
|
<ul className="mt-2 grid grid-cols-3 gap-3">
|
||||||
{option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' },
|
{option(
|
||||||
<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="1.75" strokeLinecap="round"><circle cx="12" cy="12" r="8" /><path d="m6.5 17.5 11-11" /></svg>,
|
'none',
|
||||||
|
t('none'),
|
||||||
|
NO_BACKGROUND,
|
||||||
|
{ backgroundColor: 'var(--background)' },
|
||||||
|
<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="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<path d="m6.5 17.5 11-11" />
|
||||||
|
</svg>,
|
||||||
)}
|
)}
|
||||||
{BACKGROUND_PRESETS.filter((preset) => !dark || preset.dark !== null).map((preset) => {
|
{BACKGROUND_PRESETS.filter((preset) => !dark || preset.dark !== null).map((preset) => {
|
||||||
const css = (dark ? preset.dark : preset.light) ?? preset.light;
|
const css = (dark ? preset.dark : preset.light) ?? preset.light;
|
||||||
@@ -247,7 +320,20 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
|||||||
{uploading ? (
|
{uploading ? (
|
||||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
) : (
|
) : (
|
||||||
<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"><path d="M12 5v14" /><path d="M5 12h14" /></svg>
|
<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"
|
||||||
|
>
|
||||||
|
<path d="M12 5v14" />
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-xs text-muted-foreground">{t('upload')}</span>
|
<span className="truncate text-xs text-muted-foreground">{t('upload')}</span>
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export function greetingPeriod(date: Date): 'morning' | 'day' | 'evening' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Vorname aus dem Anzeigenamen (erstes Wort), sonst der Benutzername. */
|
/** Vorname aus dem Anzeigenamen (erstes Wort), sonst der Benutzername. */
|
||||||
export function greetingName(displayName: string | null | undefined, username: string | undefined): string {
|
export function greetingName(
|
||||||
|
displayName: string | null | undefined,
|
||||||
|
username: string | undefined,
|
||||||
|
): string {
|
||||||
const first = displayName?.trim().split(/\s+/)[0];
|
const first = displayName?.trim().split(/\s+/)[0];
|
||||||
return first || username || '';
|
return first || username || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ interface EditModeToggleProps {
|
|||||||
/**
|
/**
|
||||||
* Pencil / Checkmark icon button to toggle dashboard edit mode (D-01).
|
* Pencil / Checkmark icon button to toggle dashboard edit mode (D-01).
|
||||||
* Active state (edit mode) uses primary background.
|
* Active state (edit mode) uses primary background.
|
||||||
* Schwebt in der festen Aktionsleiste unten rechts (quick-260916-dyv):
|
* Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der
|
||||||
* inaktiv mit Karten-Hintergrund, Rahmen und Schatten, damit der Stift
|
* Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus
|
||||||
* ueber Widgets sichtbar bleibt.
|
* der gelbe Knopf „Fertig“.
|
||||||
*/
|
*/
|
||||||
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
@@ -21,8 +21,9 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={`btn ${isEditMode ? 'btn-primary' : 'btn-subtle'}`}
|
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`}
|
||||||
aria-pressed={isEditMode}
|
aria-pressed={isEditMode}
|
||||||
|
aria-label={isEditMode ? undefined : t('editShort')}
|
||||||
title={isEditMode ? t('saveChanges') : t('editMode')}
|
title={isEditMode ? t('saveChanges') : t('editMode')}
|
||||||
>
|
>
|
||||||
{isEditMode ? (
|
{isEditMode ? (
|
||||||
@@ -60,7 +61,7 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
|||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{isEditMode ? t('done') : t('editShort')}
|
{isEditMode ? t('done') : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { WidgetIcon } from './widget-icon';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
selectServers,
|
selectServers,
|
||||||
widgetKeyFigure,
|
widgetKeyFigure,
|
||||||
} from './proxmox-widget-model';
|
} from './proxmox-widget-model';
|
||||||
|
import { WidgetIcon } from './widget-icon';
|
||||||
|
|
||||||
/** Minutentakt: so oft liest die Kachel das Zwischenlager neu. */
|
/** Minutentakt: so oft liest die Kachel das Zwischenlager neu. */
|
||||||
const REFRESH_MS = 60_000;
|
const REFRESH_MS = 60_000;
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ const PATHS: Record<string, ReactNode> = {
|
|||||||
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 13h6M10 17h6M8 13h.01M8 17h.01" />
|
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 13h6M10 17h6M8 13h.01M8 17h.01" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
favorites: <path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />,
|
favorites: (
|
||||||
|
<path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||||
|
),
|
||||||
clock: (
|
clock: (
|
||||||
<>
|
<>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
@@ -67,7 +69,13 @@ const PATHS: Record<string, ReactNode> = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WidgetIcon({ type, variant = 'chip' }: { type: string; variant?: 'chip' | 'plain' }) {
|
export function WidgetIcon({
|
||||||
|
type,
|
||||||
|
variant = 'chip',
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
variant?: 'chip' | 'plain';
|
||||||
|
}) {
|
||||||
const paths = PATHS[type];
|
const paths = PATHS[type];
|
||||||
if (!paths) return null;
|
if (!paths) return null;
|
||||||
const svg = (
|
const svg = (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
|
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
|
||||||
@@ -14,6 +14,12 @@ vi.mock('next-intl', () => ({
|
|||||||
: key,
|
: key,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const setWidgetConfig = vi.fn();
|
||||||
|
vi.mock('@/lib/stores/dashboard-store', () => ({
|
||||||
|
useDashboardStore: (selector: (state: { setWidgetConfig: typeof setWidgetConfig }) => unknown) =>
|
||||||
|
selector({ setWidgetConfig }),
|
||||||
|
}));
|
||||||
|
|
||||||
import { WidgetWrapper } from './widget-wrapper';
|
import { WidgetWrapper } from './widget-wrapper';
|
||||||
|
|
||||||
describe('WidgetWrapper', () => {
|
describe('WidgetWrapper', () => {
|
||||||
@@ -54,4 +60,53 @@ describe('WidgetWrapper', () => {
|
|||||||
),
|
),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// quick-260928-wtt: Titel je Widget ein-/ausblenden
|
||||||
|
it('Bearbeitungsmodus: Schalter blendet den Titel aus und speichert hideTitle', () => {
|
||||||
|
setWidgetConfig.mockClear();
|
||||||
|
render(
|
||||||
|
<WidgetWrapper
|
||||||
|
widget={{ id: 'w-uhr', widgetType: 'clock', config: {} }}
|
||||||
|
isEditMode={true}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' }));
|
||||||
|
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
|
||||||
|
// im Bearbeitungsmodus bleibt der Titel sichtbar
|
||||||
|
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ausgeblendeter Titel: Schalter blendet wieder ein, Ansicht markiert die Karte', () => {
|
||||||
|
setWidgetConfig.mockClear();
|
||||||
|
const { rerender } = render(
|
||||||
|
<WidgetWrapper
|
||||||
|
widget={{ id: 'w-uhr', widgetType: 'clock', config: { hideTitle: true } }}
|
||||||
|
isEditMode={true}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'showTitle' }));
|
||||||
|
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<WidgetWrapper
|
||||||
|
widget={{ id: 'w-uhr', widgetType: 'clock', config: { hideTitle: true } }}
|
||||||
|
isEditMode={false}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('article')).toHaveAttribute('data-hide-title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Widgets ohne Titelzeile (Suche) bekommen keinen Schalter', () => {
|
||||||
|
render(
|
||||||
|
<WidgetWrapper
|
||||||
|
widget={{ id: 'w-suche', widgetType: 'search', config: {} }}
|
||||||
|
isEditMode={true}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button', { name: 'hideTitle' })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { CSSProperties } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
|
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
|
||||||
import { WidgetIcon } from './widget-icon';
|
import { WidgetIcon } from './widget-icon';
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ import { WidgetIcon } from './widget-icon';
|
|||||||
*/
|
*/
|
||||||
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
|
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den
|
||||||
|
* Schalter „Titel ausblenden“ (quick-260928-wtt). Die Wahl steht als
|
||||||
|
* `hideTitle: true` in der Widget-Konfiguration und gilt nur in der Ansicht;
|
||||||
|
* im Bearbeitungsmodus bleibt der Titel sichtbar (und editierbar).
|
||||||
|
*/
|
||||||
|
const TITLE_TYPES = new Set([...FRAME_HEADER_TYPES, 'note', 'favorites', 'proxmox', 'xframe']);
|
||||||
|
|
||||||
interface WidgetWrapperProps {
|
interface WidgetWrapperProps {
|
||||||
widget: {
|
widget: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,6 +51,9 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
|||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
||||||
const WidgetComponent = definition?.component;
|
const WidgetComponent = definition?.component;
|
||||||
|
const setWidgetConfig = useDashboardStore((state) => state.setWidgetConfig);
|
||||||
|
const hasTitle = TITLE_TYPES.has(widget.widgetType);
|
||||||
|
const titleHidden = widget.config?.hideTitle === true;
|
||||||
|
|
||||||
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
|
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
|
||||||
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
|
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
|
||||||
@@ -54,6 +66,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
|||||||
className={cardClassName}
|
className={cardClassName}
|
||||||
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 80}ms` } as CSSProperties}
|
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 80}ms` } as CSSProperties}
|
||||||
data-widget-id={widget.id}
|
data-widget-id={widget.id}
|
||||||
|
data-hide-title={!isEditMode && hasTitle && titleHidden ? '' : undefined}
|
||||||
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
||||||
>
|
>
|
||||||
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
|
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
|
||||||
@@ -81,6 +94,39 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
|||||||
<circle cx="12" cy="15" r="1.5" />
|
<circle cx="12" cy="15" r="1.5" />
|
||||||
<circle cx="19" cy="15" r="1.5" />
|
<circle cx="19" cy="15" r="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{hasTitle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
|
||||||
|
}}
|
||||||
|
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
|
||||||
|
aria-label={titleHidden ? t('showTitle') : t('hideTitle')}
|
||||||
|
title={titleHidden ? t('showTitle') : t('hideTitle')}
|
||||||
|
aria-pressed={titleHidden}
|
||||||
|
data-no-drag=""
|
||||||
|
>
|
||||||
|
{/* „T“ mit Querstrich, solange der Titel ausgeblendet ist */}
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="4 7 4 4 20 4 20 7" />
|
||||||
|
<line x1="9" y1="20" x2="15" y2="20" />
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20" />
|
||||||
|
{titleHidden && <line x1="3" y1="3" x2="21" y2="21" />}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -4,3 +4,11 @@
|
|||||||
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
|
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
|
||||||
*/
|
*/
|
||||||
export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
|
export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einhaengepunkt rechts in der Kopfzeile, vor „Fehler melden“ (quick-260928-vxe).
|
||||||
|
* Das Dashboard zeichnet dort seine Aktionen hinein (Bearbeiten, im
|
||||||
|
* Bearbeitungsmodus Hintergrund, Widget hinzufuegen, Fertig) — so belegen sie
|
||||||
|
* keine eigene Zeile ueber den Widgets.
|
||||||
|
*/
|
||||||
|
export const HEADER_ACTIONS_SLOT_ID = 'header-actions-slot';
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ describe('Header — Sitzungswaechter (quick-260917-gyd)', () => {
|
|||||||
tenantId: 't1',
|
tenantId: 't1',
|
||||||
hasAvatar: false,
|
hasAvatar: false,
|
||||||
accentColor: null,
|
accentColor: null,
|
||||||
|
dashboardBackground: { kind: 'preset', id: 'dunes' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,6 +112,8 @@ describe('Header — Sitzungswaechter (quick-260917-gyd)', () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(useAuthStore.getState().user?.username).toBe('schalli'),
|
expect(useAuthStore.getState().user?.username).toBe('schalli'),
|
||||||
);
|
);
|
||||||
|
// quick-260928-ujj: der Hintergrund kommt mit derselben Sitzungsantwort wie accentColor.
|
||||||
|
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({ kind: 'preset', id: 'dunes' });
|
||||||
expect((window as any).location.href).toBe('');
|
expect((window as any).location.href).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { fetchSessionState, logout } from '@/lib/auth-actions';
|
|||||||
import { buildNextParam } from '@/lib/safe-next';
|
import { buildNextParam } from '@/lib/safe-next';
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
import { BugReportButton } from '@/components/bug-report/bug-report-button';
|
import { BugReportButton } from '@/components/bug-report/bug-report-button';
|
||||||
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
import { HEADER_ACTIONS_SLOT_ID, HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
|
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ export function Header() {
|
|||||||
tenantId: u.tenantId,
|
tenantId: u.tenantId,
|
||||||
hasAvatar: u.hasAvatar,
|
hasAvatar: u.hasAvatar,
|
||||||
accentColor: u.accentColor,
|
accentColor: u.accentColor,
|
||||||
|
dashboardBackground: u.dashboardBackground ?? null,
|
||||||
});
|
});
|
||||||
} else if (state.status === 'unauthenticated') {
|
} else if (state.status === 'unauthenticated') {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -168,6 +169,10 @@ export function Header() {
|
|||||||
|
|
||||||
{/* Right: Actions */}
|
{/* Right: Actions */}
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{/* Einhaengepunkt fuer die Dashboard-Aktionen (quick-260928-vxe);
|
||||||
|
bleibt im React-Baum der Kopfzeile leer, `empty:hidden` nimmt ihm
|
||||||
|
auf anderen Seiten den Abstand. */}
|
||||||
|
<div id={HEADER_ACTIONS_SLOT_ID} className="flex items-center gap-1 empty:hidden" />
|
||||||
<BugReportButton />
|
<BugReportButton />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ interface PageHeaderProps {
|
|||||||
* 24/32 px halbfett, optionale Beschreibung 14 px gedaempft, Aktionen rechts.
|
* 24/32 px halbfett, optionale Beschreibung 14 px gedaempft, Aktionen rechts.
|
||||||
* Modulseiten tragen ihre App-Kachel (36 px) links vom Titel.
|
* Modulseiten tragen ihre App-Kachel (36 px) links vom Titel.
|
||||||
*/
|
*/
|
||||||
export function PageHeader({ title, description, actions, moduleSlug, className = '' }: PageHeaderProps) {
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
moduleSlug,
|
||||||
|
className = '',
|
||||||
|
}: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-wrap items-start justify-between gap-x-6 gap-y-3 ${className}`}>
|
<div className={`flex flex-wrap items-start justify-between gap-x-6 gap-y-3 ${className}`}>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const linkClass = (href: string) => itemClass(isActive(href));
|
const linkClass = (href: string) => itemClass(isActive(href));
|
||||||
|
|
||||||
|
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
||||||
|
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
||||||
|
// die 20-px-Modulkachel passt weiter hinein.
|
||||||
|
const moduleItemClass = (active: boolean) =>
|
||||||
|
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
||||||
|
|
||||||
const fetchActiveModules = useCallback(async () => {
|
const fetchActiveModules = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/modules/active`, {
|
const res = await fetch(`${API_URL}/modules/active`, {
|
||||||
@@ -205,10 +211,10 @@ export function Sidebar() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleCategory(category)}
|
onClick={() => toggleCategory(category)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="group flex h-7 w-full items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<span className="truncate">{categoryLabel(category)}</span>
|
<span className="truncate">{categoryLabel(category)}</span>
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -219,7 +225,7 @@ export function Sidebar() {
|
|||||||
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
||||||
return (
|
return (
|
||||||
<li key={mod.id}>
|
<li key={mod.id}>
|
||||||
<Link href={href} className={itemClass(active)}>
|
<Link href={href} className={moduleItemClass(active)}>
|
||||||
{active && <SelectionPill />}
|
{active && <SelectionPill />}
|
||||||
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||||
<span className="truncate">{mod.name}</span>
|
<span className="truncate">{mod.name}</span>
|
||||||
|
|||||||
@@ -212,11 +212,7 @@ export function ReleaseNoticeDialog({ notice, onClose }: ReleaseNoticeDialogProp
|
|||||||
>
|
>
|
||||||
{t('showAll')}
|
{t('showAll')}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button type="button" onClick={close} className="btn btn-primary">
|
||||||
type="button"
|
|
||||||
onClick={close}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
{t('confirm')}
|
{t('confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import type { DashboardBackground } from '@tessera/shared';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ export interface AuthUser {
|
|||||||
isLocalUser?: boolean;
|
isLocalUser?: boolean;
|
||||||
hasAvatar?: boolean;
|
hasAvatar?: boolean;
|
||||||
accentColor?: string | null;
|
accentColor?: string | null;
|
||||||
|
/** quick-260928-ujj: null = nie gewaehlt. */
|
||||||
|
dashboardBackground?: DashboardBackground | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
@@ -236,6 +239,30 @@ export async function updateAccentColorAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert den Dashboard-Hintergrund des angemeldeten Benutzers
|
||||||
|
* (quick-260928-ujj). Die API prueft die Wahl (parseDashboardBackground)
|
||||||
|
* und antwortet bei ungueltigem Wert mit 400.
|
||||||
|
*/
|
||||||
|
export async function updateDashboardBackgroundAction(
|
||||||
|
background: DashboardBackground,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const session = cookieStore.get('session')?.value;
|
||||||
|
if (!session) return { success: false, error: 'notAuthenticated' };
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/users/me/dashboard-background`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: `session=${session}` },
|
||||||
|
body: JSON.stringify({ background }),
|
||||||
|
});
|
||||||
|
if (!response.ok) return { success: false, error: 'saveError' };
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: 'networkError' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionState =
|
export type SessionState =
|
||||||
| { status: 'authenticated'; user: AuthUser }
|
| { status: 'authenticated'; user: AuthUser }
|
||||||
| { status: 'unauthenticated' }
|
| { status: 'unauthenticated' }
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { DARK_ON_ACCENT, LIGHT_ON_ACCENT, normalizeHexColor, readableOnAccent, relativeLuminance } from './color';
|
import {
|
||||||
|
DARK_ON_ACCENT,
|
||||||
|
LIGHT_ON_ACCENT,
|
||||||
|
normalizeHexColor,
|
||||||
|
readableOnAccent,
|
||||||
|
relativeLuminance,
|
||||||
|
} from './color';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* color.test — Normalisierung der Hex-Eingabe der Akzentfarbe
|
* color.test — Normalisierung der Hex-Eingabe der Akzentfarbe
|
||||||
@@ -61,7 +67,15 @@ describe('readableOnAccent (Design „Mosaik“)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('haelt fuer eine Reihe von Akzenten mindestens 3:1 (Symbol auf der Kachel)', () => {
|
it('haelt fuer eine Reihe von Akzenten mindestens 3:1 (Symbol auf der Kachel)', () => {
|
||||||
for (const accent of ['#ffed00', '#123456', '#e11d48', '#0ea5e9', '#16a34a', '#777777', '#f97316']) {
|
for (const accent of [
|
||||||
|
'#ffed00',
|
||||||
|
'#123456',
|
||||||
|
'#e11d48',
|
||||||
|
'#0ea5e9',
|
||||||
|
'#16a34a',
|
||||||
|
'#777777',
|
||||||
|
'#f97316',
|
||||||
|
]) {
|
||||||
expect(contrast(accent, readableOnAccent(accent))).toBeGreaterThanOrEqual(3);
|
expect(contrast(accent, readableOnAccent(accent))).toBeGreaterThanOrEqual(3);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,48 +1,74 @@
|
|||||||
|
import { DASHBOARD_BACKGROUND_PRESET_IDS } from '@tessera/shared';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
BACKGROUND_PRESETS,
|
BACKGROUND_PRESETS,
|
||||||
DARK_FALLBACK_ID,
|
DARK_FALLBACK_ID,
|
||||||
loadDashboardBackground,
|
|
||||||
presetBackground,
|
presetBackground,
|
||||||
saveDashboardBackground,
|
takeLegacyDashboardBackground,
|
||||||
} from './dashboard-background';
|
} from './dashboard-background';
|
||||||
|
|
||||||
|
const IMAGE_ID = '3f2b8c1e-9a4d-4e7f-8b21-0c5d6e7f8a9b';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dashboard-background (Design „Mosaik“, Prototyp)', () => {
|
describe('dashboard-background (Design „Mosaik“)', () => {
|
||||||
it('ohne gespeicherte Wahl: kein Hintergrund', () => {
|
describe('takeLegacyDashboardBackground (quick-260928-ujj)', () => {
|
||||||
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
it('ohne alte Wahl: null', () => {
|
||||||
|
expect(takeLegacyDashboardBackground('u1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert eine gueltige alte Wahl je Benutzer und entfernt den Schluessel', () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'tessera.dashboardBackground.u1',
|
||||||
|
JSON.stringify({ kind: 'preset', id: 'bloom' }),
|
||||||
|
);
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'tessera.dashboardBackground.u2',
|
||||||
|
JSON.stringify({ kind: 'image', imageId: IMAGE_ID }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(takeLegacyDashboardBackground('u1')).toEqual({ kind: 'preset', id: 'bloom' });
|
||||||
|
expect(window.localStorage.getItem('tessera.dashboardBackground.u1')).toBeNull();
|
||||||
|
// der Schluessel des anderen Benutzers bleibt, bis dieser sich anmeldet
|
||||||
|
expect(window.localStorage.getItem('tessera.dashboardBackground.u2')).not.toBeNull();
|
||||||
|
expect(takeLegacyDashboardBackground('u2')).toEqual({ kind: 'image', imageId: IMAGE_ID });
|
||||||
|
// zweiter Aufruf: nichts mehr da
|
||||||
|
expect(takeLegacyDashboardBackground('u1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['{kaputt'],
|
||||||
|
[JSON.stringify({ kind: 'preset', id: 'regenbogen' })],
|
||||||
|
// Prototyp-Werte mit beliebiger Bildkennung werden nicht uebernommen
|
||||||
|
[JSON.stringify({ kind: 'image', imageId: 'img-1' })],
|
||||||
|
[JSON.stringify(null)],
|
||||||
|
])('ungueltiger Wert %j: null, Schluessel entfernt', (raw) => {
|
||||||
|
window.localStorage.setItem('tessera.dashboardBackground.u1', raw);
|
||||||
|
|
||||||
|
expect(takeLegacyDashboardBackground('u1')).toBeNull();
|
||||||
|
expect(window.localStorage.getItem('tessera.dashboardBackground.u1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gesperrter Speicher: null ohne Ausnahme', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
});
|
||||||
|
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => takeLegacyDashboardBackground('u1')).not.toThrow();
|
||||||
|
expect(takeLegacyDashboardBackground('u1')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('speichert je Benutzer getrennt', () => {
|
it('die eingebauten Hintergruende sind deckungsgleich mit der Pruefregel in @tessera/shared', () => {
|
||||||
saveDashboardBackground('u1', { kind: 'preset', id: 'bloom' });
|
expect(BACKGROUND_PRESETS.map((p) => p.id).sort()).toEqual(
|
||||||
saveDashboardBackground('u2', { kind: 'image', imageId: 'img-1' });
|
[...DASHBOARD_BACKGROUND_PRESET_IDS].sort(),
|
||||||
expect(loadDashboardBackground('u1')).toEqual({ kind: 'preset', id: 'bloom' });
|
|
||||||
expect(loadDashboardBackground('u2')).toEqual({ kind: 'image', imageId: 'img-1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verwirft kaputte oder unbekannte Werte', () => {
|
|
||||||
window.localStorage.setItem('tessera.dashboardBackground.u1', '{kaputt');
|
|
||||||
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
|
||||||
window.localStorage.setItem(
|
|
||||||
'tessera.dashboardBackground.u1',
|
|
||||||
JSON.stringify({ kind: 'preset', id: 'regenbogen' }),
|
|
||||||
);
|
);
|
||||||
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gesperrter Speicher wirft nicht', () => {
|
|
||||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
||||||
throw new Error('blocked');
|
|
||||||
});
|
|
||||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
||||||
throw new Error('blocked');
|
|
||||||
});
|
|
||||||
expect(() => saveDashboardBackground('u1', { kind: 'preset', id: 'mist' })).not.toThrow();
|
|
||||||
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('jeder eingebaute Hintergrund liefert hell und dunkel ein Bild (dunkel notfalls den Ersatz)', () => {
|
it('jeder eingebaute Hintergrund liefert hell und dunkel ein Bild (dunkel notfalls den Ersatz)', () => {
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Dashboard-Hintergrund (Prototyp, Design „Mosaik“).
|
* Dashboard-Hintergrund (Design „Mosaik“).
|
||||||
*
|
*
|
||||||
* Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und
|
* Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und
|
||||||
* drei abstrakte Motive in Gelb, Grau und Graphit — keine Regenbogen-
|
* drei abstrakte Motive in Gelb, Grau und Graphit — keine Regenbogen-
|
||||||
* verlaeufe) oder ein eigenes Bild aus den Bilderrahmen-Bildern
|
* verlaeufe) oder ein eigenes Bild aus den Bilderrahmen-Bildern
|
||||||
* (`/dashboard/images`, keine API-Aenderung). Gespeichert wird vorerst im
|
* (`/dashboard/images`).
|
||||||
* localStorage des Browsers, Schluessel je Benutzerkennung; ein Datenbankfeld
|
*
|
||||||
* kann folgen, wenn die Funktion bleibt.
|
* Gespeichert wird seit quick-260928-ujj in der Datenbank
|
||||||
|
* (`User.dashboardBackground`, `PATCH /users/me/dashboard-background`) und
|
||||||
|
* mit der Sitzungsantwort gelesen — die Wahl folgt dem Benutzer auf jedes
|
||||||
|
* Geraet. Typ, Preset-Kennungen und Pruefregel kommen aus `@tessera/shared`
|
||||||
|
* (EINE Regel fuer API und Web). Der fruehere localStorage-Schluessel wird
|
||||||
|
* nur noch einmalig ausgelesen und entfernt ({@link takeLegacyDashboardBackground}).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type BackgroundPresetId = 'mist' | 'pebble' | 'bloom' | 'dunes' | 'mosaic';
|
import {
|
||||||
|
type DashboardBackground,
|
||||||
|
type DashboardBackgroundPresetId,
|
||||||
|
parseDashboardBackground,
|
||||||
|
} from '@tessera/shared';
|
||||||
|
|
||||||
export type DashboardBackground =
|
export type { DashboardBackground };
|
||||||
| { kind: 'none' }
|
|
||||||
| { kind: 'preset'; id: BackgroundPresetId }
|
export type BackgroundPresetId = DashboardBackgroundPresetId;
|
||||||
| { kind: 'image'; imageId: string };
|
|
||||||
|
|
||||||
export const NO_BACKGROUND: DashboardBackground = { kind: 'none' };
|
export const NO_BACKGROUND: DashboardBackground = { kind: 'none' };
|
||||||
|
|
||||||
@@ -22,7 +30,8 @@ function svgUrl(svg: string): string {
|
|||||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEW = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice"';
|
const VIEW =
|
||||||
|
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice"';
|
||||||
|
|
||||||
function bloom(base: string, a: string, b: string, c: string): string {
|
function bloom(base: string, a: string, b: string, c: string): string {
|
||||||
return svgUrl(
|
return svgUrl(
|
||||||
@@ -121,35 +130,34 @@ export function presetBackground(id: BackgroundPresetId, dark: boolean): string
|
|||||||
return BACKGROUND_PRESETS.find((p) => p.id === DARK_FALLBACK_ID)?.dark ?? null;
|
return BACKGROUND_PRESETS.find((p) => p.id === DARK_FALLBACK_ID)?.dark ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function storageKey(userId: string): string {
|
/** Schluessel der frueheren localStorage-Speicherung (Prototyp, vor quick-260928-ujj). */
|
||||||
|
function legacyStorageKey(userId: string): string {
|
||||||
return `tessera.dashboardBackground.${userId}`;
|
return `tessera.dashboardBackground.${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValid(value: unknown): value is DashboardBackground {
|
/**
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
* Liest eine im Browser gemerkte Wahl aus der Zeit vor der Datenbank-
|
||||||
const v = value as Record<string, unknown>;
|
* speicherung und entfernt den Schluessel — gueltig oder nicht, damit die
|
||||||
if (v.kind === 'none') return true;
|
* Uebernahme genau einmal passiert. Geprueft mit derselben Regel wie die API
|
||||||
if (v.kind === 'preset') return BACKGROUND_PRESETS.some((p) => p.id === v.id);
|
* (`parseDashboardBackground`); kaputte Werte, unbekannte Kennungen und ein
|
||||||
if (v.kind === 'image') return typeof v.imageId === 'string' && v.imageId !== '';
|
* gesperrter Speicher (privates Fenster) ergeben `null`, nie eine Ausnahme.
|
||||||
return false;
|
*/
|
||||||
}
|
export function takeLegacyDashboardBackground(userId: string): DashboardBackground | null {
|
||||||
|
let raw: string | null = null;
|
||||||
/** Liest die Wahl; jeder Fehler (privates Fenster, kaputter Wert) ergibt „kein Hintergrund“. */
|
|
||||||
export function loadDashboardBackground(userId: string): DashboardBackground {
|
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(storageKey(userId));
|
raw = window.localStorage.getItem(legacyStorageKey(userId));
|
||||||
if (!raw) return NO_BACKGROUND;
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
|
||||||
return isValid(parsed) ? parsed : NO_BACKGROUND;
|
|
||||||
} catch {
|
} catch {
|
||||||
return NO_BACKGROUND;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
if (raw === null) return null;
|
||||||
|
try {
|
||||||
export function saveDashboardBackground(userId: string, value: DashboardBackground): void {
|
window.localStorage.removeItem(legacyStorageKey(userId));
|
||||||
try {
|
} catch {
|
||||||
window.localStorage.setItem(storageKey(userId), JSON.stringify(value));
|
// Entfernen gesperrt — die Wahl wird trotzdem hoechstens einmal je Sitzung uebernommen.
|
||||||
} catch {
|
}
|
||||||
// Speicher gesperrt — die Wahl gilt dann nur fuer diese Sitzung.
|
try {
|
||||||
|
return parseDashboardBackground(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DashboardBackground } from '@tessera/shared';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { readableOnAccent } from '@/lib/color';
|
import { readableOnAccent } from '@/lib/color';
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ export interface AuthUser {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
hasAvatar?: boolean;
|
hasAvatar?: boolean;
|
||||||
accentColor?: string | null;
|
accentColor?: string | null;
|
||||||
|
/** quick-260928-ujj: Dashboard-Hintergrund aus der Sitzungsantwort; null = nie gewaehlt. */
|
||||||
|
dashboardBackground?: DashboardBackground | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface DashboardState {
|
|||||||
updateLayouts: (layouts: Record<string, unknown>) => void;
|
updateLayouts: (layouts: Record<string, unknown>) => void;
|
||||||
addWidget: (widgetType: WidgetType) => Promise<void>;
|
addWidget: (widgetType: WidgetType) => Promise<void>;
|
||||||
removeWidget: (id: string) => Promise<void>;
|
removeWidget: (id: string) => Promise<void>;
|
||||||
|
/** Teil-Konfiguration eines Widgets setzen — sofort sichtbar, bei Fehler zurueck. */
|
||||||
|
setWidgetConfig: (id: string, partial: Record<string, unknown>) => Promise<void>;
|
||||||
loadDashboard: () => Promise<void>;
|
loadDashboard: () => Promise<void>;
|
||||||
saveLayout: () => Promise<void>;
|
saveLayout: () => Promise<void>;
|
||||||
selectDashboard: (id: string) => Promise<void>;
|
selectDashboard: (id: string) => Promise<void>;
|
||||||
@@ -159,6 +161,22 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setWidgetConfig: async (id: string, partial: Record<string, unknown>) => {
|
||||||
|
const before = get().widgets.find((w) => w.id === id)?.config;
|
||||||
|
if (!before) return;
|
||||||
|
const patch = (config: Record<string, unknown>) =>
|
||||||
|
set((state) => ({
|
||||||
|
widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)),
|
||||||
|
}));
|
||||||
|
patch({ ...before, ...partial });
|
||||||
|
try {
|
||||||
|
await api.updateWidgetConfig(id, partial);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update widget config:', err);
|
||||||
|
patch(before);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
loadDashboard: async () => {
|
loadDashboard: async () => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
"background": {
|
"background": {
|
||||||
"button": "Hintergrund",
|
"button": "Hintergrund",
|
||||||
"title": "Hintergrund des Dashboards",
|
"title": "Hintergrund des Dashboards",
|
||||||
"hint": "Gilt nur für Sie und nur in diesem Browser.",
|
"hint": "Gilt nur für Sie – auf jedem Gerät, auf dem Sie sich anmelden.",
|
||||||
"builtIn": "Eingebaut",
|
"builtIn": "Eingebaut",
|
||||||
"none": "Keiner",
|
"none": "Keiner",
|
||||||
"ownImages": "Eigene Bilder",
|
"ownImages": "Eigene Bilder",
|
||||||
@@ -236,6 +236,8 @@
|
|||||||
"catalogTitle": "Widget hinzufügen",
|
"catalogTitle": "Widget hinzufügen",
|
||||||
"catalogClose": "Dialog schließen",
|
"catalogClose": "Dialog schließen",
|
||||||
"deleteTooltip": "Widget entfernen",
|
"deleteTooltip": "Widget entfernen",
|
||||||
|
"hideTitle": "Titel ausblenden",
|
||||||
|
"showTitle": "Titel einblenden",
|
||||||
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
|
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
|
||||||
"editMode": "Dashboard bearbeiten",
|
"editMode": "Dashboard bearbeiten",
|
||||||
"saveChanges": "Änderungen speichern",
|
"saveChanges": "Änderungen speichern",
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
"background": {
|
"background": {
|
||||||
"button": "Background",
|
"button": "Background",
|
||||||
"title": "Dashboard background",
|
"title": "Dashboard background",
|
||||||
"hint": "Applies only to you and only in this browser.",
|
"hint": "Applies only to you – on every device you sign in on.",
|
||||||
"builtIn": "Built-in",
|
"builtIn": "Built-in",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"ownImages": "Your images",
|
"ownImages": "Your images",
|
||||||
@@ -236,6 +236,8 @@
|
|||||||
"catalogTitle": "Add widget",
|
"catalogTitle": "Add widget",
|
||||||
"catalogClose": "Close dialog",
|
"catalogClose": "Close dialog",
|
||||||
"deleteTooltip": "Remove widget",
|
"deleteTooltip": "Remove widget",
|
||||||
|
"hideTitle": "Hide title",
|
||||||
|
"showTitle": "Show title",
|
||||||
"dragHint": "Drag the tile to move it",
|
"dragHint": "Drag the tile to move it",
|
||||||
"editMode": "Edit dashboard",
|
"editMode": "Edit dashboard",
|
||||||
"saveChanges": "Save changes",
|
"saveChanges": "Save changes",
|
||||||
|
|||||||
+12
-10
@@ -29,7 +29,7 @@ Tessera ist das zentrale Portal, in dem alle Arbeitswerkzeuge Ihres Unternehmens
|
|||||||
|
|
||||||
## Anmeldung
|
## Anmeldung
|
||||||
|
|
||||||
Rufen Sie die Anmeldeseite auf. Auf der linken Seite sehen Sie das Tessera-Logo, rechts das Anmeldeformular mit den Feldern **Benutzername** und **Passwort**.
|
Rufen Sie die Anmeldeseite auf. Auf einem großen Bildschirm ist sie geteilt: links ein dunkler Bereich mit dem Tessera-Zeichen, dem Satz „Ihre Werkzeuge an einem Ort." und einem Farbmosaik, rechts das Anmeldeformular mit den Feldern **Benutzername** und **Passwort**. Auf kleineren Bildschirmen steht das Tessera-Zeichen über dem Formular.
|
||||||
|
|
||||||
**Wichtig:** Melden Sie sich immer mit Ihrem **Benutzernamen** an — nicht mit Ihrer E-Mail-Adresse. Das Anmeldefeld heißt zwar oft wie eine E-Mail-Eingabe aus, akzeptiert aber ausschließlich den Benutzernamen. Wird stattdessen eine E-Mail-Adresse eingegeben, meldet das System schlicht „Benutzername oder Passwort ungültig" — es sieht dann so aus, als sei die Anmeldung grundsätzlich kaputt, obwohl nur das falsche Feld befüllt wurde. Fragen Sie im Zweifel Ihren Administrator nach Ihrem Benutzernamen.
|
**Wichtig:** Melden Sie sich immer mit Ihrem **Benutzernamen** an — nicht mit Ihrer E-Mail-Adresse. Das Anmeldefeld heißt zwar oft wie eine E-Mail-Eingabe aus, akzeptiert aber ausschließlich den Benutzernamen. Wird stattdessen eine E-Mail-Adresse eingegeben, meldet das System schlicht „Benutzername oder Passwort ungültig" — es sieht dann so aus, als sei die Anmeldung grundsätzlich kaputt, obwohl nur das falsche Feld befüllt wurde. Fragen Sie im Zweifel Ihren Administrator nach Ihrem Benutzernamen.
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ Falls Ihr Administrator beim Anlegen Ihres Kontos eine Passwort-Änderung erzwun
|
|||||||
|
|
||||||
Die Portal-Oberfläche gliedert sich in drei feste Bereiche:
|
Die Portal-Oberfläche gliedert sich in drei feste Bereiche:
|
||||||
|
|
||||||
**Kopfleiste (oben)**
|
**App-Leiste (oben)**
|
||||||
Links steht das Tessera-Logo, in der Mitte der aktuelle Seitentitel. Rechts finden Sie drei Bedienelemente:
|
Die schmale dunkle Leiste am oberen Rand. Links steht das Tessera-Logo, in der Mitte der aktuelle Seitentitel — auf dem Dashboard stattdessen die Reiter Ihrer Dashboards. Rechts finden Sie drei Bedienelemente:
|
||||||
- Einen Knopf **Fehler melden** (Käfer-Symbol) — siehe [Einen Fehler melden](#einen-fehler-melden).
|
- Einen Knopf **Fehler melden** (Käfer-Symbol) — siehe [Einen Fehler melden](#einen-fehler-melden).
|
||||||
- Einen Schalter zum Umschalten zwischen hellem und dunklem Erscheinungsbild (siehe [Persönliche Einstellungen](#persönliche-einstellungen)).
|
- Einen Schalter zum Umschalten zwischen hellem und dunklem Erscheinungsbild (siehe [Persönliche Einstellungen](#persönliche-einstellungen)).
|
||||||
- Ihr Benutzersymbol (Avatar oder Ihr Anfangsbuchstabe). Ein Klick öffnet das **Benutzermenü** mit:
|
- Ihr Benutzersymbol (Avatar oder Ihr Anfangsbuchstabe). Ein Klick öffnet das **Benutzermenü** mit:
|
||||||
@@ -52,26 +52,28 @@ Links steht das Tessera-Logo, in der Mitte der aktuelle Seitentitel. Rechts find
|
|||||||
- **Abmelden**.
|
- **Abmelden**.
|
||||||
|
|
||||||
**Seitenleiste (links)**
|
**Seitenleiste (links)**
|
||||||
Ganz oben stehen zwei feste Einträge: **Dashboard** (Ihre Startseite) und **Marktplatz**. Darunter folgt ein Suchfeld „Module suchen…", mit dem Sie die Modulliste filtern können, und darunter die Liste der für Sie freigegebenen Module, gruppiert nach **Kategorien**. Ein Klick auf eine Kategorie klappt sie auf und zeigt die einzelnen Module darin. Sind für Sie noch keine Module aktiv, steht dort „Keine Module".
|
Ganz oben stehen zwei feste Einträge: **Dashboard** (Ihre Startseite) und **Marktplatz**. Darunter folgt ein Suchfeld „Module suchen…", mit dem Sie die Modulliste filtern können, und darunter die Liste der für Sie freigegebenen Module, gruppiert nach **Kategorien** mit deutschen Namen. Jedes Modul trägt eine eigene kleine Kachel mit Symbol. Die Kategorien sind anfangs aufgeklappt; ein Klick auf eine Kategorie klappt sie zu und wieder auf, Tessera merkt sich Ihre Wahl. Das Modul, in dem Sie gerade arbeiten, ist in Ihrer persönlichen Akzentfarbe hervorgehoben. Sind für Sie noch keine Module aktiv, steht dort „Keine Module".
|
||||||
|
|
||||||
Unten in der Seitenleiste finden Sie die Sprachumschaltung (Deutsch/English) sowie Ihren Namen mit Rolle. Über den Pfeil-Button am unteren Rand können Sie die Seitenleiste ein- und wieder ausklappen — im eingeklappten Zustand bleiben nur die Symbole sichtbar, das spart Platz auf kleineren Bildschirmen. Ganz unten steht die Versionsnummer von Tessera; ein Klick darauf öffnet die Seite [Was ist neu](#was-ist-neu).
|
Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Namen und nennt das heutige Datum. Darunter können Sie die Seitenleiste über **Einklappen** schmal machen — im eingeklappten Zustand bleiben nur die Modul-Kacheln sichtbar (der Name erscheint, wenn Sie mit der Maus darüberfahren), das spart Platz auf kleineren Bildschirmen. Ganz unten steht die Versionsnummer von Tessera; ein Klick darauf öffnet die Seite [Was ist neu](#was-ist-neu). Auf dem Handy öffnen Sie die Seitenleiste über das Menü-Symbol links in der App-Leiste; sie legt sich dann als Schublade über die ganze Seite.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets**. Ist noch kein Widget platziert, sehen Sie nur das Tessera-Symbol mit dem Hinweis „Keine Widgets aktiv".
|
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, das mittig auf der Seite steht. Jedes Widget trägt oben ein gelbes Symbol-Feld, hebt sich leicht an, wenn Sie mit der Maus darüberfahren, und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
|
||||||
|
|
||||||
**Mehrere Dashboards (Reiter):** Über dem Raster steht eine Reiterleiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
|
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
|
||||||
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
|
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
|
||||||
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).
|
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).
|
||||||
- Einen Reiter löschen: Klicken Sie auf das Kreuz daneben und bestätigen Sie die Rückfrage — seine Kacheln und seine Anordnung werden dabei mit gelöscht. Der letzte verbleibende Reiter lässt sich nicht löschen, der Knopf dafür erscheint dort gar nicht erst.
|
- Einen Reiter löschen: Klicken Sie auf das Kreuz daneben und bestätigen Sie die Rückfrage — seine Kacheln und seine Anordnung werden dabei mit gelöscht. Der letzte verbleibende Reiter lässt sich nicht löschen, der Knopf dafür erscheint dort gar nicht erst.
|
||||||
|
|
||||||
**Widgets hinzufügen und anordnen:** Unten rechts auf dem Dashboard schwebt der Stift-Schalter **„Dashboard bearbeiten"**; im Bearbeitungsmodus wird daraus ein Häkchen **„Änderungen speichern"**, und daneben erscheint **„Widget hinzufügen"**. Sobald der Bearbeitungsmodus aktiv ist:
|
**Widgets hinzufügen und anordnen:** Oben rechts über den Kacheln steht eine kleine Leiste mit dem Knopf **„Bearbeiten"**; im Bearbeitungsmodus wird daraus **„Fertig"**, und daneben erscheinen **„Widget hinzufügen"** und **„Hintergrund"**. Sobald der Bearbeitungsmodus aktiv ist:
|
||||||
- Erscheint der Button **„Widget hinzufügen"**, der eine Auswahl aller verfügbaren Widget-Typen als Kachel-Katalog öffnet. Ein Klick auf einen Eintrag fügt das Widget sofort dem Dashboard hinzu.
|
- Erscheint der Button **„Widget hinzufügen"**, der eine Auswahl aller verfügbaren Widget-Typen als Kachel-Katalog öffnet. Ein Klick auf einen Eintrag fügt das Widget sofort dem Dashboard hinzu.
|
||||||
- Können Sie bestehende Widgets per Ziehen an eine neue Position verschieben. Fassen Sie die Kachel dazu an einer beliebigen Stelle an — Eingabefelder, Knöpfe und Links ausgenommen; ein grauer Griff am oberen Kachelrand zeigt, dass die Kachel beweglich ist. Abgelegt wird nur dort, wo Platz ist: über einer anderen Kachel springt sie an ihren Ausgangspunkt zurück.
|
- Können Sie bestehende Widgets per Ziehen an eine neue Position verschieben. Fassen Sie die Kachel dazu an einer beliebigen Stelle an — Eingabefelder, Knöpfe und Links ausgenommen; ein grauer Griff am oberen Kachelrand zeigt, dass die Kachel beweglich ist. Abgelegt wird nur dort, wo Platz ist: über einer anderen Kachel springt sie an ihren Ausgangspunkt zurück.
|
||||||
- Können Sie Widgets an der Ecke in der Größe ziehen (jeder Widget-Typ hat eine Mindestgröße, bei der er gerade noch bedienbar bleibt — kleiner geht es nicht, größer jederzeit). Position und Größe rasten dabei in feinen Schritten ein, sodass sich auch kleine Anpassungen vornehmen lassen.
|
- Können Sie Widgets an der Ecke in der Größe ziehen (jeder Widget-Typ hat eine Mindestgröße, bei der er gerade noch bedienbar bleibt — kleiner geht es nicht, größer jederzeit). Position und Größe rasten dabei in feinen Schritten ein, sodass sich auch kleine Anpassungen vornehmen lassen.
|
||||||
- Erscheint an jedem Widget rechts im Griff ein Symbol zum Entfernen.
|
- Erscheint an jedem Widget rechts im Griff ein Symbol zum Entfernen.
|
||||||
|
|
||||||
Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlassen Sie den Bearbeitungsmodus, ist das Dashboard wieder fest — Verschieben und Größenänderung sind dann gesperrt, damit Sie es im normalen Gebrauch nicht versehentlich verstellen.
|
Ihre Änderungen werden über **„Fertig"** übernommen. Verlassen Sie den Bearbeitungsmodus, ist das Dashboard wieder fest — Verschieben und Größenänderung sind dann gesperrt, damit Sie es im normalen Gebrauch nicht versehentlich verstellen.
|
||||||
|
|
||||||
|
**Hintergrund:** Im Bearbeitungsmodus öffnet der Knopf **„Hintergrund"** ein kleines Fenster mit Vorschaubildern. Wählen Sie **Keiner**, eine der eingebauten ruhigen Flächen und Motive (Nebel, Kiesel, Blüte, Dünen, Mosaik) oder eines Ihrer eigenen Bilder — dazu zählen die Bilder, die Sie für den Bilderrahmen hochgeladen haben; über **„Bild hochladen"** fügen Sie direkt ein neues hinzu. Ihre Wahl gilt sofort und nur für Sie. Tessera speichert sie mit Ihrem Konto, sodass derselbe Hintergrund auf jedem Gerät erscheint, auf dem Sie sich anmelden, auch in der Desktop-App. Eine Wahl, die Sie vor dieser Änderung nur in einem Browser getroffen hatten, übernimmt Tessera beim nächsten Öffnen des Dashboards automatisch. Im dunklen Erscheinungsbild werden eigene Bilder etwas abgedunkelt, damit die Schrift auf den Kacheln lesbar bleibt, und statt „Blüte" erscheint dort „Nebel".
|
||||||
|
|
||||||
**Verfügbare Widgets:**
|
**Verfügbare Widgets:**
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Uhr | Zeigt die aktuelle Uhrzeit an (optional mit Datum). Die Uhrzeit wächst und schrumpft mit der Kachel; wer eine feste Größe möchte, stellt sie unter Einstellungen > Dashboard als Schriftgröße in Punkt ein |
|
| Uhr | Zeigt die aktuelle Uhrzeit an (optional mit Datum). Die Uhrzeit wächst und schrumpft mit der Kachel; wer eine feste Größe möchte, stellt sie unter Einstellungen > Dashboard als Schriftgröße in Punkt ein |
|
||||||
| Suchleiste | Schnellsuche im Web über frei konfigurierbare Suchanbieter |
|
| Suchleiste | Schnellsuche im Web über frei konfigurierbare Suchanbieter |
|
||||||
| Kalender | Monatsübersicht mit der Anzahl der Termine je Tag (die Termine eines Tages erscheinen, wenn Sie mit der Maus darüberfahren) und darunter die nächsten Termine aus Ihren verbundenen Kalenderquellen. Ob die Monatsansicht erscheint, wie viele Termine und welcher Zeitraum gezeigt werden, stellen Sie unter Einstellungen > Dashboard > Widgets ein |
|
| Kalender | Monatsübersicht mit der Anzahl der Termine je Tag (die Termine eines Tages erscheinen, wenn Sie mit der Maus darüberfahren) und darunter die nächsten Termine aus Ihren verbundenen Kalenderquellen als kompakte, einzeilige Liste — bei Terminen von heute und morgen steht „Heute" bzw. „Morgen" statt des Datums, den Ort sehen Sie, wenn Sie mit der Maus über einen Termin fahren. Ob die Monatsansicht erscheint, wie viele Termine und welcher Zeitraum gezeigt werden, stellen Sie unter Einstellungen > Dashboard > Widgets ein |
|
||||||
| Notizen | Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken |
|
| Notizen | Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken |
|
||||||
| Taschenrechner | Grundrechenarten, auch per Tastatur bedienbar |
|
| Taschenrechner | Grundrechenarten, auch per Tastatur bedienbar |
|
||||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt. Im Bearbeitungsformular tragen Sie stattdessen eine eigene Logo-Adresse ein oder laden ein eigenes Symbol hoch (PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB) — ein hochgeladenes Symbol hat Vorrang und lässt sich über „Hochgeladenes Symbol entfernen“ wieder zurücknehmen; blockiert eine Seite automatische Abrufe (z. B. durch eine Cloudflare-Prüfung), meldet Tessera das beim Speichern |
|
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt. Im Bearbeitungsformular tragen Sie stattdessen eine eigene Logo-Adresse ein oder laden ein eigenes Symbol hoch (PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB) — ein hochgeladenes Symbol hat Vorrang und lässt sich über „Hochgeladenes Symbol entfernen“ wieder zurücknehmen; blockiert eine Seite automatische Abrufe (z. B. durch eine Cloudflare-Prüfung), meldet Tessera das beim Speichern |
|
||||||
|
|||||||
@@ -677,6 +677,26 @@ Fenster aus; Punkte unter „Unveröffentlicht“ erscheinen darin nie. Lokal st
|
|||||||
setzen. Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds, entsteht
|
setzen. Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds, entsteht
|
||||||
kein Fenster und nichts wird gemerkt.
|
kein Fenster und nichts wird gemerkt.
|
||||||
|
|
||||||
|
**Dashboard-Hintergrund pro Benutzer (quick-260928-ujj):** Die Hintergrundwahl des Designs „Mosaik“
|
||||||
|
steht in der Spalte `User.dashboardBackground` (JSONB, Migration
|
||||||
|
`20260928120000_user_dashboard_background`): `null` = nie gewählt, sonst ein normalisiertes Objekt
|
||||||
|
`{ kind: 'none' }`, `{ kind: 'preset', id }` oder `{ kind: 'image', imageId }`. Geschrieben wird
|
||||||
|
nur über `PATCH /users/me/dashboard-background` mit `{ background }` — ohne Kennungsparameter,
|
||||||
|
ausschließlich die eigene Zeile (`forTenant()`, `where: { id: currentUser.id }`); ein ungültiger
|
||||||
|
Wert ergibt 400 und schreibt nichts. Gelesen wird der Wert mit der Sitzungsantwort (`GET /auth/me`,
|
||||||
|
`AuthService.getMe`, neben `accentColor`) und landet über die `setUser`-Abbildung in `header.tsx`
|
||||||
|
im Auth-Store; `useDashboardBackground()` in
|
||||||
|
`apps/web/src/components/dashboard/dashboard-background.tsx` liest von dort und speichert über
|
||||||
|
`updateDashboardBackgroundAction` (optimistisch, bei Fehlschlag zurückgesetzt). Einzige Prüfregel
|
||||||
|
ist `parseDashboardBackground` in `packages/shared/src/index.ts` (Laufzeit-Import in API und Web,
|
||||||
|
angewendet beim Schreiben UND beim Lesen): `kind` und die Preset-Kennungen
|
||||||
|
(`DASHBOARD_BACKGROUND_PRESET_IDS`) aus einer festen Liste, `imageId` nur als UUID, weil das Web
|
||||||
|
den Wert als CSS-Hintergrund `url("...")` rendert. Wer ein neues Preset ergänzt, trägt es dort und
|
||||||
|
in `BACKGROUND_PRESETS` (`apps/web/src/lib/dashboard-background.ts`) ein — ein Test prüft, dass
|
||||||
|
beide Listen deckungsgleich sind. Der frühere localStorage-Schlüssel
|
||||||
|
`tessera.dashboardBackground.<userId>` wird von `takeLegacyDashboardBackground` einmal gelesen,
|
||||||
|
entfernt und nur übernommen, wenn der Server-Wert noch `null` ist.
|
||||||
|
|
||||||
**i18n — Schlüsselparität zwischen de.json und en.json:** Jeder benutzersichtbare Text gehört in
|
**i18n — Schlüsselparität zwischen de.json und en.json:** Jeder benutzersichtbare Text gehört in
|
||||||
beide Sprachdateien, `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json`. Ein
|
beide Sprachdateien, `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json`. Ein
|
||||||
strukturelle Wächter-Test, `apps/web/src/messages/tenderRadar-parity.spec.ts`, prüft für den
|
strukturelle Wächter-Test, `apps/web/src/messages/tenderRadar-parity.spec.ts`, prüft für den
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ werden.
|
|||||||
| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |
|
| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |
|
||||||
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an und liest beim Start alle Mandanten fuer die Standardgruppen-Reparatur — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Fuenfter und bislang einziger bereits vollstaendig richtiger Fall der Hintergrunddienst-Falle (Befund K, siehe Abschnitt unten). 3c-Befund (260914-eym): einziger Lesezugriff außerhalb der Schleife, `Tenant` ohne Regel — kein Systemkontext nötig, Datei unverändert, Stand bleibt `ungebunden`. |
|
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an und liest beim Start alle Mandanten fuer die Standardgruppen-Reparatur — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Fuenfter und bislang einziger bereits vollstaendig richtiger Fall der Hintergrunddienst-Falle (Befund K, siehe Abschnitt unten). 3c-Befund (260914-eym): einziger Lesezugriff außerhalb der Schleife, `Tenant` ohne Regel — kein Systemkontext nötig, Datei unverändert, Stand bleibt `ungebunden`. |
|
||||||
| apps/api/src/user/admin-seed.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `bewusst-uebergreifend` auf `beides`, weil die bisherige Begruendung ("es gibt strukturell keinen Mandanten zum Binden") nachweislich FALSCH war (Befund J) — der Mandant wird eine Anweisung vorher angelegt und ist bekannt. Die Erstanlage-Pruefung bleibt bewusst ungebunden (kein Mandant existiert zu diesem Zeitpunkt, `username` ist plattformweit eindeutig); die Erstanlage des Administrators selbst laeuft seit Aufgabe 2 ueber `forTenant()`, gebunden an den unmittelbar zuvor angelegten Mandanten. Eine P2002-Kollision beim Anlegen wird wie "Administrator existiert bereits" behandelt statt den Start abzubrechen (Befund I). |
|
| apps/api/src/user/admin-seed.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `bewusst-uebergreifend` auf `beides`, weil die bisherige Begruendung ("es gibt strukturell keinen Mandanten zum Binden") nachweislich FALSCH war (Befund J) — der Mandant wird eine Anweisung vorher angelegt und ist bekannt. Die Erstanlage-Pruefung bleibt bewusst ungebunden (kein Mandant existiert zu diesem Zeitpunkt, `username` ist plattformweit eindeutig); die Erstanlage des Administrators selbst laeuft seit Aufgabe 2 ueber `forTenant()`, gebunden an den unmittelbar zuvor angelegten Mandanten. Eine P2002-Kollision beim Anlegen wird wie "Administrator existiert bereits" behandelt statt den Start abzubrechen (Befund I). |
|
||||||
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; seit quick-260925-bow ebenso die drei Zugriffe der zwei Selbstbedienungswege des „Was ist neu“-Fensters (`GET me/release-notice` liest, `POST me/release-seen` liest und schreibt `lastSeenReleaseVersion`), weiterhin `forTenant()` mit `where: { id: currentUser.id }` und ohne Kennungsparameter; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. |
|
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; seit quick-260925-bow ebenso die drei Zugriffe der zwei Selbstbedienungswege des „Was ist neu“-Fensters (`GET me/release-notice` liest, `POST me/release-seen` liest und schreibt `lastSeenReleaseVersion`), weiterhin `forTenant()` mit `where: { id: currentUser.id }` und ohne Kennungsparameter; seit quick-260928-ujj schreibt der Selbstbedienungsweg `PATCH me/dashboard-background` das Feld `dashboardBackground` (vorher geprueft durch `parseDashboardBackground` aus `@tessera/shared`), ebenfalls `forTenant()` mit `where: { id: currentUser.id }` und ohne Kennungsparameter; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. |
|
||||||
| apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
|
| apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
|
||||||
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
|
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
|
||||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
||||||
|
|||||||
@@ -194,3 +194,65 @@ export interface ReleaseNoticeResponse {
|
|||||||
currentRelease: string | null;
|
currentRelease: string | null;
|
||||||
lastSeenReleaseVersion: string | null;
|
lastSeenReleaseVersion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard-Hintergrund pro Benutzer (quick-260928-ujj) — EINE Pruefregel
|
||||||
|
* fuer API und Web.
|
||||||
|
*
|
||||||
|
* Gespeichert in `User.dashboardBackground` (JSONB), geschrieben nur ueber
|
||||||
|
* `PATCH /users/me/dashboard-background`, gelesen mit der Sitzungsantwort
|
||||||
|
* (`GET /auth/me`, neben `accentColor`). Das Web rendert die Wahl als
|
||||||
|
* CSS-Hintergrund (`url("...")`) — deshalb ist die Pruefung streng
|
||||||
|
* (T-ujj-01): `kind` und Preset-Kennung aus einer festen Liste, `imageId`
|
||||||
|
* nur als UUID (Kennung eines Bilderrahmen-Bildes, `DashboardImage.id`).
|
||||||
|
*
|
||||||
|
* Laufzeit-Import aus `@tessera/shared` (siehe Warnkommentar ueber
|
||||||
|
* `WIDGET_TYPES`): nur loeschbare Syntax, keine relativen Importe.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_BACKGROUND_PRESET_IDS = [
|
||||||
|
'mist',
|
||||||
|
'pebble',
|
||||||
|
'bloom',
|
||||||
|
'dunes',
|
||||||
|
'mosaic',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DashboardBackgroundPresetId = (typeof DASHBOARD_BACKGROUND_PRESET_IDS)[number];
|
||||||
|
|
||||||
|
export type DashboardBackground =
|
||||||
|
| { kind: 'none' }
|
||||||
|
| { kind: 'preset'; id: DashboardBackgroundPresetId }
|
||||||
|
| { kind: 'image'; imageId: string };
|
||||||
|
|
||||||
|
const DASHBOARD_BACKGROUND_IMAGE_ID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine gueltige Wahl als FRISCH aufgebautes Objekt (nur die
|
||||||
|
* erlaubten Felder, Zusatzschluessel fallen weg) oder `null`, wenn der Wert
|
||||||
|
* keine gueltige Wahl ist. `null` bedeutet beim Lesen „nie gewaehlt“ und
|
||||||
|
* beim Schreiben „abweisen“.
|
||||||
|
*/
|
||||||
|
export function parseDashboardBackground(value: unknown): DashboardBackground | null {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const v = value as Record<string, unknown>;
|
||||||
|
if (v.kind === 'none') {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
if (v.kind === 'preset') {
|
||||||
|
const id = v.id;
|
||||||
|
if (typeof id !== 'string') return null;
|
||||||
|
const known = DASHBOARD_BACKGROUND_PRESET_IDS.find((p) => p === id);
|
||||||
|
return known ? { kind: 'preset', id: known } : null;
|
||||||
|
}
|
||||||
|
if (v.kind === 'image') {
|
||||||
|
const imageId = v.imageId;
|
||||||
|
if (typeof imageId !== 'string' || !DASHBOARD_BACKGROUND_IMAGE_ID_PATTERN.test(imageId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { kind: 'image', imageId };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user