33 Commits

Author SHA1 Message Date
schalli bdaa3c8db5 feat(web): Dashboard-Aktionen in die App-Leiste, Bearbeiten als Stift
Tessera CI/CD / Build & Publish Images (push) Successful in 2m53s
Tessera CI/CD / Lint & Type Check (push) Successful in 1m3s
Tessera CI/CD / Tests (push) Successful in 1m35s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m9s
Der Knopf Bearbeiten belegte eine eigene Zeile ueber den Widgets. Die
Aktionen rendern jetzt per Portal in einen Einhaengepunkt rechts in der
Kopfzeile (HEADER_ACTIONS_SLOT_ID); in der Ansicht nur der Stift, im
Bearbeitungsmodus Hintergrund, Widget hinzufuegen und Fertig. Version 1.5.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:12:38 +02:00
schalli 03026d616f docs(changelog): Version 1.5.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 2m49s
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m2s
Tessera CI/CD / Tests (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:23:55 +02:00
schalli 105b66ed8d docs(quick-260928-ujj): Design Mosaik uebernommen, Hintergrund pro Benutzer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:23:45 +02:00
schalli cb45d2663a docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik
- Unveroeffentlicht: Hintergrundwahl (Neu), neues Aussehen (Geaendert), Resize-Fehler (Behoben)
- Anwender-Anleitung: App-Leiste, Seitenleiste, Anmeldeseite, Befehlsleiste, Hintergrund, Kalender
- Entwickler-Anleitung: User.dashboardBackground, PATCH /users/me/dashboard-background, parseDashboardBackground

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:18:51 +02:00
schalli 69d17302e1 fix(260928-ujj): Biome-Formatierung der mit Design Mosaik eingebrachten Dateien
- Nur Formatierung und Importreihenfolge, keine Verhaltensaenderung
- Betrifft ausschliesslich Befunde, die der Merge neu eingebracht hat

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:14:57 +02:00
schalli 0aaa15240d feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank
- Spalte User.dashboardBackground (JSONB) samt Migration
- PATCH /users/me/dashboard-background, geprueft mit parseDashboardBackground aus @tessera/shared (Allowlist, UUID-Bildkennung)
- getMe liefert dashboardBackground normalisiert neben accentColor
- Web liest die Wahl aus dem Auth-Store, speichert ueber die Server-Aktion, alte localStorage-Wahl wird einmalig uebernommen
- Hinweistext: gilt auf jedem Geraet

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:13:49 +02:00
schalli 76f6d87973 test(260928-ujj): rote Tests fuer Dashboard-Hintergrund in der Datenbank
- PATCH me/dashboard-background: Allowlist, UUID-Bildkennung, Mandantenbindung
- getMe liefert dashboardBackground normalisiert
- Web: Uebernahme der alten localStorage-Wahl, Hook liest aus dem Auth-Store

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:08:00 +02:00
schalli 9fa0a3f498 feat(260928-ujj): Design Mosaik uebernehmen
- Bringt den Resize-Achsen-Fallback mit, damit sich Widgets auch bei leicht wackelnder Maus schmaler ziehen lassen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:04:52 +02:00
schalli 76d17fe3c9 style(web): lebendigere Kacheln deutlich sichtbar - gelber Symbol-Chip, 4px Anheben, spuerbares Einblenden
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:26:08 +02:00
schalli a8a910fd29 wip: design-mosaik paused at 4/7
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:06:28 +02:00
schalli 8440db8c6e style(web): Reiter zurueck in die Kopfzeile, Begruessung in die Seitenleiste
Rueckmeldung Runde 3: die Reiter in der Seitenleiste gefallen nicht -
zurueck auf den Stand vor 8532b63 (Umschalter in der Kopfzeile). Statt
dessen sitzt die Begruessung jetzt zweizeilig im sonst leeren unteren
Bereich der Seitenleiste ueber "Einklappen"; ueber den Kacheln bleibt nur
die Befehlsleiste rechts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:34 +02:00
schalli 8532b63097 feat(web): Design Mosaik Runde 3 - Reiter in der Seitenleiste, lebendigere Kacheln
- Dashboard-Reiter stehen in der aufgeklappten Seitenleiste als Liste
  unter "Dashboard" (Ziehen sortiert senkrecht, Umbenennen/Loeschen/Neu
  im Bearbeitungsmodus); eingeklappt und auf dem Handy wie bisher in der
  Kopfzeile. Die Kopfzeile zeigt sonst den Namen des Dashboards.
- Begruessung einzeilig auf Hoehe der Befehlsleiste.
- Kachel-Groesse: kollidiert die gezogene Groesse mit einem Nachbarn, wird
  nur die freie Achse uebernommen - vorher verwarf react-grid-layout mit
  preventCollision die ganze Aenderung, sobald die Maus eine Zeile nach
  unten wackelte (Favoriten liessen sich deshalb oft nicht schmaler ziehen).
- Ansicht: belegte Spalten mittig, Begruessung/Befehlsleiste ruecken mit.
- Kalender: einzeilige Terminliste mit "Heute"/"Morgen" in Akzentfarbe,
  Ort im Tooltip, Standardhoehe 16 statt 12.
- Hintergrund "Bluete" im Dunkelmodus weggelassen (Farbstufen), Ersatz Nebel.
- Kacheln: Symbol-Chip in der Akzentfarbe, leichtes Anheben beim Zeigen,
  gestaffeltes Einblenden beim Laden (ohne bei reduzierter Bewegung).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:58:01 +02:00
schalli b06a2d1066 feat(web): Dashboard mit Begruessung, Befehlsleiste und ruhigeren Kacheln
Begruessung nach Tageszeit mit Datum, Befehlsleiste oben rechts statt
schwebendem Knopf, einheitliche Kachel-Kopfzeilen mit Symbol, Kalender
mit Pfeilknoepfen, runden Tagen und Terminpunkten, Favoriten als
36-px-Zeilen bzw. App-Starter-Kacheln, Notiz-Kaestchen im Akzent,
leerer Zustand mit drei Vorschlaegen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:09:05 +02:00
schalli 228112014c style(web): Modul-Kacheln einfarbig, Akzent nur fuer das Modul im Fokus
Statt bunter Modulfarben neutrale graue Kacheln; gelb (persoenliche
Akzentfarbe) nur fuer den gewaehlten Eintrag der Seitenleiste und den
Seitenkopf des Moduls. Schrift auf Akzentflaechen wird jetzt je nach
Akzentfarbe hell oder dunkel gewaehlt (readableOnAccent). Anmelde-
Mosaik und Dashboard-Hintergruende nur noch in Gelb, Grau, Graphit;
Salbei ersetzt durch Kiesel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:09:05 +02:00
schalli 76520b2010 fix(web): mobile Schublade ueber der App-Leiste, Filterpillen ohne Leiste
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:54:10 +02:00
schalli 30e4bc3a4f style(web): Seiten auf Mosaik-Muster umgestellt
Einheitliche Seitenkoepfe mit Modul-Kachel, Knopf- und Linkklassen
statt gelber Schrift, Karten ohne harte Rahmen, Tabellenkoepfe ohne
Grossbuchstaben, Marktplatz mit Fluent-Reitern, Filterpillen und
Kachel-Karten, Einstellungs-/Verwaltungsnavigation mit Auswahlpille,
leere Zustaende mit Symbol im Kreis, Proxmox ohne leuchtende Schatten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:49:01 +02:00
schalli 219038b3ee feat(web): Dashboard-Kacheln im Fluent-Stil und waehlbarer Hintergrund
Einheitliche Kachel-Kopfzeile, ruhigere Notiz-/Kalender-/Favoriten-
Darstellung, 12 px Raster-Abstand, gestrichelte Kontur und Griff im
Bearbeitungsmodus. Neuer Knopf Hintergrund: Keiner, fuenf eingebaute
(Nebel, Salbei, Bluete, Duenen, Mosaik) oder eigenes Bild aus den
Bilderrahmen-Bildern; Prototyp speichert im localStorage je Benutzer.
Mit Hintergrund werden Kacheln Mica-artig durchscheinend (Kontrast
>= 4,5:1 auch im schlechtesten Fall).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:49:01 +02:00
schalli 522cdfd368 style(web): Anmeldeseite geteilt mit dunklem Markenpanel und Farbmosaik
Ab lg links 45 % dunkles Panel (Ton der App-Leiste) mit Bildmarke,
Claim und stillem Mosaik aus den Modulfarben; rechts die Anmeldekarte
auf der Arbeitsflaeche. Darunter nur die Karte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:49:01 +02:00
schalli b80db49c5e style(web): dunkle App-Leiste und Fluent-Seitenleiste
Kopfzeile 48 px dunkel mit Seitentitel statt immer Startseite,
Dashboard-Reiter mit Akzent-Unterstrich; Seitenleiste mit
Auswahlpille, Modul-Kacheln, aufgeklappten Kategorien und gefuelltem
Suchfeld; eingeklappt nur Kacheln. Einheitlicher PageHeader.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:32:37 +02:00
schalli 7192be223a feat(web): Modul-Identitaet mit App-Kacheln und deutschen Kategorienamen
Jedes Modul bekommt Farbe und Symbol (tender-radar Ocker, dkv-fleet
Petrol, cert-manager Violett, domaincheck Blau, proxmox Rotorange,
unbekannte stabil per Hash). Kategorien in Alltagssprache; dazu die
Texte fuer die neue Anmeldeseite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:32:37 +02:00
schalli 3c62a1a29c style(web): Design-Tokens Mosaik (Fluent 2 / GitHub)
Kuehlgraue Arbeitsflaeche, weisse Karten mit Fluent-Schatten statt
Rahmen, dunkle App-Leiste, Link-Blau, neutraler Fokusring, 6/8/12 px
Radien, Segoe-UI-Schriftstapel, Knopf-/Link-Klassen, Fluent-Eingabefeld
und reduzierte Bewegung. Die persoenliche Akzentfarbe setzt nur noch
--primary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:32:37 +02:00
schalli 3fc33e3507 docs(state): Stirling-PDF-Idee verworfen
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m19s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:55:51 +02:00
schalli acfffa3097 docs(quick-260925-bow): Was-ist-neu-Fenster
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m13s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 09:01:48 +02:00
schalli 59e9c34fa7 fix(260925-bow): Link "Alle Aenderungen ansehen" lesbar (dunkler Text, gelbe Unterstreichung)
Gelber Text auf weissem Grund lag weit unter 3:1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 09:01:48 +02:00
schalli b3b7b5d5e5 docs(260925-bow): Was-ist-neu-Fenster in CHANGELOG, Anwender- und Entwicklerdoku
- CHANGELOG Unveroeffentlicht -> Neu
- Anwenderhandbuch: Fenster nach einem Versionswechsel
- Entwicklerdoku: erweiterte Importregel fuer @/lib/changelog, Versionsquelle,
  Endpunkte, Spalte, Folge fuer die Freigabe
- Kopfkommentare changelog.ts und next.config.ts angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:54:38 +02:00
schalli 2aeb3e8ce3 fix(260925-bow): Umlaut-Waechter kennt das korrekte Wort Verbessert
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:52:26 +02:00
schalli 4fa5aafc54 docs(260925-bow): RLS-Buchfuehrung nachgemessen (user 8/17/0, Summe 61/216/6)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:50:06 +02:00
schalli 5ae9aaa0d6 feat(260925-bow): neue Benutzer bekommen die laufende Version eingetragen
- UserService.create (Admin-Anlage, beide LDAP-Wege) und AdminSeedService
  setzen lastSeenReleaseVersion = getRunningRelease(), null auf dev

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:50:06 +02:00
schalli 25c8db746a test(260925-bow): Anlagewege tragen die laufende Version ein (rot)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:49:21 +02:00
schalli 187fb76c91 feat(260925-bow): Was-ist-neu-Fenster im Portal-Rahmen
- release-notes.ts: reine Auswahl der Versionsabschnitte aus CHANGELOG.md
- release-notice-actions.ts ('use server'): Abruf und Merken ueber die API
- ReleaseNoticeDialog: barrierefreies Fenster, Eintraege ueber ChangelogView (variant plain)
- ReleaseNoticeHost in AppShell: einmal je Seitenladung, merkt erst beim Schliessen
- Texte releaseNotice in de.json und en.json

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:48:25 +02:00
schalli cf7784e409 test(260925-bow): Auswahl, Server-Aktionen, Fenster und Host des Was-ist-neu-Fensters
- selectReleaseNotice: Bereich, Deckel 3, null, unparsebar, nur Neu/Geaendert/Behoben
- Server-Aktionen: Cookie, Antwortform, Fehler still
- Fenster: role=dialog, Fokusfalle, alle Schliesswege genau einmal
- Host: merkt erst beim Schliessen, nicht auf /change-password, StrictMode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:48:25 +02:00
schalli 59db32a01f feat(260925-bow): gesehene Version pro Benutzer merken - Spalte, Versionsfunktionen, API
- packages/shared: parseReleaseVersion, compareReleaseVersions, ReleaseNoticeResponse
- getRunningRelease(): einzige Quelle der laufenden Version (APP_VERSION der API)
- User.lastSeenReleaseVersion (nullbar, Migration 20260925120000)
- GET /users/me/release-notice, POST /users/me/release-seen (gebunden an Benutzer und Mandant)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:42:11 +02:00
schalli b35edd5f31 test(260925-bow): Versionsvergleich und Was-ist-neu-Endpunkte (rot)
- parseReleaseVersion/compareReleaseVersions/getRunningRelease
- GET me/release-notice, POST me/release-seen: Format, nicht ueber laufend,
  nie absenken, Mandanten- und Benutzerbindung, ReleaseSeenDto

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:42:11 +02:00
154 changed files with 6322 additions and 1023 deletions
+5 -3
View File
@@ -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 260924-m4n — flackernder Test entschaerft, alte DashboardImage-Spalte entfernt (mit Schutzklausel); 1.4.0 vom Nutzer ausdruecklich NICHT freigegeben 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%
@@ -475,6 +475,8 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260924-h7x | **Proxmox-Seite neu gestaltet (Status bestimmt das Bild) und Dashboard-Reiter in die Kopfzeile.** Nutzer hob am 24.09. die Umbausperre vom 23.09. selbst auf. Design-Plan aus dem frontend-design-Skill: Statusfarben als OKLCH-Tokens (`--status-ok/warn/down/idle/orphan`, dazu `-fg`-Textvarianten fuer 4,5:1), Gesundheitsbalken mit Legende, Karten mit Statusleiste links und im Statuston getoentem Schatten, eingelassene Messfelder, Knoten als Einschuebe mit Balken nach Schwellen (80/92 %, Sicherung > 26 h), PMG-Zahlfelder. **Deaktivierter Server = „Offline & verwaist“** (Vorrang vor allem, keine alten Werte, gestrichelt). Sortierung down/warn/ok/idle/orphan, Spaltenfluss statt Raster. Reiter als eingelassener Umschalter per Portal in der Kopfzeilenmitte (`header-center-slot`), eigene Zeile entfallen, Pfeiltasten, weiche Randausblendung bei Ueberlauf; unter 640 px Logo nur Bildmarke. Browser: hell/dunkel 1400 px, 390 px ohne Ueberlauf. web 789 gruen. | 2026-09-24 | 0fa7ce0,57c338f,7416a92,0b659d6,57a4196 | [260924-h7x-proxmox-seite-status-design-und-dashboar](./quick/260924-h7x-proxmox-seite-status-design-und-dashboar/) | | 260924-h7x | **Proxmox-Seite neu gestaltet (Status bestimmt das Bild) und Dashboard-Reiter in die Kopfzeile.** Nutzer hob am 24.09. die Umbausperre vom 23.09. selbst auf. Design-Plan aus dem frontend-design-Skill: Statusfarben als OKLCH-Tokens (`--status-ok/warn/down/idle/orphan`, dazu `-fg`-Textvarianten fuer 4,5:1), Gesundheitsbalken mit Legende, Karten mit Statusleiste links und im Statuston getoentem Schatten, eingelassene Messfelder, Knoten als Einschuebe mit Balken nach Schwellen (80/92 %, Sicherung > 26 h), PMG-Zahlfelder. **Deaktivierter Server = „Offline & verwaist“** (Vorrang vor allem, keine alten Werte, gestrichelt). Sortierung down/warn/ok/idle/orphan, Spaltenfluss statt Raster. Reiter als eingelassener Umschalter per Portal in der Kopfzeilenmitte (`header-center-slot`), eigene Zeile entfallen, Pfeiltasten, weiche Randausblendung bei Ueberlauf; unter 640 px Logo nur Bildmarke. Browser: hell/dunkel 1400 px, 390 px ohne Ueberlauf. web 789 gruen. | 2026-09-24 | 0fa7ce0,57c338f,7416a92,0b659d6,57a4196 | [260924-h7x-proxmox-seite-status-design-und-dashboar](./quick/260924-h7x-proxmox-seite-status-design-und-dashboar/) |
| 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/) |
| 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
@@ -520,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
@@ -0,0 +1,326 @@
---
phase: quick-260925-bow
plan: 01
quick_id: 260925-bow
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260925-bow]
files_modified:
- packages/shared/src/index.ts
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql (neu)
- apps/api/src/health/app-version.ts
- apps/api/src/health/release-version.spec.ts (neu)
- apps/api/src/user/dto/release-seen.dto.ts (neu)
- apps/api/src/user/user.controller.ts
- apps/api/src/user/user.controller.spec.ts
- apps/api/src/user/user.service.ts
- apps/api/src/user/user.service.spec.ts
- apps/api/src/user/admin-seed.service.ts
- apps/api/src/user/admin-seed.service.spec.ts
- apps/web/src/lib/release-notes.ts (neu)
- apps/web/src/lib/release-notes.test.ts (neu)
- apps/web/src/lib/release-notice-actions.ts (neu)
- apps/web/src/lib/release-notice-actions.test.ts (neu)
- apps/web/src/components/release-notice/release-notice-dialog.tsx (neu)
- apps/web/src/components/release-notice/release-notice-dialog.test.tsx (neu)
- apps/web/src/components/release-notice/release-notice-host.tsx (neu)
- apps/web/src/components/release-notice/release-notice-host.test.tsx (neu)
- apps/web/src/components/changelog/changelog-view.tsx
- apps/web/src/components/layout/app-shell.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/lib/changelog.ts (nur Kopfkommentar)
- apps/web/next.config.ts (nur Kommentar)
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
- CHANGELOG.md
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "Wer sich nach einem Versionswechsel zum ersten Mal im Portal anmeldet (laufende freigegebene Version liegt über der zuletzt gesehenen), sieht einmalig ein Fenster „Neu in Version X.Y.Z“ mit den Gruppen „Neu“, „Verbessert“ (aus Geändert) und „Behoben“ aus CHANGELOG.md, neueste Version zuerst (D-02, D-03, D-07)"
- "„Verstanden“, das Schließen-Kreuz, Escape, ein Klick auf den abgedunkelten Hintergrund oder auf „Alle Änderungen ansehen“ schließen das Fenster und merken die Version dauerhaft pro Benutzer in der Datenbank – im Browser und in der Desktop-App erscheint es danach nicht mehr; gemerkt wird erst beim Schließen, nie beim Öffnen (D-01, D-05)"
- "Wer mehrere Versionen verpasst hat, sieht höchstens die drei neuesten; bei mehr steht ein Satz mit der Zahl der weiteren Versionen im Fenster; unten steht immer der Link „Alle Änderungen ansehen“ zu /changelog (D-03)"
- "Bestandsbenutzer ohne gemerkten Stand sehen nur die laufende Version; Benutzer, die ein Administrator anlegt, die der LDAP-Abgleich anlegt, und der Erst-Administrator bekommen bei der Anlage die laufende Version eingetragen und sehen kein Fenster bis zur nächsten Version (D-04)"
- "Ohne gültige freigegebene Versionsnummer (lokaler Stand „dev“, bloßer Commit-Stempel) erscheint nie ein Fenster; auf der Anmeldeseite und auf /change-password erscheint es auch nicht; der Erststart-Dialog der Desktop-App und die Bildschirmfoto-Funktion von „Fehler melden“ bleiben unberührt (D-02, D-08)"
- "Der Server nimmt als „gesehen“ nur eine wohlgeformte Version X.Y.Z an, die nicht über der laufenden liegt, senkt einen gemerkten Stand nie ab und ändert ausschließlich die Zeile des angemeldeten Benutzers in dessen Mandanten (D-05)"
- "Der Text der Änderungsliste bleibt im Server-Bundle; in den öffentlich abrufbaren Client-Chunks unter /_next/static steht er nicht (Bestandsregel aus quick-260916-dcz)"
artifacts:
- path: "packages/shared/src/index.ts"
provides: "parseReleaseVersion, compareReleaseVersions, ReleaseNoticeResponse — eine Implementierung für API und Web (D-02, D-06)"
contains: "export function parseReleaseVersion"
- path: "apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql"
provides: "nullbare Spalte User.lastSeenReleaseVersion (D-01)"
contains: "lastSeenReleaseVersion"
- path: "apps/api/src/health/app-version.ts"
provides: "getRunningRelease() — einzige Quelle der laufenden Version (API-APP_VERSION)"
contains: "export function getRunningRelease"
- path: "apps/api/src/user/user.controller.ts"
provides: "GET /users/me/release-notice, POST /users/me/release-seen"
contains: "me/release-seen"
- path: "apps/web/src/lib/release-notes.ts"
provides: "selectReleaseNotice — reine Auswahl der Versionsabschnitte (Bereich, Deckel 3, null, unparsbar)"
contains: "export function selectReleaseNotice"
- path: "apps/web/src/lib/release-notice-actions.ts"
provides: "Server-Aktionen fetchReleaseNotice / markReleaseSeenAction ('use server')"
contains: "'use server'"
- path: "apps/web/src/components/release-notice/release-notice-dialog.tsx"
provides: "barrierefreies Fenster (role=dialog, aria-modal, Fokusfalle, Escape)"
contains: "aria-modal"
- path: "apps/web/src/components/release-notice/release-notice-host.tsx"
provides: "lädt die Nachricht einmal je Seitenladung im Portal-Rahmen und merkt beim Schließen"
contains: "markReleaseSeenAction"
key_links:
- from: "apps/web/src/components/layout/app-shell.tsx"
to: "apps/web/src/components/release-notice/release-notice-host.tsx"
via: "<ReleaseNoticeHost /> im Portal-Rahmen (nur (portal)-Layout, nie /login)"
pattern: "ReleaseNoticeHost"
- from: "apps/web/src/lib/release-notice-actions.ts"
to: "GET /users/me/release-notice"
via: "fetch mit Session-Cookie über API_INTERNAL_URL, danach selectReleaseNotice(changelogMarkdown, …)"
pattern: "users/me/release-notice"
- from: "apps/web/src/components/release-notice/release-notice-host.tsx"
to: "POST /users/me/release-seen"
via: "markReleaseSeenAction(notice.currentRelease) im onClose"
pattern: "markReleaseSeenAction"
- from: "apps/api/src/user/user.service.ts + admin-seed.service.ts"
to: "apps/api/src/health/app-version.ts"
via: "lastSeenReleaseVersion: getRunningRelease() bei jeder Benutzeranlage"
pattern: "getRunningRelease"
---
# Quick 260925-bow — „Was ist neu“-Fenster beim ersten Anmelden nach einem Versionswechsel
<objective>
Nach einem Versionswechsel zeigt Tessera jedem Benutzer beim ersten Laden des Portals einmal ein
Fenster mit den für ihn wichtigen Änderungen (Neu / Verbessert / Behoben) aus CHANGELOG.md. Der
gesehene Stand wird pro Benutzer in der Datenbank gemerkt, damit das Fenster im Browser und in der
Desktop-App genau einmal erscheint.
Purpose: Anwender erfahren ohne Suchen, was sich geändert hat und welche Fehler behoben sind
(Nutzerwunsch vom 25.09.).
Output: neue Spalte + Migration, zwei API-Endpunkte, reine Versions- und Auswahlfunktionen mit
Tests, Server-Aktionen, barrierefreies Fenster im Portal-Rahmen, Doku, CHANGELOG-Eintrag.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
## Verbindliche Entscheidungen des Orchestrators (hier nummeriert, in den Aufgaben zitiert)
- **D-01** Pro Benutzer in der DB: neue nullbare Spalte `User.lastSeenReleaseVersion String?` (Prisma-Migration). Gilt damit für Browser und Desktop-App.
- **D-02** Nur freigegebene Versionen zählen: die laufende Version kommt aus APP_VERSION (führendes `v` und den Describe-Anhang `-N-g<sha>` entfernen, z. B. `v10.2.3-5-gabc1234` → `10.2.3`). Nicht parsebar (`dev`, bloßer Commit-SHA) → nie ein Fenster.
- **D-03** Einmal nach der Anmeldung im Portal-Rahmen (nicht auf der Anmeldeseite), wenn laufende Version > gemerkte. Inhalt: jede freigegebene Version aus CHANGELOG mit gemerkt < Version ≤ laufend, neueste zuerst, nur die Abschnitte Neu / Geändert / Behoben (andere Abschnitte und leere weglassen). Höchstens 3 Versionen; bei mehr ein Satz plus Link „Alle Änderungen ansehen“ zu /changelog. Unten immer ein Link zu /changelog.
- **D-04** `lastSeen === null` (Bestandsbenutzer beim ersten Ausrollen): nur der Abschnitt der laufenden Version. Neu angelegte Benutzer bekommen bei der Anlage die laufende Version eingetragen (alle Anlagewege). Die API braucht dafür die Version selbst — eine einzige Quelle wählen und dokumentieren.
- **D-05** Schließen („Verstanden“, Escape, Klick auf den Hintergrund) merkt über einen kleinen angemeldeten Endpunkt (`POST /users/me/release-seen` mit der Version); der Server prüft Wohlgeformtheit und „nicht größer als die laufende Version“. Erst beim Schließen merken, nicht beim Öffnen.
- **D-06** Versionsvergleich: numerischer semver-Vergleich, reine Funktion, mit Unit-Tests.
- **D-07** Barrierefreies Fenster (role="dialog", aria-modal, Fokusfalle, Anfangsfokus auf Überschrift oder Schließen-Knopf, Escape schließt, reduzierte Bewegung). Vorbilder: `widget-catalog-modal.tsx`, `bug-report-dialog.tsx`. Tailwind-4-Tokens, Dunkelmodus. Titel „Neu in Version 1.4.0“; Gruppen „Neu“, „Verbessert“ (für Geändert), „Behoben“; Einträge mit demselben Renderer wie die Seite /changelog. Sie-Form, Schlüssel in de.json + en.json.
- **D-08** Nicht auf der Anmeldeseite; darf den Erststart-Dialog der Desktop-App nicht blockieren; darf die Bildschirmfoto-Funktion von „Fehler melden“ nicht stören (offen sein ist in Ordnung).
- **D-09** Tests: Parser/Auswahl (Bereich, Deckel 3, null, unparsebar), semver-Vergleich, Fenster rendern/schließen merkt, nicht gezeigt wenn aktuell, Endpunkt-Validierung + Mandanten-/Benutzerbindung, Anlagewege setzen das Feld. Volle API- und Web-Suiten, `turbo type-check lint`, Biome-Warnungen Web ≤ 53, API ≤ 82 (Stand vorher gemessen: 53 / 82), RLS-Bestandsaufnahme-Spec + `docs/mandantentrennung-zugriffsklassifikation.md` nachziehen.
- **D-10** CHANGELOG „Unveröffentlicht → Neu“-Eintrag; Erwähnung im Anwenderhandbuch.
- **D-11** Browserprüfung macht der Orchestrator (siehe `<verification>`), nicht der Executor.
## Einzige Quelle der laufenden Version (Entscheidung zu D-04, von Claude getroffen)
**Die API-Umgebungsvariable `APP_VERSION`, gelesen über `getRunningRelease()` in
`apps/api/src/health/app-version.ts`.** Begründung: zwei der drei Anlagewege laufen ohne jede
Web-Anfrage (LDAP-Abgleich per Zeitplan, Erst-Administrator beim API-Start) und können die Version
nur aus der API kennen; die Prüfung „nicht größer als laufend“ in `POST /users/me/release-seen` ebenso.
Das Web wertet für diese Funktion seine eigene `NEXT_PUBLIC_APP_VERSION` NICHT aus, sondern nimmt
`currentRelease` aus der Antwort von `GET /users/me/release-notice`. Beide Abbilder bekommen im
CI denselben `APP_VERSION`-Wert (`.gitea/scripts/publish-images.sh`, eine Schleife für web und
api), deshalb passen Änderungsliste (im Web-Abbild) und Version (aus der API) im Betrieb zusammen.
Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds, entsteht einfach
kein Fenster (und nichts wird gemerkt). Parse- und Vergleichsfunktion stehen EINMAL in
`packages/shared/src/index.ts` und werden von API und Web importiert.
## Bestand, den der Executor kennen muss (vom Planer gelesen)
- `apps/web/src/lib/changelog.ts`: `changelogMarkdown` (Bauzeit-Text aus `TESSERA_CHANGELOG_MD`), `filterChangelogForChannel`. Dieses Modul darf nur Server-Code importieren — sonst landet der Text in öffentlichen Client-Chunks. Eine `'use server'`-Datei ist Server-Code (Client-Komponenten bekommen nur eine Aktions-Referenz).
- `apps/web/src/components/changelog/changelog-view.tsx`: `ChangelogView` rendert Markdown mit `MDEditor.Markdown` + `rehype-sanitize`, Farbmodus nach Mount. Wird wiederverwendet (D-07).
- `apps/web/src/components/layout/app-shell.tsx`: Portal-Rahmen (nur im `(portal)`-Layout; `/login` liegt in `(auth)` ohne AppShell). Anmeldung navigiert per `window.location.href` → AppShell wird frisch gemountet.
- `apps/web/src/lib/auth-actions.ts` + `auth-actions.test.ts`: Muster für Server-Aktionen (Cookie `session` → `Cookie: session=…` an `API_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'`) und deren Tests (`next/headers` gemockt, `fetch` per `vi.stubGlobal`).
- `apps/web/src/middleware.ts` leitet bei `mustChangePassword` auf `/change-password` (liegt IM Portal-Rahmen).
- `apps/api/src/health/app-version.ts`: `getAppVersion()` liest `process.env.APP_VERSION || 'dev'` zur Laufzeit (Tests: `vi.stubEnv`, Vorbild `health.controller.spec.ts`).
- `packages/shared/src/index.ts`: rohes TypeScript ohne Bauschritt, die API lädt es im Betrieb über das Type-Stripping von Node 24 → nur löschbare Syntax (keine `enum`, kein `namespace`, keine Parameter-Eigenschaften), keine relativen Importe in neue Dateien (CJS-`require` findet keine `.ts`-Endung) — deshalb alles direkt in `index.ts`. Das Web importiert bereits Laufzeitwerte daraus (`widget-registry.tsx`).
- `apps/api/src/user/user.controller.ts`: `@Controller('users')` + `@UseGuards(RolesGuard)`; Selbstbedienungswege ohne `@Roles` (Vorbild `PATCH me/accent-color`: `forTenant(this.prisma, currentUser.tenantId)` → `tenantPrisma.user.update({ where: { id: currentUser.id } … })`). Globale `ValidationPipe({ whitelist: true, transform: true })` → Body braucht eine DTO-Klasse mit class-validator-Dekoratoren, sonst werden Felder entfernt. Route-Reihenfolge: neue statische `me/…`-Routen VOR `@Get(':id')` einfügen (Projektregel gegen 404-Shadowing).
- `apps/api/src/user/user.controller.spec.ts`: Zwei-Klienten-Attrappe (`forTenant` gemockt → `prisma.__makeBoundClient(tenantId)`, `scopedFindUnique`/`scopedUpdate` filtern nach Mandant, `boundCallLog`).
- Benutzer-Anlagewege (per grep `user\.create` vollständig ermittelt): `UserService.create()` in `apps/api/src/user/user.service.ts` (einziger Erzeugungspunkt für Admin-Anlage `POST /users` UND beide LDAP-Wege `LdapService.upsertMappedUser`/`importUsersByDn`) und `AdminSeedService` in `apps/api/src/user/admin-seed.service.ts` (Erst-Administrator). `apps/api/scripts/rls-scratch-check.mjs` legt nur Wegwerf-Testbenutzer in einer Prüf-DB an — kein Produktweg, bleibt unverändert.
- Migrationen laufen beim API-Start (`apps/api/scripts/migrate-and-start.sh`). Letzte vorhandene: `20260924120000_dashboard_image_drop_data`. Die Anmelde-Funktionen `auth_lookup_*` liefern eine feste Spaltenliste (`RETURNS TABLE`) — eine neue Spalte berührt sie nicht.
- RLS-Buchführung: `apps/api/src/prisma/rls-access-inventory.spec.ts` prüft Paare (Datei, Modell); das Paar `user.controller.ts | user | gebunden` existiert. Die Übersichtszeile `| user | 8 | 14 | 0 |` und die Summenzeile `| **Summe** | **61** | **213** | **6** |` in `docs/mandantentrennung-zugriffsklassifikation.md` werden mit der Gate-Schleife nachgerechnet (siehe Aufgabe 2).
- Stilregeln neuer UI-Dateien (aus quick-260924-i8v übernommen, Gate in Aufgabe 3): keine Versal- oder Sperrschrift-Klassen, keine Mittelpunkt- oder Pfeilzeichen in Texten, kein rohes HTML-Einfügen.
- Commits je Aufgabe mit `feat(260925-bow)` / `test(260925-bow)` / `docs(260925-bow)`; `.planning/**` committet der Executor nicht. Nicht pushen, kein Tag, keine Freigabe.
<!-- planner-discipline-allow: lib/changelog -->
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Aufgabe 1 (Tracer): Bestandsbenutzer mit altem Stand sieht das Fenster, Schließen merkt die Version — DB → API → Server-Aktion → Fenster im Portal</name>
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql, apps/api/src/health/app-version.ts, apps/api/src/health/release-version.spec.ts, apps/api/src/user/dto/release-seen.dto.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.controller.spec.ts, apps/web/src/lib/release-notes.ts, apps/web/src/lib/release-notes.test.ts, apps/web/src/lib/release-notice-actions.ts, apps/web/src/lib/release-notice-actions.test.ts, apps/web/src/components/release-notice/release-notice-dialog.tsx, apps/web/src/components/release-notice/release-notice-dialog.test.tsx, apps/web/src/components/release-notice/release-notice-host.tsx, apps/web/src/components/release-notice/release-notice-host.test.tsx, apps/web/src/components/changelog/changelog-view.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>
- apps/web/src/lib/changelog.ts, apps/web/src/lib/changelog.test.ts (Abschnittszerlegung, CRLF-Normalisierung, Testform)
- apps/web/src/components/changelog/changelog-view.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx (Hintergrund als echte Schaltfläche, role=dialog, Escape) und apps/web/src/components/bug-report/bug-report-dialog.tsx (Knopfklassen `bg-primary … text-primary-foreground`)
- apps/web/src/lib/auth-actions.ts (fetchSessionState) und apps/web/src/lib/auth-actions.test.ts
- apps/web/src/components/layout/app-shell.tsx, apps/web/src/components/layout/header.tsx (Server-Aktion aus useEffect, Ref-Sperre gegen StrictMode-Doppeleffekt)
- packages/shared/src/index.ts (Warnkommentar über WIDGET_TYPES), apps/api/src/health/app-version.ts, apps/api/src/health/health.controller.spec.ts
- apps/api/src/user/user.controller.ts (Selbstbedienungswege ab `me/avatar`), apps/api/src/user/user.controller.spec.ts, apps/api/src/user/dto/create-user.dto.ts
- apps/api/prisma/schema.prisma (model User), apps/api/prisma/migrations/20260702000000_add_user_accent_color/migration.sql (Form einer Spaltenergänzung)
</read_first>
<behavior>
- Versionen (shared, getestet in apps/api/src/health/release-version.spec.ts): parseReleaseVersion liefert für `v10.2.3` → `10.2.3`, `10.2.3` → `10.2.3`, `v10.2.3-5-gabc1234` → `10.2.3`, `10.2.3-12-g0123456789abcdef` → `10.2.3`, führende Nullen `010.02.3` → `10.2.3`; `null` für `dev`, leer, bloßen SHA `abc1234`, `10.2`, `10.2.3-rc.1`, `10.2.3-dirty`, Leerzeichen am Rand, Eingaben über 64 Zeichen. compareReleaseVersions: `1.10.0` > `1.9.0` (numerisch, nicht lexikografisch), `2.0.0` > `1.99.99`, gleich → 0, `v10.2.3` gegen `10.2.3` → 0; wirft bei nicht parsebarer Eingabe. getRunningRelease mit `vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234')` → `10.2.3`, mit `dev` oder ungesetzt → null
- API GET /users/me/release-notice: liefert `{ currentRelease, lastSeenReleaseVersion }` des angemeldeten Benutzers, gelesen über den an `currentUser.tenantId` gebundenen Klienten (boundCallLog), fremder Benutzer desselben Ids in anderem Mandanten ist unsichtbar → NotFoundException
- API POST /users/me/release-seen: `10.2.3` bei laufend `10.2.3` → gespeichert, Antwort nennt den gespeicherten Stand; Formate `v10.2.3`, `10.2`, `abc`, `10.2.3-5-gabc1234`, Nicht-String → BadRequestException; Version über der laufenden → BadRequestException; laufend nicht parsebar (`dev`) → BadRequestException; gemerkt `10.2.3`, gesendet `10.1.0` → bleibt `10.2.3` (nie absenken); gemerkter Wert unparsebar → wird überschrieben; zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden ändert sich
- Web selectReleaseNotice (release-notes.test.ts, eigene Markdown-Fixtures): current null oder unparsebar → null; lastSeen null → nur der Abschnitt der laufenden Version; lastSeen ≥ current → null; lastSeen `1.3.0`, current `1.4.0` → Versionen 1.4.0 und 1.3.1 (neueste zuerst), omittedCount 0; lastSeen `1.0.0` bei fünf Versionen darüber → die drei neuesten, omittedCount 2; lastSeen unparsebar → wie null; „Unveröffentlicht“ mit Punkten wird nie ausgewählt; Versionen über current (Web neuer als API) werden ausgelassen; laufende Version ohne Abschnitt in der Liste → null; nur Neu/Geändert/Behoben in fester Reihenfolge new, changed, fixed unabhängig von der Dateireihenfolge; „Entfernt“ und leere Abschnitte fehlen; eine Version nur mit „Entfernt“ fällt ganz weg; CRLF wird normalisiert
- Web Server-Aktionen: ohne Cookie → null und kein fetch; API 200 → Ergebnis von selectReleaseNotice auf dem (gemockten) changelogMarkdown; API nicht-ok oder Netzfehler → null; markReleaseSeenAction schickt POST mit `Content-Type: application/json`, Cookie und `{ version }`, liefert `{ success: false }` bei nicht-ok
- Fenster: Titel „Neu in Version 1.4.0“ (Schlüssel), role=dialog mit aria-modal und aria-labelledby auf die Überschrift, Anfangsfokus auf der Überschrift; Gruppenüberschriften aus den Schlüsseln new/changed/fixed; Versionsunterüberschriften nur bei mehr als einer Version; Satz über weitere Versionen nur bei omittedCount > 0; Link zu /changelog immer vorhanden; „Verstanden“, Kreuz, Escape, Hintergrund und Link rufen onClose genau einmal; Tab vom letzten fokussierbaren Element springt zum ersten, Umschalt+Tab vom ersten zum letzten
- Host: ohne Nachricht rendert er nichts; mit Nachricht erscheint das Fenster, markReleaseSeenAction wird beim Öffnen NICHT aufgerufen; nach Schließen verschwindet das Fenster und markReleaseSeenAction wurde genau einmal mit currentRelease aufgerufen; auf `/change-password` wird fetchReleaseNotice nicht aufgerufen, nach dem Wechsel auf `/` genau einmal; StrictMode-Doppeleffekt führt nicht zu zwei Abrufen
</behavior>
<action>
Umsetzung in dieser Reihenfolge, jeweils Test zuerst (rot), dann Code (grün):
1. **Versionen (D-02, D-06)** in `packages/shared/src/index.ts` direkt (keine neue Datei, nur löschbare Syntax; den Warnkommentar über `WIDGET_TYPES` um den Hinweis ergänzen, dass diese Funktionen der zweite Laufzeit-Import der API sind): `parseReleaseVersion(raw: string): string | null` mit dem Muster optionales `v`, drei Zifferngruppen zu je 1–6 Ziffern, optional genau der Describe-Anhang `-<Zahl>-g<4–40 Hex-Zeichen>`, ganze Zeichenkette verankert, kein Trimmen, Länge über 64 ergibt null; Rückgabe kanonisch `Number(a).Number(b).Number(c)`. `compareReleaseVersions(a: string, b: string): number` parst beide, wirft `Error` bei null, vergleicht die drei Zahlen der Reihe nach und liefert -1/0/1. Dazu `export interface ReleaseNoticeResponse { currentRelease: string | null; lastSeenReleaseVersion: string | null }`. Tests in `apps/api/src/health/release-version.spec.ts` (API-Suite, weil `packages/shared` keinen eigenen Testlauf hat; Vorbild `widget-module-map.spec.ts`).
2. **Laufende Version der API (einzige Quelle, siehe Kontext):** in `apps/api/src/health/app-version.ts` `export function getRunningRelease(): string | null` = `parseReleaseVersion(getAppVersion().version)`, Laufzeit-Import aus `@tessera/shared`, Kopfkommentar ergänzen (warum die API die Quelle ist). Tests im selben Spec.
3. **Spalte (D-01):** `lastSeenReleaseVersion String?` im `model User` (neben `accentColor`), Migration `apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql` mit Kopfkommentar (quick-260925-bow, wozu die Spalte dient, null = Bestandsbenutzer) und genau `ALTER TABLE "User" ADD COLUMN "lastSeenReleaseVersion" TEXT;`. Danach `pnpm --filter @tessera/api exec prisma generate`. Kein Standardwert, kein Backfill (D-04: null ist gewollt).
4. **Endpunkte (D-05)** in `apps/api/src/user/user.controller.ts`, beide VOR `@Get(':id')`, ohne `@Roles` (jeder angemeldete Benutzer), beide über `forTenant(this.prisma, currentUser.tenantId)` mit `where: { id: currentUser.id }` — kein Kennungsparameter aus der Anfrage. `GET me/release-notice`: `findUnique` mit `select: { lastSeenReleaseVersion: true }`, fehlt die Zeile, dann `NotFoundException`, sonst `ReleaseNoticeResponse` mit `currentRelease: getRunningRelease()`. `POST me/release-seen` mit `@HttpCode(HttpStatus.OK)` und neuer DTO `apps/api/src/user/dto/release-seen.dto.ts` (`ReleaseSeenDto`, `version` mit `@IsString()`, `@MaxLength(32)`, `@Matches` auf die kanonische Form X.Y.Z): im Rumpf zusätzlich prüfen (Unit-Tests rufen die Methode ohne Pipe): `typeof version === 'string'` und `parseReleaseVersion(version) === version`, sonst `BadRequestException`; ist `getRunningRelease()` null, dann `BadRequestException` (keine freigegebene Version); ist `compareReleaseVersions(version, running) > 0`, dann `BadRequestException`. Dann gemerkten Stand lesen (`findUnique`, fehlt er, dann `NotFoundException`); nur wenn der gemerkte Wert null oder unparsebar ist oder die neue Version größer ist, `update` mit `data: { lastSeenReleaseVersion: version }`; Antwort `{ success: true, lastSeenReleaseVersion: <gespeicherter Stand> }`. JSDoc je Methode mit Bezug auf quick-260925-bow und die Mandantenbindung. Tests im bestehenden `user.controller.spec.ts` mit der vorhandenen Zwei-Klienten-Attrappe, `APP_VERSION` per `vi.stubEnv` (in `afterEach` `vi.unstubAllEnvs()`).
5. **Auswahl (D-03, D-04)** in neuer reiner Datei `apps/web/src/lib/release-notes.ts` (importiert NICHT das Changelog-Modul und keine React-/Next-Module, damit Typen daraus auch in Client-Komponenten sicher sind): Typen `ReleaseSectionKind = 'new' | 'changed' | 'fixed'`, `ReleaseNotesSection { kind; markdown }` (nur die Zeilen unter der Gruppenüberschrift, Leerzeilen am Rand entfernt), `ReleaseNotesVersion { version; sections }`, `ReleaseNotice { currentRelease; versions; omittedCount }`, Konstante `RELEASE_NOTICE_MAX_VERSIONS = 3`. `parseChangelogReleases(markdown)`: CRLF normalisieren, an `## `-Überschriften zerlegen, Version = erstes Wort der Überschrift durch `parseReleaseVersion` (aus `@tessera/shared`) — „Unveröffentlicht“ und alles Unparsebare fällt weg; darin `### `-Gruppen genau „Neu“ als new, „Geändert“ als changed, „Behoben“ als fixed, andere Gruppen verwerfen, Gruppe ohne Listenpunkt (`^\s*[-*] `) verwerfen, Ausgabe in fester Reihenfolge new/changed/fixed, Versionen ohne Gruppe verwerfen. `selectReleaseNotice(markdown, currentRelease, lastSeen)`: Regeln wie im behavior-Block; Sortierung absteigend per `compareReleaseVersions` (nicht der Dateireihenfolge vertrauen); ergibt sich keine Version, Rückgabe null.
6. **Server-Aktionen** in neuer Datei `apps/web/src/lib/release-notice-actions.ts` mit `'use server'` (eigene Datei, damit `auth-actions.ts` die Änderungsliste nicht importiert): `fetchReleaseNotice(): Promise<ReleaseNotice | null>` liest das Cookie `session` wie `fetchSessionState`, ruft `GET ${API_URL}/users/me/release-notice` mit `cache: 'no-store'`, prüft die Antwortform (beide Felder string oder null, sonst null) und gibt `selectReleaseNotice(changelogMarkdown, body.currentRelease, body.lastSeenReleaseVersion)` zurück; jeder Fehler still mit Rückgabe null. `markReleaseSeenAction(version: string): Promise<{ success: boolean }>` schickt `POST ${API_URL}/users/me/release-seen`. `changelogMarkdown` kommt aus `@/lib/changelog` — erlaubt, weil Server-Code. Tests nach Vorbild `auth-actions.test.ts`, `@/lib/changelog` per `vi.mock` mit eigenem Markdown.
7. **Renderer wiederverwenden (D-07):** `ChangelogView` bekommt eine optionale Eigenschaft `variant?: 'card' | 'plain'` (Vorgabe `card`, Seite /changelog unverändert); `plain` lässt Rahmen, Hintergrund und Innenabstand weg, `data-testid` bleibt. Die bestehenden Tests der Seite müssen unverändert grün bleiben.
8. **Fenster (D-03, D-07, D-08)** `apps/web/src/components/release-notice/release-notice-dialog.tsx` (`'use client'`), Eigenschaften `{ notice: ReleaseNotice; onClose: () => void }`. Aufbau nach `widget-catalog-modal.tsx`: äußerer `fixed inset-0 z-50`-Container, Hintergrund als echte Schaltfläche mit aria-label (Schlüssel `releaseNotice.close`) und `bg-black/50`, Dialog `role="dialog"`, `aria-modal="true"`, `aria-labelledby` auf die Überschrift (`useId`), `bg-card border border-border rounded-lg shadow-xl`, `w-full max-w-lg mx-4 max-h-[85vh] flex flex-col`. Kopf: `h2` „Neu in Version {version}“ mit `tabIndex={-1}` und Anfangsfokus per Ref beim Mount, daneben Schließen-Kreuz (SVG aus dem Katalog-Fenster, aria-label `common.close`). Mitte: Einleitungssatz, dann scrollbarer Bereich (`overflow-y-auto`, `tabIndex={0}`, aria-label) mit je Version (Unterüberschrift „Version {version}“ nur bei mehr als einer Version) je Gruppe eine `h3` (`text-sm font-semibold text-foreground`) und `<ChangelogView markdown={section.markdown} variant="plain" />`. Fuß: bei `omittedCount > 0` der Satz `moreVersions` (ICU-Plural), links `next/link` „Alle Änderungen ansehen“ auf `/changelog` (Klick ruft onClose, Navigation läuft normal weiter), rechts Hauptknopf „Verstanden“ mit den Knopfklassen aus `bug-report-dialog.tsx`. Tastatur: `keydown`-Listener auf `document` — Escape ruft onClose; Tab/Umschalt+Tab zyklisch innerhalb der aktuell fokussierbaren Elemente des Dialogs (`a[href]`, `button:not([disabled])`, `[tabindex]:not([tabindex="-1"])`, dynamisch abgefragt, weil die Markdown-Ausgabe Links enthalten kann); liegt der Fokus auf der Überschrift, springt Tab auf das erste Element. Beim Unmount den Fokus auf das zuvor aktive Element zurückgeben. Keine Einblendanimation; falls doch ein Übergang nötig ist, nur mit `motion-safe:`-Präfix. Nur Tailwind-Tokens (`bg-card`, `text-foreground`, `text-muted-foreground`, `border-border`, `bg-primary`), damit Dunkelmodus automatisch stimmt. Stilregeln aus dem Kontext beachten.
9. **Host** `apps/web/src/components/release-notice/release-notice-host.tsx` (`'use client'`): Zustand `notice`, Ref-Sperre „schon abgefragt“; `useEffect` auf `usePathname()`: ist die Sperre gesetzt oder beginnt der Pfad mit `/change-password`, nichts tun; sonst Sperre setzen und `fetchReleaseNotice()` aufrufen, Ergebnis in den Zustand (Fehler still). Das Fenster per `React.lazy` + `Suspense fallback={null}` laden, damit der Markdown-Renderer nur geladen wird, wenn wirklich eine Nachricht da ist. `onClose`: zuerst `setNotice(null)` (Fenster sofort weg), dann `void markReleaseSeenAction(notice.currentRelease)` — schlägt das Merken fehl, erscheint das Fenster beim nächsten Laden erneut (gewollt, nicht stumm verloren). In `app-shell.tsx` `<ReleaseNoticeHost />` nach `</main>` einfügen (nur dort; Anmeldeseite hat keine AppShell, der Erststart-Dialog der Desktop-App ist die lokale `apps/desktop/src/setup.html` vor dem Portal und wird nicht berührt; die Bildschirmfoto-Funktion rastert `document.body` und nimmt ein offenes Fenster einfach mit).
10. **Texte (D-07)** neuer Namensraum `releaseNotice` in `de.json` und `en.json` mit identischem Schlüsselsatz: `title` („Neu in Version {version}“ / „New in version {version}“), `intro` („Tessera wurde aktualisiert. Das hat sich für Sie geändert:“ / „Tessera has been updated. Here is what changed for you:“), `versionHeading` („Version {version}“), `section.new` („Neu“ / „New“), `section.changed` („Verbessert“ / „Improved“), `section.fixed` („Behoben“ / „Fixed“), `moreVersions` (DE: „Dazu kommen Änderungen aus {count, plural, one {# älteren Version} other {# älteren Versionen}}.“, EN: „There are also changes from {count, plural, one {# earlier version} other {# earlier versions}}.“), `showAll` („Alle Änderungen ansehen“ / „View all changes“), `confirm` („Verstanden“ / „Got it“), `close` („Fenster schließen“ / „Close window“), `contentLabel` („Änderungen“ / „Changes“). Echte Umlaute, Sie-Form.
Commit(s): `feat(260925-bow): …` und `test(260925-bow): …` (TDD-Reihenfolge darf in einzelnen Commits sichtbar sein).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec prisma generate >/dev/null && pnpm --filter @tessera/api exec prisma validate && node -e "const s=require('./packages/shared/src/index.ts'); if (s.parseReleaseVersion('v10.2.3-5-gabc1234')!=='10.2.3' || s.parseReleaseVersion('dev')!==null || s.compareReleaseVersions('1.10.0','1.9.0')<=0) process.exit(1)" && pnpm --filter @tessera/api exec vitest run release-version user.controller && pnpm --filter @tessera/web exec vitest run release-notes release-notice changelog && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -lE '^import .*lib/changelog' apps/web/src/lib/release-notes.ts apps/web/src/components/release-notice/*.tsx)" && grep -q "^'use server'" apps/web/src/lib/release-notice-actions.ts && test "$(grep -rl '<ReleaseNoticeHost' apps/web/src --include=*.tsx | grep -v '\.test\.tsx$')" = "apps/web/src/components/layout/app-shell.tsx" && awk '/me\/release-notice|me\/release-seen/{n=NR} /@Get\(.:id.\)/{if(!g)g=NR} END{exit !(n && g && n<g)}' apps/api/src/user/user.controller.ts</automated>
</verify>
<done>Mit gesetzter APP_VERSION liefert die API die laufende Version und den gemerkten Stand, nimmt nur gültige, nicht zu hohe Versionen als gesehen an und bindet beides an den angemeldeten Benutzer im eigenen Mandanten; das Web wählt die richtigen Abschnitte (Bereich, Deckel 3, null, unparsebar), zeigt im Portal-Rahmen ein barrierefreies Fenster und merkt erst beim Schließen. Gezielte API- und Web-Tests grün, beide type-checks grün, Node lädt die gemeinsamen Funktionen ohne Bauschritt.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Neue Benutzer sehen kein Verlaufsfenster — alle Anlagewege tragen die laufende Version ein; RLS-Buchführung nachziehen</name>
<files>apps/api/src/user/user.service.ts, apps/api/src/user/user.service.spec.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/admin-seed.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>
- apps/api/src/user/user.service.ts (`create()` samt Kopfkommentar „EINZIGER Erzeugungspunkt“), apps/api/src/user/user.service.spec.ts (describe „create — Standardgruppen-Mitgliedschaft“)
- apps/api/src/user/admin-seed.service.ts (Erstanlage), apps/api/src/user/admin-seed.service.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md: Abschnitt „Übersicht je Bereich“ (Zeile `| user |` und `| **Summe** |`), Fundstellenzeile `apps/api/src/user/user.controller.ts | user`
</read_first>
<behavior>
- UserService.create mit `APP_VERSION=v10.2.3-3-gabc1234` → die an `tenantPrisma.user.create` übergebenen Daten enthalten `lastSeenReleaseVersion: '10.2.3'`; mit `APP_VERSION=dev` bzw. ungesetzt → `lastSeenReleaseVersion: null`
- Der Wert lässt sich über die Parameter von create() nicht von außen setzen (kein neues Feld in der Signatur)
- AdminSeedService legt den Erst-Administrator mit `lastSeenReleaseVersion` der laufenden Version an (`10.2.3` bzw. null bei `dev`)
- LDAP-Anlage: beide LDAP-Wege gehen über UserService.create (grep-Nachweis, kein eigener `user.create` in apps/api/src/ldap)
</behavior>
<action>
Per D-04: In `UserService.create()` in den `data` von `tenantPrisma.user.create` das Feld `lastSeenReleaseVersion: getRunningRelease()` ergänzen (Import aus `../health/app-version`), NICHT als Parameter der Methode — der Wert ist eine Eigenschaft des Servers, nicht des Aufrufers. Kopfkommentar von `create()` um einen Absatz ergänzen: neue Benutzer (Admin-Anlage, beide LDAP-Wege) bekommen die laufende freigegebene Version eingetragen, damit sie kein „Was ist neu“-Fenster mit Altlasten sehen (quick-260925-bow); `null` auf Ständen ohne freigegebene Version. Dasselbe in `AdminSeedService` bei der Erstanlage des Administrators (dort ebenfalls kurzer Kommentar). Tests zuerst: in `user.service.spec.ts` und `admin-seed.service.spec.ts` je ein Fall mit `vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234')` und ein Fall mit `dev`, `vi.unstubAllEnvs()` im `afterEach`.
RLS-Buchführung (D-09): Aufgabe 1 hat in `user.controller.ts` neue gebundene Rohtreffer `tenantPrisma.user.` hinzugefügt (erwartet +3: ein `findUnique` im GET, `findUnique` + `update` im POST). Das Paar (Datei, Modell) bleibt `gebunden`, die Spec braucht keine neue Zeile. Mit der Gate-Schleife nachrechnen (je Bereichsverzeichnis `grep -ro` auf `this.prisma.<Modell>`, `tenantPrisma.<Modell>.`, `systemPrisma.<Modell>.`, ohne spec-Dateien; Summe über alle Bereiche) und eintragen: Zeile `| user | … |` mit den gemessenen Werten und einem vorangestellten Vermerk im etablierten Stil (**quick-260925-bow:** +N gebunden in `user.controller.ts`, „Was ist neu“-Fenster, `GET me/release-notice` und `POST me/release-seen`, nachgemessen mit der Gate-Schleife; danach „Vorher:“ und der bisherige Text); Summenzeile: Werte ersetzen, den Vermerk **quick-260925-bow:** an den Anfang der Hinweisspalte stellen und den bisherigen Text mit „Vorher:“ anhängen (die Gates erwarten den Vermerk jeweils direkt nach den Zahlen); in der Fundstellenzeile `apps/api/src/user/user.controller.ts | user` die Aufzählung der Selbstbedienungszugriffe um die beiden neuen Wege ergänzen (weiterhin `forTenant()`, `where: { id: currentUser.id }`). Werte messen, nicht aus diesem Plan abschreiben.
Commit(s): `feat(260925-bow): …`, `test(260925-bow): …`, `docs(260925-bow): RLS-Buchfuehrung …`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run user.service admin-seed rls-access-inventory && test -z "$(grep -rnE '(tenantPrisma|this\.prisma|tx)\.user\.create' apps/api/src/ldap --include=*.ts | grep -v '\.spec\.ts')" && grep -q 'getRunningRelease' apps/api/src/user/user.service.ts && grep -q 'getRunningRelease' apps/api/src/user/admin-seed.service.ts && K=docs/mandantentrennung-zugriffsklassifikation.md && U=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/user | grep -v spec | wc -l | tr -d ' ') && B=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/user | grep -v spec | wc -l | tr -d ' ') && S=$(grep -ro "systemPrisma\.[a-zA-Z]*\." apps/api/src/user | grep -v spec | wc -l | tr -d ' ') && { grep -qE "^\| user \| ${U} \| ${B} \| ${S} \| \*\*quick-260925-bow" "$K" || { echo "ZEILE user nennt nicht ${U}/${B}/${S} mit Vermerk"; exit 1; }; } && TU=0 && TB=0 && TS=0 && for d in apps/api/src/*/; do u=$(grep -ro "this\.prisma\.[a-zA-Z]*" "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); b=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); s=$(grep -ro "systemPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); TU=$((TU+u)); TB=$((TB+b)); TS=$((TS+s)); done && echo "ABGELEITET ${TU}/${TB}/${TS}" && { grep -qE "^\| \*\*Summe\*\* \| \*\*${TU}\*\* \| \*\*${TB}\*\* \| \*\*${TS}\*\* \| \*\*quick-260925-bow" "$K" || { echo "SUMMENZEILE nennt nicht ${TU}/${TB}/${TS} mit Vermerk"; exit 1; }; } && grep -E '^\| apps/api/src/user/user\.controller\.ts \| user \|' "$K" | grep -q 'release'</automated>
</verify>
<done>Jeder Anlageweg (Admin-Anlage, LDAP-Abgleich und -Import über UserService.create, Erst-Administrator) trägt die laufende freigegebene Version ein, auf dev-Ständen null; Tests dafür grün; RLS-Bestandsaufnahme-Spec grün; Übersichts-, Summen- und Fundstellenzeile nachgemessen und mit Vermerk fortgeschrieben.</done>
</task>
<task type="auto">
<name>Aufgabe 3: Doku, CHANGELOG, Kommentare zur Importregel; volle Suiten, Lint, Biome-Grenzen und Nachweis, dass die Änderungsliste nicht in Client-Chunks landet</name>
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md, apps/web/src/lib/changelog.ts, apps/web/next.config.ts</files>
<read_first>
- CHANGELOG.md (Kopf bis „## 1.4.0“; „## Unveröffentlicht“ ist derzeit leer)
- docs/anleitung-anwender.md, Abschnitt „## Was ist neu“
- docs/anleitung-entwicklung.md, Absatz „**Änderungsliste (`CHANGELOG.md`):**“ (enthält die Regel, welche Datei das Changelog-Modul importieren darf)
- Kopfkommentar von apps/web/src/lib/changelog.ts und Kommentarblock oben in apps/web/next.config.ts
</read_first>
<action>
Per D-10: Unter `## Unveröffentlicht` in CHANGELOG.md eine Gruppe `### Neu` mit einem Punkt in Alltagssprache, Sie-Form, echte Umlaute, ohne Dateinamen/Fachbegriffe, sinngemäß: 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 bleibt unter „Was ist neu“. Das Wort „Versionswechsel“ muss im Punkt vorkommen (Gate).
Anwenderhandbuch `docs/anleitung-anwender.md`, Abschnitt „Was ist neu“: neuen Absatz (Sie-Form) — das Fenster nach einem Versionswechsel, was es zeigt (Neu / Verbessert / Behoben, höchstens drei Versionen, Link „Alle Änderungen ansehen“), wie es sich schließt, dass es pro Benutzer nur einmal je Version erscheint (auch in der Desktop-App), dass neu angelegte Konten es erst mit der nächsten Version sehen, und dass Beta-Punkte unter „Noch nicht freigegeben“ darin nicht vorkommen. Das Wort „Versionswechsel“ muss im Abschnitt vorkommen (Gate).
Entwicklerdoku `docs/anleitung-entwicklung.md`, im Absatz zur Änderungsliste: die Importregel erweitern (Server-Code darf das Changelog-Modul importieren: `page.tsx` UND die `'use server'`-Datei `release-notice-actions.ts`; Client-Komponenten nie, auch nicht `release-notes.ts`), danach ein kurzer Absatz zum Fenster: einzige Quelle der laufenden Version ist `APP_VERSION` der API (`getRunningRelease()`), Begründung (LDAP-Abgleich und Erst-Administrator laufen ohne Web), Endpunkte `GET /users/me/release-notice` und `POST /users/me/release-seen` (Validierung, nie absenken), Spalte `User.lastSeenReleaseVersion` (null = Bestandsbenutzer, zeigt nur die laufende Version), gemeinsame Funktionen `parseReleaseVersion`/`compareReleaseVersions` in `packages/shared`, Folge für die Freigabe: erst ein Tag `vX.Y.Z` (bzw. dessen Describe-Stand auf Beta) löst das Fenster aus, Punkte unter „Unveröffentlicht“ nie; lokal mit `dev` erscheint nie ein Fenster (zum Ausprobieren `APP_VERSION` als Build-Arg setzen). Die Kopfkommentare in `apps/web/src/lib/changelog.ts` und `apps/web/next.config.ts` an dieselbe erweiterte Importregel anpassen (nur Kommentar, kein Code).
Dann die vollen Prüfungen (D-09). Der Web-Build für den Chunk-Nachweis dauert einige Minuten (Befehl mit großzügiger Zeitgrenze ausführen); Build-Ausgaben (`apps/web/.next`, ggf. geändertes `apps/web/next-env.d.ts`) nicht committen — `next-env.d.ts` bei Änderung per `git checkout --` zurücksetzen. Scheitert `next build` lokal aus Gründen außerhalb dieser Änderung, das in der SUMMARY mit der Fehlermeldung festhalten und den Importnachweis aus Aufgabe 1 als Ersatz benennen — nicht stillschweigend überspringen. Biome-Formatierung neuer Dateien mit `biome check --write` angleichen; keine neuen `any`, Nicht-null-Behauptungen oder Biome-Ausnahmen in neuen Dateien.
Commit: `docs(260925-bow): …`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm turbo run type-check lint && W=$(pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+' || true) && A=$(pnpm --filter @tessera/api exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+' || true) && echo "Biome web=${W:-0} api=${A:-0}" && test "${W:-0}" -le 53 && test "${A:-0}" -le 82 && test -z "$(grep -vE '^\s*(//|\*|/\*|\{/\*)' apps/web/src/components/release-notice/release-notice-dialog.tsx apps/web/src/components/release-notice/release-notice-host.tsx | grep -nE 'uppercase|tracking-widest|·|→|dangerouslySetInnerHTML')" && node -e 'for (const f of ["de","en"]) { const m = require("./apps/web/src/messages/" + f + ".json").releaseNotice; if (!m) throw new Error(f + ": releaseNotice fehlt"); for (const k of ["title","intro","versionHeading","moreVersions","showAll","confirm","close","contentLabel"]) if (typeof m[k] !== "string" || !m[k]) throw new Error(f + ": fehlt releaseNotice." + k); for (const k of ["new","changed","fixed"]) if (!m.section || !m.section[k]) throw new Error(f + ": fehlt releaseNotice.section." + k); if (/·|→/.test(JSON.stringify(m))) throw new Error(f + ": verbotenes Zeichen"); }' && awk '/^## Unveröffentlicht/{f=1; next} /^## /{f=0} f' CHANGELOG.md | grep -q '^### Neu' && awk '/^## Unveröffentlicht/{f=1; next} /^## /{f=0} f' CHANGELOG.md | grep -q 'Versionswechsel' && awk '/^## Was ist neu/{f=1; next} /^## /{f=0} f' docs/anleitung-anwender.md | grep -q 'Versionswechsel' && grep -q 'release-notice-actions' docs/anleitung-entwicklung.md && grep -q 'getRunningRelease' docs/anleitung-entwicklung.md && LOG=$(mktemp) && { pnpm --filter @tessera/web build >"$LOG" 2>&1 || { tail -40 "$LOG"; exit 1; }; } && P='hochgeladene Bilder liegen jetzt im Dateibereich des Servers' && test -n "$(grep -rl "$P" apps/web/.next/server)" && test -z "$(grep -rl "$P" apps/web/.next/static)" && { git checkout -- apps/web/next-env.d.ts 2>/dev/null || true; } && git diff --quiet -- apps/web/next-env.d.ts</automated>
</verify>
<done>CHANGELOG, Anwender- und Entwicklerdoku beschreiben das Fenster; Kommentare zur Importregel stimmen; volle API- und Web-Suite grün, `turbo type-check lint` grün, Biome-Warnungen Web ≤ 53 und API ≤ 82, Stil- und i18n-Gate leer bzw. vollständig; nach `next build` steht der Änderungslistentext im Server-Bundle, aber nicht unter `.next/static`.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser/Desktop-App → Web-Server-Aktion | Client ruft `fetchReleaseNotice`/`markReleaseSeenAction`; Eingabe `version` ist unvertrauenswürdig |
| Web → API (`/users/me/release-*`) | Session-Cookie authentifiziert; Body `{ version }` unvertrauenswürdig |
| API → PostgreSQL (User-Zeile) | Zeilenschutz je Mandant über `forTenant()` |
| Änderungsliste (Bauzeit-Text) → Client-Bundles | Text darf nicht in öffentlich abrufbare `/_next/static`-Chunks |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-BOW-01 | Tampering | `POST /users/me/release-seen` | low | mitigate | DTO (`@IsString`, `@MaxLength(32)`, `@Matches` kanonisch) plus Rumpfprüfung `parseReleaseVersion(v) === v`, `≤ getRunningRelease()`, `null`-laufend → 400; nie absenken. Wirkung beschränkt auf das eigene Fenster |
| T-BOW-02 | Elevation of Privilege | `GET/POST /users/me/release-*` | medium | mitigate | Kein Kennungsparameter; `where: { id: currentUser.id }` über `forTenant(this.prisma, currentUser.tenantId)`; Test mit zwei Benutzern in zwei Mandanten (Aufgabe 1) |
| T-BOW-03 | Information Disclosure | `release-notice-actions.ts` / Client-Chunks | low | mitigate | Changelog-Modul nur aus `'use server'`-Datei und `page.tsx`; `release-notes.ts` und Fenster-Dateien ohne diesen Import (Gate Aufgabe 1); Build-Nachweis `.next/static` enthält den Text nicht (Gate Aufgabe 3) |
| T-BOW-04 | Tampering (XSS) | `ReleaseNoticeDialog` | low | mitigate | Markdown ausschließlich über `ChangelogView` (`MDEditor.Markdown` + `rehype-sanitize`), kein rohes HTML-Einfügen (Stil-Gate Aufgabe 3); Quelle ist die versionierte CHANGELOG.md |
| T-BOW-05 | Denial of Service | Versionsparser | low | mitigate | Eingabelänge ≤ 64 im Parser, `@MaxLength(32)` in der DTO, verankerter Ausdruck ohne verschachtelte Wiederholungen |
| T-BOW-06 | Information Disclosure | `GET /users/me/release-notice` | low | accept | Nennt nur die laufende Version, die `GET /health/version` ohnehin öffentlich liefert (T-KU1-03), und den eigenen gemerkten Stand |
| T-BOW-07 | Tampering | Migration `20260925120000_user_last_seen_release` | low | accept | Reines `ADD COLUMN` nullbar ohne Standardwert, kein Datenumbau; `auth_lookup_*` liefern feste Spaltenlisten und bleiben unberührt |
</threat_model>
<verification>
Executor (automatisiert, siehe Aufgaben): gezielte Tests je Schicht (Aufgabe 1), Anlagewege + RLS-Buchführung (Aufgabe 2), volle API- und Web-Suite, `pnpm turbo run type-check lint`, Biome-Warnungen Web ≤ 53 / API ≤ 82, Stil- und i18n-Gate, Chunk-Nachweis per `next build` (Aufgabe 3).
Orchestrator (D-11, Browserprüfung mit Playwright am lokalen Stack, NICHT Aufgabe des Executors):
1. Lokal mit einer freigegebenen Versionsnummer bauen, damit überhaupt ein Fenster entstehen kann (lokal steht sonst `dev`): `docker compose build --build-arg APP_VERSION=1.4.0-1-g0000000 api web && docker compose up -d --force-recreate api web` — die Migration läuft beim API-Start.
2. `docker exec tessera-ctl-db-1 psql -U tessera -d tessera -c "UPDATE \"User\" SET \"lastSeenReleaseVersion\"='1.0.0' WHERE username='admin'"` → Anmelden: Fenster „Neu in Version 1.4.0“ mit 1.4.0, 1.3.1, 1.3.0, Satz „Dazu kommen Änderungen aus 2 älteren Versionen.“, Link zu /changelog; Überschrift hat den Fokus; Tab bleibt im Fenster; Dunkelmodus prüfen.
3. „Verstanden“ → DB zeigt `1.4.0`; Neuladen → kein Fenster.
4. `lastSeenReleaseVersion = NULL` → nur Abschnitt 1.4.0, keine Versionsunterüberschrift. `= '1.4.0'` → kein Fenster. Escape und Hintergrundklick schließen ebenfalls und merken.
5. Anmeldeseite zeigt nie ein Fenster; „Fehler melden“ mit offenem Fenster funktioniert (Bildschirmfoto enthält das Fenster).
</verification>
<success_criteria>
- Bestandsbenutzer mit älterem Stand sehen nach einem Versionswechsel genau einmal das Fenster mit Neu / Verbessert / Behoben der verpassten Versionen (höchstens drei, neueste zuerst, Hinweis auf weitere), Schließen merkt dauerhaft pro Benutzer.
- Neu angelegte Benutzer (Admin, LDAP, Erst-Administrator) und dev-Stände sehen kein Fenster.
- Server validiert und bindet an Benutzer und Mandant; RLS-Buchführung nachgemessen.
- Alle Suiten, type-check, lint grün; Biome Web ≤ 53, API ≤ 82; Änderungsliste nicht in Client-Chunks.
- CHANGELOG „Unveröffentlicht → Neu“, Anwender- und Entwicklerdoku ergänzt.
</success_criteria>
<output>
Create `.planning/quick/260925-bow-was-ist-neu-fenster-beim-ersten-anmelden/260925-bow-SUMMARY.md` when done (gemessene Biome-Zahlen, gemessene RLS-Zählwerte user/Summe, Ergebnis des Chunk-Nachweises, Testzahlen API/Web).
</output>
@@ -0,0 +1,167 @@
---
phase: quick-260925-bow
plan: 01
quick_id: 260925-bow
status: complete
subsystem: web + api (Benutzer, Änderungsliste)
tags: [release-notice, changelog, user, prisma, a11y, rls]
requires:
- CHANGELOG.md als Bauzeit-Text (quick-260916-dcz)
- APP_VERSION als Laufzeit-ENV der API (quick-260914-ku1)
provides:
- "Spalte User.lastSeenReleaseVersion (Migration 20260925120000_user_last_seen_release)"
- "parseReleaseVersion / compareReleaseVersions / ReleaseNoticeResponse in @tessera/shared"
- "getRunningRelease() als einzige Quelle der laufenden Version"
- "GET /users/me/release-notice, POST /users/me/release-seen"
- "selectReleaseNotice (Web), Server-Aktionen, ReleaseNoticeDialog, ReleaseNoticeHost in AppShell"
affects:
- apps/web/src/components/layout/app-shell.tsx
- apps/api/src/user/user.service.ts (create)
- apps/api/src/user/admin-seed.service.ts
tech-stack:
added: []
patterns:
- "Server-Aktion in eigener 'use server'-Datei als einziger Web-Importeur der Änderungsliste neben page.tsx"
- "Fenster per React.lazy nur bei vorhandener Nachricht geladen"
key-files:
created:
- apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql
- apps/api/src/health/release-version.spec.ts
- apps/api/src/user/dto/release-seen.dto.ts
- apps/web/src/lib/release-notes.ts
- apps/web/src/lib/release-notes.test.ts
- apps/web/src/lib/release-notice-actions.ts
- apps/web/src/lib/release-notice-actions.test.ts
- apps/web/src/components/release-notice/release-notice-dialog.tsx
- apps/web/src/components/release-notice/release-notice-dialog.test.tsx
- apps/web/src/components/release-notice/release-notice-host.tsx
- apps/web/src/components/release-notice/release-notice-host.test.tsx
modified:
- packages/shared/src/index.ts
- apps/api/prisma/schema.prisma
- apps/api/src/health/app-version.ts
- apps/api/src/user/user.controller.ts
- apps/api/src/user/user.controller.spec.ts
- apps/api/src/user/user.service.ts
- apps/api/src/user/user.service.spec.ts
- apps/api/src/user/admin-seed.service.ts
- apps/api/src/user/admin-seed.service.spec.ts
- apps/web/src/components/changelog/changelog-view.tsx
- apps/web/src/components/layout/app-shell.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- apps/web/src/lib/changelog.ts
- apps/web/next.config.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
- CHANGELOG.md
decisions:
- "Einzige Quelle der laufenden Version ist APP_VERSION der API (getRunningRelease()); das Web nimmt currentRelease aus GET /users/me/release-notice"
- "Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds ganz, gibt es kein Fenster; eine laufende Version nur mit „Entfernt“ zählt ebenso als ohne Abschnitt"
- "Scrollbereich des Fensters ist ein benannter section ohne tabIndex (keine neue Biome-Warnung, keine Biome-Ausnahme)"
metrics:
duration: 16min
completed: 2026-09-25
actuals:
tokens: 30400
tasks: 3
commits: 9
plan_head_before: 9225ed1bf980aa688e50b55f23162927bf95b40c
---
# Quick 260925-bow: „Was ist neu“-Fenster beim ersten Anmelden nach einem Versionswechsel Summary
Nach einem Versionswechsel zeigt Tessera jedem Benutzer beim ersten Laden des Portals einmal ein Fenster „Neu in Version X.Y.Z“ mit Neu / Verbessert / Behoben aus CHANGELOG.md (höchstens drei Versionen, neueste zuerst). Der gesehene Stand liegt pro Benutzer in der neuen Spalte `User.lastSeenReleaseVersion` und wird erst beim Schließen über `POST /users/me/release-seen` gemerkt, gebunden an Benutzer und Mandanten.
## Was gebaut wurde
**Aufgabe 1 (Tracer, DB → API → Server-Aktion → Fenster)**
- `packages/shared`: `parseReleaseVersion` (optionales `v`, drei Zifferngruppen zu je 1 bis 6 Ziffern, optional Describe-Anhang, verankert, ≤ 64 Zeichen, kanonisch ohne führende Nullen), `compareReleaseVersions` (numerisch, wirft bei Unparsebarem), `ReleaseNoticeResponse`. Warnkommentar über `WIDGET_TYPES` ergänzt.
- `getRunningRelease()` in `app-version.ts` mit Begründung, warum die API die Quelle ist.
- Spalte + Migration (`ALTER TABLE "User" ADD COLUMN "lastSeenReleaseVersion" TEXT;`), kein Standardwert, kein Backfill. **Lokal auf die DB angewendet** (`prisma migrate deploy` über 172.19.0.2); `tessera_app` hat Rechte auf Tabellenebene und damit auch auf die neue Spalte.
- `GET me/release-notice` und `POST me/release-seen` in `user.controller.ts` vor der Kennungs-Route, ohne `@Roles`, beide über `forTenant()` + `where: { id: currentUser.id }`. `ReleaseSeenDto` mit `@IsString`, `@MaxLength(32)`, `@Matches` auf kanonisches X.Y.Z; im Rumpf zusätzlich Format-, dev- und „nicht über laufend“-Prüfung; nie absenken, unparsebaren Altwert überschreiben.
- Web: `release-notes.ts` (reine Auswahl, kein Import der Änderungsliste), `release-notice-actions.ts` (`'use server'`), `ReleaseNoticeDialog` (role=dialog, aria-modal, aria-labelledby, Anfangsfokus Überschrift, Escape, Fokusfalle mit dynamischer Elementliste, Fokus-Rückgabe, keine Animation, nur Tailwind-Tokens), `ReleaseNoticeHost` (Ref-Sperre, nicht auf `/change-password`, `React.lazy`, merkt erst beim Schließen), eingebunden nur in `AppShell`. `ChangelogView` mit `variant="plain"`. Texte `releaseNotice` in de.json/en.json.
**Aufgabe 2 (Anlagewege)**
- `UserService.create()` (Admin-Anlage und beide LDAP-Wege) und `AdminSeedService` setzen `lastSeenReleaseVersion: getRunningRelease()`; kein neuer Parameter. grep-Nachweis: kein eigener `user.create` in `apps/api/src/ldap`.
- RLS-Buchführung nachgemessen mit der Gate-Schleife: **user 8/17/0** (vorher 8/14/0, +3 gebunden in `user.controller.ts`), **Summe 61/216/6** (vorher 61/213/6). Übersichts-, Summen- und Fundstellenzeile mit Vermerk **quick-260925-bow** fortgeschrieben. `rls-access-inventory.spec.ts` grün (Paar `user.controller.ts | user | gebunden` unverändert).
**Aufgabe 3 (Doku und volle Prüfungen)**
- CHANGELOG „Unveröffentlicht → Neu“, Abschnitt „Was ist neu“ im Anwenderhandbuch, Entwicklerdoku (Importregel erweitert, Versionsquelle, Endpunkte, Spalte, Folge für die Freigabe, Ausprobieren mit `APP_VERSION`), Kopfkommentare `changelog.ts` und `next.config.ts`.
## Gemessene Ergebnisse
| Prüfung | Ergebnis |
|---|---|
| API-Suite (voll) | 85 Dateien, **1435 Tests grün** |
| Web-Suite (voll) | 95 Dateien, **924 Tests grün** |
| `pnpm turbo run type-check lint` | 9/9 Tasks erfolgreich |
| Biome-Warnungen | **Web 53** (Grenze 53), **API 82** (Grenze 82) |
| Stil-Gate (Versal/Sperrschrift, `·`, `→`, rohes HTML) | leer |
| i18n-Gate `releaseNotice` de/en | vollständig |
| RLS user / Summe | 8/17/0 / 61/216/6 |
| `next build` | erfolgreich |
| Chunk-Nachweis | Satz „hochgeladene Bilder liegen jetzt im Dateibereich des Servers“: 2 Dateien unter `.next/server` (`changelog/page.js` und der Server-Chunk der Aktion), **0 unter `.next/static`**; ebenso der neue CHANGELOG-Eintrag (2 / 0). `next-env.d.ts` unverändert. |
Abgleich mit der echten CHANGELOG.md: gemerkt `1.0.0`, laufend `1.4.0` ergibt 1.4.0 (new/changed/fixed), 1.3.1 (changed/fixed), 1.3.0 (new/changed/fixed) und `omittedCount` 2, also genau das, was die Browserprüfung des Orchestrators erwartet; gemerkt `null` ergibt nur 1.4.0; gemerkt `1.4.0` ergibt kein Fenster.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] StrictMode-Doppeleffekt verwarf das Abrufergebnis im Host**
- **Found during:** Aufgabe 1 (Host-Test „StrictMode-Doppeleffekt führt nicht zu zwei Abrufen“)
- **Issue:** Ein Abbruch-Flag im Aufräumen des Effekts wurde beim simulierten Unmount gesetzt, der zweite Effektlauf fragte wegen der Ref-Sperre nicht erneut, das einzige Ergebnis ging verloren, kein Fenster.
- **Fix:** Abbruch-Flag entfernt (setState nach Unmount ist in React 19 folgenlos), Kommentar dazu.
- **Commit:** 187fb76
**2. [Rule 3 - Blocking] Umlaut-Wächter kannte „Verbessert“ nicht**
- **Found during:** Aufgabe 3 (volle Web-Suite)
- **Issue:** `umlaut-guard.spec.ts` meldete das korrekt geschriebene Wort „Verbessert“ (Gruppe `releaseNotice.section.changed`) als unbekanntes „ss“-Wort.
- **Fix:** „Verbessert“ in `UMLAUT_ALLOWLIST` (`umlaut-dictionary.ts`) mit Vermerk aufgenommen.
- **Commit:** 2aeb3e8
**3. [Rule 3 - Blocking] Scrollbereich ohne `tabIndex={0}`**
- **Issue:** Der geplante `div tabIndex={0} aria-label` erzeugte zwei neue Biome-Warnungen (`noNoninteractiveTabindex`, `useAriaPropsSupportedByRole`) und hätte die Grenze Web ≤ 53 gerissen; Biome-Ausnahmen in neuen Dateien sind laut Plan verboten.
- **Fix:** Benannter `<section aria-label={contentLabel}>` ohne tabIndex. Heutige Chromium- und WebKit-Versionen (Browser und Desktop-App) machen einen Scroll-Container ohne fokussierbaren Inhalt selbst per Tastatur erreichbar; enthält er Links, scrollt der Tab-Fokus mit. Innere Versionsblöcke sind `div` statt `section`, damit keine verschachtelten Landmarken entstehen.
- **Commit:** 187fb76
**4. [Kleinigkeit] Überschriften-Hierarchie**
- Bei einer Version sind die Gruppen `h3` (wie geplant); bei mehreren Versionen trägt die Version `h3` und die Gruppen `h4` (statt einer Versionszeile ohne Überschriftenrolle). Test entsprechend.
**5. [Hinweis] `prisma validate` braucht DATABASE_URL**
- Das Aufgabe-1-Gate ruft `prisma validate` ohne Umgebung auf; hier scheitert das nur an der fehlenden `DATABASE_URL` (P1012). Mit gesetzter URL (echte Container-IP bzw. Platzhalter) ist das Schema gültig. `prisma format` wurde bewusst NICHT verwendet, weil es die ganze Schemadatei umformatiert hätte; die neue Zeile ist von Hand eingetragen.
**6. [Hinweis] Bestehende Format-Befunde nicht angefasst**
- `biome check` meldet in `app-shell.tsx` und `changelog-view.tsx` Format-/Importreihenfolge-Befunde, die schon vorher bestanden; das Lint-Skript ist `biome lint`, die Dateien wurden deshalb nicht umformatiert. Neue Dateien sind mit `biome check --write` formatiert, ohne Ausnahmen, `any` nur in Test-Attrappen wie im Bestand.
## Hinweise für den Orchestrator (Browserprüfung, D-11)
- Die Migration ist auf der lokalen DB bereits angewendet; der laufende API-Container (altes Abbild) stört sich an der zusätzlichen nullbaren Spalte nicht. Für die Prüfung muss wie im Plan beschrieben mit `--build-arg APP_VERSION=1.4.0-1-g0000000` neu gebaut werden (der Describe-Anhang braucht mindestens 4 Hex-Zeichen nach `g`, `g0000000` passt).
- Auf `/change-password` wird nicht abgefragt; die nächste Seite danach fragt einmal.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Bedrohungsmodells: die zwei Endpunkte (T-BOW-01/02/05/06), die Migration (T-BOW-07), die Server-Aktion/Chunks (T-BOW-03) und das Markdown-Rendering über `ChangelogView` mit `rehype-sanitize` (T-BOW-04) sind dort erfasst und umgesetzt.
## Commits
| Hash | Nachricht |
|---|---|
| b35edd5 | test(260925-bow): Versionsvergleich und Was-ist-neu-Endpunkte (rot) |
| 59db32a | feat(260925-bow): gesehene Version pro Benutzer merken - Spalte, Versionsfunktionen, API |
| cf7784e | test(260925-bow): Auswahl, Server-Aktionen, Fenster und Host des Was-ist-neu-Fensters |
| 187fb76 | feat(260925-bow): Was-ist-neu-Fenster im Portal-Rahmen |
| 25c8db7 | test(260925-bow): Anlagewege tragen die laufende Version ein (rot) |
| 5ae9aaa | feat(260925-bow): neue Benutzer bekommen die laufende Version eingetragen |
| 4fa5aaf | docs(260925-bow): RLS-Buchfuehrung nachgemessen (user 8/17/0, Summe 61/216/6) |
| 2aeb3e8 | fix(260925-bow): Umlaut-Waechter kennt das korrekte Wort Verbessert |
| b3b7b5d | docs(260925-bow): Was-ist-neu-Fenster in CHANGELOG, Anwender- und Entwicklerdoku |
Nicht gepusht, kein Tag, keine Freigabe. `.planning/**` nicht committet.
## Self-Check: PASSED
Alle 11 neuen Dateien vorhanden, alle 9 Commits im Verlauf (`git rev-list --count 9225ed1..HEAD` = 9).
@@ -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>
@@ -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
+25
View File
@@ -4,6 +4,31 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
## 1.5.1 – 2026-09-28
### Geändert
- Dashboard: Der Knopf „Bearbeiten“ sitzt jetzt als Stift-Symbol rechts in der dunklen Leiste oben und nimmt über den Widgets keinen Platz mehr weg. Im Bearbeitungsmodus erscheinen dort auch „Hintergrund“, „Widget hinzufügen“ und „Fertig“.
## 1.5.0 – 2026-09-28
### Neu
- Nach einem Versionswechsel zeigt Tessera bei Ihrer ersten Anmeldung ein Fenster mit den wichtigsten Änderungen der neuen Version – neue Funktionen, Verbesserungen und behobene Fehler. Haben Sie mehrere Versionen verpasst, erscheinen die drei neuesten. „Verstanden“ schließt das Fenster; es erscheint erst mit der nächsten Version wieder, im Browser wie in der Desktop-App. Die vollständige Liste finden Sie weiterhin unter „Was ist neu“.
- Dashboard: Sie können jetzt einen Hintergrund wählen. Im Bearbeitungsmodus öffnet der Knopf „Hintergrund“ eine Auswahl – keiner, ruhige Flächen und Motive in Gelb, Grau und Graphit, oder ein eigenes Bild aus Ihren Bilderrahmen-Bildern bzw. ein neu hochgeladenes. Der Hintergrund gilt nur für Sie und folgt Ihnen auf jedes Gerät, auf dem Sie sich anmelden, auch in die Desktop-App. Eine bisher nur in Ihrem Browser gemerkte Wahl wird dabei automatisch übernommen.
### Geändert
- Neues Aussehen: Tessera hat eine dunkle App-Leiste oben und eine neu gestaltete Seitenleiste. Jedes Modul erscheint dort mit einer eigenen kleinen Kachel, die Kategorien tragen deutsche Namen und sind anfangs aufgeklappt, und unten in der Seitenleiste begrüßt Sie Tessera mit Ihrem Namen und dem heutigen Datum. Die persönliche Akzentfarbe hebt nur noch das Modul hervor, in dem Sie gerade arbeiten. Auch die übrigen Seiten – Marktplatz, Module, Einstellungen und Verwaltung – folgen diesem ruhigeren Stil.
- Neue Anmeldeseite: Auf großen Bildschirmen ist sie geteilt, links ein dunkler Bereich mit dem Tessera-Zeichen und einem Farbmosaik, rechts das Anmeldeformular.
- Dashboard: Die Kacheln stehen mittig auf der Seite. Jedes Widget trägt oben ein gelbes Symbol-Feld, hebt sich beim Darüberfahren leicht an und blendet beim Laden sanft ein. Die Knöpfe „Bearbeiten“, „Widget hinzufügen“ und „Hintergrund“ sitzen jetzt als Leiste oben rechts über den Kacheln statt unten rechts. Kalender, Favoriten und Notizen sind ruhiger gestaltet, und ein leeres Dashboard schlägt Ihnen passende erste Kacheln vor.
- Kalender-Widget: Die nächsten Termine stehen jetzt in einer kompakten, einzeiligen Liste, in der „Heute“ und „Morgen“ statt des Datums erscheinen – so passen auch in eine kleine Kachel mehrere Termine. Den Ort eines Termins sehen Sie, wenn Sie mit der Maus darüberfahren.
- Auf dem Handy öffnet sich die Seitenleiste als Schublade über der ganzen Seite, samt App-Leiste.
### Behoben
- Dashboard: Widgets ließen sich manchmal nicht schmaler ziehen, wenn die Maus dabei leicht nach oben oder unten wackelte. Jetzt klappt das zuverlässig.
## 1.4.0 – 2026-09-25 ## 1.4.0 – 2026-09-25
### Neu ### Neu
@@ -0,0 +1,15 @@
-- quick-260925-bow: "Was ist neu"-Fenster nach einem Versionswechsel.
--
-- Merkt pro Benutzer die zuletzt gesehene freigegebene Version (X.Y.Z), damit
-- das Fenster im Browser und in der Desktop-App genau einmal je Version
-- erscheint. Gesetzt wird der Wert nur ueber POST /users/me/release-seen
-- (beim Schliessen des Fensters) und bei der Anlage neuer Benutzer (laufende
-- Version). NULL = Bestandsbenutzer ohne gemerkten Stand; sie sehen beim
-- ersten Mal nur den Abschnitt der laufenden Version. 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 "lastSeenReleaseVersion" TEXT;
@@ -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;
+7
View File
@@ -43,6 +43,13 @@ model User {
lastLoginAt DateTime? lastLoginAt DateTime?
avatarPath String? avatarPath String?
accentColor String? accentColor String?
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
lastSeenReleaseVersion String?
// quick-260928-ujj: gewaehlter Dashboard-Hintergrund; null = nie gewaehlt,
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
dashboardBackground Json?
passwordResetTokens PasswordResetToken[] passwordResetTokens PasswordResetToken[]
groupMemberships GroupMembership[] groupMemberships GroupMembership[]
moduleGrants ModuleGrant[] moduleGrants ModuleGrant[]
+29
View File
@@ -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');
+6 -1
View File
@@ -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,
}; };
+22 -1
View File
@@ -1,4 +1,4 @@
import type { VersionResponse } from '@tessera/shared'; import { parseReleaseVersion, type VersionResponse } from '@tessera/shared';
/** /**
* Versionsstempel der API (quick-260914-ku1). * Versionsstempel der API (quick-260914-ku1).
@@ -30,3 +30,24 @@ export function formatAppVersionLine(v: VersionResponse = getAppVersion()): stri
const base = `Tessera API ${v.version} (${v.channel})`; const base = `Tessera API ${v.version} (${v.channel})`;
return v.commit ? `${base} ${v.commit}` : base; return v.commit ? `${base} ${v.commit}` : base;
} }
/**
* Laufende FREIGEGEBENE Version der API als `X.Y.Z` oder `null`
* (quick-260925-bow, D-02/D-04).
*
* Einzige Quelle der laufenden Version fuer das "Was ist neu"-Fenster:
* `APP_VERSION` der API. Warum die API und nicht das Web: zwei der drei
* Anlagewege neuer Benutzer laufen ohne jede Web-Anfrage (LDAP-Abgleich per
* Zeitplan, Erst-Administrator beim API-Start) und tragen die Version bei der
* Anlage ein; ausserdem prueft `POST /users/me/release-seen` gegen diesen
* Wert. Das Web nimmt `currentRelease` aus `GET /users/me/release-notice`
* und wertet seine eigene `NEXT_PUBLIC_APP_VERSION` dafuer nicht aus. Beide
* Abbilder bekommen im CI denselben `APP_VERSION`-Wert
* (`.gitea/scripts/publish-images.sh`).
*
* `v1.4.0-5-gabc1234` (Beta, Describe-Stand) → `1.4.0`; `dev` oder ein
* blosser Commit-Stempel → `null` (dann erscheint nie ein Fenster).
*/
export function getRunningRelease(): string | null {
return parseReleaseVersion(getAppVersion().version);
}
+122
View File
@@ -0,0 +1,122 @@
import { compareReleaseVersions, parseReleaseVersion } from '@tessera/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRunningRelease } from './app-version';
/**
* Freigegebene Versionen (quick-260925-bow, D-02/D-06).
*
* `parseReleaseVersion` und `compareReleaseVersions` stehen EINMAL in
* `packages/shared/src/index.ts` und werden von API und Web benutzt. Getestet
* wird hier in der API-Suite, weil `packages/shared` keinen eigenen Testlauf
* hat (Vorbild `widget-module-map.spec.ts`).
*
* `getRunningRelease()` ist die einzige Quelle der laufenden Version fuer das
* "Was ist neu"-Fenster: `APP_VERSION` der API, auf X.Y.Z gekuerzt.
*/
afterEach(() => {
vi.unstubAllEnvs();
});
describe('parseReleaseVersion (quick-260925-bow)', () => {
it.each([
['v10.2.3', '10.2.3'],
['10.2.3', '10.2.3'],
['v10.2.3-5-gabc1234', '10.2.3'],
['10.2.3-12-g0123456789abcdef', '10.2.3'],
['010.02.3', '10.2.3'],
['1.4.0', '1.4.0'],
])('%s → %s', (raw, expected) => {
expect(parseReleaseVersion(raw)).toBe(expected);
});
it.each([
['dev'],
[''],
['abc1234'],
['10.2'],
['10.2.3-rc.1'],
['10.2.3-dirty'],
[' 10.2.3'],
['10.2.3 '],
['V10.2.3'],
['vv10.2.3'],
['10.2.3.4'],
['1234567.0.0'],
['10.2.3-5-gxyz1234'],
['10.2.3-5-gabc'],
['10.2.3-g0123456'],
])('%j → null', (raw) => {
expect(parseReleaseVersion(raw)).toBeNull();
});
it('Eingaben ueber 64 Zeichen ergeben null, auch wenn das Muster sonst passen wuerde', () => {
const long = `1.2.3-5-g${'a'.repeat(40)}`;
expect(long.length).toBeLessThanOrEqual(64);
expect(parseReleaseVersion(long)).toBe('1.2.3');
expect(parseReleaseVersion(`${'1'.repeat(70)}.0.0`)).toBeNull();
expect(parseReleaseVersion('x'.repeat(10_000))).toBeNull();
});
it('Nicht-Zeichenketten ergeben null statt eines Fehlers', () => {
expect(parseReleaseVersion(undefined as unknown as string)).toBeNull();
expect(parseReleaseVersion(null as unknown as string)).toBeNull();
expect(parseReleaseVersion(123 as unknown as string)).toBeNull();
});
});
describe('compareReleaseVersions (quick-260925-bow)', () => {
it('vergleicht numerisch, nicht lexikografisch', () => {
expect(compareReleaseVersions('1.10.0', '1.9.0')).toBe(1);
expect(compareReleaseVersions('1.9.0', '1.10.0')).toBe(-1);
});
it('die hoehere Hauptversion gewinnt', () => {
expect(compareReleaseVersions('2.0.0', '1.99.99')).toBe(1);
expect(compareReleaseVersions('1.99.99', '2.0.0')).toBe(-1);
});
it('Patch-Stelle entscheidet bei gleicher Haupt- und Nebenversion', () => {
expect(compareReleaseVersions('1.3.1', '1.3.0')).toBe(1);
});
it('gleiche Versionen ergeben 0, auch mit v-Praefix und Describe-Anhang', () => {
expect(compareReleaseVersions('1.4.0', '1.4.0')).toBe(0);
expect(compareReleaseVersions('v10.2.3', '10.2.3')).toBe(0);
expect(compareReleaseVersions('v10.2.3-5-gabc1234', '10.2.3')).toBe(0);
});
it('wirft bei nicht parsebarer Eingabe', () => {
expect(() => compareReleaseVersions('dev', '1.0.0')).toThrow();
expect(() => compareReleaseVersions('1.0.0', 'abc1234')).toThrow();
});
});
describe('getRunningRelease (quick-260925-bow)', () => {
it('liest APP_VERSION und kuerzt den Describe-Stand auf X.Y.Z', () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
expect(getRunningRelease()).toBe('10.2.3');
});
it('ein Tag-Stand ergibt die Version selbst', () => {
vi.stubEnv('APP_VERSION', 'v1.4.0');
expect(getRunningRelease()).toBe('1.4.0');
});
it('dev ergibt null', () => {
vi.stubEnv('APP_VERSION', 'dev');
expect(getRunningRelease()).toBeNull();
});
it('ungesetzt oder leer ergibt null', () => {
vi.stubEnv('APP_VERSION', undefined);
expect(getRunningRelease()).toBeNull();
vi.stubEnv('APP_VERSION', '');
expect(getRunningRelease()).toBeNull();
});
it('ein blosser Commit-Stempel ergibt null', () => {
vi.stubEnv('APP_VERSION', 'abc1234');
expect(getRunningRelease()).toBeNull();
});
});
+35 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminSeedService } from './admin-seed.service'; import { AdminSeedService } from './admin-seed.service';
/** /**
@@ -95,6 +95,40 @@ describe('AdminSeedService', () => {
service = new AdminSeedService(prisma, configService, groupsService); service = new AdminSeedService(prisma, configService, groupsService);
}); });
afterEach(() => {
vi.unstubAllEnvs();
});
// quick-260925-bow (D-04): der Erst-Administrator bekommt die laufende
// freigegebene Version eingetragen und sieht kein "Was ist neu"-Fenster
// mit Altlasten; auf dev-Staenden null.
it('legt den Erst-Administrator mit lastSeenReleaseVersion der laufenden Version an', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234');
let createdData: any;
prisma.__setUserCreateImpl(async (args: any) => {
createdData = args.data;
return { id: 'u1' };
});
await service.onApplicationBootstrap();
expect(createdData.lastSeenReleaseVersion).toBe('10.2.3');
expect(createdData.role).toBe('SUPER_ADMIN');
});
it('legt den Erst-Administrator auf dev-Staenden mit lastSeenReleaseVersion null an', async () => {
vi.stubEnv('APP_VERSION', 'dev');
let createdData: any;
prisma.__setUserCreateImpl(async (args: any) => {
createdData = args.data;
return { id: 'u1' };
});
await service.onApplicationBootstrap();
expect(createdData).toHaveProperty('lastSeenReleaseVersion', null);
});
it('ruft auf einer frischen Installation in genau dieser Reihenfolge auf: tenant.upsert, ensureDefaultGroup, dann user.create', async () => { it('ruft auf einer frischen Installation in genau dieser Reihenfolge auf: tenant.upsert, ensureDefaultGroup, dann user.create', async () => {
await service.onApplicationBootstrap(); await service.onApplicationBootstrap();
+5
View File
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { GroupsService } from '../groups/groups.service'; import { GroupsService } from '../groups/groups.service';
import { getRunningRelease } from '../health/app-version';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error'; import { prismaErrorCode } from '../prisma/prisma-error';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -116,6 +117,10 @@ export class AdminSeedService implements OnApplicationBootstrap {
tenantId: tenant.id, tenantId: tenant.id,
mustChangePassword: forceChange, mustChangePassword: forceChange,
isActive: true, isActive: true,
// quick-260925-bow (D-04): laufende freigegebene Version, damit der
// Erst-Administrator kein "Was ist neu"-Fenster mit Altlasten sieht;
// null auf dev-Staenden.
lastSeenReleaseVersion: getRunningRelease(),
}, },
}); });
} catch (err: unknown) { } catch (err: unknown) {
+17
View File
@@ -0,0 +1,17 @@
import { IsString, Matches, MaxLength } from 'class-validator';
/**
* Body von `POST /users/me/release-seen` (quick-260925-bow, D-05).
*
* Nur die kanonische Form `X.Y.Z` (ohne `v`, ohne fuehrende Nullen, ohne
* Describe-Anhang) — so, wie `GET /users/me/release-notice` sie als
* `currentRelease` liefert. Die Methode im Controller prueft zusaetzlich
* `parseReleaseVersion(version) === version` und "nicht ueber der laufenden
* Version", weil Unit-Tests sie ohne ValidationPipe aufrufen.
*/
export class ReleaseSeenDto {
@IsString()
@MaxLength(32)
@Matches(/^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/)
version!: string;
}
+268 -2
View File
@@ -1,6 +1,9 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReleaseSeenDto } from './dto/release-seen.dto';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
/** /**
@@ -397,4 +400,267 @@ describe('UserController', () => {
expect(userService.delete).not.toHaveBeenCalled(); expect(userService.delete).not.toHaveBeenCalled();
}); });
}); });
/**
* quick-260925-bow: "Was ist neu"-Fenster. Beide Wege lesen und schreiben
* ausschliesslich die Zeile des angemeldeten Benutzers ueber den an dessen
* Mandanten gebundenen Klienten; es gibt keinen Kennungsparameter.
* Die laufende Version kommt aus APP_VERSION (getRunningRelease()).
*/
describe('Was-ist-neu-Fenster (quick-260925-bow)', () => {
const me = { role: Role.USER, tenantId: 't1', id: 'u-me', username: 'me', mustChangePassword: false };
afterEach(() => {
vi.unstubAllEnvs();
});
function seedMe(lastSeenReleaseVersion: string | null, tenantId = 't1', id = 'u-me') {
prisma.__seedUser({ id, username: id, tenantId, lastSeenReleaseVersion });
}
describe('GET me/release-notice', () => {
it('liefert laufende Version und gemerkten Stand, gelesen ueber den gebundenen Klienten des eigenen Mandanten', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
seedMe('10.1.0');
const result = await controller.getReleaseNotice(me);
expect(result).toEqual({ currentRelease: '10.2.3', lastSeenReleaseVersion: '10.1.0' });
expectBoundCall(prisma, 't1', 'user', 'findUnique');
});
it('Bestandsbenutzer ohne gemerkten Stand: lastSeenReleaseVersion ist null; dev-Stand: currentRelease ist null', async () => {
vi.stubEnv('APP_VERSION', 'dev');
seedMe(null);
const result = await controller.getReleaseNotice(me);
expect(result).toEqual({ currentRelease: null, lastSeenReleaseVersion: null });
});
it('ein Benutzer derselben Kennung in einem fremden Mandanten ist unsichtbar: NotFoundException', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe('10.1.0', 't2');
await expect(controller.getReleaseNotice(me)).rejects.toBeInstanceOf(NotFoundException);
expectBoundCall(prisma, 't1', 'user', 'findUnique');
});
});
describe('POST me/release-seen', () => {
it('merkt die laufende Version und nennt den gespeicherten Stand', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null);
const result = await controller.markReleaseSeen({ version: '10.2.3' }, me);
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
const after = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } });
expect(after.lastSeenReleaseVersion).toBe('10.2.3');
expectBoundCall(prisma, 't1', 'user', 'findUnique');
expectBoundCall(prisma, 't1', 'user', 'update');
});
it('eine aeltere, aber nicht zu hohe Version wird angenommen, wenn sie ueber dem gemerkten Stand liegt', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
seedMe('10.0.0');
const result = await controller.markReleaseSeen({ version: '10.1.0' }, me);
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.1.0' });
});
it.each([
['v10.2.3'],
['10.2'],
['abc'],
['10.2.3-5-gabc1234'],
['010.2.3'],
[''],
[' 10.2.3'],
])('nicht kanonisches Format %j → BadRequestException, nichts gespeichert', async (version) => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null);
await expect(controller.markReleaseSeen({ version }, me)).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
});
it.each([[123], [null], [undefined], [{ version: '10.2.3' }], [['10.2.3']]])(
'Nicht-Zeichenkette %j → BadRequestException',
async (version) => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null);
await expect(
controller.markReleaseSeen({ version } as unknown as ReleaseSeenDto, me),
).rejects.toBeInstanceOf(BadRequestException);
},
);
it('eine Version ueber der laufenden → BadRequestException', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null);
await expect(controller.markReleaseSeen({ version: '10.2.4' }, me)).rejects.toBeInstanceOf(BadRequestException);
await expect(controller.markReleaseSeen({ version: '11.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
});
it.each([['dev'], ['abc1234'], [undefined]])(
'laufende Version nicht freigegeben (%j) → BadRequestException',
async (appVersion) => {
vi.stubEnv('APP_VERSION', appVersion);
seedMe(null);
await expect(controller.markReleaseSeen({ version: '1.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException);
},
);
it('ein gemerkter Stand wird nie abgesenkt', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe('10.2.3');
const result = await controller.markReleaseSeen({ version: '10.1.0' }, me);
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
});
it('ein unparsebarer gemerkter Wert wird ueberschrieben', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe('kaputt');
const result = await controller.markReleaseSeen({ version: '10.2.3' }, me);
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
});
it('zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden aendert sich', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null, 't1', 'u-me');
seedMe(null, 't2', 'u-other');
await controller.markReleaseSeen({ version: '10.2.3' }, me);
const mine = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } });
const other = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-other' } });
expect(mine.lastSeenReleaseVersion).toBe('10.2.3');
expect(other.lastSeenReleaseVersion).toBeNull();
expect(prisma.__boundCallLog.filter((c: any) => c.method === 'update').every((c: any) => c.tenantId === 't1')).toBe(true);
});
it('fehlt die eigene Zeile im eigenen Mandanten (Kennung nur in fremdem Mandanten) → NotFoundException, nichts geaendert', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null, 't2', 'u-me');
await expect(controller.markReleaseSeen({ version: '10.2.3' }, me)).rejects.toBeInstanceOf(NotFoundException);
const foreign = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-me' } });
expect(foreign.lastSeenReleaseVersion).toBeNull();
});
});
describe('ReleaseSeenDto (globale ValidationPipe)', () => {
async function errorsFor(body: unknown) {
return validate(plainToInstance(ReleaseSeenDto, body));
}
it('nimmt die kanonische Form X.Y.Z an', async () => {
expect(await errorsFor({ version: '1.4.0' })).toHaveLength(0);
});
it.each([
[{ version: 'v1.4.0' }],
[{ version: '1.4' }],
[{ version: '1.4.0-5-gabc1234' }],
[{ version: 123 }],
[{}],
[{ version: `1.4.0${'0'.repeat(40)}` }],
])('lehnt %j ab', async (body) => {
expect((await errorsFor(body)).length).toBeGreaterThan(0);
});
});
});
/**
* 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();
});
});
}); });
+144
View File
@@ -5,6 +5,8 @@ import {
Delete, Delete,
ForbiddenException, ForbiddenException,
Get, Get,
HttpCode,
HttpStatus,
NotFoundException, NotFoundException,
Param, Param,
Patch, Patch,
@@ -16,6 +18,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import {
compareReleaseVersions,
type DashboardBackground,
parseDashboardBackground,
parseReleaseVersion,
type ReleaseNoticeResponse,
} from '@tessera/shared';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { Response } from 'express'; import { Response } from 'express';
@@ -25,7 +34,9 @@ import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { getRunningRelease } from '../health/app-version';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { ReleaseSeenDto } from './dto/release-seen.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@@ -48,6 +59,15 @@ function resolveAvatarsDir(): string {
* gebundenen Klienten (`tenantPrisma`, Konvention aus `ldap`, `groups`, * gebundenen Klienten (`tenantPrisma`, Konvention aus `ldap`, `groups`,
* `dkv`, `auth`, `user.service.ts`) oder ueber die uebergreifenden Methoden * `dkv`, `auth`, `user.service.ts`) oder ueber die uebergreifenden Methoden
* von `UserService`, deren Rumpf je Mandant gebunden ist. * von `UserService`, deren Rumpf je Mandant gebunden ist.
*
* quick-260925-bow: dazu kommen drei gebundene Zugriffe der beiden
* Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen`
* ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit
* `where: { id: currentUser.id }`.
*
* quick-260928-ujj: dazu kommt ein gebundener Zugriff des
* Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls
* `forTenant()` mit `where: { id: currentUser.id }`.
*/ */
@Controller('users') @Controller('users')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@@ -111,6 +131,93 @@ export class UserController {
}); });
} }
/**
* GET /users/me/release-notice (quick-260925-bow, D-03/D-04)
*
* Grundlage des "Was ist neu"-Fensters: die laufende freigegebene Version
* der API (`getRunningRelease()`, `null` auf `dev`-Staenden) und der
* gemerkte Stand des angemeldeten Benutzers (`null` = Bestandsbenutzer).
* Welche Abschnitte der Aenderungsliste gezeigt werden, entscheidet das Web.
*
* Jeder angemeldete Benutzer, kein `@Roles`. Mandantenbindung: gelesen wird
* ausschliesslich die eigene Zeile (`where: { id: currentUser.id }`) ueber
* `forTenant(this.prisma, currentUser.tenantId)`; es gibt keinen
* Kennungsparameter. Statische `me/...`-Route steht VOR der Kennungs-Route (GET :id).
*/
@Get('me/release-notice')
async getReleaseNotice(@CurrentUser() currentUser: AuthUser): Promise<ReleaseNoticeResponse> {
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id },
select: { lastSeenReleaseVersion: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
return {
currentRelease: getRunningRelease(),
lastSeenReleaseVersion: user.lastSeenReleaseVersion,
};
}
/**
* POST /users/me/release-seen (quick-260925-bow, D-05)
*
* Merkt beim Schliessen des Fensters die gesehene Version. Angenommen wird
* nur die kanonische Form `X.Y.Z`, die nicht ueber der laufenden Version
* liegt; ohne freigegebene laufende Version (`dev`) wird nichts gemerkt.
* Ein gemerkter Stand wird nie abgesenkt; ein unparsebarer gemerkter Wert
* wird ueberschrieben. Die DTO prueft das Format schon in der
* ValidationPipe; die Pruefungen im Rumpf gelten zusaetzlich (Unit-Tests
* rufen die Methode ohne Pipe auf).
*
* Jeder angemeldete Benutzer, kein `@Roles`. Mandantenbindung wie oben:
* Lesen und Schreiben nur der eigenen Zeile ueber den an
* `currentUser.tenantId` gebundenen Klienten.
*/
@Post('me/release-seen')
@HttpCode(HttpStatus.OK)
async markReleaseSeen(
@Body() body: ReleaseSeenDto,
@CurrentUser() currentUser: AuthUser,
): Promise<{ success: true; lastSeenReleaseVersion: string }> {
const version: unknown = body?.version;
if (typeof version !== 'string' || parseReleaseVersion(version) !== version) {
throw new BadRequestException('Invalid version. Use X.Y.Z.');
}
const running = getRunningRelease();
if (running === null) {
throw new BadRequestException('No released version is running.');
}
if (compareReleaseVersions(version, running) > 0) {
throw new BadRequestException('Version is newer than the running version.');
}
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id },
select: { lastSeenReleaseVersion: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
const stored = user.lastSeenReleaseVersion;
if (
stored !== null &&
parseReleaseVersion(stored) !== null &&
compareReleaseVersions(version, stored) <= 0
) {
return { success: true, lastSeenReleaseVersion: stored };
}
await tenantPrisma.user.update({
where: { id: currentUser.id },
data: { lastSeenReleaseVersion: version },
});
return { success: true, lastSeenReleaseVersion: version };
}
/** /**
* GET /users/:id * GET /users/:id
*/ */
@@ -382,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.
+51 -1
View File
@@ -1,5 +1,5 @@
import { ConflictException } from '@nestjs/common'; import { ConflictException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { UserService } from './user.service'; import { UserService } from './user.service';
/** /**
@@ -251,6 +251,56 @@ describe('UserService', () => {
}); });
}); });
/**
* quick-260925-bow (D-04): neue Benutzer bekommen die laufende freigegebene
* Version eingetragen, damit sie kein "Was ist neu"-Fenster mit Altlasten
* sehen. Der Wert ist eine Eigenschaft des Servers (APP_VERSION), nicht des
* Aufrufers — die Signatur von create() bekommt kein neues Feld.
*/
describe('create — lastSeenReleaseVersion (quick-260925-bow)', () => {
let prisma: any;
let service: UserService;
beforeEach(() => {
prisma = makeFakePrisma();
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined) } as any);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('traegt bei freigegebener laufender Version X.Y.Z ein (Describe-Stand gekuerzt)', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234');
const result = await service.create({ username: 'neu', email: 'neu@example.com', tenantId: 't1' });
expect(result.lastSeenReleaseVersion).toBe('10.2.3');
expectBoundCall(prisma, 't1', 'user', 'create');
});
it('traegt auf dev-Staenden null ein', async () => {
vi.stubEnv('APP_VERSION', 'dev');
const result = await service.create({ username: 'neu2', email: 'neu2@example.com', tenantId: 't1' });
expect(result.lastSeenReleaseVersion).toBeNull();
});
it('traegt ohne APP_VERSION null ein', async () => {
vi.stubEnv('APP_VERSION', undefined);
const result = await service.create({ username: 'neu3', email: 'neu3@example.com', tenantId: 't1' });
expect(result.lastSeenReleaseVersion).toBeNull();
});
it('ein von aussen untergeschobenes Feld setzt den Wert nicht (kein Parameter der Signatur)', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
const result = await service.create({
username: 'neu4',
email: 'neu4@example.com',
tenantId: 't1',
lastSeenReleaseVersion: '99.0.0',
} as unknown as Parameters<UserService['create']>[0]);
expect(result.lastSeenReleaseVersion).toBe('10.2.3');
});
});
describe('findById', () => { describe('findById', () => {
let prisma: any; let prisma: any;
let service: UserService; let service: UserService;
+10
View File
@@ -2,6 +2,7 @@ import { ConflictException, Injectable, Logger } from '@nestjs/common';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import type { User } from '@prisma/client'; import type { User } from '@prisma/client';
import { GroupsService } from '../groups/groups.service'; import { GroupsService } from '../groups/groups.service';
import { getRunningRelease } from '../health/app-version';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error'; import { prismaErrorCode } from '../prisma/prisma-error';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -140,6 +141,14 @@ export class UserService {
* endet damit bei JEDEM Aufrufer an dieser einen Stelle. Die Meldung * endet damit bei JEDEM Aufrufer an dieser einen Stelle. Die Meldung
* nennt WEDER den Halter NOCH dessen Mandanten, weil das sonst eine * nennt WEDER den Halter NOCH dessen Mandanten, weil das sonst eine
* Aussage ueber einen fremden Mandanten waere (T-DAS-08). * Aussage ueber einen fremden Mandanten waere (T-DAS-08).
*
* "Was ist neu"-Fenster (quick-260925-bow, D-04): neue Benutzer (Admin-
* Anlage, AD-Abgleich und AD-Import) bekommen hier die laufende
* freigegebene Version (`getRunningRelease()`) als
* `lastSeenReleaseVersion` eingetragen, damit sie kein Fenster mit
* Aenderungen aus der Zeit vor ihrem Konto sehen; auf Staenden ohne
* freigegebene Version (`dev`) `null`. Der Wert ist eine Eigenschaft des
* Servers, kein Parameter dieser Methode.
*/ */
async create(data: { async create(data: {
username: string; username: string;
@@ -164,6 +173,7 @@ export class UserService {
...rest, ...rest,
username: rest.username.toLowerCase(), username: rest.username.toLowerCase(),
passwordHash: password ? await argon2.hash(password) : null, passwordHash: password ? await argon2.hash(password) : null,
lastSeenReleaseVersion: getRunningRelease(),
}, },
}); });
} catch (err: unknown) { } catch (err: unknown) {
+4 -2
View File
@@ -7,8 +7,10 @@ import createNextIntlPlugin from 'next-intl/plugin';
// dem Wurzelverzeichnis wird hier gelesen und als `env.TESSERA_CHANGELOG_MD` // dem Wurzelverzeichnis wird hier gelesen und als `env.TESSERA_CHANGELOG_MD`
// abgelegt. Next.js ersetzt `process.env.TESSERA_CHANGELOG_MD` fuer webpack UND // abgelegt. Next.js ersetzt `process.env.TESSERA_CHANGELOG_MD` fuer webpack UND
// Turbopack ueber denselben Define-Mechanismus. Der Text bleibt nur im // Turbopack ueber denselben Define-Mechanismus. Der Text bleibt nur im
// Server-Bundle, weil ausschliesslich die Server-Seite (changelog/page.tsx) // Server-Bundle, weil ausschliesslich Server-Code `@/lib/changelog` importiert:
// `@/lib/changelog` importiert — Importdisziplin, kein Client-Chunk. // die Server-Seite (changelog/page.tsx) und die 'use server'-Datei
// lib/release-notice-actions.ts (quick-260925-bow) — Importdisziplin, kein
// Client-Chunk.
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
+90 -46
View File
@@ -5,14 +5,13 @@ import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { login } from '@/lib/auth-actions'; import { login } from '@/lib/auth-actions';
import { sanitizeNextPath } from '@/lib/safe-next'; import { sanitizeNextPath } from '@/lib/safe-next';
import { BRAND_YELLOW } from '@/components/brand/brand';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links'; import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
/** /**
* Split-screen login page (D-01). * Split-screen login page (D-01), Design „Mosaik“.
* Left: Tessera branding on the brand yellow background (BRAND_YELLOW). * Left (ab lg): dunkles Markenpanel mit Bildmarke, Claim und Farbmosaik.
* Right: Login form on neutral background. * Right: Anmeldekarte auf der Arbeitsflaeche; unter lg nur die Karte.
* No sidebar or header (D-04, handled by (auth) layout). * No sidebar or header (D-04, handled by (auth) layout).
*/ */
export default function LoginPage() { export default function LoginPage() {
@@ -42,41 +41,52 @@ export default function LoginPage() {
} }
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen bg-background">
{/* Left: Branding panel (hidden on mobile, visible on md+) */} {/* Links (ab lg): dunkles Markenpanel im Ton der App-Leiste mit
<div grosser Bildmarke, Produktname, einem ruhigen Satz und einem
className="hidden md:flex md:flex-1 items-center justify-center" stillen Mosaik in Grau und Gelb — kein Verlauf, keine Animation. */}
style={{ backgroundColor: BRAND_YELLOW }} <div className="relative hidden w-[45%] shrink-0 flex-col overflow-hidden bg-appbar px-14 py-12 text-appbar-foreground lg:flex">
>
<div className="px-8 text-gray-900">
<TesseraLogo <TesseraLogo
variant="horizontal" variant="horizontal"
size={104} size={40}
plateOutline={false} plateOutline="always"
tagline={t('branding.tagline')} className="gap-3"
className="gap-6" wordmarkClassName="text-xl font-semibold tracking-normal text-appbar-foreground"
wordmarkClassName="text-5xl"
taglineClassName="text-lg text-gray-800 max-w-xs"
/> />
</div>
</div>
{/* Right: Login form */} <div className="my-auto max-w-[30rem]">
<div className="flex flex-1 items-center justify-center bg-background px-4 py-8"> <LoginMosaic />
<div className="w-full max-w-sm space-y-8"> <p className="mt-12 text-[32px] font-semibold leading-10 tracking-tight">
{/* Mobile-only branding */} {t('branding.claim')}
<div className="text-center md:hidden"> </p>
<TesseraLogo variant="horizontal" size={32} /> <p className="mt-3 text-base leading-6 text-appbar-muted">
<p className="mt-2 text-sm text-muted-foreground">
{t('branding.tagline')} {t('branding.tagline')}
</p> </p>
</div> </div>
</div>
{/* Rechts: Anmeldekarte zentriert auf der Arbeitsflaeche */}
<div className="flex flex-1 items-center justify-center px-4 py-10">
<div className="w-full max-w-[400px]">
{/* Unter lg: Bildmarke ueber der Karte */}
<div className="mb-6 flex justify-center lg:hidden">
<TesseraLogo
variant="horizontal"
size={32}
className="gap-2.5"
wordmarkClassName="text-lg font-semibold tracking-normal"
/>
</div>
<div className="surface space-y-6 p-6 sm:p-8">
{/* Login heading */} {/* Login heading */}
<div className="text-center"> <div>
<h2 className="text-2xl font-bold text-foreground"> <h1 className="text-2xl font-semibold text-foreground">
{t('login')} {t('login')}
</h2> </h1>
<p className="mt-1 text-sm text-muted-foreground">
{t('loginSubtitle')}
</p>
</div> </div>
{/* Error message */} {/* Error message */}
@@ -87,12 +97,12 @@ export default function LoginPage() {
)} )}
{/* Login form */} {/* Login form */}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Username */} {/* Username */}
<div className="space-y-2"> <div className="space-y-1.5">
<label <label
htmlFor="username" htmlFor="username"
className="text-sm font-medium text-foreground" className="block text-sm font-semibold text-foreground"
> >
{t('username')} {t('username')}
</label> </label>
@@ -102,16 +112,15 @@ export default function LoginPage() {
type="text" type="text"
required required
autoComplete="username" autoComplete="username"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
placeholder={t('username')}
/> />
</div> </div>
{/* Password */} {/* Password */}
<div className="space-y-2"> <div className="space-y-1.5">
<label <label
htmlFor="password" htmlFor="password"
className="text-sm font-medium text-foreground" className="block text-sm font-semibold text-foreground"
> >
{t('password')} {t('password')}
</label> </label>
@@ -121,33 +130,30 @@ export default function LoginPage() {
type="password" type="password"
required required
autoComplete="current-password" autoComplete="current-password"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
placeholder={t('password')}
/> />
</div> </div>
{/* Remember me (D-02) */} {/* Angemeldet bleiben (D-02) und Passwort vergessen (D-03) in einer Zeile */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
id="rememberMe" id="rememberMe"
name="rememberMe" name="rememberMe"
type="checkbox" type="checkbox"
defaultChecked defaultChecked
className="h-4 w-4 rounded border-input text-primary focus:ring-ring" className="h-4 w-4 rounded border-input"
/> />
<label <label
htmlFor="rememberMe" htmlFor="rememberMe"
className="text-sm text-muted-foreground" className="whitespace-nowrap text-sm text-muted-foreground"
> >
{t('rememberMe')} {t('rememberMe')}
</label> </label>
</div> </div>
{/* Forgot password link (D-03) */}
<div className="flex justify-end">
<Link <Link
href="/reset-password" href="/reset-password"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="whitespace-nowrap text-sm link"
> >
{t('forgotPassword')} {t('forgotPassword')}
</Link> </Link>
@@ -157,7 +163,7 @@ export default function LoginPage() {
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary w-full"
> >
{isPending ? ( {isPending ? (
<svg <svg
@@ -186,11 +192,49 @@ export default function LoginPage() {
)} )}
</button> </button>
</form> </form>
</div>
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */} {/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */}
<div className="mt-6">
<DesktopDownloadLinks /> <DesktopDownloadLinks />
</div> </div>
</div> </div>
</div> </div>
</div>
);
}
/**
* Stilles Mosaik (Design „Mosaik“): acht Kacheln in einem lockeren
* 4 x 3-Raster in Grautoenen, eine davon in der Akzentfarbe und — wie die
* Signalkachel der Bildmarke — leicht gedreht. Rein dekorativ.
*/
function LoginMosaic() {
// Runde 2: nur Gelb und Graphit-/Grautoene des dunklen Panels.
const tiles: { col: number; row: number; color: string; rotate?: boolean }[] = [
{ col: 1, row: 1, color: 'oklch(1 0 0 / 0.14)' },
{ col: 2, row: 1, color: 'oklch(1 0 0 / 0.08)' },
{ col: 4, row: 1, color: 'oklch(1 0 0 / 0.1)' },
{ col: 2, row: 2, color: 'oklch(1 0 0 / 0.18)' },
{ col: 3, row: 2, color: 'var(--primary)', rotate: true },
{ col: 4, row: 2, color: 'oklch(1 0 0 / 0.06)' },
{ col: 1, row: 3, color: 'oklch(1 0 0 / 0.1)' },
{ col: 3, row: 3, color: 'oklch(1 0 0 / 0.05)' },
];
return (
<div aria-hidden="true" className="grid w-max grid-cols-4 grid-rows-3 gap-3">
{tiles.map((tile) => (
<span
key={`${tile.col}-${tile.row}`}
className="h-14 w-14 rounded-xl"
style={{
gridColumn: tile.col,
gridRow: tile.row,
backgroundColor: tile.color,
transform: tile.rotate ? 'rotate(12deg)' : undefined,
}}
/>
))}
</div>
); );
} }
@@ -83,7 +83,7 @@ export default function ResetPasswordTokenPage() {
{/* Heading */} {/* Heading */}
<div className="text-center"> <div className="text-center">
<TesseraLogo variant="horizontal" size={32} /> <TesseraLogo variant="horizontal" size={32} />
<h2 className="mt-4 text-2xl font-bold text-foreground"> <h2 className="mt-4 text-2xl font-semibold text-foreground">
{t('resetPassword.newPasswordTitle')} {t('resetPassword.newPasswordTitle')}
</h2> </h2>
</div> </div>
@@ -99,7 +99,7 @@ export default function ResetPasswordTokenPage() {
</p> </p>
<Link <Link
href="/login" href="/login"
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="btn btn-primary w-full"
> >
{t('resetPassword.backToLogin')} {t('resetPassword.backToLogin')}
</Link> </Link>
@@ -164,7 +164,7 @@ export default function ResetPasswordTokenPage() {
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary w-full"
> >
{isPending ? ( {isPending ? (
<svg <svg
@@ -54,7 +54,7 @@ export default function ResetPasswordPage() {
{/* Heading */} {/* Heading */}
<div className="text-center"> <div className="text-center">
<TesseraLogo variant="horizontal" size={32} /> <TesseraLogo variant="horizontal" size={32} />
<h2 className="mt-4 text-2xl font-bold text-foreground"> <h2 className="mt-4 text-2xl font-semibold text-foreground">
{t('resetPassword.title')} {t('resetPassword.title')}
</h2> </h2>
</div> </div>
@@ -67,7 +67,7 @@ export default function ResetPasswordPage() {
</div> </div>
<Link <Link
href="/login" href="/login"
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="btn btn-primary w-full"
> >
{t('resetPassword.backToLogin')} {t('resetPassword.backToLogin')}
</Link> </Link>
@@ -104,7 +104,7 @@ export default function ResetPasswordPage() {
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary w-full"
> >
{isPending ? ( {isPending ? (
<svg <svg
@@ -88,7 +88,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-4">{t('deleteConfirm.title')}</h2> <h2 className="text-lg font-semibold text-foreground mb-4">{t('deleteConfirm.title')}</h2>
{loadingImpact ? ( {loadingImpact ? (
@@ -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="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('cancel')} {tCommon('cancel')}
</button> </button>
<button <button
@@ -77,7 +77,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-4"> <h2 className="text-lg font-semibold text-foreground mb-4">
{group ? t('edit') : t('create')} {group ? t('edit') : t('create')}
</h2> </h2>
@@ -101,7 +101,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
{!group && ( {!group && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('createLdapHint')}{' '} {t('createLdapHint')}{' '}
<Link href="/admin/ldap" className="text-primary hover:underline"> <Link href="/admin/ldap" className="link">
{t('goToLdap')} {t('goToLdap')}
</Link> </Link>
</p> </p>
@@ -139,14 +139,14 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors" className="btn btn-secondary"
> >
{tCommon('cancel')} {tCommon('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary"
> >
{saving ? tCommon('loading') : tCommon('save')} {saving ? tCommon('loading') : tCommon('save')}
</button> </button>
@@ -158,7 +158,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-4"> <h2 className="text-lg font-semibold text-foreground mb-4">
{t('members.title')} — {group.name} {t('members.title')} — {group.name}
</h2> </h2>
@@ -249,18 +249,14 @@ 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="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('close')} {tCommon('close')}
</button> </button>
<button <button
type="button" type="button"
onClick={handleAdd} onClick={handleAdd}
disabled={adding || selectedUserIds.length === 0} disabled={adding || selectedUserIds.length === 0}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary"
> >
{adding ? tCommon('loading') : `${t('members.addTitle')} (${selectedUserIds.length})`} {adding ? tCommon('loading') : `${t('members.addTitle')} (${selectedUserIds.length})`}
</button> </button>
@@ -124,11 +124,11 @@ export default function AdminGroupsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button <button
type="button" type="button"
onClick={openCreate} onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{t('create')} {t('create')}
</button> </button>
@@ -149,7 +149,7 @@ export default function AdminGroupsPage() {
<button <button
type="button" type="button"
onClick={openCreate} onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{t('create')} {t('create')}
</button> </button>
+2 -2
View File
@@ -12,9 +12,9 @@ export default function AdminLayout({
const t = useTranslations('settings'); const t = useTranslations('settings');
return ( return (
<div className="flex h-full"> <div className="flex h-full flex-col md:flex-row">
<AdminSidebar /> <AdminSidebar />
<div className="flex-1 overflow-y-auto p-6"> <div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
<Link <Link
href="/" href="/"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground" className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
+11 -11
View File
@@ -547,7 +547,7 @@ export default function AdminLdapPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
{/* Section 1: Connection Settings */} {/* Section 1: Connection Settings */}
<section className="rounded-lg border border-border p-6"> <section className="rounded-lg border border-border p-6">
@@ -646,7 +646,7 @@ export default function AdminLdapPage() {
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary"
> >
{saving ? tCommon('loading') : t('save')} {saving ? tCommon('loading') : t('save')}
</button> </button>
@@ -654,7 +654,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={!config && !formData.serverUrl} disabled={!config && !formData.serverUrl}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50" className="btn btn-secondary"
> >
{t('testConnection')} {t('testConnection')}
</button> </button>
@@ -816,7 +816,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleDiscoverGroups} onClick={handleDiscoverGroups}
disabled={discovering} disabled={discovering}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50" className="btn btn-secondary"
> >
{discovering ? tCommon('loading') : t('groupFilter.discover')} {discovering ? tCommon('loading') : t('groupFilter.discover')}
</button> </button>
@@ -919,7 +919,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSaveGroupFilter} onClick={handleSaveGroupFilter}
disabled={savingFilter} disabled={savingFilter}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary"
> >
{savingFilter ? tCommon('loading') : t('groupFilter.save')} {savingFilter ? tCommon('loading') : t('groupFilter.save')}
</button> </button>
@@ -941,7 +941,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleDiscoverGroupsToImport} onClick={handleDiscoverGroupsToImport}
disabled={discoveringGroupImport} disabled={discoveringGroupImport}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50" className="btn btn-secondary"
> >
{discoveringGroupImport {discoveringGroupImport
? tCommon('loading') ? tCommon('loading')
@@ -1015,7 +1015,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleImportGroups} onClick={handleImportGroups}
disabled={importingGroups || selectedImportDns.length === 0} disabled={importingGroups || selectedImportDns.length === 0}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary"
> >
{importingGroups {importingGroups
? tCommon('loading') ? tCommon('loading')
@@ -1142,7 +1142,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={() => handleImportUsers(selectedUserDns)} onClick={() => handleImportUsers(selectedUserDns)}
disabled={importingUsers || selectedUserDns.length === 0} disabled={importingUsers || selectedUserDns.length === 0}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary"
> >
{importingUsers {importingUsers
? tCommon('loading') ? tCommon('loading')
@@ -1234,7 +1234,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSaveExcludeList} onClick={handleSaveExcludeList}
disabled={savingExclude} disabled={savingExclude}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary"
> >
{savingExclude ? tCommon('loading') : t('userExclude.save')} {savingExclude ? tCommon('loading') : t('userExclude.save')}
</button> </button>
@@ -1297,7 +1297,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary"
> >
{t('save')} {t('save')}
</button> </button>
@@ -1319,7 +1319,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSync} onClick={handleSync}
disabled={syncing} disabled={syncing}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary"
> >
{syncing ? t('sync.syncing') : t('sync.trigger')} {syncing ? t('sync.syncing') : t('sync.trigger')}
</button> </button>
@@ -142,7 +142,7 @@ export function ActivateModuleDialog({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
> >
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="mb-2 break-words text-lg font-semibold text-foreground"> <h2 className="mb-2 break-words text-lg font-semibold text-foreground">
{t('title', { module: moduleName })} {t('title', { module: moduleName })}
</h2> </h2>
@@ -157,7 +157,7 @@ export function ActivateModuleDialog({
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} disabled={isSubmitting}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50" className="btn btn-secondary"
> >
{t('cancel')} {t('cancel')}
</button> </button>
@@ -165,7 +165,7 @@ export function ActivateModuleDialog({
type="button" type="button"
onClick={handleConfigureLater} onClick={handleConfigureLater}
disabled={isSubmitting} disabled={isSubmitting}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50" className="btn btn-secondary"
> >
{t('configureLater')} {t('configureLater')}
</button> </button>
@@ -173,7 +173,7 @@ export function ActivateModuleDialog({
type="button" type="button"
onClick={handleGrantNow} onClick={handleGrantNow}
disabled={grantNowDisabled} disabled={grantNowDisabled}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50" className="btn btn-primary"
> >
{t('grantNow')} {t('grantNow')}
</button> </button>
@@ -189,7 +189,7 @@ export default function AdminModuleGrantsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div> </div>
{error && ( {error && (
@@ -205,7 +205,7 @@ export default function AdminModuleGrantsPage() {
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p> <p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link <Link
href="/admin/modules" href="/admin/modules"
className="mt-2 inline-block text-sm font-medium text-primary hover:underline" className="mt-2 inline-block text-sm font-medium link"
> >
{t('emptyModulesLink')} {t('emptyModulesLink')}
</Link> </Link>
@@ -134,12 +134,12 @@ export default function AdminModulesPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p> <p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div> </div>
<Link <Link
href="/admin/modules/grants" href="/admin/modules/grants"
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors" className="btn btn-secondary"
> >
{t('grantsLink')} {t('grantsLink')}
</Link> </Link>
@@ -146,11 +146,11 @@ export default function AdminTenantsPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button <button
type="button" type="button"
onClick={openCreate} onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{t('create')} {t('create')}
</button> </button>
@@ -244,7 +244,7 @@ export default function AdminTenantsPage() {
{/* Create/Edit modal */} {/* Create/Edit modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4"> <h2 className="text-lg font-bold text-foreground mb-4">
{editingTenant ? t('edit') : t('create')} {editingTenant ? t('edit') : t('create')}
</h2> </h2>
@@ -287,13 +287,13 @@ export default function AdminTenantsPage() {
<button <button
type="button" type="button"
onClick={() => setShowForm(false)} onClick={() => setShowForm(false)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors" className="btn btn-secondary"
> >
{tCommon('cancel')} {tCommon('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{tCommon('save')} {tCommon('save')}
</button> </button>
@@ -306,7 +306,7 @@ export default function AdminTenantsPage() {
{/* Delete confirmation modal */} {/* Delete confirmation modal */}
{deleteConfirm && ( {deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<p className="text-sm text-foreground mb-4"> <p className="text-sm text-foreground mb-4">
{t('deleteConfirm')} {t('deleteConfirm')}
</p> </p>
@@ -314,7 +314,7 @@ export default function AdminTenantsPage() {
<button <button
type="button" type="button"
onClick={() => setDeleteConfirm(null)} onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors" className="btn btn-secondary"
> >
{tCommon('cancel')} {tCommon('cancel')}
</button> </button>
@@ -128,7 +128,7 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
> >
<div className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2> <h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2>
<button <button
@@ -227,11 +227,11 @@ export default function AdminUsersPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button <button
type="button" type="button"
onClick={openCreate} onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{t('create')} {t('create')}
</button> </button>
@@ -349,7 +349,7 @@ export default function AdminUsersPage() {
{/* Create/Edit modal */} {/* Create/Edit modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4"> <h2 className="text-lg font-bold text-foreground mb-4">
{editingUser ? t('edit') : t('create')} {editingUser ? t('edit') : t('create')}
</h2> </h2>
@@ -455,13 +455,13 @@ export default function AdminUsersPage() {
<button <button
type="button" type="button"
onClick={() => setShowForm(false)} onClick={() => setShowForm(false)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors" className="btn btn-secondary"
> >
{tCommon('cancel')} {tCommon('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{tCommon('save')} {tCommon('save')}
</button> </button>
@@ -474,7 +474,7 @@ export default function AdminUsersPage() {
{/* Delete confirmation modal */} {/* Delete confirmation modal */}
{deleteConfirm && ( {deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<p className="text-sm text-foreground mb-4"> <p className="text-sm text-foreground mb-4">
{t('deleteConfirm')} {t('deleteConfirm')}
</p> </p>
@@ -490,7 +490,7 @@ export default function AdminUsersPage() {
<button <button
type="button" type="button"
onClick={() => setDeleteConfirm(null)} onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors" className="btn btn-secondary"
> >
{tCommon('cancel')} {tCommon('cancel')}
</button> </button>
@@ -47,7 +47,7 @@ export default function ChangePasswordPage() {
return ( return (
<div className="mx-auto max-w-md py-8 px-4"> <div className="mx-auto max-w-md py-8 px-4">
<h1 className="text-2xl font-bold text-foreground mb-6"> <h1 className="text-2xl font-semibold text-foreground mb-6">
{t('changePassword.title')} {t('changePassword.title')}
</h1> </h1>
@@ -129,7 +129,7 @@ export default function ChangePasswordPage() {
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50" className="btn btn-primary w-full"
> >
{isPending ? ( {isPending ? (
<svg <svg
+4 -4
View File
@@ -1,5 +1,6 @@
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
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';
@@ -22,14 +23,13 @@ export default async function ChangelogPage() {
}); });
return ( return (
<div className="mx-auto max-w-3xl py-8 px-4"> <div className="mx-auto max-w-3xl p-3 sm:p-6">
<h1 className="text-2xl font-bold text-foreground mb-2">{t('title')}</h1> <PageHeader title={t('title')} description={t('intro')} className="mb-6" />
<p className="text-sm text-muted-foreground mb-6">{t('intro')}</p>
{hasUnreleased && ( {hasUnreleased && (
<div <div
data-testid="changelog-unreleased-hint" data-testid="changelog-unreleased-hint"
className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 px-4 py-3 text-sm text-yellow-800 dark:text-yellow-200 mb-6" className="mb-6 rounded-md border-l-4 border-status-warn bg-status-warn/10 px-4 py-3 text-sm text-foreground"
> >
{t('unreleasedHint')} {t('unreleasedHint')}
</div> </div>
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useToastStore } from '../components/Toast'; import { useToastStore } from '../components/Toast';
@@ -32,6 +34,7 @@ interface CatalogModule {
export default function ModuleDetailPage() { export default function ModuleDetailPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const t = useTranslations('marketplace'); const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore(); const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
const addToast = useToastStore((s) => s.addToast); const addToast = useToastStore((s) => s.addToast);
@@ -148,7 +151,7 @@ export default function ModuleDetailPage() {
const isActive = module.isActiveForTenant; const isActive = module.isActiveForTenant;
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"> <Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" /> <path d="m15 18-6-6 6-6" />
@@ -157,23 +160,11 @@ export default function ModuleDetailPage() {
</Link> </Link>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="rounded-lg bg-muted p-4 shrink-0"> <ModuleTile slug={module.slug} size={56} />
{module.icon === 'Globe' ? (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
) : (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
)}
</div>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">{module.name}</h1> <h1 className="text-2xl font-semibold text-foreground">{module.name}</h1>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{module.category}</span> <span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{categoryLabel(module.category)}</span>
<span className="text-xs text-muted-foreground">{t('detailVersion', { version: module.version })}</span> <span className="text-xs text-muted-foreground">{t('detailVersion', { version: module.version })}</span>
</div> </div>
</div> </div>
@@ -190,11 +181,7 @@ export default function ModuleDetailPage() {
type="button" type="button"
onClick={handleToggle} onClick={handleToggle}
disabled={toggling} disabled={toggling}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${ className={`btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
> >
{toggling ? ( {toggling ? (
<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" />
@@ -45,7 +45,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true">
<div className="max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-2">{t('deactivateDialogTitle')}</h2> <h2 className="text-lg font-semibold text-foreground mb-2">{t('deactivateDialogTitle')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
{t('deactivateDialogBody', { moduleName })} {t('deactivateDialogBody', { moduleName })}
@@ -55,7 +55,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
type="button" type="button"
ref={cancelRef} ref={cancelRef}
onClick={onCancel} onClick={onCancel}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted" className="btn btn-secondary"
> >
{t('deactivateDialogCancel')} {t('deactivateDialogCancel')}
</button> </button>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCategoryLabel } from '@/lib/use-category-label';
interface CategoryFilterProps { interface CategoryFilterProps {
categories: string[]; categories: string[];
@@ -10,17 +11,23 @@ interface CategoryFilterProps {
export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) { export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) {
const t = useTranslations('marketplace'); const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
// Dezente Pillen (Fluent „subtle“): gewaehlt = dunkle Fuellung wie bei
// GitHub-Filtern, nicht die gelbe Akzentflaeche.
const chip = (selected: boolean) =>
`inline-flex h-7 items-center rounded-full border px-3 text-xs font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
selected
? 'border-foreground bg-foreground text-background'
: 'border-border bg-card text-foreground hover:bg-muted'
}`;
return ( return (
<div className="flex gap-2 overflow-x-auto pb-1"> <div className="flex gap-2 overflow-x-auto p-0.5 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<button <button
type="button" type="button"
onClick={() => onChange('all')} onClick={() => onChange('all')}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${ aria-pressed={value === 'all'}
value === 'all' className={chip(value === 'all')}
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
> >
{t('categoryAll')} {t('categoryAll')}
</button> </button>
@@ -29,13 +36,10 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
key={cat} key={cat}
type="button" type="button"
onClick={() => onChange(cat)} onClick={() => onChange(cat)}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${ aria-pressed={value === cat}
value === cat className={chip(value === cat)}
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
> >
{cat} {categoryLabel(cat)}
</button> </button>
))} ))}
</div> </div>
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
/** /**
* Props for the MarketplaceCard component. * Props for the MarketplaceCard component.
@@ -25,53 +27,6 @@ export interface MarketplaceCardProps {
onLockedClick: () => void; onLockedClick: () => void;
} }
/**
* Default icon mapping for known module icons.
* Renders inline SVG to avoid external icon library dependency.
*/
function ModuleIcon({ icon }: { icon?: string }) {
if (icon === 'Globe') {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
}
// Default puzzle-piece icon for generic modules
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
);
}
/** /**
* Marketplace card showing a module with activation controls. * Marketplace card showing a module with activation controls.
* *
@@ -84,7 +39,6 @@ export function MarketplaceCard({
name, name,
slug, slug,
description, description,
icon,
category, category,
isActive, isActive,
hasAccess, hasAccess,
@@ -95,6 +49,7 @@ export function MarketplaceCard({
onLockedClick, onLockedClick,
}: MarketplaceCardProps) { }: MarketplaceCardProps) {
const t = useTranslations('marketplace'); const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'; const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.de || ''; const localizedDescription = description[locale] || description.en || description.de || '';
@@ -114,10 +69,9 @@ export function MarketplaceCard({
return ( return (
<div <div
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${ className={`group relative flex min-h-[176px] flex-col rounded-lg bg-card p-4 shadow-sm transition-shadow dark:border dark:border-border ${
locked ? 'opacity-60' : 'hover:shadow-md hover:border-primary/30' locked ? 'opacity-60' : isActive ? 'hover:shadow-md' : ''
}`} }`}
style={{ minHeight: '180px' }}
> >
{/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte {/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte
Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */} Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */}
@@ -126,54 +80,47 @@ export function MarketplaceCard({
type="button" type="button"
onClick={handleCardClick} onClick={handleCardClick}
aria-label={t('openDetail', { name })} aria-label={t('openDetail', { name })}
className={`absolute inset-0 rounded-lg ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`} className={`absolute inset-0 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
/> />
)} )}
{/* Header: icon + name + badges */} {/* Kopf: App-Kachel + Name + Kategorie */}
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3">
<div className="rounded-md bg-muted p-2.5 shrink-0"> <ModuleTile slug={slug} size={40} />
<ModuleIcon icon={icon} />
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-semibold text-base leading-tight truncate">{name}</h3> <h3 className="truncate text-base font-semibold leading-snug text-foreground">{name}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1"> <p className="truncate text-xs text-muted-foreground">{categoryLabel(category)}</p>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"> </div>
{category} </div>
</span>
{/* Beschreibung */}
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
{localizedDescription}
</p>
{/* Fuss: Status links, Aktion rechts */}
<div className="mt-auto flex items-center justify-between gap-2 pt-4">
<div className="flex flex-wrap items-center gap-1.5">
<span <span
role="status" role="status"
className={`rounded-full px-2 py-0.5 text-xs ${ className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
isActive isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' ? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground'
}`} }`}
> >
{isActive && <span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-status-ok" />}
{isActive ? t('statusActive') : t('statusAvailable')} {isActive ? t('statusActive') : t('statusAvailable')}
</span> </span>
{locked && ( {locked && (
<span <span className="rounded-full bg-status-warn/15 px-2 py-0.5 text-xs font-medium text-status-warn-fg">
className={[
'rounded-full px-2 py-0.5 text-xs',
'bg-amber-100 text-amber-700',
'dark:bg-amber-900/30 dark:text-amber-400',
].join(' ')}
>
{t('statusNoAccess')} {t('statusNoAccess')}
</span> </span>
)} )}
</div> </div>
</div>
</div>
{/* Description */} {/* Aktion — nur fuer Benutzer, die aktivieren/deaktivieren duerfen */}
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
{localizedDescription}
</p>
{/* Footer: action button — only rendered for users who may activate/deactivate */}
{canManage && ( {canManage && (
<div className="relative flex items-center justify-end">
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@@ -181,11 +128,7 @@ export function MarketplaceCard({
onToggle(id, isActive); onToggle(id, isActive);
}} }}
disabled={isToggling} disabled={isToggling}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${ className={`relative btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
> >
{isToggling ? ( {isToggling ? (
<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" />
@@ -195,8 +138,8 @@ export function MarketplaceCard({
t('activate') t('activate')
)} )}
</button> </button>
</div>
)} )}
</div> </div>
</div>
); );
} }
@@ -23,13 +23,30 @@ export function MarketplaceSearch({ onChange }: MarketplaceSearchProps) {
}, [value, onChange]); }, [value, onChange]);
return ( return (
<div className="relative"> <div className="relative max-w-md">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute left-2.5 top-2 text-muted-foreground"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input <input
type="text" type="text"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder={t('searchPlaceholder')} placeholder={t('searchPlaceholder')}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={t('searchPlaceholder')}
className="flex h-8 w-full rounded-md border border-input bg-card pl-8 pr-8 text-sm placeholder:text-muted-foreground"
/> />
{value && ( {value && (
<button <button
@@ -21,25 +21,37 @@ export function StatusFilter({ value, counts, onChange }: StatusFilterProps) {
return ( return (
<div className="flex gap-1 border-b border-border" role="tablist"> <div className="flex gap-1 border-b border-border" role="tablist">
{tabs.map((tab) => ( {tabs.map((tab) => {
const selected = value === tab.key;
return (
<button <button
key={tab.key} key={tab.key}
type="button" type="button"
role="tab" role="tab"
aria-selected={value === tab.key} aria-selected={selected}
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
className={`px-3 py-2 text-sm font-medium transition-colors ${ className={`group relative -mb-px inline-flex h-10 items-center px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
value === tab.key selected ? 'font-semibold text-foreground' : 'text-muted-foreground hover:text-foreground'
? 'text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
{t(tab.label)} {t(tab.label)}
<span className="ml-1.5 text-xs text-muted-foreground"> <span
className={`ml-2 rounded-full px-1.5 text-xs font-medium ${
selected ? 'bg-foreground/10 text-foreground' : 'bg-muted text-muted-foreground'
}`}
>
{counts[tab.key]} {counts[tab.key]}
</span> </span>
{/* Fluent-Reiter: 2 px Akzentlinie unten, beim Zeigen eine graue */}
<span
aria-hidden="true"
className={`absolute inset-x-3 bottom-0 h-[2px] rounded-full ${
selected ? 'bg-primary-strong' : 'bg-transparent group-hover:bg-border'
}`}
/>
</button> </button>
))} );
})}
</div> </div>
); );
} }
@@ -37,8 +37,8 @@ export function TenantContextSelector() {
if (!isSuperAdmin) return null; if (!isSuperAdmin) return null;
return ( return (
<div className="bg-muted/50 rounded-lg p-3 mb-4 border border-border"> <div className="flex flex-wrap items-center gap-3 rounded-lg bg-card px-4 py-3 shadow-sm dark:border dark:border-border">
<label htmlFor="tenant-context-select" className="block text-xs font-semibold text-muted-foreground mb-1.5"> <label htmlFor="tenant-context-select" className="text-sm font-semibold text-foreground">
{t('tenantContextLabel')} {t('tenantContextLabel')}
</label> </label>
{loading ? ( {loading ? (
@@ -48,7 +48,7 @@ export function TenantContextSelector() {
id="tenant-context-select" id="tenant-context-select"
onChange={(e) => setSelectedTenantId(e.target.value || null)} onChange={(e) => setSelectedTenantId(e.target.value || null)}
defaultValue="" defaultValue=""
className="bg-card border border-border rounded-md px-3 py-1.5 text-sm min-w-[200px]" className="h-8 min-w-[200px] rounded-md border border-input bg-card px-2.5 text-sm"
> >
<option value="">—</option> <option value="">—</option>
{tenants.map((tenant) => ( {tenants.map((tenant) => (
@@ -39,7 +39,7 @@ export function ToastContainer() {
{toasts.map((toast) => ( {toasts.map((toast) => (
<div <div
key={toast.id} key={toast.id}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]" className="flex items-center gap-3 rounded-lg bg-card dark:border dark:border-border p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]"
> >
{toast.type === 'success' ? ( {toast.type === 'success' ? (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400 shrink-0"> <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400 shrink-0">
+18 -13
View File
@@ -10,6 +10,7 @@ import { MarketplaceSearch } from './components/MarketplaceSearch';
import { StatusFilter, type StatusValue } from './components/StatusFilter'; import { StatusFilter, type StatusValue } from './components/StatusFilter';
import { CategoryFilter } from './components/CategoryFilter'; import { CategoryFilter } from './components/CategoryFilter';
import { ToastContainer, useToastStore } from './components/Toast'; import { ToastContainer, useToastStore } from './components/Toast';
import { PageHeader } from '@/components/layout/page-header';
import { TenantContextSelector } from './components/TenantContextSelector'; import { TenantContextSelector } from './components/TenantContextSelector';
import { ActivationDialog } from './components/ActivationDialog'; import { ActivationDialog } from './components/ActivationDialog';
@@ -179,13 +180,10 @@ export default function MarketplacePage() {
}; };
return ( return (
<div className="space-y-6"> <div className="mx-auto max-w-7xl space-y-6 p-3 sm:p-6">
<TenantContextSelector /> <PageHeader title={t('title')} description={t('subtitle')} />
<div> <TenantContextSelector />
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
</div>
{error && ( {error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
@@ -199,29 +197,30 @@ export default function MarketplacePage() {
</div> </div>
) : modules.length === 0 ? ( ) : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center"> <div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
<span className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="64" width="28"
height="64" height="28"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1" strokeWidth="1.75"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="text-muted-foreground/40 mb-4"
> >
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /> <path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" /> <path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" /> <path d="M12 22V12" />
</svg> </svg>
<h2 className="text-lg font-semibold text-foreground">{t('emptyTitle')}</h2> </span>
<h2 className="text-base font-semibold text-foreground">{t('emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p> <p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p>
</div> </div>
) : ( ) : (
<> <>
<div className="space-y-4"> <div className="space-y-3">
<MarketplaceSearch onChange={setSearchQuery} /> <MarketplaceSearch onChange={setSearchQuery} />
<StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} /> <StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} />
<CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} /> <CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} />
@@ -229,7 +228,13 @@ export default function MarketplacePage() {
{filteredModules.length === 0 && hasFiltersApplied ? ( {filteredModules.length === 0 && hasFiltersApplied ? (
<div className="flex flex-col items-center justify-center min-h-[30vh] text-center"> <div className="flex flex-col items-center justify-center min-h-[30vh] text-center">
<h2 className="text-lg font-semibold text-foreground">{t('filteredEmptyTitle')}</h2> <span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</span>
<h2 className="text-base font-semibold text-foreground">{t('filteredEmptyTitle')}</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('filteredEmptyBody')}</p> <p className="mt-1 text-sm text-muted-foreground max-w-md">{t('filteredEmptyBody')}</p>
</div> </div>
) : ( ) : (
@@ -38,16 +38,16 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
return ( return (
<div className="mx-auto max-w-2xl space-y-6 p-6"> <div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-lg bg-muted p-4 mb-4"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<svg <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="48" width="28"
height="48" height="28"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.75"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="text-muted-foreground" className="text-muted-foreground"
@@ -57,13 +57,13 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
<path d="m9 9 6 6" /> <path d="m9 9 6 6" />
</svg> </svg>
</div> </div>
<h2 className="text-xl font-semibold mb-2">{t('notFound')}</h2> <h2 className="mb-1 text-base font-semibold">{t('notFound')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
{t('notFoundDescription')} {t('notFoundDescription')}
</p> </p>
<Link <Link
href={`/modules/${category}`} href={`/modules/${category}`}
className="text-sm font-medium text-primary hover:underline" className="btn btn-secondary"
> >
{t('backToCategory')} {t('backToCategory')}
</Link> </Link>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import Link from 'next/link'; import Link from 'next/link';
/** /**
@@ -14,54 +15,6 @@ export interface ModuleCardProps {
category: string; category: string;
} }
/**
* Default icon mapping for known module icons.
* Renders inline SVG to avoid external icon library dependency.
*/
function ModuleIcon({ icon }: { icon?: string }) {
// Globe icon for domain-related modules
if (icon === 'Globe') {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
}
// Default puzzle-piece icon for generic modules
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
);
}
/** /**
* Module card for the category grid view. * Module card for the category grid view.
* *
@@ -69,7 +22,7 @@ function ModuleIcon({ icon }: { icon?: string }) {
* to the expanded module view. Per D-05a: uniform card appearance, * to the expanded module view. Per D-05a: uniform card appearance,
* compact, looks good even with a single module. * compact, looks good even with a single module.
*/ */
export function ModuleCard({ name, slug, description, icon, category }: ModuleCardProps) { export function ModuleCard({ name, slug, description, category }: ModuleCardProps) {
const t = useTranslations('modules'); const t = useTranslations('modules');
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'; const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.de || ''; const localizedDescription = description[locale] || description.en || description.de || '';
@@ -79,14 +32,12 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
href={`/modules/${category}/${slug}`} href={`/modules/${category}/${slug}`}
className="group block" className="group block"
> >
<div className="rounded-lg border border-border bg-card p-5 shadow-sm transition-all hover:shadow-md hover:border-primary/30 group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2"> <div className="rounded-lg bg-card p-4 shadow-sm transition-shadow hover:shadow-md dark:border dark:border-border group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2">
{/* Header: icon + name */} {/* Header: icon + name */}
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0"> <ModuleTile slug={slug} size={40} />
<ModuleIcon icon={icon} />
</div>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="font-semibold text-base leading-tight truncate"> <h3 className="truncate pt-2 text-base font-semibold leading-snug">
{name} {name}
</h3> </h3>
</div> </div>
@@ -99,7 +50,7 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
{/* Footer: open link */} {/* Footer: open link */}
<div className="flex justify-end"> <div className="flex justify-end">
<span className="text-sm font-medium text-primary group-hover:underline"> <span className="text-sm font-medium text-link group-hover:underline">
{t('openModule')} {t('openModule')}
</span> </span>
</div> </div>
@@ -95,7 +95,7 @@ export default function CategoryPage() {
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{t('categoryTitle', { category: categoryTitle })} {t('categoryTitle', { category: categoryTitle })}
</h1> </h1>
</div> </div>
@@ -76,7 +76,7 @@ export function ConvertTab({ file, pemText, password, onTargetFormatChange }: Co
type="button" type="button"
onClick={handleConvert} onClick={handleConvert}
disabled={loading || !canConvert} disabled={loading || !canConvert}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{loading ? t('actions.processing') : t('actions.convert')} {loading ? t('actions.processing') : t('actions.convert')}
</button> </button>
@@ -46,7 +46,7 @@ export function InspectTab({ file, pemText, password }: InspectTabProps) {
type="button" type="button"
onClick={handleInspect} onClick={handleInspect}
disabled={loading || !canInspect} disabled={loading || !canInspect}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{loading ? t('actions.processing') : t('actions.inspect')} {loading ? t('actions.processing') : t('actions.inspect')}
</button> </button>
@@ -139,7 +139,7 @@ export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
data-testid="merge-action-btn" data-testid="merge-action-btn"
onClick={handleMerge} onClick={handleMerge}
disabled={loading || !canMerge} disabled={loading || !canMerge}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{loading ? t('actions.processing') : t('actions.merge')} {loading ? t('actions.processing') : t('actions.merge')}
</button> </button>
@@ -82,7 +82,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
type="button" type="button"
onClick={handleSplit} onClick={handleSplit}
disabled={loading || !file} disabled={loading || !file}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity" className="btn btn-primary"
> >
{loading ? t('actions.processing') : t('actions.split')} {loading ? t('actions.processing') : t('actions.split')}
</button> </button>
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { DropZone } from './components/DropZone'; import { DropZone } from './components/DropZone';
@@ -98,15 +99,11 @@ export default function CertManagerPage() {
}; };
return ( return (
<div className="mx-auto max-w-4xl space-y-6 p-6"> <div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
{/* Header */} <PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
{/* Shared input card */} {/* Shared input card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4"> <div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
{/* DropZone */} {/* DropZone */}
<DropZone <DropZone
onFile={handleFile} onFile={handleFile}
@@ -148,7 +145,7 @@ export default function CertManagerPage() {
onClick={() => handleTabChange(tab)} onClick={() => handleTabChange(tab)}
className={`pb-2 text-sm font-medium transition-colors ${ className={`pb-2 text-sm font-medium transition-colors ${
activeTab === tab activeTab === tab
? 'border-b-2 border-primary text-foreground' ? 'border-b-2 border-primary-strong font-semibold text-foreground'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
@@ -158,7 +155,7 @@ export default function CertManagerPage() {
</nav> </nav>
{/* Tab content card */} {/* Tab content card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm"> <div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
{renderActiveTab()} {renderActiveTab()}
</div> </div>
</div> </div>
@@ -61,7 +61,7 @@ export function ExportFileList({ historyItems }: ExportFileListProps) {
<a <a
href={exportFileUrl(filename)} href={exportFileUrl(filename)}
download download
className="text-sm text-primary hover:underline" className="text-sm link"
> >
{filename} {filename}
</a> </a>
@@ -89,7 +89,7 @@ export function InvoiceHistoryTable({
const totalPages = Math.ceil(total / 25); const totalPages = Math.ceil(total / 25);
const theadCls = const theadCls =
'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left'; 'px-4 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
return ( return (
<div className="rounded border border-border overflow-hidden"> <div className="rounded border border-border overflow-hidden">
@@ -170,7 +170,7 @@ export function InvoiceHistoryTable({
<a <a
href={exportFileUrl(row.exportFilename)} href={exportFileUrl(row.exportFilename)}
download download
className="inline-flex items-center gap-1 text-primary text-sm hover:underline" className="inline-flex items-center gap-1 link text-sm"
> >
<DownloadIcon /> <DownloadIcon />
{row.exportFilename} {row.exportFilename}
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api'; import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api';
import { InvoiceHistoryTable } from './components/InvoiceHistoryTable'; import { InvoiceHistoryTable } from './components/InvoiceHistoryTable';
import { ExportFileList } from './components/ExportFileList'; import { ExportFileList } from './components/ExportFileList';
@@ -75,40 +76,34 @@ export default function DkvFleetPage() {
}; };
return ( return (
<div className="mx-auto max-w-5xl space-y-6 p-6"> <div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
{/* Page Header Row */} {/* Seitenkopf (Design „Mosaik“): App-Kachel, Titel, Stand; Aktionen rechts */}
<div className="flex items-start justify-between gap-4"> <PageHeader
<div> moduleSlug="dkv-fleet"
<h1 className="text-2xl font-semibold tracking-tight"> title={t('pageTitle')}
{t('pageTitle')} description={
</h1> lastChecked
<p className="mt-1 text-sm text-muted-foreground">
{lastChecked
? t('lastChecked', { time: formatLastChecked(lastChecked) }) ? t('lastChecked', { time: formatLastChecked(lastChecked) })
: t('neverChecked')} : t('neverChecked')
</p> }
</div> actions={
<>
{/* Einstellungen-Link */}
<Link <Link
href={`${basePath}/settings`} href={`${basePath}/settings`}
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="btn btn-secondary btn-icon"
title={t('settingsTitle')} title={t('settingsTitle')}
aria-label={t('settingsTitle')}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg> </svg>
</Link> </Link>
{/* "Jetzt prüfen" button per 07-UI-SPEC button states */}
<button <button
type="button" type="button"
onClick={handleCheckNow} onClick={handleCheckNow}
disabled={isChecking} disabled={isChecking}
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${ className="btn btn-primary"
isChecking ? ' opacity-80 cursor-not-allowed' : ''
}`}
> >
{isChecking ? ( {isChecking ? (
<> <>
@@ -119,7 +114,9 @@ export default function DkvFleetPage() {
t('checkNow') t('checkNow')
)} )}
</button> </button>
</div> </>
}
/>
{/* Error banner — conditional per UI-SPEC */} {/* Error banner — conditional per UI-SPEC */}
{pollError && ( {pollError && (
@@ -103,14 +103,14 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button <button
type="button" type="button"
onClick={handleButtonClick} onClick={handleButtonClick}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" className="btn btn-secondary"
> >
{t('importCsv')} {t('importCsv')}
</button> </button>
{/* Import mode inline dialog (below button, per UI-SPEC) */} {/* Import mode inline dialog (below button, per UI-SPEC) */}
{showDialog && selectedFile && ( {showDialog && selectedFile && (
<div className="absolute right-0 top-10 z-30 w-80 rounded border border-border bg-card p-4 shadow-md"> <div className="absolute right-0 top-10 z-30 w-80 rounded bg-card dark:border dark:border-border p-4 shadow-md">
<div className="mb-3 rounded bg-muted/50 px-3 py-2 text-xs text-muted-foreground space-y-0.5"> <div className="mb-3 rounded bg-muted/50 px-3 py-2 text-xs text-muted-foreground space-y-0.5">
<p>{t('csvFormatHelp')}</p> <p>{t('csvFormatHelp')}</p>
<p className="font-mono">{t('csvFormatExample')}</p> <p className="font-mono">{t('csvFormatExample')}</p>
@@ -185,7 +185,7 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button <button
type="button" type="button"
onClick={handleCancel} onClick={handleCancel}
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted" className="btn btn-secondary"
> >
{t('form.cancelDialog')} {t('form.cancelDialog')}
</button> </button>
@@ -525,7 +525,7 @@ export function InboxConfigForm() {
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={isTesting || isSaving} disabled={isTesting || isSaving}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-secondary"
> >
{isTesting ? t('form.testTesting') : t('form.testConnection')} {isTesting ? t('form.testTesting') : t('form.testConnection')}
</button> </button>
@@ -533,7 +533,7 @@ export function InboxConfigForm() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || isTesting} disabled={isSaving || isTesting}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-primary"
> >
{t('form.save')} {t('form.save')}
</button> </button>
@@ -542,7 +542,7 @@ export function InboxConfigForm() {
type="button" type="button"
onClick={handleCheckNow} onClick={handleCheckNow}
disabled={isChecking || isSaving || isTesting} disabled={isChecking || isSaving || isTesting}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-secondary"
> >
{isChecking ? 'Prüfe...' : 'Jetzt prüfen'} {isChecking ? 'Prüfe...' : 'Jetzt prüfen'}
</button> </button>
@@ -77,7 +77,7 @@ const vehicleToEdit = (v: DkvVehicle): EditState => ({
// --- Shared classes --- // --- Shared classes ---
const theadCls = const theadCls =
'px-3 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left'; 'px-3 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
const iconBtnCls = const iconBtnCls =
'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors'; 'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors';
const editInputCls = const editInputCls =
@@ -95,7 +95,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
const t = useTranslations('dkvFleet'); const t = useTranslations('dkvFleet');
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="rounded border border-border bg-card px-6 py-5 shadow-lg max-w-sm w-full"> <div className="rounded bg-card dark:border dark:border-border px-6 py-5 shadow-lg max-w-sm w-full">
<h3 className="text-base font-semibold text-foreground mb-2"> <h3 className="text-base font-semibold text-foreground mb-2">
{t('deleteVehicle')} {t('deleteVehicle')}
</h3> </h3>
@@ -107,7 +107,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isDeleting} disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-secondary"
> >
{t('form.cancelDialog')} {t('form.cancelDialog')}
</button> </button>
@@ -132,7 +132,7 @@ interface ToastProps {
function Toast({ message }: ToastProps) { function Toast({ message }: ToastProps) {
return ( return (
<div className="fixed bottom-4 right-4 z-50 rounded border border-border bg-card shadow-md px-4 py-3 text-sm"> <div className="fixed bottom-4 right-4 z-50 rounded bg-card dark:border dark:border-border shadow-md px-4 py-3 text-sm">
{message} {message}
</div> </div>
); );
@@ -317,7 +317,7 @@ export function VehicleTable() {
type="button" type="button"
onClick={startAdd} onClick={startAdd}
disabled={isAddingNew} disabled={isAddingNew}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-primary"
> >
{t('addVehicle')} {t('addVehicle')}
</button> </button>
@@ -408,7 +408,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')} aria-label={t('form.saveRow')}
onClick={() => saveEdit(v.id)} onClick={() => saveEdit(v.id)}
disabled={isSavingRow} disabled={isSavingRow}
className={`${iconBtnCls} hover:text-primary`} className={`${iconBtnCls} hover:text-foreground`}
> >
<CheckIcon /> <CheckIcon />
</button> </button>
@@ -515,7 +515,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')} aria-label={t('form.saveRow')}
onClick={saveNew} onClick={saveNew}
disabled={isSavingNew} disabled={isSavingNew}
className={`${iconBtnCls} hover:text-primary`} className={`${iconBtnCls} hover:text-foreground`}
> >
<CheckIcon /> <CheckIcon />
</button> </button>
@@ -24,7 +24,7 @@ export default function DkvFleetSettingsPage() {
const tabCls = (tab: ActiveTab) => const tabCls = (tab: ActiveTab) =>
`px-4 py-2 text-sm transition-colors ${ `px-4 py-2 text-sm transition-colors ${
activeTab === tab activeTab === tab
? 'border-b-2 border-primary text-foreground font-medium' ? 'border-b-2 border-primary-strong text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
}`; }`;
@@ -33,7 +33,7 @@ export function DomainInput({ onSubmit, isLoading }: DomainInputProps) {
<button <button
type="submit" type="submit"
disabled={isLoading || !domain.trim()} disabled={isLoading || !domain.trim()}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:pointer-events-none disabled:opacity-50" className="btn btn-primary disabled:pointer-events-none"
> >
{isLoading ? t('checking') : t('checkButton')} {isLoading ? t('checking') : t('checkButton')}
</button> </button>
@@ -33,7 +33,7 @@ export function ResultList({ results }: ResultListProps) {
{suggestions.length > 0 && ( {suggestions.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <p className="text-xs font-medium text-muted-foreground">
{t('suggestions')} {t('suggestions')}
</p> </p>
{suggestions.map((result) => ( {suggestions.map((result) => (
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { type DomainCheckResult, checkDomainAction } from './actions'; import { type DomainCheckResult, checkDomainAction } from './actions';
@@ -37,17 +38,11 @@ export default function DomaincheckPage() {
}; };
return ( return (
<div className="mx-auto max-w-2xl space-y-6 p-6"> <div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
{/* Header */} <PageHeader moduleSlug="domaincheck" title={t('title')} description={t('description')} />
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('description')}
</p>
</div>
{/* Card panel (per D-05a) */} {/* Card panel (per D-05a) */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4"> <div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
{/* Domain input */} {/* Domain input */}
<DomainInput onSubmit={handleSubmit} isLoading={isLoading} /> <DomainInput onSubmit={handleSubmit} isLoading={isLoading} />
@@ -443,8 +443,8 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
data-testid="server-card" data-testid="server-card"
data-health={health} data-health={health}
aria-label={`${server.name}: ${t(`health.${health}`)}`} aria-label={`${server.name}: ${t(`health.${health}`)}`}
className={`relative flex h-full flex-col overflow-hidden rounded-xl border bg-card ${ className={`relative flex h-full flex-col overflow-hidden rounded-lg bg-card ${
isOrphan ? 'border-dashed border-border' : 'border-border' isOrphan ? 'border border-dashed border-border' : 'dark:border dark:border-border'
} ${style.shadow}`} } ${style.shadow}`}
> >
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} /> <span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api'; import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
import { HealthBar } from '@/components/proxmox/HealthBar'; import { HealthBar } from '@/components/proxmox/HealthBar';
@@ -36,7 +37,7 @@ function RefreshIcon({ spinning }: { spinning: boolean }) {
function SkeletonCard() { function SkeletonCard() {
return ( return (
<div className="rounded-xl border border-border bg-card p-4 pl-5 shadow-[0_1px_2px_oklch(0_0_0/0.06)]"> <div className="rounded-xl bg-card p-4 pl-5 shadow-sm dark:border dark:border-border">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-muted" /> <div className="h-8 w-8 rounded-lg bg-muted" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
@@ -106,17 +107,14 @@ export default function ProxmoxPage() {
return ( return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6"> <div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-4"> <PageHeader
<div className="min-w-0"> moduleSlug="proxmox"
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1> title={t('title')}
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p> description={t('description')}
</div> actions={
{isAdmin && ( isAdmin ? (
<div className="flex items-center gap-4"> <>
<Link <Link href="/modules/proxmox/settings" className="btn btn-subtle">
href="/modules/proxmox/settings"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{t('card.settingsNav')} {t('card.settingsNav')}
</Link> </Link>
{servers !== null && servers.length > 0 && ( {servers !== null && servers.length > 0 && (
@@ -124,15 +122,16 @@ export default function ProxmoxPage() {
type="button" type="button"
onClick={handleRefresh} onClick={handleRefresh}
disabled={isRefreshing} disabled={isRefreshing}
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-70" className="btn btn-primary"
> >
<RefreshIcon spinning={isRefreshing} /> <RefreshIcon spinning={isRefreshing} />
{isRefreshing ? t('card.refreshing') : t('card.refresh')} {isRefreshing ? t('card.refreshing') : t('card.refresh')}
</button> </button>
)} )}
</div> </>
)} ) : undefined
</div> }
/>
{error && ( {error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground"> <p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
@@ -179,7 +179,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
}; };
return ( return (
<div className="space-y-4 rounded-lg border border-border bg-card p-4 shadow-sm"> <div className="space-y-4 rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm">
<div> <div>
<label htmlFor="proxmox-name" className={labelCls}> <label htmlFor="proxmox-name" className={labelCls}>
{t('settings.nameLabel')} {t('settings.nameLabel')}
@@ -368,7 +368,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || !form.name || !form.baseUrl} disabled={isSaving || !form.name || !form.baseUrl}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-primary"
> >
{isSaving ? t('settings.saving') : t('settings.save')} {isSaving ? t('settings.saving') : t('settings.save')}
</button> </button>
@@ -383,7 +383,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button" type="button"
onClick={handleTest} onClick={handleTest}
disabled={isTesting || !form.baseUrl} disabled={isTesting || !form.baseUrl}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-secondary"
> >
{isTesting ? t('settings.testTesting') : t('settings.testConnection')} {isTesting ? t('settings.testTesting') : t('settings.testConnection')}
</button> </button>
@@ -17,7 +17,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
const t = useTranslations('proxmox'); const t = useTranslations('proxmox');
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded border border-border bg-card px-6 py-5 shadow-lg"> <div className="w-full max-w-sm rounded bg-card dark:border dark:border-border px-6 py-5 shadow-lg">
<h3 className="mb-2 text-base font-semibold text-foreground"> <h3 className="mb-2 text-base font-semibold text-foreground">
{t('settings.deleteConfirmTitle')} {t('settings.deleteConfirmTitle')}
</h3> </h3>
@@ -29,7 +29,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isDeleting} disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" className="btn btn-secondary"
> >
{t('settings.deleteCancelButton')} {t('settings.deleteCancelButton')}
</button> </button>
@@ -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="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t('settings.addServer')} {t('settings.addServer')}
</button> </button>
)} )}
@@ -155,7 +151,7 @@ export default function ProxmoxSettingsPage() {
) : ( ) : (
<li <li
key={server.id} key={server.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 shadow-sm" className="flex items-center justify-between rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm"
> >
<div> <div>
<div className="font-medium">{server.name}</div> <div className="font-medium">{server.name}</div>
@@ -167,7 +163,7 @@ export default function ProxmoxSettingsPage() {
<button <button
type="button" type="button"
onClick={() => setEditingId(server.id)} onClick={() => setEditingId(server.id)}
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted" className="btn btn-secondary"
> >
{t('settings.edit')} {t('settings.edit')}
</button> </button>
@@ -157,7 +157,7 @@ export function FilterPanel() {
const labelCls = 'mb-1 block text-sm text-foreground'; const labelCls = 'mb-1 block text-sm text-foreground';
return ( return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-4"> <div className="rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm space-y-4">
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div className="min-w-[220px] flex-1"> <div className="min-w-[220px] flex-1">
<label htmlFor="tr-filter-q" className={labelCls}> <label htmlFor="tr-filter-q" className={labelCls}>
@@ -297,7 +297,7 @@ export function FilterPanel() {
autoComplete="off" autoComplete="off"
/> />
{cpvMatches.length > 0 && ( {cpvMatches.length > 0 && (
<ul className="absolute z-10 mt-1 w-full max-h-56 overflow-auto rounded border border-border bg-card shadow-md"> <ul className="absolute z-10 mt-1 w-full max-h-56 overflow-auto rounded bg-card dark:border dark:border-border shadow-md">
{cpvMatches.map((d) => ( {cpvMatches.map((d) => (
<li key={d.code}> <li key={d.code}>
<button <button
@@ -65,7 +65,7 @@ export function PollNowButton({ onPolled }: PollNowButtonProps) {
onClick={handlePoll} onClick={handlePoll}
disabled={isPolling} disabled={isPolling}
title={t('page.pollNowTitle')} title={t('page.pollNowTitle')}
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${ className={`btn btn-primary flex items-center${
isPolling ? ' opacity-80 cursor-not-allowed' : '' isPolling ? ' opacity-80 cursor-not-allowed' : ''
}`} }`}
> >
@@ -210,7 +210,7 @@ export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) {
const totalPages = Math.max(1, Math.ceil(total / limit)); const totalPages = Math.max(1, Math.ceil(total / limit));
const thStaticCls = const thStaticCls =
'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left'; 'px-4 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
const thSortCls = `${thStaticCls} cursor-pointer select-none hover:text-foreground transition-colors`; const thSortCls = `${thStaticCls} cursor-pointer select-none hover:text-foreground transition-colors`;
return ( return (
@@ -219,7 +219,7 @@ export function SavedSearchBar() {
}; };
return ( return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-3"> <div className="rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm space-y-3">
<h2 className="text-sm font-semibold text-foreground">{t('savedSearch.title')}</h2> <h2 className="text-sm font-semibold text-foreground">{t('savedSearch.title')}</h2>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
@@ -276,7 +276,7 @@ export function SavedSearchBar() {
type="button" type="button"
aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })} aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })}
onClick={() => confirmRename(profile.id)} onClick={() => confirmRename(profile.id)}
className="text-xs font-medium text-primary hover:underline" className="text-xs font-medium link"
> >
{t('savedSearch.save')} {t('savedSearch.save')}
</button> </button>
@@ -67,7 +67,7 @@ function formatPublished(value: string): string {
} }
function fieldLabelCls() { function fieldLabelCls() {
return 'text-xs font-semibold uppercase tracking-wider text-muted-foreground'; return 'text-xs font-semibold text-muted-foreground';
} }
/** /**
@@ -234,7 +234,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
href={source.sourceUrl} href={source.sourceUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80" className="inline-block text-sm font-medium link"
> >
{t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })} {t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })}
</a> </a>
@@ -251,7 +251,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
href={tender.sourceUrl} href={tender.sourceUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-2 inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80" className="mt-2 inline-block text-sm font-medium link"
> >
{t('detail.sourceLinkFallback')} {t('detail.sourceLinkFallback')}
</a> </a>
@@ -76,7 +76,7 @@ export default function TenderRadarMySourcesPage() {
<div className="mt-8 border-t border-border pt-6"> <div className="mt-8 border-t border-border pt-6">
<Link <Link
href="/modules/tender-radar/settings" href="/modules/tender-radar/settings"
className="text-sm text-primary hover:underline" className="text-sm link"
> >
{t('page.settingsTitle')} &rarr; {t('page.settingsTitle')} &rarr;
</Link> </Link>
@@ -4,6 +4,7 @@ import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { CoverageBanner } from './components/CoverageBanner'; import { CoverageBanner } from './components/CoverageBanner';
import { FilterPanel } from './components/FilterPanel'; import { FilterPanel } from './components/FilterPanel';
import { PollNowButton } from './components/PollNowButton'; import { PollNowButton } from './components/PollNowButton';
@@ -59,14 +60,12 @@ function TenderRadarContent() {
}; };
return ( return (
<div className="mx-auto max-w-5xl space-y-6 p-6"> <div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<div className="flex items-start justify-between gap-4"> <PageHeader
<h1 className="text-2xl font-bold tracking-tight"> moduleSlug="tender-radar"
{t('page.title')} title={t('page.title')}
</h1> actions={
<>
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
<div className="flex items-center gap-2">
{/* {/*
* Zahnrad-Link. Absolute literal href (NOT the dynamic * Zahnrad-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): tender-radar routes only * [category]/[moduleSlug] basePath): tender-radar routes only
@@ -82,7 +81,7 @@ function TenderRadarContent() {
*/} */}
<Link <Link
href="/modules/tender-radar/my-sources" href="/modules/tender-radar/my-sources"
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="btn btn-secondary btn-icon"
title={t('page.mySourcesTitle')} title={t('page.mySourcesTitle')}
aria-label={t('page.mySourcesTitle')} aria-label={t('page.mySourcesTitle')}
> >
@@ -93,8 +92,9 @@ function TenderRadarContent() {
</Link> </Link>
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} /> <PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
</div> </>
</div> }
/>
<CoverageBanner /> <CoverageBanner />
<SavedSearchBar /> <SavedSearchBar />
@@ -449,7 +449,7 @@ export function EmailAlertConfigForm() {
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={isTesting || isSaving} disabled={isTesting || isSaving}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-secondary"
> >
{isTesting ? t('emailAlerts.testTesting') : t('emailAlerts.testConnection')} {isTesting ? t('emailAlerts.testTesting') : t('emailAlerts.testConnection')}
</button> </button>
@@ -457,7 +457,7 @@ export function EmailAlertConfigForm() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || isTesting} disabled={isSaving || isTesting}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-primary"
> >
{isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')} {isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
</button> </button>
@@ -230,7 +230,7 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
type="button" type="button"
onClick={handleAdd} onClick={handleAdd}
disabled={isAdding} disabled={isAdding}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-primary"
> >
{isAdding ? t('rssFeeds.adding') : t('rssFeeds.add')} {isAdding ? t('rssFeeds.adding') : t('rssFeeds.add')}
</button> </button>
@@ -216,7 +216,7 @@ export function SourceConfigForm() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" className="btn btn-primary"
> >
{isSaving ? t('sourceConfig.saving') : t('sourceConfig.save')} {isSaving ? t('sourceConfig.saving') : t('sourceConfig.save')}
</button> </button>
@@ -71,7 +71,7 @@ export default function TenderRadarSettingsPage() {
</p> </p>
<Link <Link
href="/modules/tender-radar/my-sources" href="/modules/tender-radar/my-sources"
className="text-sm text-primary hover:underline" className="text-sm link"
> >
{t('mySources.title')} &rarr; {t('mySources.title')} &rarr;
</Link> </Link>
+18 -12
View File
@@ -11,12 +11,16 @@ const messages: Record<string, Record<string, string>> = {
editMode: 'Dashboard bearbeiten', editMode: 'Dashboard bearbeiten',
saveChanges: 'Änderungen speichern', saveChanges: 'Änderungen speichern',
addWidget: 'Widget hinzufügen', addWidget: 'Widget hinzufügen',
editShort: 'Bearbeiten',
done: 'Fertig',
toolbarLabel: 'Dashboard-Aktionen',
layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.', layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
}, },
}; };
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key, useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key,
useLocale: () => 'de',
})); }));
const mockStore = { const mockStore = {
@@ -106,45 +110,47 @@ afterEach(() => {
}); });
describe('DashboardPage (quick-260916-dyv)', () => { describe('DashboardPage (quick-260916-dyv)', () => {
it('Test 1: Ansichtsmodus — Stift in fester Leiste unten rechts, kein "Widget hinzufuegen", kein mt-8/top-2, Grid direkt im Container p-2', async () => { it('Test 1: Ansichtsmodus — Befehlsleiste rechts oben mit „Bearbeiten“, kein "Widget hinzufuegen", Begruessung, Grid direkt im Container p-2 (Design „Mosaik“)', async () => {
const { default: DashboardPage } = await import('./page'); const { default: DashboardPage } = await import('./page');
render(<DashboardPage />); render(<DashboardPage />);
const toggle = screen.getByRole('button', { name: 'Dashboard bearbeiten' }); const toolbar = screen.getByRole('toolbar', { name: 'Dashboard-Aktionen' });
const bar = toggle.parentElement as HTMLElement; const toggle = screen.getByRole('button', { name: 'Bearbeiten' });
expect(bar.classList.contains('fixed')).toBe(true); expect(toggle.parentElement).toBe(toolbar);
expect(bar.classList.contains('bottom-6')).toBe(true); expect(toggle).toHaveAttribute('aria-pressed', 'false');
expect(bar.classList.contains('right-6')).toBe(true); // kein schwebender Knopf mehr unten rechts
expect(bar.classList.contains('z-20')).toBe(true); expect(toolbar.classList.contains('fixed')).toBe(false);
expect(document.querySelector('.bottom-6')).toBeNull();
expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull();
expect(document.querySelector('.mt-8')).toBeNull(); expect(document.querySelector('.mt-8')).toBeNull();
expect(document.querySelector('.top-2')).toBeNull(); expect(document.querySelector('.top-2')).toBeNull();
// Runde 3: die Begruessung steht in der Seitenleiste, nicht mehr hier.
expect(screen.queryByTestId('dashboard-greeting')).toBeNull();
const grid = screen.getByTestId('dashboard-grid'); const grid = screen.getByTestId('dashboard-grid');
expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true); expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true);
expect(grid.getAttribute('data-edit')).toBe('false'); expect(grid.getAttribute('data-edit')).toBe('false');
}); });
it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben dem Haekchen in derselben Leiste, Grid im Bearbeitungsmodus', async () => { it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben „Fertig“ in derselben Leiste, Grid im Bearbeitungsmodus', async () => {
mockStore.isEditMode = true; mockStore.isEditMode = true;
const { default: DashboardPage } = await import('./page'); const { default: DashboardPage } = await import('./page');
render(<DashboardPage />); render(<DashboardPage />);
const toggle = screen.getByRole('button', { name: 'Änderungen speichern' }); const toggle = screen.getByRole('button', { name: 'Fertig' });
const add = screen.getByRole('button', { name: 'Widget hinzufügen' }); const add = screen.getByRole('button', { name: 'Widget hinzufügen' });
expect(add.parentElement).toBe(toggle.parentElement); expect(add.parentElement).toBe(toggle.parentElement);
// "Widget hinzufuegen" steht im DOM VOR dem Umschalter (links daneben).
expect(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true'); expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true');
}); });
it('Test 3: Umschalten — Klick auf "Dashboard bearbeiten" ruft setEditMode(true) genau einmal', async () => { it('Test 3: Umschalten — Klick auf "Bearbeiten" ruft setEditMode(true) genau einmal', async () => {
const { default: DashboardPage } = await import('./page'); const { default: DashboardPage } = await import('./page');
render(<DashboardPage />); render(<DashboardPage />);
fireEvent.click(screen.getByRole('button', { name: 'Dashboard bearbeiten' })); fireEvent.click(screen.getByRole('button', { name: 'Bearbeiten' }));
expect(mockStore.setEditMode).toHaveBeenCalledTimes(1); expect(mockStore.setEditMode).toHaveBeenCalledTimes(1);
expect(mockStore.setEditMode).toHaveBeenCalledWith(true); expect(mockStore.setEditMode).toHaveBeenCalledWith(true);
}); });
+63 -43
View File
@@ -1,10 +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 { 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';
@@ -45,6 +48,17 @@ interface ActiveModule {
export default function DashboardPage() { export default function DashboardPage() {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [catalogOpen, setCatalogOpen] = useState(false); const [catalogOpen, setCatalogOpen] = useState(false);
// Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
// Einhaengepunkt der Kopfzeile fuer die Dashboard-Aktionen (quick-260928-vxe)
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
useEffect(() => {
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
}, []);
// Hintergrund je Benutzer (Design „Mosaik“), seit quick-260928-ujj in der
// Datenbank gespeichert und mit der Sitzung geladen.
const { background, choose: chooseBackground } = useDashboardBackground();
const hasBackground = background.kind !== 'none';
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf — // 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
// "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed. // "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed.
@@ -114,48 +128,16 @@ export default function DashboardPage() {
); );
} }
return ( const actions = (
<div className="relative p-2"> <div role="toolbar" aria-label={t('toolbarLabel')} className="flex items-center gap-1">
{/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal {isEditMode && <BackgroundPicker value={background} onChange={chooseBackground} />}
in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
kurze Ladezeile. */}
<DashboardTabs
dashboards={dashboards}
activeDashboardId={activeDashboardId}
isEditMode={isEditMode}
onSelect={selectDashboard}
onCreate={createDashboard}
onRename={renameDashboard}
onDelete={deleteDashboard}
onReorder={reorderDashboards}
/>
{isSwitchingDashboard ? (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
</div>
) : (
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
)}
{/* Feste Aktionsleiste unten rechts.
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
{isEditMode && ( {isEditMode && (
<button <button
type="button" type="button"
onClick={() => setCatalogOpen(true)} onClick={() => setCatalogOpen(true)}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity" aria-label={t('addWidget')}
title={t('addWidget')}
className="btn btn-appbar"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -172,14 +154,52 @@ export default function DashboardPage() {
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
{t('addWidget')} <span className="hidden lg:inline">{t('addWidget')}</span>
</button> </button>
)} )}
<EditModeToggle <EditModeToggle isEditMode={isEditMode} onToggle={() => setEditMode(!isEditMode)} />
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div> </div>
);
return (
<div className={`relative p-2 ${hasBackground ? 'dashboard-has-bg' : ''}`}>
<DashboardBackdrop background={background} />
{/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal
in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
kurze Ladezeile. */}
<DashboardTabs
dashboards={dashboards}
activeDashboardId={activeDashboardId}
isEditMode={isEditMode}
onSelect={selectDashboard}
onCreate={createDashboard}
onRename={renameDashboard}
onDelete={deleteDashboard}
onReorder={reorderDashboards}
/>
{/* Dashboard-Aktionen (quick-260928-vxe): per Portal rechts in der
App-Leiste statt als eigene Zeile ueber dem Raster. Fehlt der
Einhaengepunkt (etwa in Tests ohne Kopfzeile), stehen sie hier. */}
{actionsSlot ? createPortal(actions, actionsSlot) : actions}
{isSwitchingDashboard ? (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
</div>
) : (
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
onOpenCatalog={() => setCatalogOpen(true)}
onQuickAdd={(type) => addWidget(type)}
/>
)}
{/* Widget catalog modal */} {/* Widget catalog modal */}
<WidgetCatalogModal <WidgetCatalogModal
@@ -17,9 +17,9 @@ export default function SettingsLayout({
const t = useTranslations('settings'); const t = useTranslations('settings');
return ( return (
<div className="flex h-full"> <div className="flex h-full flex-col md:flex-row">
<SettingsSidebar /> <SettingsSidebar />
<div className="flex-1 overflow-y-auto p-6"> <div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
{/* Back to Dashboard link */} {/* Back to Dashboard link */}
<Link <Link
href="/" href="/"
+467 -42
View File
@@ -57,45 +57,90 @@
--color-status-idle-fg: var(--status-idle-fg); --color-status-idle-fg: var(--status-idle-fg);
--color-status-orphan-fg: var(--status-orphan-fg); --color-status-orphan-fg: var(--status-orphan-fg);
--color-well: var(--well); --color-well: var(--well);
--color-link: var(--link);
--color-tile: var(--tile);
--color-tile-foreground: var(--tile-foreground);
--color-primary-strong: var(--primary-strong);
--color-input-strong: var(--input-strong);
--color-appbar: var(--appbar);
--color-appbar-foreground: var(--appbar-foreground);
--color-appbar-muted: var(--appbar-muted);
--color-appbar-hover: var(--appbar-hover);
--color-sidebar-hover: var(--sidebar-hover);
--radius-sm: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius); --radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px); --radius-lg: calc(var(--radius) + 2px);
} }
/* Fluent-2-Hoehenstufen: shadow-sm = shadow2 (ruhende Karte), shadow-md =
shadow4 (angehobene/Hover-Karte), shadow-lg = shadow16 (Menues, Dialoge).
Im dunklen Erscheinungsbild tragen Haarlinien statt Schatten (--border). */
@theme {
--shadow-sm: 0 0 2px oklch(0 0 0 / 0.12), 0 1px 2px oklch(0 0 0 / 0.14);
--shadow-md: 0 0 2px oklch(0 0 0 / 0.12), 0 4px 8px oklch(0 0 0 / 0.14);
--shadow-lg: 0 0 2px oklch(0 0 0 / 0.12), 0 8px 16px oklch(0 0 0 / 0.14);
--shadow-xl: 0 0 8px oklch(0 0 0 / 0.12), 0 14px 28px oklch(0 0 0 / 0.14);
}
:root { :root {
--radius: 0.5rem; /* Design „Mosaik“ (Fluent 2 / GitHub als Vorbild): 6 px fuer Bedien-
--font-sans: "Inter", system-ui, -apple-system, sans-serif; elemente (rounded-md), 8 px fuer Karten (rounded-lg), 12 px fuer grosse
Flaechen (rounded-xl). */
--radius: 0.375rem;
/* Segoe UI Variable zuerst: unter Windows (Desktop-Client) wirkt die
Oberflaeche damit wie eine native Anwendung; sonst Inter bzw. Systemschrift. */
--font-sans: "Segoe UI Variable Text", "Segoe UI", Inter, system-ui, -apple-system, sans-serif;
/* Layout dimensions */ /* Layout dimensions */
--sidebar-width: 240px; --sidebar-width: 240px;
--sidebar-width-collapsed: 64px; --sidebar-width-collapsed: 64px;
--header-height: 60px; --header-height: 48px;
/* Light mode colors (OKLCH) */ /* Light mode colors (OKLCH) — Arbeitsflaeche leicht kuehlgrau, Karten weiss */
--background: oklch(0.99 0 0); --background: oklch(0.972 0.003 260);
--foreground: oklch(0.15 0 0); --foreground: oklch(0.2 0.006 260);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.15 0 0); --card-foreground: oklch(0.2 0.006 260);
--primary: oklch(0.91 0.19 102); --primary: oklch(0.91 0.19 102);
--primary-foreground: oklch(0.20 0.02 90); --primary-foreground: oklch(0.20 0.02 90);
--secondary: oklch(0.96 0 0); --secondary: oklch(0.955 0.003 260);
--secondary-foreground: oklch(0.15 0 0); --secondary-foreground: oklch(0.2 0.006 260);
--muted: oklch(0.96 0 0); --muted: oklch(0.955 0.003 260);
--muted-foreground: oklch(0.55 0 0); --muted-foreground: oklch(0.49 0.01 260);
--accent: oklch(0.96 0 0); --accent: oklch(0.955 0.003 260);
--accent-foreground: oklch(0.15 0 0); --accent-foreground: oklch(0.2 0.006 260);
--destructive: oklch(0.55 0.2 27); --destructive: oklch(0.55 0.2 27);
--destructive-foreground: oklch(0.98 0 0); --destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.90 0 0); --border: oklch(0.915 0.004 260);
--input: oklch(0.90 0 0); --input: oklch(0.87 0 0);
--ring: oklch(0.91 0.19 102); /* Untere Kante von Eingabefeldern (Fluent-Eingabefeld). */
--input-strong: oklch(0.58 0.005 260);
/* Tastaturfokus: dunkler Ring statt Gelb — Gelb auf Weiss erreicht nur
1,3:1 und waere als Fokusanzeige kaum sichtbar. */
--ring: oklch(0.3 0.01 260);
/* Verweise: nie gelbe Schrift auf Weiss. 5,9:1 auf --card. */
--link: oklch(0.50 0.15 255);
/* Neutrale Modul-Kachel (Runde 2: einfarbig statt bunt). */
--tile: oklch(0.93 0.004 260);
--tile-foreground: oklch(0.35 0.01 260);
/* Akzent fuer duenne Markierungen (Auswahlpille, Reiter-Unterstrich) auf
hellem Grund: die Akzentfarbe um knapp ein Drittel abgedunkelt, damit
auch Gelb neben Hellgrau noch erkennbar bleibt. */
--primary-strong: color-mix(in oklab, var(--primary) 70%, oklch(0.3 0.03 90));
/* Kopfzeile (dunkle App-Leiste wie GitHub / Microsoft 365) */
--appbar: oklch(0.24 0.012 260);
--appbar-foreground: oklch(0.985 0 0);
--appbar-muted: oklch(0.985 0 0 / 0.72);
--appbar-hover: oklch(1 0 0 / 0.08);
/* Sidebar tokens */ /* Sidebar tokens */
--sidebar: oklch(0.97 0 0); --sidebar: oklch(0.975 0.003 260);
--sidebar-foreground: oklch(0.15 0 0); --sidebar-foreground: oklch(0.2 0.006 260);
--sidebar-accent: oklch(0.93 0 0); --sidebar-accent: oklch(0.925 0.005 260);
--sidebar-accent-foreground: oklch(0.25 0 0); --sidebar-accent-foreground: oklch(0.16 0.006 260);
--sidebar-border: oklch(0.90 0 0); --sidebar-hover: oklch(0.945 0.004 260);
--sidebar-border: oklch(0.915 0.004 260);
/* Statusfarben (quick-260924-h7x). Die Flaechentoene stammen aus dem /* Statusfarben (quick-260924-h7x). Die Flaechentoene stammen aus dem
Design-Plan; als Schrift auf Weiss erreichen sie aber nur 2,6:1 bis Design-Plan; als Schrift auf Weiss erreichen sie aber nur 2,6:1 bis
@@ -113,34 +158,46 @@
--status-idle-fg: oklch(0.50 0.02 260); --status-idle-fg: oklch(0.50 0.02 260);
--status-orphan-fg: oklch(0.50 0.01 260); --status-orphan-fg: oklch(0.50 0.01 260);
/* Eingelassene Messfelder. */ /* Eingelassene Messfelder. */
--well: oklch(0.975 0.003 260); --well: oklch(0.972 0.003 260);
} }
.dark { .dark {
--background: oklch(0.23 0.01 260); --background: oklch(0.2 0.01 260);
--foreground: oklch(0.95 0 0); --foreground: oklch(0.95 0.003 260);
--card: oklch(0.27 0.01 260); --card: oklch(0.245 0.01 260);
--card-foreground: oklch(0.95 0 0); --card-foreground: oklch(0.95 0.003 260);
--primary: oklch(0.91 0.19 102); --primary: oklch(0.91 0.19 102);
--primary-foreground: oklch(0.20 0.02 90); --primary-foreground: oklch(0.20 0.02 90);
--secondary: oklch(0.30 0.01 260); --secondary: oklch(0.28 0.01 260);
--secondary-foreground: oklch(0.95 0 0); --secondary-foreground: oklch(0.95 0.003 260);
--muted: oklch(0.30 0.01 260); --muted: oklch(0.28 0.01 260);
--muted-foreground: oklch(0.65 0 0); --muted-foreground: oklch(0.72 0.01 260);
--accent: oklch(0.30 0.01 260); --accent: oklch(0.28 0.01 260);
--accent-foreground: oklch(0.95 0 0); --accent-foreground: oklch(0.95 0.003 260);
--destructive: oklch(0.55 0.2 27); --destructive: oklch(0.6 0.2 27);
--destructive-foreground: oklch(0.98 0 0); --destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.30 0.01 260); /* Dunkel: feine helle Haarlinie statt Schatten. */
--input: oklch(0.30 0.01 260); --border: oklch(1 0 0 / 0.09);
--ring: oklch(0.91 0.19 102); --input: oklch(1 0 0 / 0.16);
--input-strong: oklch(1 0 0 / 0.42);
--ring: oklch(0.92 0.005 260);
--link: oklch(0.78 0.11 250);
--tile: oklch(0.32 0.01 260);
--tile-foreground: oklch(0.88 0.005 260);
--primary-strong: var(--primary);
--appbar: oklch(0.18 0.01 260);
--appbar-foreground: oklch(0.985 0 0);
--appbar-muted: oklch(0.985 0 0 / 0.7);
--appbar-hover: oklch(1 0 0 / 0.08);
/* Sidebar tokens (dark) */ /* Sidebar tokens (dark) */
--sidebar: oklch(0.20 0.01 260); --sidebar: oklch(0.21 0.01 260);
--sidebar-foreground: oklch(0.95 0 0); --sidebar-foreground: oklch(0.93 0.003 260);
--sidebar-accent: oklch(0.91 0.19 102 / 0.15); --sidebar-accent: oklch(1 0 0 / 0.08);
--sidebar-accent-foreground: oklch(0.91 0.19 102); --sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(0.30 0.01 260); --sidebar-hover: oklch(1 0 0 / 0.05);
--sidebar-border: oklch(1 0 0 / 0.08);
/* Statusfarben (dark). Textvariante auf --card gemessen: ok 7,4:1, /* Statusfarben (dark). Textvariante auf --card gemessen: ok 7,4:1,
warn 8,5:1, down 6,1:1, idle 6,1:1, orphan 5,6:1. */ warn 8,5:1, down 6,1:1, idle 6,1:1, orphan 5,6:1. */
@@ -154,11 +211,13 @@
--status-down-fg: oklch(0.74 0.16 27); --status-down-fg: oklch(0.74 0.16 27);
--status-idle-fg: oklch(0.72 0.02 260); --status-idle-fg: oklch(0.72 0.02 260);
--status-orphan-fg: oklch(0.70 0.01 260); --status-orphan-fg: oklch(0.70 0.01 260);
--well: oklch(0.235 0.01 260); --well: oklch(0.215 0.01 260);
} }
body { body {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 0.875rem;
line-height: 1.25rem;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@@ -181,3 +240,369 @@ body {
.wmde-markdown ul ul { list-style: circle; } .wmde-markdown ul ul { list-style: circle; }
.wmde-markdown ol { list-style: decimal; } .wmde-markdown ol { list-style: decimal; }
.wmde-markdown ul.contains-task-list, .wmde-markdown li.task-list-item { list-style: none; } .wmde-markdown ul.contains-task-list, .wmde-markdown li.task-list-item { list-style: none; }
/* Tastaturfokus sichtbar ueberall, auch wo ein Bauteil keinen eigenen
Fokusring setzt (im Basis-Layer, damit `outline-none` + eigener Ring
eines Bauteils Vorrang behalten). Mausklicks loesen :focus-visible nicht aus. */
@layer base {
:where(a, button, [role="tab"], [role="button"], summary, [tabindex]):focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
}
/* Fluent-Eingabefeld: untere Kante eine Stufe dunkler; Fokus = dunklerer
Rahmen plus 2 px Akzentlinie unten. Gilt fuer Textfelder und Auswahllisten
mit dem ueblichen `border-input`/`border-border`-Rahmen; Fehlerzustaende
(aria-invalid, border-destructive) behalten ihre Farbe. Bewusst ausserhalb
eines @layer, damit die Regel die Tailwind-Utilities schlaegt. */
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive) {
border-bottom-color: var(--input-strong);
}
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive):focus {
outline: none;
border-color: var(--input-strong);
border-bottom-color: var(--primary);
box-shadow: inset 0 -1px 0 var(--primary);
}
/* Karteneffekt in einer Klasse fuer neue Flaechen: hell Schatten, dunkel Haarlinie. */
.surface {
background: var(--card);
border-radius: 0.5rem;
box-shadow: var(--shadow-sm);
}
.dark .surface {
box-shadow: none;
outline: 1px solid var(--border);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Knoepfe (Design „Mosaik“, Fluent 2): 32 px hoch, 6 px Radius, 14 px
halbfett. Primaer = Akzentflaeche mit dunkler Schrift, Sekundaer = weisse
Flaeche mit Rahmen, Subtil = ohne Flaeche. Im Komponenten-Layer, damit
einzelne Utilities (etwa `w-full`) weiter ueberschreiben koennen. */
@layer components {
.btn {
display: inline-flex;
height: 2rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.375rem;
border: 1px solid transparent;
padding-inline: 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 600;
white-space: nowrap;
transition: background-color 120ms, border-color 120ms, color 120ms;
}
.btn:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.btn:disabled {
cursor: not-allowed;
}
/* Fluent: deaktiviert = graue Flaeche, graue Schrift (nicht verblasstes Gelb). */
.btn-primary:disabled {
background-color: var(--muted);
color: var(--muted-foreground);
border-color: var(--border);
}
.btn-secondary:disabled, .btn-subtle:disabled {
color: var(--muted-foreground);
}
.btn-primary {
background-color: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover:not(:disabled) {
background-color: color-mix(in oklab, var(--primary) 88%, black);
}
.btn-secondary {
background-color: var(--card);
color: var(--foreground);
border-color: oklch(0.87 0 0);
box-shadow: 0 1px 2px oklch(0 0 0 / 0.06);
}
.btn-secondary:hover:not(:disabled) {
background-color: oklch(0.965 0 0);
}
.dark .btn-secondary {
border-color: var(--input);
box-shadow: none;
}
.dark .btn-secondary:hover:not(:disabled) {
background-color: var(--muted);
}
.btn-subtle {
background-color: transparent;
color: var(--foreground);
}
.btn-subtle:hover:not(:disabled) {
background-color: var(--muted);
}
.btn-icon {
width: 2rem;
padding-inline: 0;
}
/* Knopf auf der dunklen App-Leiste (Dashboard-Aktionen, quick-260928-vxe). */
.btn-appbar {
background-color: transparent;
color: var(--appbar-muted);
}
.btn-appbar:hover:not(:disabled) {
background-color: var(--appbar-hover);
color: var(--appbar-foreground);
}
.btn-appbar:focus-visible {
outline-color: var(--appbar-foreground);
outline-offset: -2px;
}
/* Verweis: dunkle bzw. blaue Schrift, nie gelb auf Weiss. */
.link {
color: var(--link);
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
text-decoration-color: color-mix(in oklab, var(--link) 40%, transparent);
}
.link:hover {
text-decoration-color: currentColor;
}
}
/* Markdown (Notiz-Kachel, „Was ist neu“): Fluent-Schriftgroessen statt der
GitHub-Vorgaben (16 px Grundschrift, 2em-Ueberschriften), Hintergrund
transparent, Farben aus den Tokens. */
:root .wmde-markdown {
font-family: var(--font-sans);
font-size: 0.875rem;
line-height: 1.5;
background-color: transparent !important;
color: var(--foreground) !important;
--color-border-default: var(--border);
--color-border-muted: var(--border);
--color-accent-fg: var(--link);
--color-fg-default: var(--foreground);
--color-fg-muted: var(--muted-foreground);
}
:root .wmde-markdown h1 { font-size: 1.25rem; line-height: 1.75rem; font-weight: 600; }
:root .wmde-markdown h2 { font-size: 1rem; line-height: 1.375rem; font-weight: 600; }
:root .wmde-markdown h3, :root .wmde-markdown h4 { font-size: 0.875rem; line-height: 1.25rem; font-weight: 600; }
:root .w-md-editor {
background-color: transparent !important;
box-shadow: none !important;
}
/* Navigationseintrag mit Fluent-Auswahlpille, gesteuert ueber aria-current
(Einstellungs- und Verwaltungsnavigation). */
.nav-item {
position: relative;
}
.nav-item[aria-current="page"]::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 3px;
height: 1rem;
margin-top: -0.5rem;
border-radius: 9999px;
background-color: var(--primary-strong);
}
/* Checkboxen und Optionsfelder in der Link-Farbe statt Browser-Blau bzw.
Gelb (Gelb mit weissem Haken waere kaum lesbar). */
input[type="checkbox"], input[type="radio"] {
accent-color: var(--link);
}
/* ---------------------------------------------------------------------------
Dashboard-Kacheln (Design „Mosaik“)
------------------------------------------------------------------------- */
/* Einheitliche Kachel-Kopfzeile: 40 px, 12 px Innenabstand, 14 px halbfett. */
.widget-head {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
/* Runde 3 — mehr Leben in den Kacheln, ohne bunt zu werden.
Symbol-Chip: volle Akzentfarbe als Flaeche, Symbol in der passenden Vordergrundfarbe. */
.widget-icon-chip {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 0.4375rem;
background-color: var(--primary);
color: var(--primary-foreground);
}
/* Karte hebt sich beim Zeigen leicht an (nur Maus, nur Ansicht). */
@media (hover: hover) {
.widget-card {
transition: box-shadow 180ms ease-out, translate 180ms ease-out;
}
.widget-card:not(.widget-drag-handle):hover {
translate: 0 -4px;
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 14px 30px oklch(0 0 0 / 0.18);
}
.dark .widget-card:not(.widget-drag-handle):hover {
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.12), 0 16px 34px oklch(0 0 0 / 0.6);
}
}
/* Beim Laden blenden die Kacheln gestaffelt ein (je 80 ms, --enter-delay). */
@media (prefers-reduced-motion: no-preference) {
.widget-card {
animation: widget-enter 520ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
animation-delay: var(--enter-delay, 0ms);
}
}
@keyframes widget-enter {
from {
opacity: 0;
transform: translateY(24px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
/* Aktionen der Kachel erscheinen erst beim Zeigen oder mit Tastaturfokus —
nur auf Geraeten mit Maus; auf Touch bleiben sie sichtbar. */
@media (hover: hover) {
[data-widget-id]:not(:hover):not(:focus-within) .widget-action:not(.widget-action-pinned) {
opacity: 0;
}
}
.widget-action {
transition: opacity 120ms;
}
/* Notiz: ruhige Schrift, Innenabstand wie die Kopfzeile. */
:root .widget-note-body .wmde-markdown {
font-size: 0.875rem;
line-height: 1.5;
}
:root .widget-note-body .w-md-editor-preview,
:root .widget-note-body .wmde-markdown {
padding: 0 0.75rem 0.75rem;
}
:root .widget-note-body .wmde-markdown h1,
:root .widget-note-body .wmde-markdown h2 {
border-bottom: 0;
margin-top: 0.25rem;
font-size: 1rem;
line-height: 1.375rem;
}
/* Bearbeitungsmodus: gestrichelte Akzentkontur und sichtbarer Groessengriff. */
.react-grid-item .widget-drag-handle {
outline: 2px dashed var(--primary-strong);
outline-offset: -2px;
}
.react-grid-item > .react-resizable-handle {
z-index: 20;
}
.react-grid-item > .react-resizable-handle.react-resizable-handle-se {
width: 18px;
height: 18px;
right: 4px;
bottom: 4px;
background-image: none;
border-right: 2px solid var(--primary-strong);
border-bottom: 2px solid var(--primary-strong);
border-bottom-right-radius: 4px;
}
.react-grid-item > .react-resizable-handle.react-resizable-handle-se::after {
display: none;
}
.react-grid-item.react-grid-placeholder {
background: var(--primary);
opacity: 0.18;
border-radius: 0.5rem;
}
/* Mit Hintergrund: Kacheln als Windows-11-„Mica“ — halbtransparent mit
Unschaerfe. Die einzige Stelle mit Transparenz. Deckkraft so gewaehlt,
dass Text auch vor einem rein schwarzen (hell) bzw. rein weissen, dann auf
55 % abgedunkelten (dunkel) Bild 4,5:1 haelt. Ohne backdrop-filter oder
bei „weniger Transparenz“ bleibt die Kachel deckend. */
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
@media not (prefers-reduced-transparency: reduce) {
.dashboard-has-bg [data-widget-id] {
background-color: oklch(1 0 0 / 0.86);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.2);
}
.dark .dashboard-has-bg [data-widget-id] {
background-color: oklch(0.24 0.01 260 / 0.88);
border-color: oklch(1 0 0 / 0.1);
}
}
}
.dashboard-has-bg [data-widget-id] {
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 2px 6px oklch(0 0 0 / 0.16);
}
/* Begruessung, Befehlsleiste und leerer Zustand: ohne Hintergrund frei auf
der Arbeitsflaeche, mit Hintergrund auf derselben Kachelflaeche wie die
Widgets (deckend als Rueckfall, durchscheinend wo moeglich). */
.dashboard-has-bg .dashboard-chrome {
padding: 0.375rem 0.75rem;
background-color: var(--card);
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 2px 6px oklch(0 0 0 / 0.16);
}
.dashboard-has-bg [role="toolbar"].dashboard-chrome {
padding: 0.25rem;
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
@media not (prefers-reduced-transparency: reduce) {
.dashboard-has-bg .dashboard-chrome {
background-color: oklch(1 0 0 / 0.86);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.2);
}
.dark .dashboard-has-bg .dashboard-chrome {
background-color: oklch(0.24 0.01 260 / 0.88);
}
}
}
/* Notiz-Checklisten: Kaestchen in der Akzentfarbe; der Browser waehlt den
Haken selbst hell oder dunkel passend zur Akzentfarbe. */
:root .widget-note-body input[type="checkbox"] {
accent-color: var(--primary);
width: 1rem;
height: 1rem;
margin: 0 0.5rem 0 0;
vertical-align: -0.15em;
}
:root .widget-note-body .task-list-item {
padding-block: 0.125rem;
line-height: 1.5rem;
}
@@ -93,23 +93,23 @@ export function AdminSidebar() {
// das <nav> darunter trAegt die Navigationssemantik schon; ein zweites // das <nav> darunter trAegt die Navigationssemantik schon; ein zweites
// Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer // Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer
// dieselbe Sache. Der aria-label sitzt darum am <nav>, nicht am <aside>. // dieselbe Sache. Der aria-label sitzt darum am <nav>, nicht am <aside>.
<aside className="flex w-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar"> <aside className="flex w-full shrink-0 flex-col border-b border-border py-2 md:w-[220px] md:border-b-0">
<div className="p-4"> <div className="px-5 pb-1 pt-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-xs font-semibold text-muted-foreground">
{t('admin.label')} {t('admin.label')}
</h2> </h2>
</div> </div>
<nav className="flex flex-col gap-1 px-3" aria-label={t('admin.label')}> <nav className="flex flex-col gap-0.5 px-3" aria-label={t('admin.label')}>
{items {items
.filter((item) => item.show) .filter((item) => item.show)
.map((item) => ( .map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${ className={`nav-item flex h-9 items-center gap-2 rounded-lg px-2.5 text-sm transition-colors ${
pathname.startsWith(item.href) pathname.startsWith(item.href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-muted' : 'text-sidebar-foreground hover:bg-sidebar-hover'
}`} }`}
aria-current={pathname.startsWith(item.href) ? 'page' : undefined} aria-current={pathname.startsWith(item.href) ? 'page' : undefined}
> >
@@ -20,9 +20,10 @@ export interface TesseraLogoProps {
* Schaltet die halbtransparente helle Kontur der Grundplatte im dunklen * Schaltet die halbtransparente helle Kontur der Grundplatte im dunklen
* Erscheinungsbild ein. Auf Flaechen, die unabhaengig vom * Erscheinungsbild ein. Auf Flaechen, die unabhaengig vom
* Erscheinungsbild immer hell sind (etwa das gelbe Anmelde-Panel), muss * Erscheinungsbild immer hell sind (etwa das gelbe Anmelde-Panel), muss
* dies auf `false` gesetzt werden. Vorgabe: `true`. * dies auf `false` gesetzt werden; auf immer dunklen Flaechen (App-Leiste,
* Anmelde-Panel) auf `'always'`. Vorgabe: `true`.
*/ */
plateOutline?: boolean; plateOutline?: boolean | 'always';
/** Zusaetzliche Klassen fuer das aeusserste Element der gewaehlten Variante. */ /** Zusaetzliche Klassen fuer das aeusserste Element der gewaehlten Variante. */
className?: string; className?: string;
/** Zusaetzliche Klassen fuer den Schriftzug (nur `horizontal`). */ /** Zusaetzliche Klassen fuer den Schriftzug (nur `horizontal`). */
@@ -38,7 +39,7 @@ function LogoMark({
ariaLabel, ariaLabel,
}: { }: {
size: number; size: number;
plateOutline: boolean; plateOutline: boolean | 'always';
className?: string; className?: string;
ariaLabel: string; ariaLabel: string;
}) { }) {
@@ -61,7 +62,9 @@ function LogoMark({
fill={BRAND_PLATE} fill={BRAND_PLATE}
strokeWidth={2} strokeWidth={2}
className={ className={
plateOutline plateOutline === 'always'
? 'stroke-white/20'
: plateOutline
? 'stroke-transparent dark:stroke-white/25' ? 'stroke-transparent dark:stroke-white/25'
: 'stroke-transparent' : 'stroke-transparent'
} }
@@ -34,7 +34,7 @@ export function BugReportButton() {
<button <button
type="button" type="button"
onClick={handleClick} onClick={handleClick}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-foreground transition-colors disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
aria-label={t('button')} aria-label={t('button')}
title={t('button')} title={t('button')}
disabled={capturing} disabled={capturing}
@@ -103,7 +103,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
aria-labelledby="bug-report-title" aria-labelledby="bug-report-title"
data-bug-report-ignore="true" data-bug-report-ignore="true"
> >
<div className="w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-lg rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2"> <h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2">
{t('title')} {t('title')}
</h2> </h2>
@@ -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="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t('close')} {t('close')}
</button> </button>
</div> </div>
@@ -179,19 +175,14 @@ 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="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50"
>
{t('cancel')} {t('cancel')}
</button> </button>
<button <button
type="button" type="button"
onClick={handleSend} onClick={handleSend}
disabled={busy} disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="btn btn-primary"
> >
{busy ? t('sending') : t('send')} {busy ? t('sending') : t('send')}
</button> </button>
@@ -15,8 +15,19 @@ import { useTheme } from 'next-themes';
* uebereinstimmen (mounted-Guard wie in AppShell). Der Markdown-Text kommt * uebereinstimmen (mounted-Guard wie in AppShell). Der Markdown-Text kommt
* als Prop von der Server-Seite; dieses Modul importiert `@/lib/changelog` * als Prop von der Server-Seite; dieses Modul importiert `@/lib/changelog`
* bewusst NICHT, damit der Text nicht in Client-Chunks landet. * bewusst NICHT, damit der Text nicht in Client-Chunks landet.
*
* quick-260925-bow: `variant="plain"` laesst Rahmen, Hintergrund und
* Innenabstand weg — so rendert das "Was ist neu"-Fenster seine Abschnitte mit
* demselben Renderer wie die Seite /changelog (Vorgabe `card`, Seite
* unveraendert).
*/ */
export function ChangelogView({ markdown }: { markdown: string }) { export function ChangelogView({
markdown,
variant = 'card',
}: {
markdown: string;
variant?: 'card' | 'plain';
}) {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -27,7 +38,10 @@ export function ChangelogView({ markdown }: { markdown: string }) {
const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light'; const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
return ( return (
<div data-testid="changelog-markdown" className="rounded-md border border-border bg-card p-4"> <div
data-testid="changelog-markdown"
className={variant === 'card' ? 'rounded-md border border-border bg-card p-4' : undefined}
>
<MDEditor.Markdown <MDEditor.Markdown
source={markdown} source={markdown}
rehypePlugins={[[rehypeSanitize]]} rehypePlugins={[[rehypeSanitize]]}
@@ -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();
});
});
@@ -0,0 +1,362 @@
'use client';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { updateDashboardBackgroundAction } from '@/lib/auth-actions';
import {
BACKGROUND_PRESETS,
type DashboardBackground,
NO_BACKGROUND,
presetBackground,
takeLegacyDashboardBackground,
} from '@/lib/dashboard-background';
import {
type DashboardImageMeta,
dashboardImageSrc,
fetchDashboardImages,
uploadDashboardImage,
} from '@/lib/dashboard-images-api';
import { useAuthStore } from '@/lib/stores/auth-store';
/** Schreibt die Wahl in den Auth-Store, sofern noch derselbe Benutzer angemeldet ist. */
function setStoredBackground(userId: string, value: DashboardBackground | null) {
const current = useAuthStore.getState().user;
if (!current || current.id !== userId) return;
useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
}
/**
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
*
* 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(
(value: DashboardBackground) => {
if (!userId) return;
save(userId, value, stored);
},
[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 };
}
/** CSS fuer eine Wahl; Flaechenfarbe oder Bild, jeweils bildschirmfuellend. */
function backgroundStyle(value: DashboardBackground, dark: boolean): CSSProperties | null {
if (value.kind === 'none') return null;
if (value.kind === 'image') {
return {
backgroundImage: `url("${dashboardImageSrc(value.imageId)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
};
}
const css = presetBackground(value.id, dark);
if (!css) return null;
return css.startsWith('url(')
? { backgroundImage: css, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: css };
}
/**
* Bildschirmfuellende Hintergrundebene hinter dem Dashboard. Liegt fest
* unter Kopfzeile und Seitenleiste (beide haben eigene Flaechen). Im dunklen
* Erscheinungsbild werden eigene Bilder abgedunkelt — so bleibt der Text auf
* den durchscheinenden Kacheln auch bei einem sehr hellen Foto lesbar.
*/
export function DashboardBackdrop({ background }: { background: DashboardBackground }) {
const { resolvedTheme } = useTheme();
const dark = resolvedTheme === 'dark';
const style = backgroundStyle(background, dark);
if (!style) return null;
return (
<div
aria-hidden="true"
data-testid="dashboard-backdrop"
data-background={background.kind === 'preset' ? background.id : background.kind}
className={`pointer-events-none fixed inset-0 z-0 ${
background.kind === 'image' ? 'dark:brightness-[0.55]' : ''
}`}
style={style}
/>
);
}
interface BackgroundPickerProps {
value: DashboardBackground;
onChange: (value: DashboardBackground) => void;
}
function sameChoice(a: DashboardBackground, b: DashboardBackground): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === 'preset' && b.kind === 'preset') return a.id === b.id;
if (a.kind === 'image' && b.kind === 'image') return a.imageId === b.imageId;
return true;
}
/**
* Knopf „Hintergrund“ (nur im Bearbeitungsmodus) mit einem kleinen Fenster:
* Vorschaubilder fuer „Keiner“, die eingebauten Hintergruende, die eigenen
* Bilder und eine Kachel zum Hochladen eines neuen Bildes.
*/
export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
const t = useTranslations('dashboard.background');
const { resolvedTheme } = useTheme();
const dark = resolvedTheme === 'dark';
const [open, setOpen] = useState(false);
const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open || images !== null) return;
fetchDashboardImages()
.then(setImages)
.catch(() => setImages([]));
}, [open, images]);
useEffect(() => {
if (!open) return;
function onPointer(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [open]);
async function handleUpload(file: File | undefined) {
if (!file) return;
setError(null);
setUploading(true);
try {
const meta = await uploadDashboardImage(file);
setImages((prev) => [meta, ...(prev ?? [])]);
onChange({ kind: 'image', imageId: meta.id });
} catch (e) {
setError(e instanceof Error ? e.message : t('uploadError'));
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
}
}
const option = (
key: string,
label: string,
choice: DashboardBackground,
preview: CSSProperties,
content?: React.ReactNode,
) => {
const selected = sameChoice(value, choice);
return (
<li key={key}>
<button
type="button"
onClick={() => onChange(choice)}
aria-pressed={selected}
title={label}
className="group flex w-full flex-col items-stretch gap-1 rounded-md text-left focus-visible:outline-none"
>
<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 ${
selected
? 'border-transparent ring-2 ring-foreground'
: 'border-border group-hover:border-input-strong'
}`}
style={preview}
>
{content}
</span>
<span
className={`truncate text-xs ${selected ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}
>
{label}
</span>
</button>
</li>
);
};
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="dialog"
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"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
</svg>
{/* In der App-Leiste ist der Platz knapp: Beschriftung erst ab lg. */}
<span className="hidden lg:inline">{t('button')}</span>
</button>
{open && (
<div
role="dialog"
aria-label={t('title')}
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
>
<h2 className="text-sm font-semibold">{t('title')}</h2>
<p className="mt-0.5 text-xs text-muted-foreground">{t('hint')}</p>
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('builtIn')}</h3>
<ul className="mt-2 grid grid-cols-3 gap-3">
{option(
'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) => {
const css = (dark ? preset.dark : preset.light) ?? preset.light;
return option(
preset.id,
t(`presets.${preset.labelKey}`),
{ kind: 'preset', id: preset.id },
css.startsWith('url(')
? { backgroundImage: css, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: css },
);
})}
</ul>
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('ownImages')}</h3>
<ul className="mt-2 grid max-h-40 grid-cols-3 gap-3 overflow-y-auto p-0.5">
{(images ?? []).map((image) =>
option(
image.id,
image.originalName,
{ kind: 'image', imageId: image.id },
{
backgroundImage: `url("${dashboardImageSrc(image.id)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
},
),
)}
<li>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="group flex w-full flex-col items-stretch gap-1 rounded-md text-left focus-visible:outline-none"
>
<span className="flex h-12 items-center justify-center rounded-md border border-dashed border-input-strong text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring">
{uploading ? (
<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>
)}
</span>
<span className="truncate text-xs text-muted-foreground">{t('upload')}</span>
</button>
</li>
</ul>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={(e) => handleUpload(e.target.files?.[0])}
/>
{images !== null && images.length === 0 && (
<p className="mt-2 text-xs text-muted-foreground">{t('noImages')}</p>
)}
{error && (
<p role="alert" className="mt-2 text-xs text-destructive">
{error}
</p>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,62 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/stores/auth-store';
/** Tageszeit fuer die Begruessung: 5–10 Uhr Morgen, 11–17 Uhr Tag, sonst Abend. */
export function greetingPeriod(date: Date): 'morning' | 'day' | 'evening' {
const hour = date.getHours();
if (hour >= 5 && hour < 11) return 'morning';
if (hour >= 11 && hour < 18) return 'day';
return 'evening';
}
/** Vorname aus dem Anzeigenamen (erstes Wort), sonst der Benutzername. */
export function greetingName(
displayName: string | null | undefined,
username: string | undefined,
): string {
const first = displayName?.trim().split(/\s+/)[0];
return first || username || '';
}
/**
* Ruhige Begruessung (Design „Mosaik“): „Guten Morgen, Max“ plus Wochentag
* und Datum. Seit Runde 3 unten in der Seitenleiste ueber „Einklappen“. Keine Kennzahlen. Die Uhrzeit wird
* erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und
* jede Minute aufgefrischt.
*/
export function DashboardGreeting() {
const t = useTranslations('dashboard.greeting');
const locale = useLocale();
const user = useAuthStore((s) => s.user);
const [now, setNow] = useState<Date | null>(null);
useEffect(() => {
setNow(new Date());
const id = window.setInterval(() => setNow(new Date()), 60_000);
return () => window.clearInterval(id);
}, []);
if (!now) return <div className="h-9" aria-hidden="true" />;
const name = greetingName(user?.displayName, user?.username);
const period = greetingPeriod(now);
const dateLine = new Intl.DateTimeFormat(locale === 'en' ? 'en-GB' : 'de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(now);
// Runde 3: unten in der Seitenleiste statt ueber den Kacheln — zwei
// ruhige Zeilen, nimmt dem Dashboard keinen Platz mehr weg.
return (
<div data-testid="dashboard-greeting" className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">
{name ? t(`${period}Named`, { name }) : t(period)}
</p>
<p className="truncate text-xs text-muted-foreground">{dateLine}</p>
</div>
);
}
@@ -1,7 +1,8 @@
import { Children, isValidElement } from 'react'; import { Children, isValidElement } from 'react';
import { act, cleanup, render, screen } from '@testing-library/react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer'; import { stubResizeObserver } from '@/test/fake-resize-observer';
import { centeringOffset, RESIZE_AXIS_FALLBACK } from './dashboard-grid';
// Mock CSS imports that vitest cannot resolve // Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({})); vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -13,6 +14,8 @@ vi.mock('next-intl', () => ({
const map: Record<string, Record<string, string>> = { const map: Record<string, Record<string, string>> = {
widgets: { widgets: {
emptyHeading: 'No active widgets', emptyHeading: 'No active widgets',
'emptyState.title': 'Your dashboard is still empty',
'emptyState.body': 'Build your start page from tiles.',
emptyBody: 'Click edit to add widgets to your dashboard.', emptyBody: 'Click edit to add widgets to your dashboard.',
addWidget: 'Add widget', addWidget: 'Add widget',
catalogTitle: 'Add widget', catalogTitle: 'Add widget',
@@ -104,7 +107,33 @@ describe('DashboardGrid', () => {
/>, />,
); );
expect(screen.getByText('No active widgets')).toBeInTheDocument(); expect(screen.getByText('Your dashboard is still empty')).toBeInTheDocument();
// Ohne Rueckrufe keine Knoepfe.
expect(screen.queryByRole('button', { name: 'Add widget' })).toBeNull();
});
it('Design „Mosaik“: leerer Zustand bietet drei Vorschlaege und den Katalog an', async () => {
const { DashboardGrid } = await import('./dashboard-grid');
const onQuickAdd = vi.fn();
const onOpenCatalog = vi.fn();
render(
<DashboardGrid
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
widgets={[]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
onQuickAdd={onQuickAdd}
onOpenCatalog={onOpenCatalog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Calendar' }));
expect(onQuickAdd).toHaveBeenCalledWith('calendar');
expect(screen.getByRole('button', { name: 'Clock' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Notes' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add widget' }));
expect(onOpenCatalog).toHaveBeenCalledTimes(1);
}); });
it('renders widget instance when widgets are provided', async () => { it('renders widget instance when widgets are provided', async () => {
@@ -165,7 +194,7 @@ describe('DashboardGrid', () => {
expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument(); expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument();
}); });
it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 8, Breakpoints unveraendert, containerPadding folgt dem margin', async () => { it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => {
captured.props = null; captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
render( render(
@@ -181,7 +210,7 @@ describe('DashboardGrid', () => {
expect(captured.props).not.toBeNull(); expect(captured.props).not.toBeNull();
expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }); expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 });
expect(captured.props?.rowHeight).toBe(20); expect(captured.props?.rowHeight).toBe(20);
expect(captured.props?.margin).toEqual([8, 8]); expect(captured.props?.margin).toEqual([12, 12]);
expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }); expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 });
expect(captured.props?.containerPadding).toBeUndefined(); expect(captured.props?.containerPadding).toBeUndefined();
}); });
@@ -409,7 +438,7 @@ describe('DashboardGrid', () => {
); );
// Leerzustand: der gemessene Knoten ist gar nicht eingehaengt. // Leerzustand: der gemessene Knoten ist gar nicht eingehaengt.
expect(screen.getByText('No active widgets')).toBeInTheDocument(); expect(screen.getByText('Your dashboard is still empty')).toBeInTheDocument();
expect(captured.props).toBeNull(); expect(captured.props).toBeNull();
// Erste Kachel erscheint -> der Raster-<div> wird eingehaengt -> measureRef // Erste Kachel erscheint -> der Raster-<div> wird eingehaengt -> measureRef
@@ -453,3 +482,30 @@ describe('DashboardGrid', () => {
expect(captured.props?.width).toBe(1600); expect(captured.props?.width).toBe(1600);
}); });
}); });
describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
// Favoriten (0,0,6x10) mit einer Kachel direkt darunter (0,10,6x8).
const layout = [
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 },
{ i: 'below', x: 0, y: 10, w: 6, h: 8 },
];
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4];
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 });
});
it('laesst eine freie Groesse unveraendert', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
});
});
describe('centeringOffset (Design „Mosaik“, Runde 3)', () => {
it('stellt belegte Spalten mittig und laesst eine volle Anordnung stehen', () => {
// 24 Spalten auf 1212 px: Spalte 38 px + 12 px Abstand = 50 px je Spalte.
expect(centeringOffset([{ x: 0, w: 12 }], 24, 1212)).toBe(300);
expect(centeringOffset([{ x: 12, w: 12 }], 24, 1212)).toBe(0);
expect(centeringOffset([], 24, 1212)).toBe(0);
});
});
@@ -3,12 +3,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout'; import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout'; import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry'; import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry';
import { WidgetIcon } from './widgets/widget-icon';
import { WidgetWrapper } from './widgets/widget-wrapper'; import { WidgetWrapper } from './widgets/widget-wrapper';
import { TesseraLogo } from '@/components/brand/tessera-logo';
// quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten, // quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten,
// 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen // 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen
@@ -44,6 +45,74 @@ export const WIDGET_DRAG_CANCEL_SELECTOR =
// `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666). // `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true }; export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true };
/**
* Groesse aendern trotz Nachbarn (Design „Mosaik“, Runde 3).
*
* Mit `preventCollision` verwirft react-grid-layout 2.2.3 beim Ziehen am
* Groessengriff die GANZE neue Groesse, sobald sie irgendeinen Nachbarn
* beruehrt (chunk-WGL5FSZH.mjs, onResize: w UND h zurueck auf alt). Wer eine
* Kachel nur schmaler ziehen will und dabei die Maus eine Rasterzeile nach
* unten wackelt, stoesst an die Kachel darunter — und die Breite bleibt
* stehen. Genau so liess sich die Favoriten-Kachel oft nicht schmaler machen.
*
* Diese Regel laeuft vor der Kollisionspruefung: kollidiert die gewuenschte
* Groesse, wird zuerst nur die Breite (alte Hoehe), dann nur die Hoehe (alte
* Breite) uebernommen — was frei ist, gewinnt. Nur fuer Griffe, die x/y nicht
* verschieben (se, e, s); unser Raster zeigt nur den se-Griff.
*/
function overlaps(
a: { x: number; y: number; w: number; h: number },
b: { x: number; y: number; w: number; h: number },
): boolean {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
export const RESIZE_AXIS_FALLBACK: LayoutConstraint = {
name: 'resizeAxisFallback',
constrainSize(item, w, h, handle, context) {
if (handle !== 'se' && handle !== 'e' && handle !== 's') return { w, h };
const current = context.layout.find((l) => l.i === item.i) ?? item;
const others = context.layout.filter((l) => l.i !== item.i);
const free = (cw: number, ch: number) =>
!others.some((o) => overlaps({ x: current.x, y: current.y, w: cw, h: ch }, o));
if (free(w, h)) return { w, h };
if (free(w, current.h)) return { w, h: current.h };
if (free(current.w, h)) return { w: current.w, h };
return { w, h };
},
};
const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS_FALLBACK];
const GRID_MARGIN = 12;
/**
* Seitlicher Versatz, um die belegten Spalten in der Ansicht mittig zu
* stellen (Design „Mosaik“, Runde 3): auf breiten Bildschirmen blieb rechts
* ein leerer Streifen, weil die Kacheln links beginnen. Belegt die Anordnung
* schon (fast) die ganze Breite, bleibt es bei 0.
*/
export function centeringOffset(
items: Array<{ x: number; w: number }> | undefined,
cols: number,
width: number,
): number {
if (!items || items.length === 0 || cols <= 0 || width <= 0) return 0;
const usedCols = Math.min(cols, Math.max(...items.map((l) => l.x + l.w)));
if (usedCols >= cols) return 0;
const colWidth = (width - GRID_MARGIN * (cols + 1)) / cols;
const freeWidth = (cols - usedCols) * (colWidth + GRID_MARGIN);
return Math.max(0, Math.floor(freeWidth / 2));
}
/** Breakpoint zur Breite — gleiche Regel wie react-grid-layout (strikt groesser). */
function breakpointFor(width: number): keyof typeof COLS {
const order = (Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>).sort(
(a, b) => BREAKPOINTS[b] - BREAKPOINTS[a],
);
return order.find((bp) => width > BREAKPOINTS[bp]) ?? 'xxs';
}
interface LayoutItemShape { interface LayoutItemShape {
i: string; i: string;
x: number; x: number;
@@ -60,8 +129,17 @@ interface DashboardGridProps {
isEditMode: boolean; isEditMode: boolean;
onLayoutChange: (allLayouts: ResponsiveLayouts) => void; onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
onRemoveWidget: (id: string) => void; onRemoveWidget: (id: string) => void;
/** Leerer Zustand: oeffnet den Widget-Katalog. */
onOpenCatalog?: () => void;
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
onQuickAdd?: (type: WidgetType) => void;
/** Meldet den seitlichen Versatz der zentrierten Ansicht (0 im Bearbeitungsmodus). */
onInsetChange?: (px: number) => void;
} }
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
/** /**
* quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben. * quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben.
* *
@@ -123,6 +201,9 @@ export function DashboardGrid({
isEditMode, isEditMode,
onLayoutChange, onLayoutChange,
onRemoveWidget, onRemoveWidget,
onOpenCatalog,
onQuickAdd,
onInsetChange,
}: DashboardGridProps) { }: DashboardGridProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [width, setWidth] = useState(1200); const [width, setWidth] = useState(1200);
@@ -203,33 +284,78 @@ export function DashboardGrid({
// stabil bleibt. // stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
// Empty state (D-02) // Zentrieren nur in der Ansicht — im Bearbeitungsmodus braucht es das
// volle Raster, damit Kacheln auch in die freien Spalten rechts passen.
const breakpoint = breakpointFor(width);
const inset = isEditMode
? 0
: centeringOffset(effectiveLayouts[breakpoint] ?? effectiveLayouts.lg, COLS[breakpoint], width);
useEffect(() => {
onInsetChange?.(widgets.length === 0 ? 0 : inset);
}, [inset, onInsetChange, widgets.length]);
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
// Hinzufuegen und der Hauptknopf zum Katalog.
if (widgets.length === 0) { if (widgets.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4"> <div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
{/* Bildmarke statt des frueheren handgezeichneten Vier-Quadrate-Symbols. <div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
Ohne Rahmen und ohne Kontur: die Marke steht hier frei auf dem <span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
Seitenhintergrund, nicht auf einer eigenen Flaeche. */} <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<TesseraLogo variant="mark" size={64} plateOutline={false} /> <rect x="3" y="3" width="7" height="9" rx="1.5" />
<h2 className="text-lg font-semibold text-muted-foreground"> <rect x="14" y="3" width="7" height="5" rx="1.5" />
{t('emptyHeading')} <rect x="14" y="12" width="7" height="9" rx="1.5" />
</h2> <rect x="3" y="16" width="7" height="5" rx="1.5" />
<p className="text-sm text-muted-foreground"> </svg>
{t('emptyBody')} </span>
</p> <h2 className="text-base font-semibold text-foreground">{t('emptyState.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('emptyState.body')}</p>
{onQuickAdd && (
<ul className="mt-4 flex flex-wrap justify-center gap-2">
{QUICK_ADD_TYPES.map((type) => (
<li key={type}>
<button
type="button"
onClick={() => onQuickAdd(type)}
className="inline-flex h-8 items-center gap-2 rounded-full border border-border bg-card px-3 text-sm text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<WidgetIcon type={type} variant="plain" />
{t(WIDGET_REGISTRY[type].nameKey)}
</button>
</li>
))}
</ul>
)}
{onOpenCatalog && (
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
<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"><path d="M12 5v14M5 12h14" /></svg>
{t('addWidget')}
</button>
)}
</div>
</div> </div>
); );
} }
return ( return (
<div ref={measureRef}> // overflow-x-clip: die verschobene Rasterflaeche ragt rechts ueber den
// Rand; `clip` (nicht `hidden`) laesst die Schatten oben/unten stehen.
<div ref={measureRef} className="overflow-x-clip">
<div
data-testid="dashboard-grid-inset"
className="transition-transform duration-200 ease-out motion-reduce:transition-none"
style={inset > 0 ? { transform: `translateX(${inset}px)` } : undefined}
>
<Responsive <Responsive
width={width} width={width}
breakpoints={BREAKPOINTS} breakpoints={BREAKPOINTS}
cols={COLS} cols={COLS}
layouts={effectiveLayouts as ResponsiveLayouts} layouts={effectiveLayouts as ResponsiveLayouts}
rowHeight={20} rowHeight={20}
margin={[8, 8] as [number, number]} margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
constraints={GRID_CONSTRAINTS}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt // containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin). // dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{ dragConfig={{
@@ -244,7 +370,7 @@ export function DashboardGrid({
compactor={FREE_PLACEMENT_COMPACTOR} compactor={FREE_PLACEMENT_COMPACTOR}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)} onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
> >
{widgets.map((widget) => { {widgets.map((widget, index) => {
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType]; const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
return ( return (
<div <div
@@ -264,11 +390,13 @@ export function DashboardGrid({
widget={widget} widget={widget}
isEditMode={isEditMode} isEditMode={isEditMode}
onRemove={onRemoveWidget} onRemove={onRemoveWidget}
enterIndex={index}
/> />
</div> </div>
); );
})} })}
</Responsive> </Responsive>
</div> </div>
</div>
); );
} }
@@ -312,18 +312,20 @@ export function DashboardTabs({
const bar = ( const bar = (
<nav <nav
aria-label={t('tabs.navLabel')} aria-label={t('tabs.navLabel')}
className="flex min-w-0 max-w-full items-center gap-1.5" className="flex h-[var(--header-height)] min-w-0 max-w-full items-center gap-1.5"
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined} title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
> >
{/* Eingelassene Spur (32 px): der aktive Reiter liegt erhaben darauf. */} {/* Reiter im Fluent-Stil auf der dunklen App-Leiste (Design „Mosaik“):
<div className="h-8 min-w-0 max-w-full rounded-lg bg-muted p-0.5 shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)] md:max-w-[min(56vw,720px)]"> volle Leistenhoehe, der aktive Reiter weiss und halbfett mit 2 px
Akzentlinie an der Unterkante der Leiste. */}
<div className="h-[var(--header-height)] min-w-0 max-w-full md:max-w-[min(56vw,720px)]">
<div <div
ref={scrollRef} ref={scrollRef}
role="tablist" role="tablist"
aria-describedby={dashboards.length > 1 ? hintId : undefined} aria-describedby={dashboards.length > 1 ? hintId : undefined}
onScroll={measureOverflow} onScroll={measureOverflow}
style={maskStyle} style={maskStyle}
className="flex h-7 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" className="flex h-full items-stretch gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
> >
{orderedTabs.map((tab, index) => { {orderedTabs.map((tab, index) => {
const isActive = tab.id === activeDashboardId; const isActive = tab.id === activeDashboardId;
@@ -339,16 +341,22 @@ export function DashboardTabs({
}} }}
data-tab-index={index} data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`} data-testid={`dashboard-tab-${tab.id}`}
className={`flex h-7 shrink-0 touch-none items-center rounded-md transition-colors ${ className={`relative flex shrink-0 touch-none items-center transition-colors ${
isActive isActive
? 'bg-card text-foreground shadow-sm' ? 'text-appbar-foreground'
: 'text-muted-foreground hover:bg-card/60 hover:text-foreground' : 'text-appbar-muted hover:text-appbar-foreground'
}`} }`}
onPointerDown={(e) => handlePointerDown(e, tab.id)} onPointerDown={(e) => handlePointerDown(e, tab.id)}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel} onPointerCancel={handlePointerCancel}
> >
{isActive && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-3 bottom-0 h-[2px] rounded-full bg-primary"
/>
)}
{isRenaming ? ( {isRenaming ? (
<input <input
ref={renameInputRef} ref={renameInputRef}
@@ -366,7 +374,7 @@ export function DashboardTabs({
}} }}
maxLength={40} maxLength={40}
aria-label={t('tabs.renameInputLabel')} aria-label={t('tabs.renameInputLabel')}
className="h-6 w-32 rounded border border-border bg-background px-2 text-sm text-foreground" className="h-7 w-32 rounded-md border border-white/25 bg-white/10 px-2 text-sm text-appbar-foreground outline-none focus:border-primary"
/> />
) : ( ) : (
<button <button
@@ -380,8 +388,9 @@ export function DashboardTabs({
tabIndex={isFocusable ? 0 : -1} tabIndex={isFocusable ? 0 : -1}
onClick={() => handleTabClick(tab.id)} onClick={() => handleTabClick(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)} onKeyDown={(e) => handleTabKeyDown(e, index)}
className={`h-7 cursor-grab whitespace-nowrap rounded-md px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${ data-text={tab.name}
isActive ? 'font-medium' : '' className={`inline-flex h-8 cursor-grab flex-col items-center justify-center whitespace-nowrap rounded-md px-3 text-sm transition-colors hover:bg-appbar-hover focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground after:invisible after:h-0 after:overflow-hidden after:font-semibold after:content-[attr(data-text)] ${
isActive ? 'font-semibold' : ''
}`} }`}
> >
{tab.name} {tab.name}
@@ -394,7 +403,7 @@ export function DashboardTabs({
onClick={() => startRename(tab)} onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')} aria-label={t('tabs.renameButtonLabel')}
title={t('tabs.renameButtonLabel')} title={t('tabs.renameButtonLabel')}
className="-ml-1.5 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="-ml-1.5 rounded p-1 text-appbar-muted hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -419,7 +428,7 @@ export function DashboardTabs({
onClick={() => setPendingDeleteId(tab.id)} onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')} aria-label={t('tabs.deleteButtonLabel')}
title={t('tabs.deleteButtonLabel')} title={t('tabs.deleteButtonLabel')}
className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`} className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-appbar-muted hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground`}
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -453,7 +462,7 @@ export function DashboardTabs({
onClick={onCreate} onClick={onCreate}
aria-label={t('tabs.add')} aria-label={t('tabs.add')}
title={t('tabs.add')} title={t('tabs.add')}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-white/20 text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -498,7 +507,7 @@ export function DashboardTabs({
role="alertdialog" role="alertdialog"
aria-modal="true" aria-modal="true"
aria-label={t('tabs.deleteDialogTitle')} aria-label={t('tabs.deleteDialogTitle')}
className="relative z-[60] mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl" className="relative z-[60] mx-4 max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-xl"
> >
<h3 className="mb-2 text-lg font-semibold text-foreground"> <h3 className="mb-2 text-lg font-semibold text-foreground">
{t('tabs.deleteDialogTitle')} {t('tabs.deleteDialogTitle')}
@@ -510,7 +519,7 @@ export function DashboardTabs({
<button <button
type="button" type="button"
onClick={() => setPendingDeleteId(null)} onClick={() => setPendingDeleteId(null)}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" className="btn btn-secondary"
> >
{tCommon('cancel')} {tCommon('cancel')}
</button> </button>
@@ -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,13 +21,9 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className={`inline-flex items-center justify-center rounded-md p-2 shadow-lg transition-colors ${ className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`}
isEditMode
? 'bg-primary text-primary-foreground hover:opacity-90'
: 'border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
aria-pressed={isEditMode} aria-pressed={isEditMode}
aria-label={isEditMode ? t('saveChanges') : t('editMode')} aria-label={isEditMode ? undefined : t('editShort')}
title={isEditMode ? t('saveChanges') : t('editMode')} title={isEditMode ? t('saveChanges') : t('editMode')}
> >
{isEditMode ? ( {isEditMode ? (
@@ -35,8 +31,8 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<svg <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="20" width="16"
height="20" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -46,13 +42,14 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
> >
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
) : ( ) : null}
{isEditMode ? null : (
// Pencil icon // Pencil icon
<svg <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="20" width="16"
height="20" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -64,6 +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') : null}
</button> </button>
); );
} }
@@ -74,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(WIDGET_CONSTRAINTS).toEqual({ expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 }, calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 }, calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 }, favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
@@ -50,7 +50,9 @@ export const WIDGET_CONSTRAINTS: Record<
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe // quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block. // fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 }, // Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin.
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
// Phase 8 new widgets (D-01) // Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat // minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
@@ -313,7 +313,7 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
// Klassen stehen woertlich als Literale, damit der Tailwind-Scanner sie findet. // Klassen stehen woertlich als Literale, damit der Tailwind-Scanner sie findet.
const baseBtn = 'h-full min-h-7 cursor-pointer select-none active:scale-95'; const baseBtn = 'h-full min-h-7 cursor-pointer select-none active:scale-95';
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground text-[clamp(11px,min(4cqw,4.5cqh),40px)]`; const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`; const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-[clamp(10px,min(3.2cqw,3.6cqh),32px)]`; const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-[clamp(10px,min(3.2cqw,3.6cqh),32px)]`;
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`; const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
// Speicherzeile bleibt bewusst klein (h-7 text-xs) — Nebenfunktion, kein Hauptinhalt. // Speicherzeile bleibt bewusst klein (h-7 text-xs) — Nebenfunktion, kein Hauptinhalt.
@@ -283,6 +283,27 @@ export function formatEventDate(event: CalendarEvent): string {
}).format(start); }).format(start);
} }
/**
* Abstand des Terminbeginns zu heute in Kalendertagen (0 = heute,
* 1 = morgen, negativ = laeuft seit gestern oder frueher). Gezaehlt wird in
* Ortszeit, nicht in 24-Stunden-Bloecken.
*/
export function eventDayOffset(event: CalendarEvent, now: Date = new Date()): number {
const start = new Date(event.start);
const a = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
const b = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round((a - b) / 86_400_000);
}
/** Kurzes Datum ohne Uhrzeit, „Sa., 03.10.“. */
export function formatEventDay(event: CalendarEvent): string {
return new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}).format(new Date(event.start));
}
/** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */ /** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */
export function formatEventTime(iso: string): string { export function formatEventTime(iso: string): string {
return new Intl.DateTimeFormat('de-DE', { return new Intl.DateTimeFormat('de-DE', {

Some files were not shown because too many files have changed in this diff Show More