Compare commits
57 Commits
c294bfddf2
...
v1.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 12eea333ba | |||
| bdaa3c8db5 | |||
| 03026d616f | |||
| 105b66ed8d | |||
| cb45d2663a | |||
| 69d17302e1 | |||
| 0aaa15240d | |||
| 76f6d87973 | |||
| 9fa0a3f498 | |||
| 76d17fe3c9 | |||
| a8a910fd29 | |||
| 8440db8c6e | |||
| 8532b63097 | |||
| b06a2d1066 | |||
| 228112014c | |||
| 76520b2010 | |||
| 30e4bc3a4f | |||
| 219038b3ee | |||
| 522cdfd368 | |||
| b80db49c5e | |||
| 7192be223a | |||
| 3c62a1a29c | |||
| 3fc33e3507 | |||
| acfffa3097 | |||
| 59e9c34fa7 | |||
| b3b7b5d5e5 | |||
| 2aeb3e8ce3 | |||
| 4fa5aafc54 | |||
| 5ae9aaa0d6 | |||
| 25c8db746a | |||
| 187fb76c91 | |||
| cf7784e409 | |||
| 59db32a01f | |||
| b35edd5f31 | |||
| 9225ed1bf9 | |||
| da0ee8256f | |||
| dd54ec5d42 | |||
| b10734f382 | |||
| dd09c08311 | |||
| 602a45c8b6 | |||
| 586da44fa1 | |||
| 377b6e37b2 | |||
| a217d606bb | |||
| 92bf1307f6 | |||
| a906c6705d | |||
| 8bfa4fc467 | |||
| 60b6973933 | |||
| 57a4196c4b | |||
| 0b659d67e8 | |||
| 7416a925a6 | |||
| 57c338fe53 | |||
| 0fa7ce0f44 | |||
| 3d266418fc | |||
| 61f95c8c52 | |||
| 7704372c3c | |||
| bf4384ad73 | |||
| b03ffb5d10 |
+11
-3
@@ -7,7 +7,7 @@ status: verified
|
|||||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||||
last_updated: "2026-09-23T15:30:00.000Z"
|
last_updated: "2026-09-23T15:30:00.000Z"
|
||||||
last_activity: 2026-09-23
|
last_activity: 2026-09-23
|
||||||
last_activity_desc: Quick 260923-le6 — letzte Abnahmebefunde zum Proxmox-Modul behoben (PMG-Teilsumme, Aktualisieren-Knopf nur fuer Admins); 1.3.1 laeuft auf live (Nutzer bestaetigt)
|
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%
|
||||||
|
|
||||||
@@ -469,6 +469,14 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
|||||||
| 260923-ku6 | **Drei Befunde aus dem Proxmox-Rundgang behoben.** (1) „Verbindung testen“ pruefte den GESPEICHERTEN Stand statt der Eingabe — wer den Zugang tippt und vor dem Speichern testet, bekam die Antwort zum alten Wert; jetzt eigene Route `POST servers/test` mit Merge-Regel: normale Felder folgen dem Formular (auch geleert), Geheimnisfelder folgen „leer → gespeicherten Wert behalten“, weil das Formular Geheimnisse nie vorbefuellt. (2) Ein frisch angelegter Server zeigte „Ein unerwarteter Fehler ist aufgetreten“, obwohl nur noch nichts abgefragt war — jetzt eigener ruhiger Zustand mit Verweis auf „Jetzt aktualisieren“. (3) Die Klasse `uppercase` faerbte die ganze Zeile und zeigte die Adresse als „HTTPS://…“ — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, 53 Warnungen gehalten (eine neu ausgeloeste `useOptionalChain`-Warnung gleich mit aufgeloest). | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
| 260923-ku6 | **Drei Befunde aus dem Proxmox-Rundgang behoben.** (1) „Verbindung testen“ pruefte den GESPEICHERTEN Stand statt der Eingabe — wer den Zugang tippt und vor dem Speichern testet, bekam die Antwort zum alten Wert; jetzt eigene Route `POST servers/test` mit Merge-Regel: normale Felder folgen dem Formular (auch geleert), Geheimnisfelder folgen „leer → gespeicherten Wert behalten“, weil das Formular Geheimnisse nie vorbefuellt. (2) Ein frisch angelegter Server zeigte „Ein unerwarteter Fehler ist aufgetreten“, obwohl nur noch nichts abgefragt war — jetzt eigener ruhiger Zustand mit Verweis auf „Jetzt aktualisieren“. (3) Die Klasse `uppercase` faerbte die ganze Zeile und zeigte die Adresse als „HTTPS://…“ — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, 53 Warnungen gehalten (eine neu ausgeloeste `useOptionalChain`-Warnung gleich mit aufgeloest). | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
||||||
| 260923-ku6 | **Drei Nachbesserungen aus dem Browser-Rundgang zu 260923-dhh (Proxmox-Modul).** Befund 1 (wichtig): „Verbindung testen" pruefte den gespeicherten Server statt des Formulars — im Formular abgeschaltete Zertifikatspruefung oder ein neu eingetipptes Geheimnis griffen erst nach dem Speichern. Fix: neues `TestProxmoxServerDto` + Merge-Baustein `resolveEffectiveTestServer` in `ProxmoxService`, neue Route `POST servers/test` fuer die Neuanlage (noch kein gespeicherter Server), Geheimnisfelder behalten die bestehende „leer gelassen -> gespeicherten Wert weiterverwenden"-Regel. Befund 2 (wichtig): ein frisch angelegter, nie abgefragter Server zeigte faelschlich „Ein unerwarteter Fehler ist aufgetreten" statt eines ruhigen Hinweises — behoben ueber `status.lastPolledAt === null`. Befund 3 (kosmetisch): `uppercase` faerbte die ganze Statuszeile inkl. Adresse gross — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
| 260923-ku6 | **Drei Nachbesserungen aus dem Browser-Rundgang zu 260923-dhh (Proxmox-Modul).** Befund 1 (wichtig): „Verbindung testen" pruefte den gespeicherten Server statt des Formulars — im Formular abgeschaltete Zertifikatspruefung oder ein neu eingetipptes Geheimnis griffen erst nach dem Speichern. Fix: neues `TestProxmoxServerDto` + Merge-Baustein `resolveEffectiveTestServer` in `ProxmoxService`, neue Route `POST servers/test` fuer die Neuanlage (noch kein gespeicherter Server), Geheimnisfelder behalten die bestehende „leer gelassen -> gespeicherten Wert weiterverwenden"-Regel. Befund 2 (wichtig): ein frisch angelegter, nie abgefragter Server zeigte faelschlich „Ein unerwarteter Fehler ist aufgetreten" statt eines ruhigen Hinweises — behoben ueber `status.lastPolledAt === null`. Befund 3 (kosmetisch): `uppercase` faerbte die ganze Statuszeile inkl. Adresse gross — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
||||||
| 260923-le6 | **Zwei Abnahmebefunde zum Proxmox-Modul behoben.** (1) `sumOrNull` in `normalizePmg` liefert jetzt `null`, sobald EIN Teilwert (Spam/Viren je Richtung) fehlt — vorher stille Teilsumme als vollstaendige Zahl (Blocker aus 260923-dhh-VERIFICATION, Wahrheit 7). (2) „Jetzt aktualisieren“ nur noch fuer ADMIN/SUPER_ADMIN sichtbar (Endpunkt verlangte das schon); `ServerCard` bekommt `isAdmin`, Nicht-Admins lesen bei nie abgefragtem Server „Die Werte erscheinen nach der naechsten automatischen Abfrage“ statt eines Verweises auf den Knopf. Neuer Seitentest `proxmox-page-roles.test.tsx` (5 Rollenfaelle). Offener Randfall: inaktiver, nie abgefragter Server — Text passt dort nicht ganz, Nutzerentscheidung. Proxmox-Tests api 82, web 26 gruen; Typpruefung beider Seiten fehlerfrei; Biome ohne neue Befunde. | 2026-09-23 | c13d657,2eb86e1,2f8dd14,e1b191b | [260923-le6-proxmox-abnahmebefunde-sumornull-null-be](./quick/260923-le6-proxmox-abnahmebefunde-sumornull-null-be/) |
|
| 260923-le6 | **Zwei Abnahmebefunde zum Proxmox-Modul behoben.** (1) `sumOrNull` in `normalizePmg` liefert jetzt `null`, sobald EIN Teilwert (Spam/Viren je Richtung) fehlt — vorher stille Teilsumme als vollstaendige Zahl (Blocker aus 260923-dhh-VERIFICATION, Wahrheit 7). (2) „Jetzt aktualisieren“ nur noch fuer ADMIN/SUPER_ADMIN sichtbar (Endpunkt verlangte das schon); `ServerCard` bekommt `isAdmin`, Nicht-Admins lesen bei nie abgefragtem Server „Die Werte erscheinen nach der naechsten automatischen Abfrage“ statt eines Verweises auf den Knopf. Neuer Seitentest `proxmox-page-roles.test.tsx` (5 Rollenfaelle). Offener Randfall: inaktiver, nie abgefragter Server — Text passt dort nicht ganz, Nutzerentscheidung. Proxmox-Tests api 82, web 26 gruen; Typpruefung beider Seiten fehlerfrei; Biome ohne neue Befunde. | 2026-09-23 | c13d657,2eb86e1,2f8dd14,e1b191b | [260923-le6-proxmox-abnahmebefunde-sumornull-null-be](./quick/260923-le6-proxmox-abnahmebefunde-sumornull-null-be/) |
|
||||||
|
| 260923-fst | **Favoriten-Kachel bis auf eine Spalte schmal ziehbar** (fast): `WIDGET_CONSTRAINTS.favorites.minW` 3 -> 1; Titel kuerzt, Symbol bleibt. Im Browser gezogen: 321 -> 47 px. | 2026-09-23 | b03ffb5 | — |
|
||||||
|
| 260923-bug | **Fehler melden: Bildschirmfoto scheiterte an einem fremden Bild** (fast): `html-to-image` bricht die ganze Aufnahme ab, sobald ein `<img>` ohne CORS nicht nachladbar ist -> Haekchen gesperrt. Jetzt `imagePlaceholder` + `onImageErrorHandler`, zweiter Versuch ohne Bilder/Rahmen. Im echten Linux-Client 1.3.1 nachgestellt (Probe-Bild google favicon) und nach dem Fix gegengeprueft. | 2026-09-23 | bf4384a | — |
|
||||||
|
| 260923-lrr | **Favoriten: eigenes Symbol hochladen, Symbol sofort aktualisiert, Cloudflare-Meldung.** Versionszaehler `iconVersion` an der Symboladresse (`?v=`) statt 24-h-Zwischenspeicher mit fester Adresse (Ursache „neue Logo-Adresse, nichts passiert“); `Cache-Control: private`. Upload PNG/JPEG/GIF/WebP/ICO/SVG bis 512 KB nach Dateiinhalt, Ablage `user-files/favorite-icons/<userId>/<id>.<ext>`, Vorrang vor Logo-Adresse, Entfernen-Knopf. Neue Logo-Adresse wird beim Speichern einmal zur Probe abgerufen; scheitert es (Cloudflare-Pruefung, 403), bleibt das Formular offen mit deutscher Meldung und Hinweis aufs Hochladen. Aufraeumen der Dateien auch beim Loeschen einer Kachel/eines Reiters (T-LRR-07 geschlossen). Browser-Nachweis: rot hochgeladen -> sofort rot (v=1), blau -> sofort blau (v=2), Entfernen -> altes Logo (v=3), httpbin 403 -> Meldung, google favicon -> sofort (v=4). Nachtrag Orchestrator: Zeile `dashboard.service.ts`/`favoriteLink` in der Zugriffsklassifikation. api 1368, web 742 gruen. | 2026-09-23 | 7704372,61f95c8 | [260923-lrr-favoriten-eigenes-symbol-hochladen-und-s](./quick/260923-lrr-favoriten-eigenes-symbol-hochladen-und-s/) |
|
||||||
|
| 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-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
|
||||||
|
|
||||||
@@ -514,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
|
||||||
|
|||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260923-lrr
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
subsystem: apps/api/src/favorites, apps/web/src/components/dashboard/widgets
|
||||||
|
files_modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql
|
||||||
|
- apps/api/src/favorites/favorite-icon-files.ts
|
||||||
|
- apps/api/src/favorites/favorite-icon-files.spec.ts
|
||||||
|
- apps/api/src/favorites/favorites.service.ts
|
||||||
|
- apps/api/src/favorites/favorites.service.spec.ts
|
||||||
|
- apps/api/src/favorites/favorites.controller.ts
|
||||||
|
- apps/api/src/favorites/favorites.controller.spec.ts
|
||||||
|
- docs/anleitung-betrieb.md
|
||||||
|
- apps/web/src/lib/favorites-api.ts
|
||||||
|
- apps/web/src/lib/favorites-api.test.ts
|
||||||
|
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- CHANGELOG.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
autonomous: false
|
||||||
|
requirements: [QUICK-260923-lrr]
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 160000
|
||||||
|
raw_tokens: 160000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Nach dem Speichern einer geaenderten Logo-Adresse zeigt die Favoriten-Kachel sofort das neue Symbol, nicht erst nach 24 Stunden (Bildadresse traegt ?v=<iconVersion>, iconVersion steigt bei jeder Aenderung der Symbolquelle)"
|
||||||
|
- "Laesst sich das Bild unter einer neu eingetragenen Logo-Adresse serverseitig nicht abrufen (z. B. Cloudflare-Pruefung, 403/HTML, nicht erreichbar), wird NICHT gespeichert; das Bearbeitungsformular zeigt eine deutsche Meldung mit dem Hinweis, das Symbol hochzuladen"
|
||||||
|
- "Im Bearbeitungsformular und im Hinzufuegen-Formular laesst sich ein eigenes Symbol hochladen (PNG, JPEG, GIF, WebP, ICO, SVG, hoechstens 512 KB); es erscheint sofort und hat Vorrang vor der Logo-Adresse"
|
||||||
|
- "„Hochgeladenes Symbol entfernen“ loescht das hochgeladene Symbol; die Kachel faellt auf Logo-Adresse bzw. automatische Erkennung zurueck"
|
||||||
|
- "Beim Loeschen eines Favoriten verschwindet auch seine hochgeladene Symboldatei aus user-files/favorite-icons"
|
||||||
|
- "Hochladen, Entfernen und Abrufen eines Symbols gelingt nur dem Besitzer (Benutzer UND Mandant); fremde oder unbekannte Kennung ergibt 404"
|
||||||
|
artifacts:
|
||||||
|
- path: apps/api/src/favorites/favorite-icon-files.ts
|
||||||
|
provides: "FAVORITE_ICON_MAX_BYTES, detectFavoriteIconMime, favoriteIconExtension, resolveFavoriteIconsDir, favoriteIconAbsolutePath"
|
||||||
|
- path: apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql
|
||||||
|
provides: "Spalten uploadedIconMime (TEXT NULL) und iconVersion (INTEGER NOT NULL DEFAULT 0) auf FavoriteLink"
|
||||||
|
- path: apps/api/src/favorites/favorites.service.ts
|
||||||
|
provides: "uploadIcon, removeUploadedIcon, Vorrang hochgeladene Datei in getIconBytes, Dateiloeschung in remove, Abrufprobe fuer neue Logo-Adresse, iconVersion-Erhoehung"
|
||||||
|
- path: apps/api/src/favorites/favorites.controller.ts
|
||||||
|
provides: "POST /favorites/:id/icon (multipart-Feld icon, 512 KB), DELETE /favorites/:id/icon, Cache-Control private"
|
||||||
|
- path: apps/web/src/lib/favorites-api.ts
|
||||||
|
provides: "uploadFavoriteIcon, removeFavoriteIcon, FavoriteRequestError, FAVORITE_ICON_MAX_BYTES, Felder uploadedIconMime/iconVersion"
|
||||||
|
- path: apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||||
|
provides: "versionierte Symbol-Adresse, Datei-Auswahl im Bearbeitungs- und Hinzufuegen-Formular, Entfernen-Knopf, Fehlermeldung im Formular"
|
||||||
|
key_links:
|
||||||
|
- from: "FavoriteIcon (favorites-widget.tsx)"
|
||||||
|
to: "GET /favorites/:id/icon"
|
||||||
|
via: "src /api-proxy/favorites/<id>/icon?v=<iconVersion>; Remount-Key enthaelt iconVersion und uploadedIconMime"
|
||||||
|
- from: "FavoritesService.update/uploadIcon/removeUploadedIcon"
|
||||||
|
to: "FavoriteLink.iconVersion"
|
||||||
|
via: "Prisma-Update mit iconVersion increment 1 bei jeder Aenderung der Symbolquelle"
|
||||||
|
- from: "FavoritesService.getIconBytes"
|
||||||
|
to: "user-files/favorite-icons/<userId>/<id>.<ext>"
|
||||||
|
via: "uploadedIconMime gesetzt -> Datei lesen (Vorrang), sonst iconUrl ueber IconDiscoveryService.fetchIconBytes"
|
||||||
|
- from: "uploadFavoriteIcon (favorites-api.ts)"
|
||||||
|
to: "POST /favorites/:id/icon"
|
||||||
|
via: "FormData mit Feld icon, 413 -> iconTooLarge, 400 -> iconInvalidType"
|
||||||
|
- from: "updateFavorite/createFavorite (favorites-api.ts)"
|
||||||
|
to: "422 aus der Abrufprobe"
|
||||||
|
via: "FavoriteRequestError('iconUrlUnreachable') -> t('favorites.iconUrlUnreachable') im Formular"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick-Aufgabe 260923-lrr: Favoriten — eigenes Symbol hochladen, Symbol-Zwischenspeicher nach Aenderung erneuern
|
||||||
|
|
||||||
|
## Ausgangslage (vom Orchestrator und vom Planer am Code geprueft, Stand b03ffb5)
|
||||||
|
|
||||||
|
Meldung des Nutzers: Beim Bearbeiten eines Favoriten eine eigene Logo-Adresse eintragen „bewirkt nichts“; Verdacht Cloudflare-Pruefung vor dem Bild; falls nicht behebbar, soll man ein Symbol hochladen koennen.
|
||||||
|
|
||||||
|
Befund:
|
||||||
|
|
||||||
|
1. **Zwischenspeicher-Fehler (Vorgabe 1).** `FavoriteIcon` in `favorites-widget.tsx` laedt immer `/api-proxy/favorites/<id>/icon`; `getIcon` in `favorites.controller.ts` antwortet mit `Cache-Control` `public`, 24 h. Die Adresse aendert sich bei neuer Logo-Adresse nicht, der Browser zeigt einen Tag lang das alte Bild. Der vorhandene Remount-Key (`iconUrl|url`) setzt nur die Stufe zurueck, nicht den HTTP-Zwischenspeicher.
|
||||||
|
2. **Cloudflare (Vorgabe 2).** Die Bytes holt der Server bei JEDEM Abruf ueber `IconDiscoveryService.fetchIconBytes`. Eine Cloudflare-Pruefung liefert 403/HTML → 502 → die Kachel faellt auf Stufe `direct` (Favicon der Link-Adresse) zurueck — fuer den Nutzer sieht das wie „nichts passiert“ aus. Umgehen der Bot-Sperre ist ausgeschlossen. Stattdessen: beim Speichern einer NEUEN Logo-Adresse einmal probeweise abrufen (4 s Zeitgrenze, `ICON_FETCH_TIMEOUT_MS`), bei Fehlschlag 422 mit deutscher Meldung; das Formular zeigt die Meldung. Kosten: ein Aufruf einer vorhandenen Methode — billig, wird gebaut.
|
||||||
|
3. **Neu: eigenes Symbol hochladen (Vorgabe 3).** Ablage im Dateibereich nach dem Muster `dashboard-images.service.ts` (quick-260922-hk4): `user-files/favorite-icons/<userId>/<favoriteId>.<ext>`, Dateiname IMMER servergeneriert.
|
||||||
|
4. **Texte (Vorgabe 4)** in Sie-Form, Schluessel in `de.json` UND `en.json`; der heute fest verdrahtete Platzhalter „Logo-URL (optional)“ wird dabei ebenfalls uebersetzbar.
|
||||||
|
|
||||||
|
## Festlegungen des Planers (Claude-Ermessen, gebunden fuer die Ausfuehrung)
|
||||||
|
|
||||||
|
- **Datenmodell:** `FavoriteLink` bekommt genau zwei Spalten: `uploadedIconMime String?` (erkannter Typ des hochgeladenen Symbols; `null` = keins) und `iconVersion Int @default(0)` (Zaehler fuer die Bildadresse). KEINE Pfadspalte: der Pfad ist aus `userId`, `id` und Endung des Typs vollstaendig ableitbar — damit gelangt auch kein Serverpfad in API-Antworten (Prisma liefert die ganze Zeile an den Client). `updatedAt` scheidet als Versionsquelle aus, weil Umsortieren und Titelaenderung sonst alle Symbole neu laden liessen.
|
||||||
|
- **iconVersion steigt** (Prisma `{ increment: 1 }`) genau dann, wenn sich die angezeigte Quelle aendert: gespeicherte `iconUrl` weicht vom alten Wert ab; Symbol hochgeladen; hochgeladenes Symbol entfernt. Nicht bei Titel, Link-Adresse ohne Symbolwechsel oder Reihenfolge. Bestandszeilen starten mit 0 → Adresse `?v=0` unterscheidet sich von der bisherigen unversionierten, alte 24-h-Eintraege im Browser greifen also sofort nicht mehr.
|
||||||
|
- **Vorrang:** hochgeladenes Symbol vor `iconUrl`. Fehlt die Datei trotz gesetztem Typ, wird protokolliert und auf `iconUrl` zurueckgefallen; ohne `iconUrl` → 404.
|
||||||
|
- **Abrufprobe:** nur wenn eine NICHT leere `iconUrl` uebergeben wird, die vom gespeicherten Wert abweicht (bei `update`) bzw. ueberhaupt uebergeben wird (bei `create`). Fehlschlag → `UnprocessableEntityException` (422), nichts wird geschrieben. Der Web-Klient uebersetzt 422 selbst (Schluessel `favorites.iconUrlUnreachable`), damit die Meldung sprachrichtig ist. Interne Adressen (vom SSRF-Schutz abgewiesen) fallen ebenfalls unter 422 — konsistent, denn sie wurden auch bisher nie angezeigt; der Hinweis zeigt auf das Hochladen.
|
||||||
|
- **Hochladen im Bearbeitungsformular:** Datei wird ausgewaehlt und beim Klick auf „Speichern“ hochgeladen (erst PATCH, dann Upload) — passt zu Speichern/Abbrechen. „Hochgeladenes Symbol entfernen“ wirkt sofort (wie Loeschen), das Formular bleibt offen. Im Hinzufuegen-Formular: nach `createFavorite` wird die gewaehlte Datei fuer die neue Kennung hochgeladen; scheitert nur der Upload, bleibt der Favorit angelegt und die Meldung erscheint.
|
||||||
|
- **Tracer-Modus aus (bewusst):** die Architektur ist bereits bewiesen — Dateiablage in `user-files` mit Besitzpruefung (hk4), Multer-Upload mit Routen-Grenze (pi9) und der Symbol-Proxy existieren. Eine duenne Scheibe braechte keine Information; der Ende-zu-Ende-Nachweis ist Aufgabe 3 (Browser). Reihenfolge Schnittstelle zuerst: API (Aufgabe 1), dann Web (Aufgabe 2).
|
||||||
|
- **Keine neuen Pakete.** Erkennung von ICO und SVG per Signatur bzw. Textpruefung, wie `dashboard-image-rules.ts` es fuer vier Formate vormacht.
|
||||||
|
- **Qualitaetsregeln wie bisher:** in Produktionscode keine neue `any`, keine neue `!`-Zusicherung, kein `biome-ignore`; `as unknown as` in `apps/api/src` bleibt bei hoechstens 29 (gezaehlt vom Planer an b03ffb5). `biome lint` auf `favorites-widget.tsx` meldet heute 3 vorbestehende Warnungen (noNonNullAssertion Z. 149, zweimal noImgElement) — die Zahl darf nicht steigen, also KEIN neues `<img>` (keine Vorschau im Formular; die Zeile selbst zeigt das Symbol).
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Favoriten-Symbole aktualisieren sich nach einer Aenderung sofort; eine nicht abrufbare Logo-Adresse wird beim Speichern klar gemeldet statt still gespeichert; je Favorit laesst sich ein eigenes Symbol hochladen und wieder entfernen, abgelegt im Dateibereich und beim Loeschen des Favoriten mit entfernt.
|
||||||
|
|
||||||
|
Purpose: Der Nutzer hat eine Logo-Adresse hinter einer Cloudflare-Pruefung — die ist nicht abrufbar, und selbst eine abrufbare neue Adresse erschien wegen des Zwischenspeichers einen Tag lang nicht. Hochladen ist der verlaessliche Weg.
|
||||||
|
Output: Prisma-Migration, Dateiablage-Hilfen, erweiterter Favoriten-Dienst/-Controller mit Tests, erweiterter Web-Klient und Widget mit Tests, Texte de/en, Changelog und Anleitungen, Browser-Nachweis.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
@apps/api/src/favorites/favorites.service.ts
|
||||||
|
@apps/api/src/favorites/favorites.controller.ts
|
||||||
|
@apps/api/src/dashboard/dashboard-images.service.ts
|
||||||
|
@apps/api/src/dashboard/dashboard-image-rules.ts
|
||||||
|
@apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||||
|
@apps/web/src/lib/favorites-api.ts
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
Vorhandene Vertraege, die der Ausfuehrer nutzt (am Code geprueft, nicht neu erkunden):
|
||||||
|
|
||||||
|
- `forTenant(this.prisma, tenantId, userId)` aus `../prisma/prisma-tenant.extension` — jede Methode des Favoriten-Dienstes laeuft ueber genau einen so gebundenen Klienten.
|
||||||
|
- `IconDiscoveryService.fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }>` — SSRF-geschuetzt, 4 s Zeitgrenze, wirft bei jedem Fehler (blockiert, Zeitgrenze, kein `image/*`, groesser 1 MB, ungueltige Adresse).
|
||||||
|
- `detectImageMime(buffer: Uint8Array): 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' | null` aus `apps/api/src/dashboard/dashboard-image-rules.ts` (reine Funktion, ohne Nest).
|
||||||
|
- `UploadedFileLike { buffer: Buffer; originalname: string; mimetype: string; size: number }` aus `apps/api/src/auth/types/auth-user.ts`.
|
||||||
|
- `FileInterceptor` aus `@nestjs/platform-express`; multers `LIMIT_FILE_SIZE` bildet Nest auf 413 ab (Muster `dashboard-images.controller.ts`).
|
||||||
|
- Controller-Kontext: `extractContext(req)` im Favoriten-Controller (`req.tenantId ?? req.user?.tenantId`) — die neuen Routen nutzen DIESE Quelle, NICHT `@CurrentUser()` (Begruendung im Kopfkommentar des Controllers, 260911-gwh).
|
||||||
|
- Testmuster Dienst: `favorites.service.spec.ts` (Zwei-Klienten-Fake, `makeIconDiscovery` mit `fetchIconBytes`-Attrappe ab Z. 196); echtes Temp-Verzeichnis per Umgebungsschalter wie `dashboard-images.service.spec.ts` Z. 89-93.
|
||||||
|
- Testmuster Controller: `dashboard-images.controller.spec.ts` Z. 1-30 (Attrappe fuer `FileInterceptor`) und Test 2/3.
|
||||||
|
- Testmuster Web-Klient: `apps/web/src/lib/dashboard-images-api.test.ts`.
|
||||||
|
- Next-Rewrite `/api-proxy/:path*` (next.config.ts Z. 38) reicht Query-Parameter und Cookies an die API durch.
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Aufgabe 1: API — Symbol hochladen/entfernen, Vorrang beim Ausliefern, Versionszaehler, Abrufprobe fuer neue Logo-Adresse</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql, apps/api/src/favorites/favorite-icon-files.ts, apps/api/src/favorites/favorite-icon-files.spec.ts, apps/api/src/favorites/favorites.service.ts, apps/api/src/favorites/favorites.service.spec.ts, apps/api/src/favorites/favorites.controller.ts, apps/api/src/favorites/favorites.controller.spec.ts, docs/anleitung-betrieb.md</files>
|
||||||
|
<read_first>apps/api/src/favorites/favorites.service.spec.ts (Z. 1-260, Fake-Aufbau), apps/api/src/dashboard/dashboard-images.service.spec.ts (Z. 40-110, Temp-Verzeichnis), apps/api/src/dashboard/dashboard-images.controller.spec.ts (Z. 1-100), apps/api/prisma/schema.prisma (Z. 414-430, model FavoriteLink)</read_first>
|
||||||
|
<behavior>
|
||||||
|
favorite-icon-files.spec.ts:
|
||||||
|
- detectFavoriteIconMime: PNG/JPEG/GIF/WebP-Signaturen ergeben dieselben Typen wie detectImageMime; Bytes 00 00 01 00 (mindestens 6 Bytes) ergeben 'image/x-icon'; `<svg xmlns=...>`, `<?xml version="1.0"?>` gefolgt von `<svg>`, fuehrendes BOM/Leerzeichen, Kommentar oder `<!DOCTYPE svg ...>` vor `<svg` ergeben 'image/svg+xml'; `<html><svg>`, `<!DOCTYPE html>`, leerer Puffer, Klartext, PDF-Signatur und 00 00 02 00 (CUR) ergeben null
|
||||||
|
- favoriteIconExtension: png, jpg, gif, webp, ico, svg; unbekannter Typ ergibt null
|
||||||
|
- favoriteIconAbsolutePath: liegt unter resolveFavoriteIconsDir()/<userId>/<id>.<ext>; Segmente mit '..', '/', '\\' oder leer ergeben null; unbekannter Typ ergibt null
|
||||||
|
- resolveFavoriteIconsDir beachtet FAVORITE_ICONS_DIR
|
||||||
|
favorites.service.spec.ts (neue describe-Bloecke, Temp-Verzeichnis ueber FAVORITE_ICONS_DIR):
|
||||||
|
- uploadIcon PNG: Datei liegt unter <dir>/<userId>/<id>.png mit genau den Bytes, Zeile hat uploadedIconMime 'image/png' und iconVersion um 1 hoeher, Rueckgabe ist die aktualisierte Zeile
|
||||||
|
- uploadIcon ohne Datei -> BadRequestException; Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert
|
||||||
|
- uploadIcon Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz)
|
||||||
|
- uploadIcon fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben
|
||||||
|
- erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2
|
||||||
|
- getIconBytes mit hochgeladenem Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen
|
||||||
|
- getIconBytes, Typ gesetzt aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck; ohne iconUrl -> NotFoundException
|
||||||
|
- getIconBytes ohne Upload, ohne iconUrl -> NotFoundException (Bestandsverhalten)
|
||||||
|
- removeUploadedIcon: Datei weg, uploadedIconMime null, iconVersion +1; ohne Upload -> Zeile unveraendert, keine Erhoehung; fremd -> NotFoundException
|
||||||
|
- remove() eines Favoriten mit Upload: Zeile und Datei weg; Fehler beim Datei-Entfernen wird geschluckt (Loeschen gelingt trotzdem)
|
||||||
|
- update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft die Probe -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen
|
||||||
|
- update mit unveraenderter iconUrl: keine Probe, keine Erhoehung; update nur Titel: keine Erhoehung; update mit neuer, erreichbarer iconUrl: iconVersion +1
|
||||||
|
- create mit expliziter iconUrl: Probe; Probe wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen
|
||||||
|
favorites.controller.spec.ts (neu):
|
||||||
|
- FileInterceptor wird mit 'icon' und { limits: { fileSize: 512 * 1024, files: 1 } } aufgerufen
|
||||||
|
- getIcon setzt Content-Type aus dem Dienst, Cache-Control 'private, max-age=86400', X-Content-Type-Options nosniff, Content-Security-Policy "default-src 'none'; sandbox"
|
||||||
|
- uploadIcon und removeUploadedIcon reichen tenantId aus req.tenantId (vor req.user.tenantId) und userId aus req.user.id an den Dienst; ohne Mandant -> ForbiddenException
|
||||||
|
- POST ':id/icon' und DELETE ':id/icon' sind als Routen-Metadaten vorhanden
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Zuerst die Tests aus `<behavior>` schreiben und rot sehen, dann umsetzen (Vorgaben 1-3 des Orchestrators, Festlegungen oben).
|
||||||
|
|
||||||
|
(a) Schema und Migration (Festlegung Datenmodell). In `model FavoriteLink` nach `iconUrl` die Felder `uploadedIconMime String?` und `iconVersion Int @default(0)` einfuegen, je mit kurzem Kommentar (quick-260923-lrr). Neue Migration `apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql`: ein `ALTER TABLE "FavoriteLink"` mit `ADD COLUMN "uploadedIconMime" TEXT` und `ADD COLUMN "iconVersion" INTEGER NOT NULL DEFAULT 0`. Deutscher Kopfkommentar im Stil von 20260922120000: wozu die Spalten dienen, dass die Datei unter `user-files/favorite-icons/<userId>/<id>.<ext>` liegt und kein Pfad gespeichert wird (ableitbar), dass die bestehende RLS-Regel auf `FavoriteLink` zeilenbezogen ist und fuer neue Spalten nichts braucht, dass `migrate deploy` beim Start (`apps/api/scripts/migrate-and-start.sh`) die Migration anwendet. Danach `pnpm --filter @tessera/api exec prisma generate` (kein `prisma format` auf das ganze Schema — fremder Diff).
|
||||||
|
|
||||||
|
(b) Neue Datei `apps/api/src/favorites/favorite-icon-files.ts` (rein, ohne Nest/Prisma, Kopfkommentar deutsch nach Muster `dashboard-image-rules.ts`): `FAVORITE_ICON_MAX_BYTES = 512 * 1024`; Typ `FavoriteIconMime` = die vier Typen von `detectImageMime` plus `'image/x-icon'` und `'image/svg+xml'`; `detectFavoriteIconMime(buffer)` ruft zuerst `detectImageMime` (Import aus `../dashboard/dashboard-image-rules`), prueft dann ICO (erste vier Bytes 00 00 01 00, Puffer mindestens 6 Bytes), dann SVG: die ersten 4096 Bytes als UTF-8, BOM und fuehrende Leerzeichen entfernen; der Anfang darf nur aus optionaler XML-Deklaration, beliebig vielen Kommentaren und optionalem DOCTYPE mit Wurzel svg bestehen, dann muss `<svg` folgen, direkt gefolgt von Leerzeichen, `>` oder `/` (ein regulaerer Ausdruck, gross/klein egal); alles andere null, die Funktion wirft nie. `favoriteIconExtension(mime)` bildet auf png/jpg/gif/webp/ico/svg ab, sonst null. `resolveFavoriteIconsDir()` nach Muster `resolveDashboardImagesDir()`: Umgebungsschalter `FAVORITE_ICONS_DIR` (nur Tests), sonst `path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'favorite-icons')`. `favoriteIconAbsolutePath(userId, id, mime)`: null, wenn Endung unbekannt oder ein Segment nicht nur aus Buchstaben, Ziffern und Bindestrich besteht; sonst `path.resolve(base, userId, id + '.' + ext)` mit Pruefung, dass das Ergebnis unter `base + path.sep` liegt (sonst null). Kein Byte aus der Anfrage (insbesondere nicht `originalname`) geht je in einen Pfad.
|
||||||
|
|
||||||
|
(c) `favorites.service.ts`: `Logger` ergaenzen. Kopfkommentar um einen Absatz 260923-lrr erweitern (Ablage, Vorrang, Versionszaehler, halbe Zustaende, Abrufprobe, 404 statt 403). Neue private Hilfe `assertIconUrlLoadable(iconUrl)`: ruft `this.iconDiscovery.fetchIconBytes(iconUrl)`, jeder Fehler wird zu `UnprocessableEntityException('Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.')`. Keine Umgehung von Bot-Sperren, keine anderen Header als die vorhandenen.
|
||||||
|
- `create()`: nach der Widget-Besitzpruefung und vor `create`, wenn `dto.iconUrl` nicht leer ist, `assertIconUrlLoadable(dto.iconUrl)`.
|
||||||
|
- `update()`: im Zweig mit nicht leerer `dto.iconUrl` die Probe nur, wenn der Wert von `link.iconUrl` abweicht; nach dem Aufbau von `data` gilt: ist `data.iconUrl` gesetzt und ungleich `link.iconUrl`, dann `data.iconVersion = { increment: 1 }`.
|
||||||
|
- Neue Methode `uploadIcon(tenantId, id, userId, file: UploadedFileLike | undefined)`: ohne Datei `BadRequestException('Bitte wählen Sie eine Bilddatei aus.')`; Puffer groesser `FAVORITE_ICON_MAX_BYTES` → `PayloadTooLargeException` (zweites Netz); Typ per `detectFavoriteIconMime`, null → `BadRequestException('Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.')`; Zeile ueber den gebundenen Klienten holen, `!link || link.userId !== userId || link.tenantId !== tenantId` → `NotFoundException('FavoriteLink not found')`; Zielpfad per `favoriteIconAbsolutePath(link.userId, link.id, mime)` (null → `InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.')`); Ordner rekursiv anlegen, Datei schreiben (Fehler → protokollieren, dieselbe InternalServerErrorException); dann `favoriteLink.update` mit `uploadedIconMime: mime` und `iconVersion: { increment: 1 }`. Scheitert dieses Update und weicht der neue Pfad vom alten ab, die neue Datei wieder entfernen (Fehler schlucken) und neu werfen. Nach Erfolg: hatte die Zeile vorher einen anderen Typ mit anderem Pfad, die alte Datei entfernen (Fehler protokollieren und schlucken, Muster T-HK4-04). Rueckgabe: aktualisierte Zeile.
|
||||||
|
- Neue Methode `removeUploadedIcon(tenantId, id, userId)`: gleiche Besitzpruefung; `uploadedIconMime === null` → Zeile unveraendert zurueck; sonst Update `uploadedIconMime: null`, `iconVersion: { increment: 1 }`, danach Datei entfernen (Fehler schlucken). Rueckgabe: aktualisierte Zeile.
|
||||||
|
- `remove()`: nach dem Loeschen der Zeile, falls `link.uploadedIconMime` gesetzt, die Datei entfernen (Fehler protokollieren und schlucken).
|
||||||
|
- `getIconBytes()`: Besitzpruefung auf `!link || link.userId !== userId` (404) vorziehen; ist `uploadedIconMime` gesetzt, Datei lesen und `{ contentType: link.uploadedIconMime, body }` liefern; fehlt sie, warnen und weitermachen; danach wie bisher: ohne `iconUrl` 404, sonst `fetchIconBytes`, Fehler → 502. Den Doc-Kommentar anpassen.
|
||||||
|
|
||||||
|
(d) `favorites.controller.ts`: zwei neue Routen direkt nach `getIcon` und vor `@Patch(':id')`: `@Post(':id/icon')` mit `@UseInterceptors(FileInterceptor('icon', { limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 } }))`, Parameter `@Param('id', ParseUUIDPipe)`, `@Req()`, `@UploadedFile() file?: UploadedFileLike` → `uploadIcon`; `@Delete(':id/icon')` mit `ParseUUIDPipe` → `removeUploadedIcon`. Beide ueber `extractContext`. In `getIcon` den bisherigen Wert mit `public` durch `'private, max-age=86400'` ersetzen (die Adresse ist jetzt versioniert, benutzerbezogene Inhalte gehoeren in keinen gemeinsamen Zwischenspeicher wie Nginx Proxy Manager); `nosniff` und die CSP mit `sandbox` bleiben unveraendert — sie decken auch hochgeladene SVG ab. Kopfkommentar-Routenliste um die zwei Routen und den Hinweis `?v=` ergaenzen.
|
||||||
|
|
||||||
|
(e) Tests: `favorites.service.spec.ts` erweitern — der Fake braucht fuer `favoriteLink.update` die Behandlung von `{ increment: n }` bei `iconVersion`, Bestandszeilen im Fake bekommen `uploadedIconMime: null` und `iconVersion: 0`; bestehende Tests, die `create`/`update` mit expliziter `iconUrl` aufrufen, brauchen eine aufloesende `fetchIconBytes`-Attrappe (nur so weit anpassen, wie noetig). Temp-Verzeichnis pro Test ueber `FAVORITE_ICONS_DIR`, danach Umgebung wiederherstellen und Verzeichnis loeschen. Neue Datei `favorites.controller.spec.ts` nach Muster `dashboard-images.controller.spec.ts`.
|
||||||
|
|
||||||
|
(f) `docs/anleitung-betrieb.md` Kap. 6 (Z. ~286, Liste „Hochgeladene Dateien“): „Symbole des Favoriten-Widgets unter `user-files/favorite-icons/<Benutzerkennung>/`“ ergaenzen.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec prisma generate && pnpm --filter @tessera/api exec vitest run src/favorites src/dashboard && pnpm --filter @tessera/api exec tsc --noEmit && pnpm exec biome lint apps/api/src/favorites && test "$(grep -rc 'as unknown as' apps/api/src --include=*.ts | awk -F: '{s+=$2} END {print s}')" -le 29 && grep -c "'private, max-age=86400'" apps/api/src/favorites/favorites.controller.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Alle neuen und bestehenden Tests in src/favorites und src/dashboard gruen; tsc ohne Fehler; biome lint auf apps/api/src/favorites ohne Befund; Migration 20260923160000_favorite_icon_upload vorhanden; `as unknown as` in apps/api/src hoechstens 29; Betriebsanleitung nennt user-files/favorite-icons.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Aufgabe 2: Web — versionierte Symbol-Adresse, Datei-Auswahl beim Bearbeiten und Hinzufuegen, Entfernen-Knopf, Meldungen im Formular, Texte und Doku</name>
|
||||||
|
<files>apps/web/src/lib/favorites-api.ts, apps/web/src/lib/favorites-api.test.ts, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md</files>
|
||||||
|
<read_first>apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (Z. 1-120 und 426-490), apps/web/src/lib/dashboard-images-api.test.ts, apps/web/src/lib/dashboard-images-api.ts (Muster Upload und readMessage)</read_first>
|
||||||
|
<behavior>
|
||||||
|
favorites-api.test.ts (neu, fetch per vi.stubGlobal):
|
||||||
|
- uploadFavoriteIcon schickt POST an /favorites/<id>/icon mit credentials include, FormData mit genau dem Feld icon, OHNE eigenen Content-Type-Header; 200 -> Zeile
|
||||||
|
- uploadFavoriteIcon: Datei groesser 512 KB -> FavoriteRequestError reason iconTooLarge, fetch NICHT aufgerufen; 413 -> iconTooLarge; 400 -> iconInvalidType; 500 -> iconUploadFailed
|
||||||
|
- removeFavoriteIcon schickt DELETE an /favorites/<id>/icon, liefert die Zeile
|
||||||
|
- updateFavorite und createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable; anderer Fehler -> wie bisher Error
|
||||||
|
favorites-widget.test.tsx (neue describe 'Eigenes Symbol (quick-260923-lrr)'):
|
||||||
|
- Proxy-Bild traegt ?v=<iconVersion>; Zeile ohne iconVersion -> ?v=0
|
||||||
|
- Speichern mit neuer Logo-Adresse, updateFavorite liefert iconVersion 1 -> src des Proxy-Bildes endet danach auf ?v=1 (Cache-Bust sichtbar)
|
||||||
|
- Zeile nur mit uploadedIconMime (iconUrl null) -> Proxy-Bild statt Direktbild
|
||||||
|
- Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon('fav-id-1', Datei); Zeile zeigt Ergebnis (neues ?v=), Formular schliesst
|
||||||
|
- updateFavorite wirft FavoriteRequestError('iconUrlUnreachable') -> Text 'favorites.iconUrlUnreachable' INNERHALB des Formulars (role alert), Formular bleibt offen, uploadFavoriteIcon nicht aufgerufen
|
||||||
|
- Upload wirft FavoriteRequestError('iconTooLarge') -> 'favorites.iconTooLarge' im Formular, Formular bleibt offen
|
||||||
|
- Knopf 'favorites.iconRemoveButton' nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon('fav-id-1'), danach verschwindet der Knopf, Formular bleibt offen
|
||||||
|
- Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)
|
||||||
|
- Logo-Adress-Feld zeigt den Platzhalter 'favorites.iconUrlPlaceholder' (kein fest verdrahteter Text mehr)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Tests aus `<behavior>` zuerst, dann umsetzen (Vorgaben 1, 3, 4; Festlegungen oben).
|
||||||
|
|
||||||
|
(a) `favorites-api.ts`: `FavoriteLink` um optionale Felder `uploadedIconMime?: string | null` und `iconVersion?: number` erweitern (optional, weil Testdaten und aeltere Antworten sie nicht tragen). Export `FAVORITE_ICON_MAX_BYTES = 512 * 1024`. Export `type FavoriteErrorReason = 'iconUrlUnreachable' | 'iconTooLarge' | 'iconInvalidType' | 'iconUploadFailed'` und `class FavoriteRequestError extends Error` mit oeffentlichem, schreibgeschuetztem `reason`. `createFavorite`/`updateFavorite`: bei Status 422 `FavoriteRequestError('iconUrlUnreachable')`, sonst unveraendert. Neu `uploadFavoriteIcon(id, file)`: zuerst Groessenpruefung gegen die Konstante (iconTooLarge, ohne Anfrage), dann FormData mit Feld `icon` (Dateiname mitgeben), POST an `${API_URL}/favorites/<id>/icon`, `credentials: 'include'`, KEIN Content-Type-Header (Muster `uploadDashboardImage`); 413 → iconTooLarge, 400 → iconInvalidType, sonst nicht ok → iconUploadFailed. Neu `removeFavoriteIcon(id)`: DELETE `${API_URL}/favorites/<id>/icon`, bei Fehler `Error('Failed to remove favorite icon')`. Kopfkommentar um 260923-lrr ergaenzen.
|
||||||
|
|
||||||
|
(b) `favorites-widget.tsx`:
|
||||||
|
- `FavoriteIcon`: Server-Symbol vorhanden, wenn `fav.iconUrl` oder `fav.uploadedIconMime` gesetzt ist; `proxySrc` = `/api-proxy/favorites/<encodeURIComponent(id)>/icon?v=<fav.iconVersion ?? 0>`. Der Remount-Key an der Aufrufstelle in `FavoriteTile` enthaelt zusaetzlich `iconVersion` und `uploadedIconMime`, damit nach einer Aenderung wieder mit Stufe `proxy` begonnen wird. Kopfkommentar: warum `?v=` (24-h-Zwischenspeicher, Adresse muss sich mit der Quelle aendern).
|
||||||
|
- Neuer Zustand im Widget: `editIconFile: File | null`, `editError: string | null`, `editBusy: boolean`, `newIconFile: File | null` plus Ref auf das Datei-Feld des Hinzufuegen-Formulars (zum Zuruecksetzen). Hilfsfunktion, die einen Fehler auf einen Textschluessel abbildet: `FavoriteRequestError` → `favorites.<reason>`, sonst `favorites.error`; angezeigt wird `t(schluessel)`.
|
||||||
|
- `startEdit`/`cancelEdit` setzen `editIconFile`, `editError`, `editBusy` zurueck.
|
||||||
|
- `handleSaveEdit`: `editBusy` setzen; `updateFavorite` wie bisher; ist eine Datei gewaehlt, danach `uploadFavoriteIcon(id, datei)` und dessen Zeile verwenden; Zeile im Zustand ersetzen und Formular schliessen. Fehler: `editError` setzen, Formular bleibt offen; ist `updateFavorite` gelungen und nur der Upload gescheitert, die aktualisierte Zeile trotzdem uebernehmen. `editBusy` im finally zuruecksetzen.
|
||||||
|
- Neu `handleRemoveIcon(id)`: `removeFavoriteIcon`, Zeile ersetzen, Formular bleibt offen; Fehler → `editError`.
|
||||||
|
- `handleAdd`: nach `createFavorite` und ist `newIconFile` gesetzt, `uploadFavoriteIcon(created.id, datei)`; bei Erfolg dessen Zeile anhaengen, bei Fehler die angelegte Zeile anhaengen und `setError(t(schluessel))`. Danach Felder und Datei-Feld (per Ref, `value = ''`) zuruecksetzen.
|
||||||
|
- Hinzufuegen-Formular: zwischen URL-Feld und Knopf ein Datei-Feld mit sichtbarer Beschriftung `t('favorites.iconUploadLabel')`, `data-testid="favorite-add-icon-upload"`.
|
||||||
|
- Bearbeitungsformular in `FavoriteTile` (neue Props an BEIDEN Aufrufstellen, Liste und Kacheln): Logo-Adress-Feld mit `placeholder` und `aria-label` = `t('favorites.iconUrlPlaceholder')` statt des fest verdrahteten Textes; darunter ein `label` mit `t('favorites.iconUploadLabel')` und `<input type="file">` (`accept` = image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg; `data-testid` = `favorite-icon-upload-<id>`), Hinweiszeile `t('favorites.iconUploadHint')`; bei gesetztem `uploadedIconMime` der Satz `t('favorites.iconUploadedHint')` und ein Knopf `t('favorites.iconRemoveButton')`; `editError` als `<p role="alert">` in `text-destructive` im Formular; Speichern-Knopf waehrend `editBusy` deaktiviert. Klassen im Stil der vorhandenen Felder (text-xs, border-input). KEIN neues `<img>`.
|
||||||
|
- Kopfkommentar der Datei um einen Punkt 260923-lrr ergaenzen.
|
||||||
|
|
||||||
|
(c) Testattrappe in `favorites-widget.test.tsx`: die Fabrik fuer `@/lib/favorites-api` wird asynchron und uebernimmt per `vi.importActual` die echte `FavoriteRequestError` und `FAVORITE_ICON_MAX_BYTES`; `uploadFavoriteIcon` und `removeFavoriteIcon` kommen als `vi.fn()` dazu. Datei-Auswahl per `fireEvent.change(feld, { target: { files: [datei] } })`.
|
||||||
|
|
||||||
|
(d) Texte in `widgets.favorites` (de mit echten Umlauten, en sinngleich):
|
||||||
|
- iconUrlPlaceholder: „Logo-Adresse (optional)“ / „Logo URL (optional)“
|
||||||
|
- iconUploadLabel: „Eigenes Symbol hochladen“ / „Upload custom icon“
|
||||||
|
- iconUploadHint: „PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.“ / „PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.“
|
||||||
|
- iconUploadedHint: „Für diesen Favoriten ist ein eigenes Symbol hochgeladen.“ / „A custom icon has been uploaded for this favorite.“
|
||||||
|
- iconRemoveButton: „Hochgeladenes Symbol entfernen“ / „Remove uploaded icon“
|
||||||
|
- iconUrlUnreachable: „Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.“ / englische Entsprechung
|
||||||
|
- iconTooLarge: „Die Datei ist zu groß – erlaubt sind höchstens 512 KB.“ / „The file is too large – at most 512 KB is allowed.“
|
||||||
|
- iconInvalidType: „Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.“ / „Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.“
|
||||||
|
- iconUploadFailed: „Das Symbol konnte nicht hochgeladen werden.“ / „The icon could not be uploaded.“
|
||||||
|
Meldet der Umlaut-Waechter (`src/messages`) ein korrekt geschriebenes Wort, dieses Wort in `UMLAUT_ALLOWLIST` aufnehmen — nie die Schreibweise verbiegen.
|
||||||
|
|
||||||
|
(e) `CHANGELOG.md`, Abschnitt „Unveröffentlicht“: unter „### Neu“ einen Punkt (Favoriten-Widget: eigenes Symbol je Link hochladen — PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB — beim Hinzufügen und im Bearbeitungsformular; Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“); neuen Unterabschnitt „### Behoben“ mit einem Punkt (nach Ändern der Logo-Adresse erscheint das neue Symbol sofort statt erst nach einem Tag; ist das Bild unter der Adresse nicht abrufbar, etwa wegen einer Cloudflare-Prüfung, sagt das Formular das jetzt, statt still zu speichern). Einfache Worte, Stil der vorhandenen Eintraege.
|
||||||
|
`docs/anleitung-anwender.md` Z. 85 (Tabellenzeile Favoriten, bleibt EINE Zeile): ergaenzen, dass man im Bearbeitungsformular eine Logo-Adresse eintragen oder ein eigenes Symbol hochladen kann (Formate, 512 KB, Vorrang, Entfernen-Knopf) und dass Tessera beim Speichern meldet, wenn eine Seite automatische Abrufe blockiert.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/lib/favorites-api.test.ts src/messages && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/web/src/lib/favorites-api.ts apps/web/src/lib/favorites-api.test.ts apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx && test "$(pnpm exec biome lint apps/web/src/components/dashboard/widgets/favorites-widget.tsx 2>&1 | grep -cE 'favorites-widget.tsx:[0-9]+:[0-9]+ lint/')" -le 3</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Web-Tests in src/components/dashboard, src/lib/favorites-api.test.ts und src/messages gruen (inkl. Umlaut-Waechter); tsc ohne Fehler; biome lint ohne neue Befunde (favorites-widget.tsx hoechstens die 3 vorbestehenden Warnungen); de.json und en.json tragen alle neun neuen Schluessel; CHANGELOG und Anwenderhandbuch ergaenzt.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Aufgabe 3: Browser-Nachweis am lokalen Stack (vom Orchestrator per Playwright MCP)</name>
|
||||||
|
<what-built>Symbol-Upload je Favorit mit Entfernen, versionierte Symbol-Adresse (sofortige Aktualisierung), Abrufprobe mit deutscher Meldung fuer nicht abrufbare Logo-Adressen, Dateiloeschung beim Loeschen des Favoriten.</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
Durchgefuehrt vom Orchestrator, nicht vom Nutzer. Nur lokal — nie auf dem Testserver deployen.
|
||||||
|
1. `docker compose up -d --build web api`; in `docker compose logs api` muss die Migration `20260923160000_favorite_icon_upload` als angewendet erscheinen (migrate-and-start.sh).
|
||||||
|
2. Anmelden, Dashboard in den Bearbeitungsmodus, Favoriten-Kachel (falls keine vorhanden: hinzufuegen, einen Favoriten anlegen).
|
||||||
|
3. Favorit bearbeiten, kleine PNG-Datei waehlen, Speichern: Das Symbol wechselt OHNE Neuladen der Seite. Nachweis ueber das gerenderte `img` (Attribut `src` endet auf `?v=<n>`, `naturalWidth > 0`) und Screenshot — NICHT per `fetch` aus der Seite messen (Fetch-Falle, siehe Memory).
|
||||||
|
4. Erneut bearbeiten, SVG hochladen: Symbol wechselt sofort, `?v=` ist gestiegen.
|
||||||
|
5. „Hochgeladenes Symbol entfernen“: Knopf verschwindet, Kachel zeigt wieder Logo-Adresse bzw. erkanntes Favicon.
|
||||||
|
6. Logo-Adresse auf eine andere oeffentlich abrufbare Bildadresse aendern (z. B. von `https://github.com/favicon.ico` auf `https://www.google.com/favicon.ico`), Speichern: Symbol wechselt sofort.
|
||||||
|
7. Logo-Adresse auf eine nicht abrufbare Adresse setzen (z. B. `https://example.invalid/logo.png`, oder eine Adresse hinter Cloudflare-Pruefung, falls der Nutzer eine nennt), Speichern: deutsche Meldung „Das Bild unter dieser Adresse konnte nicht geladen werden …“ im Formular, Formular bleibt offen, nichts gespeichert.
|
||||||
|
8. Datei ueber 512 KB waehlen: Meldung „Die Datei ist zu groß …“; umbenannte Textdatei als .png: Meldung „Nur Bilder im Format …“.
|
||||||
|
9. Neuen Favoriten mit gewaehlter Datei hinzufuegen: erscheint direkt mit dem hochgeladenen Symbol.
|
||||||
|
10. Favoriten mit hochgeladenem Symbol loeschen, danach `docker compose exec api ls -la /app/user-files/favorite-icons/<userId>/`: keine Datei mit dessen Kennung mehr.
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Orchestrator meldet „bestanden“ mit Screenshot-Pfaden oder beschreibt die Abweichung je Schritt.</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → API (POST /favorites/:id/icon) | nicht vertrauenswuerdige Datei (Bytes, Name, behaupteter Typ) ueberquert hier |
|
||||||
|
| API → Dateisystem (user-files/favorite-icons) | aus Zeilendaten abgeleiteter Pfad wird geschrieben/gelesen/geloescht |
|
||||||
|
| API → fremder Webserver (Abrufprobe, Icon-Proxy) | serverseitiger Abruf einer vom Nutzer genannten Adresse |
|
||||||
|
| API → Browser (GET /favorites/:id/icon) | gespeicherte, evtl. aktive Inhalte (SVG) werden ausgeliefert |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-LRR-01 | Tampering | favoriteIconAbsolutePath | high | mitigate | Dateiname nur aus Zeilen-UUID + Endung des an den Bytes ERKANNTEN Typs; Segmente nur [A-Za-z0-9-]; Ergebnis muss unter resolveFavoriteIconsDir() liegen, sonst null; originalname geht in keinen Pfad |
|
||||||
|
| T-LRR-02 | Elevation of Privilege | GET /favorites/:id/icon mit hochgeladenem SVG | high | mitigate | Typ aus Magic Bytes/SVG-Pruefung statt file.mimetype; Auslieferung behaelt X-Content-Type-Options nosniff und CSP "default-src 'none'; sandbox"; Anzeige nur als img (kein Skript) |
|
||||||
|
| T-LRR-03 | Information Disclosure | uploadIcon/removeUploadedIcon/getIconBytes | high | mitigate | forTenant-Bindung plus Vergleich userId UND tenantId gegen den Sitzungsnachweis, fremd/unbekannt -> 404 (nie 403, kein Existenzorakel); Tests fuer fremden Benutzer und fremden Mandanten |
|
||||||
|
| T-LRR-04 | Denial of Service | POST /favorites/:id/icon | medium | mitigate | multer limits fileSize 512 KB, files 1 (413); zweites Netz im Dienst; genau eine Datei je Favorit (Ueberschreiben, alte Endung wird entfernt) |
|
||||||
|
| T-LRR-05 | Information Disclosure | Cache-Control der Symbolantwort | medium | mitigate | private statt public — kein gemeinsamer Zwischenspeicher (Nginx Proxy Manager) haelt benutzerbezogene Symbole; Versionierung per ?v= macht lange Browser-Zwischenspeicherung trotzdem korrekt |
|
||||||
|
| T-LRR-06 | Spoofing | Abrufprobe beim Speichern | low | accept | nutzt unveraendert fetchIconBytes mit bestehendem SSRF-Schutz (isPublicHttpUrl, Weiterleitungswaechter, 4 s, 1 MB) — derselbe Abruf, den GET /favorites/:id/icon ohnehin ausloest; keine neue Angriffsflaeche, keine Umgehung von Bot-Sperren |
|
||||||
|
| T-LRR-07 | Denial of Service | Datei-Leichen nach Loeschen eines Widgets/Reiters | low | accept | FavoriteLink faellt dort per Datenbank-Kaskade, am Dienst vorbei; Dateien bleiben liegen, sind ohne Zeile nie abrufbar, je Favorit hoechstens 512 KB im eigenen Benutzerordner. Einzelloeschung raeumt auf (Vorgabe erfuellt); Restrisiko im SUMMARY benennen |
|
||||||
|
| T-LRR-08 | Repudiation | halbe Zustaende Upload/Loeschen | low | mitigate | Upload: Datei zuerst, Zeile danach, bei Zeilenfehler neue Datei zurueckgenommen; Loeschen: Zeile zuerst, Dateifehler protokolliert und geschluckt (Muster T-HK4-04) |
|
||||||
|
| T-LRR-SC | Tampering | Paketinstallationen | low | accept | keine neuen Pakete; ICO/SVG-Erkennung ohne Abhaengigkeit |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Nach allen Aufgaben (Ausgangslage vom Planer an b03ffb5 gemessen: api src/favorites 49/49 gruen, web favorites-widget + src/messages 23/23 gruen, beide tsc sauber, biome lint auf den Favoriten-Dateien 3 vorbestehende Warnungen in favorites-widget.tsx):
|
||||||
|
- `pnpm --filter @tessera/api exec vitest run src/favorites src/dashboard` gruen
|
||||||
|
- `pnpm --filter @tessera/web exec vitest run src/components/dashboard src/lib/favorites-api.test.ts src/messages` gruen
|
||||||
|
- `pnpm --filter @tessera/api exec tsc --noEmit` und `pnpm --filter @tessera/web exec tsc --noEmit` sauber
|
||||||
|
- `pnpm exec biome lint` auf allen beruehrten TS-Dateien: keine neuen Befunde
|
||||||
|
- Browser-Nachweis (Aufgabe 3) bestanden
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Geaenderte Logo-Adresse und hochgeladenes Symbol erscheinen ohne Seiten-Neuladen (versionierte Adresse).
|
||||||
|
- Nicht abrufbare Logo-Adresse wird mit deutscher Meldung im Formular abgewiesen.
|
||||||
|
- Upload (PNG, JPEG, GIF, WebP, ICO, SVG, ≤ 512 KB) im Bearbeitungs- und Hinzufuegen-Formular; Entfernen faellt zurueck; Loeschen des Favoriten entfernt die Datei.
|
||||||
|
- Besitzpruefung Benutzer + Mandant fuer alle neuen Wege, 404 fuer Fremdes.
|
||||||
|
- Texte de/en vollstaendig, Umlaut-Waechter gruen; Changelog, Anwender- und Betriebsanleitung ergaenzt.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260923-lrr-favoriten-eigenes-symbol-hochladen-und-s/260923-lrr-SUMMARY.md` when done — mit Restrisiko T-LRR-07 (Datei-Leichen bei Widget-/Reiter-Loeschung) als offenem Punkt.
|
||||||
|
</output>
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260923-lrr
|
||||||
|
plan: 01
|
||||||
|
subsystem: api+web (favorites)
|
||||||
|
tags: [nestjs, prisma, nextjs, upload, cache-busting, favorites]
|
||||||
|
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- "FavoriteLink.uploadedIconMime/iconVersion (Migration 20260923160000)"
|
||||||
|
- "favorite-icon-files.ts: Erkennung/Pfadbildung/Best-effort-Loeschung fuer hochgeladene Favoriten-Symbole"
|
||||||
|
- "FavoritesService.uploadIcon/removeUploadedIcon, Vorrang in getIconBytes, Abrufprobe in create/update"
|
||||||
|
- "POST/DELETE /favorites/:id/icon"
|
||||||
|
- "Web: uploadFavoriteIcon/removeFavoriteIcon/FavoriteRequestError in favorites-api.ts"
|
||||||
|
- "FavoritesWidget: versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf, Fehlermeldung im Formular"
|
||||||
|
- "DashboardService.removeWidget/deleteDashboard raeumen Symboldateien kaskadiert geloeschter Favoriten auf (T-LRR-07)"
|
||||||
|
affects: [dashboard, favorites]
|
||||||
|
|
||||||
|
actuals:
|
||||||
|
tokens: 31900
|
||||||
|
tasks: 2
|
||||||
|
commits: 2
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Dateiablage nach Muster dashboard-images.service.ts: user-files/favorite-icons/<userId>/<id>.<ext>, Dateiname immer servergeneriert"
|
||||||
|
- "Versionszaehler (iconVersion) statt updatedAt fuer Cache-Busting einer Bild-URL"
|
||||||
|
- "Abrufprobe vor dem Speichern einer externen URL statt stiller Speicherung eines nicht ladbaren Wertes"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/src/favorites/favorite-icon-files.ts
|
||||||
|
- apps/api/src/favorites/favorite-icon-files.spec.ts
|
||||||
|
- apps/api/src/favorites/favorites.controller.spec.ts
|
||||||
|
- apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql
|
||||||
|
- apps/web/src/lib/favorites-api.test.ts
|
||||||
|
modified:
|
||||||
|
- apps/api/src/favorites/favorites.service.ts
|
||||||
|
- apps/api/src/favorites/favorites.controller.ts
|
||||||
|
- apps/api/src/dashboard/dashboard.service.ts
|
||||||
|
- apps/web/src/lib/favorites-api.ts
|
||||||
|
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "iconVersion statt updatedAt als Cache-Bust-Quelle, weil Umsortieren/Titelaenderung sonst jedes Symbol neu laden liessen"
|
||||||
|
- "Hochgeladenes Symbol hat Vorrang vor iconUrl; fehlt die Datei trotz gesetztem Typ, faellt der Dienst protokolliert auf iconUrl zurueck statt 404"
|
||||||
|
- "Abrufprobe nur bei neuer/abweichender iconUrl, nicht bei jedem Speichern — vermeidet unnoetige Netzwerkaufrufe"
|
||||||
|
- "T-LRR-07 (im Plan als Restrisiko akzeptiert) zusaetzlich geschlossen: DashboardService raeumt Symboldateien kaskadiert geloeschter Favoriten jetzt best effort auf"
|
||||||
|
|
||||||
|
requirements-completed: [QUICK-260923-lrr]
|
||||||
|
|
||||||
|
duration: 45min
|
||||||
|
completed: 2026-09-23
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick-Aufgabe 260923-lrr: Favoriten — eigenes Symbol hochladen, Zwischenspeicher nach Änderung erneuern (Summary)
|
||||||
|
|
||||||
|
**Favoriten-Symbole bekommen eine versionierte Adresse (`?v=<iconVersion>`), ein hochgeladenes eigenes Symbol (PNG/JPEG/GIF/WebP/ICO/SVG, ≤512 KB) hat Vorrang vor der Logo-Adresse, und eine serverseitige Abrufprobe weist eine nicht ladbare Logo-Adresse beim Speichern mit einer deutschen Meldung ab statt sie still zu übernehmen.**
|
||||||
|
|
||||||
|
## Ausgangslage
|
||||||
|
|
||||||
|
Der Nutzer meldete: Eine eigene Logo-Adresse eintragen „bewirkt nichts“ (Verdacht: Cloudflare-Prüfung vor dem Bild), und selbst eine tatsächlich abrufbare neue Adresse erschien wegen eines 24-Stunden-Zwischenspeichers einen Tag lang nicht. Beide Ursachen wurden am Code bestätigt (siehe `260923-lrr-PLAN.md`, Abschnitt „Ausgangslage“) und in diesem Lauf behoben; zusätzlich lässt sich jetzt ein eigenes Symbol hochladen.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Dauer:** ca. 45 Minuten
|
||||||
|
- **Aufgaben:** 2 von 2 geplanten Aufgaben ausgeführt (Aufgabe 3, Browser-Nachweis, ist ein separater Checkpoint und wird vom Orchestrator im Anschluss durchgeführt — siehe unten)
|
||||||
|
- **Geänderte/neue Dateien:** 19
|
||||||
|
|
||||||
|
## Aufgaben-Commits
|
||||||
|
|
||||||
|
1. **Aufgabe 1: API — Symbol hochladen/entfernen, Vorrang, Versionszähler, Abrufprobe** — `7704372` (feat)
|
||||||
|
2. **Aufgabe 2: Web — versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf, Texte** — `61f95c8` (feat)
|
||||||
|
|
||||||
|
Beide Commits liegen direkt auf `main` (Orchestrator-Vorgabe für diesen Lauf: kein Worktree, `git.allow_default_branch_commits: true` in `.planning/config.json`).
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- **Zwischenspeicher-Fehler behoben:** `GET /favorites/:id/icon` sendet jetzt `Cache-Control: private, max-age=86400` (statt `public`); die Bildadresse im Widget trägt `?v=<iconVersion>`, das bei jeder Änderung der Symbolquelle um eins steigt — ein geändertes Symbol erscheint jetzt ohne Neuladen der Seite.
|
||||||
|
- **Cloudflare-Fall behoben:** Eine neue, explizit eingetragene Logo-Adresse wird beim Speichern einmal serverseitig abgerufen (`assertIconUrlLoadable`, 4 s Zeitgrenze über den vorhandenen `IconDiscoveryService`); scheitert der Abruf, antwortet die API mit 422 und einer deutschen Meldung im Formular — nichts wird gespeichert. Keine Umgehung von Bot-Sperren.
|
||||||
|
- **Eigenes Symbol hochladen:** Neue Felder `FavoriteLink.uploadedIconMime`/`iconVersion` (Migration `20260923160000_favorite_icon_upload`), Ablage unter `user-files/favorite-icons/<userId>/<id>.<ext>` (Dateiname immer servergeneriert, Muster `dashboard-images.service.ts`), Erkennung von PNG/JPEG/GIF/WebP/ICO/SVG an den Bytes (`favorite-icon-files.ts`). Vorrang vor `iconUrl` beim Ausliefern. Hochladen und Entfernen im Bearbeitungs- und Hinzufügen-Formular des Widgets.
|
||||||
|
- **T-LRR-07 zusätzlich geschlossen** (im Plan als Restrisiko mit Disposition „accept“ eingetragen, siehe unten): Löscht ein Nutzer ein ganzes Widget oder einen Dashboard-Reiter, entfernt die Datenbank-Kaskade (`onDelete: Cascade`) die betroffenen `FavoriteLink`-Zeilen, ohne den Favoriten-Dienst zu durchlaufen — dessen Datei-Aufräumung griff dort bisher nicht. `DashboardService.removeWidget`/`deleteDashboard` merken sich jetzt vor der Kaskade, welche Favoriten ein hochgeladenes Symbol tragen, und entfernen deren Dateien danach best effort (nie blockierend für das Löschen selbst).
|
||||||
|
|
||||||
|
## Dateien erstellt/geändert
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- `apps/api/prisma/schema.prisma` — `FavoriteLink.uploadedIconMime`/`iconVersion`
|
||||||
|
- `apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql` — neue Migration, lokal angewendet (siehe Verifikation)
|
||||||
|
- `apps/api/src/favorites/favorite-icon-files.ts` (neu) — Erkennung, Pfadbildung, best-effort Dateientfernung
|
||||||
|
- `apps/api/src/favorites/favorite-icon-files.spec.ts` (neu) — 15 Tests
|
||||||
|
- `apps/api/src/favorites/favorites.service.ts` — `uploadIcon`/`removeUploadedIcon`, Abrufprobe, Vorrang in `getIconBytes`
|
||||||
|
- `apps/api/src/favorites/favorites.service.spec.ts` — 49 Tests (24 neu)
|
||||||
|
- `apps/api/src/favorites/favorites.controller.ts` — `POST`/`DELETE /favorites/:id/icon`, `Cache-Control: private`
|
||||||
|
- `apps/api/src/favorites/favorites.controller.spec.ts` (neu) — 7 Tests
|
||||||
|
- `apps/api/src/dashboard/dashboard.service.ts` — T-LRR-07-Aufräumung in `removeWidget`/`deleteDashboard`
|
||||||
|
- `apps/api/src/dashboard/dashboard.service.spec.ts` — 66 Tests (5 neu)
|
||||||
|
- `docs/anleitung-betrieb.md` — `user-files/favorite-icons/` ergänzt
|
||||||
|
|
||||||
|
**Web:**
|
||||||
|
- `apps/web/src/lib/favorites-api.ts` — `FavoriteRequestError`, `uploadFavoriteIcon`/`removeFavoriteIcon`, `FAVORITE_ICON_MAX_BYTES`
|
||||||
|
- `apps/web/src/lib/favorites-api.test.ts` (neu) — 10 Tests
|
||||||
|
- `apps/web/src/components/dashboard/widgets/favorites-widget.tsx` — versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf, Fehlermeldung im Formular
|
||||||
|
- `apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx` — 27 Tests (10 neu, 1 bestehender Test an `?v=0` angepasst)
|
||||||
|
- `apps/web/src/messages/de.json`/`en.json` — neun neue Texte unter `widgets.favorites`
|
||||||
|
- `CHANGELOG.md`, `docs/anleitung-anwender.md` — ergänzt
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
- **iconVersion statt updatedAt:** `updatedAt` scheidet als Versionsquelle aus, weil Umsortieren oder eine Titeländerung sonst jedes Symbol neu laden ließen. `iconVersion` steigt gezielt nur bei einer Änderung der Symbolquelle.
|
||||||
|
- **Vorrang und Rückfall:** Ein hochgeladenes Symbol hat Vorrang vor `iconUrl`. Fehlt die Datei trotz gesetztem Typ (praktisch nur bei einer manuellen Änderung am Dateisystem denkbar), protokolliert der Dienst eine Warnung und fällt auf `iconUrl` zurück, statt 404 zu werfen — das entspricht dem im Plan festgelegten Verhalten.
|
||||||
|
- **Abrufprobe nur bei Änderung:** Die Probe läuft nur, wenn eine neue oder gegenüber der Zeile abweichende `iconUrl` übergeben wird — nicht bei jedem Speichern. Vermeidet unnötige Netzwerkaufrufe beim bloßen Ändern von Titel oder Reihenfolge.
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
### Vom Orchestrator angeordnete Zusatzanforderung (kein Regelabweichungsfund, sondern expliziter Auftrag)
|
||||||
|
|
||||||
|
**1. T-LRR-07 geschlossen — Datei-Leichen nach Kaskadenlöschung eines Widgets/Reiters**
|
||||||
|
- **Gefunden während:** vor Aufgabe 1, auf ausdrückliche Anweisung des Orchestrators (Constraint „closes the plan's accepted gap T-LRR-07“)
|
||||||
|
- **Befund:** `DashboardService.removeWidget` (einzelnes Widget) und `DashboardService.deleteDashboard` (ganzer Reiter) löschen `WidgetInstance`-Zeilen; `FavoriteLink.widgetId` trägt `onDelete: Cascade`, wodurch die Datenbank die zugehörigen Favoriten-Zeilen mitlöscht, OHNE `FavoritesService.remove()` zu durchlaufen — dessen Datei-Aufräumung griff dort also nicht. Der Plan hatte dies als Restrisiko T-LRR-07 mit Disposition „accept“ eingetragen (Dateien bleiben liegen, sind ohne Zeile nie abrufbar, höchstens 512 KB je Favorit).
|
||||||
|
- **Fix:** `favorite-icon-files.ts` bekam eine zusätzliche, nie werfende Funktion `removeFavoriteIconFileBestEffort(userId, id, mime)`. `DashboardService.removeWidget`/`deleteDashboard` lesen VOR der Löschung die betroffenen Favoriten mit gesetztem `uploadedIconMime` (ein reiner Lesezugriff, außerhalb der Löschtransaktion) und entfernen NACH erfolgreicher Löschung deren Dateien best effort — ein Dateifehler wird protokolliert und geschluckt, er kann das Löschen des Widgets/Reiters nie verhindern oder zurücknehmen (Muster T-HK4-04).
|
||||||
|
- **Dateien geändert:** `apps/api/src/favorites/favorite-icon-files.ts`, `apps/api/src/dashboard/dashboard.service.ts`, `apps/api/src/dashboard/dashboard.service.spec.ts`
|
||||||
|
- **Tests:** 5 neue Tests in `dashboard.service.spec.ts` (Datei wird entfernt, fehlende Datei wird geschluckt, Favoriten ohne Symbol lösen keinen Dateizugriff aus — für beide Methoden je Fall bzw. anteilig)
|
||||||
|
- **Verifikation:** `pnpm --filter @tessera/api exec vitest run src/dashboard src/favorites` grün (218 Tests)
|
||||||
|
- **Commit:** `7704372` (Teil des Aufgabe-1-Commits, da API-seitig und eng an `favorite-icon-files.ts` gekoppelt)
|
||||||
|
|
||||||
|
**Restrisiko nach diesem Fix:** Das Löschen eines EINZELNEN Favoriten über `DELETE /favorites/:id` sowie die beiden neuen Wege raumen die Datei immer auf. Ein denkbarer Rest bleibt nur, wenn ein Dateisystemfehler ausgerechnet beim best-effort-Entfernen auftritt (protokolliert, nie blockierend) — dasselbe Restrisiko, das die Bilderrahmen-Funktion (`dashboard-images.service.ts`, T-HK4-04) für ihre Bilder ebenfalls bewusst trägt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Gesamt:** 1 Zusatzanforderung umgesetzt (kein Regel-1/2/3-Fund im eigentlichen Sinn, da vom Orchestrator vorgegeben statt während der Ausführung entdeckt — inhaltlich entspricht die Umsetzung Regel 2, fehlende sicherheits-/korrektheitsrelevante Funktionalität).
|
||||||
|
**Auswirkung auf den Plan:** Kein Scope Creep über die Orchestrator-Vorgabe hinaus; alle übrigen Aufgaben wurden wie im Plan spezifiziert umgesetzt.
|
||||||
|
|
||||||
|
## Verifikation (durchgeführt)
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter @tessera/api exec prisma generate # OK
|
||||||
|
pnpm --filter @tessera/api exec vitest run src/favorites src/dashboard # 218/218 grün
|
||||||
|
pnpm --filter @tessera/api exec tsc --noEmit # sauber
|
||||||
|
pnpm exec biome lint apps/api/src/favorites apps/api/src/dashboard # keine Befunde
|
||||||
|
pnpm --filter @tessera/web exec vitest run src/components/dashboard \
|
||||||
|
src/lib/favorites-api.test.ts src/messages # 276/276 grün
|
||||||
|
pnpm --filter @tessera/web exec tsc --noEmit # sauber
|
||||||
|
pnpm exec biome lint apps/web/src/lib/favorites-api.ts \
|
||||||
|
apps/web/src/lib/favorites-api.test.ts \
|
||||||
|
apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx # keine Befunde
|
||||||
|
biome lint favorites-widget.tsx # 3 Befunde (alle vorbestehend, wie vom Plan erlaubt)
|
||||||
|
grep -rc "as unknown as" apps/api/src # ≤ 29 (Plan-Obergrenze eingehalten)
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration `20260923160000_favorite_icon_upload` wurde lokal über die Container-IP der `db` mit `prisma migrate deploy` angewendet (Vorgabe des Orchestrators: `tessera:tessera_dev`, kein Host-Port).
|
||||||
|
|
||||||
|
## Bekannte Stubs
|
||||||
|
|
||||||
|
Keine — jede neu geschriebene Funktion ist mit echten Daten verdrahtet, keine Platzhalter.
|
||||||
|
|
||||||
|
## Aufgabe 3 — Browser-Nachweis (noch offen, Orchestrator)
|
||||||
|
|
||||||
|
Aufgabe 3 des Plans (`checkpoint:human-verify`, `gate="blocking"`) ist ein separater Verifikationsschritt am lokalen Docker-Stack (Symbol hochladen/entfernen, Cache-Bust im Browser, Abrufprobe, Dateigrößen-/Typgrenzen, Dateiaufräumung nach Löschen) und wird laut Auftrag NICHT von diesem Ausführungslauf durchgeführt — das übernimmt der Orchestrator im Anschluss per Playwright MCP. Diese SUMMARY dokumentiert ausschließlich die abgeschlossenen Aufgaben 1 und 2.
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
- Orchestrator: Aufgabe 3 (Browser-Nachweis) durchführen, siehe `260923-lrr-PLAN.md`.
|
||||||
|
- Kein Blocker für Aufgabe 3 aus Sicht der API/Web-Implementierung — alle automatisierten Prüfungen sind grün, die Migration ist lokal bereits angewendet.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Quick-Aufgabe: 260923-lrr*
|
||||||
|
*Abgeschlossen (Aufgaben 1–2): 2026-09-23*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Alle in dieser Summary genannten neuen Dateien sowie beide Commits (`7704372`, `61f95c8`) wurden gegen das Repository geprüft und gefunden.
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260924-h7x
|
||||||
|
type: quick
|
||||||
|
wave: 1
|
||||||
|
autonomous: true
|
||||||
|
files_modified:
|
||||||
|
- apps/web/src/app/globals.css
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts (new)
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/*.test.tsx
|
||||||
|
- apps/web/src/components/layout/header.tsx
|
||||||
|
- apps/web/src/components/dashboard/dashboard-tabs.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
- apps/web/src/messages/de.json, en.json
|
||||||
|
- CHANGELOG.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260924-h7x — Proxmox-Seite: Status-Design mit Tiefe; Dashboard-Reiter in die Kopfzeile
|
||||||
|
|
||||||
|
Nutzerauftrag (24.09.): „Kennzeichnen als offline & verwaist. Das Proxmox-Modul scheint zu
|
||||||
|
funktionieren, aber es gefällt mir optisch gar nicht. Benutze den Design-Skill und überarbeite
|
||||||
|
etwas — mehr Tiefe, Farben nach Status etc. Die Reiter sind da, aber die müssen woanders hin,
|
||||||
|
die nehmen zu viel Platz ein. Auch mit Design.“
|
||||||
|
|
||||||
|
Die frühere Sperre „KEIN Umbau der Proxmox-Modulseite“ (23.09.) ist damit durch den Nutzer
|
||||||
|
selbst aufgehoben.
|
||||||
|
|
||||||
|
Design-Plan vom Orchestrator (frontend-design-Skill) — verbindlich, nicht neu erfinden.
|
||||||
|
Keine neuen Pakete. App-Texte deutsch in Sie-Form, alle Schlüssel in de.json UND en.json.
|
||||||
|
Keine Großbuchstaben-Etiketten (kein `uppercase`, kein `tracking-widest`), keine
|
||||||
|
Mittelpunkt-Ketten „A · B · C“ in neuen Texten, kein „→“ an Knöpfen.
|
||||||
|
|
||||||
|
## Design-Tokens (in `globals.css`, `:root` und `.dark`)
|
||||||
|
|
||||||
|
Statusfarben als OKLCH-Variablen plus Tailwind-Abbildung in `@theme inline`
|
||||||
|
(`--color-status-ok` usw., damit `bg-status-ok`, `text-status-ok`, `border-status-ok/40` gehen):
|
||||||
|
|
||||||
|
| Token | hell | dunkel | Bedeutung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--status-ok` | `oklch(0.62 0.15 152)` | `oklch(0.72 0.15 152)` | in Ordnung |
|
||||||
|
| `--status-warn` | `oklch(0.72 0.15 70)` | `oklch(0.80 0.14 75)` | Warnung |
|
||||||
|
| `--status-down` | `oklch(0.58 0.21 27)` | `oklch(0.68 0.19 27)` | nicht erreichbar / Fehler |
|
||||||
|
| `--status-idle` | `oklch(0.62 0.02 260)` | `oklch(0.60 0.02 260)` | noch nicht abgefragt |
|
||||||
|
| `--status-orphan` | `oklch(0.55 0.01 260)` | `oklch(0.52 0.01 260)` | offline & verwaist |
|
||||||
|
| `--well` | `oklch(0.975 0.003 260)` | `oklch(0.235 0.01 260)` | eingelassene Messfelder |
|
||||||
|
|
||||||
|
Kontrast prüfen: Text in Statusfarbe auf `--card` muss ≥ 4.5:1 (Fließtext) bzw. ≥ 3:1
|
||||||
|
(große/fette Zahlen) erreichen — sonst Helligkeit anpassen und die gemessene Zahl in der
|
||||||
|
SUMMARY nennen.
|
||||||
|
|
||||||
|
## Task 1 — Statuslogik als reine Funktionen (TDD)
|
||||||
|
|
||||||
|
Neue Datei `components/proxmox-status.ts`:
|
||||||
|
|
||||||
|
- `serverHealth(server): 'ok' | 'warn' | 'down' | 'idle' | 'orphan'`
|
||||||
|
- `!server.isActive` → `orphan` (VORRANG vor allem anderen — „offline & verwaist“, auch wenn
|
||||||
|
alte Messwerte im Zwischenlager liegen)
|
||||||
|
- kein `status` oder `status.lastPolledAt === null` → `idle`
|
||||||
|
- `!status.reachable` → `down`
|
||||||
|
- erreichbar + irgendein Messwert über Warnschwelle → `warn`
|
||||||
|
- sonst `ok`
|
||||||
|
- Schwellen als EINE benannte Konstante `THRESHOLDS`: Last/Arbeitsspeicher/Datenspeicher
|
||||||
|
warn ≥ 0.80, kritisch ≥ 0.92; PBS letzte Sicherung älter als 26 h → warn; `lastVerifyState`
|
||||||
|
ungleich `ok` (und nicht null) → warn; PMG `virusCount > 0` → warn.
|
||||||
|
- `meterLevel(fraction | null): 'ok' | 'warn' | 'crit' | 'unknown'` für die Balken.
|
||||||
|
- `formatAge(epochSeconds|iso, now)` → „vor 3 Min.“, „vor 5 Std.“, „vor 2 Tagen“ über
|
||||||
|
`Intl.RelativeTimeFormat('de')` bzw. next-intl — keine neue Abhängigkeit.
|
||||||
|
- Tests für jede Verzweigung inkl. `null`-Werte (unbekannt ≠ 0, nie Warnung aus `null`).
|
||||||
|
|
||||||
|
## Task 2 — Proxmox-Seite und Karte neu gestalten
|
||||||
|
|
||||||
|
**Seitenkopf:** Titel „Proxmox“ links, darunter die Beschreibung. Rechts „Jetzt aktualisieren“
|
||||||
|
(nur Admins, wie heute) als richtiger Knopf mit Kreispfeil-Symbol (dreht sich während des Laufs;
|
||||||
|
`motion-reduce:animate-none`) und „Einstellungen“ als ruhiger Textlink. Breite `max-w-5xl`.
|
||||||
|
|
||||||
|
**Gesundheitsbalken (das eine prägnante Element der Seite):** direkt unter dem Kopf ein
|
||||||
|
waagrechter Balken, 8 px hoch, voll gerundet, anteilig in Segmente je Zustand geteilt
|
||||||
|
(Reihenfolge down, warn, ok, idle, orphan; leere Zustände entfallen). Darunter eine Zeile mit
|
||||||
|
Legende: farbiger Punkt + Zahl + Wort („2 in Ordnung“, „1 nicht erreichbar“, „1 offline &
|
||||||
|
verwaist“ …, ICU-Plural). Segmente `role="img"` mit `aria-label`, das die Zusammenfassung vorliest.
|
||||||
|
Keine Zahl-in-riesig-plus-Verlauf-Heldenkachel.
|
||||||
|
|
||||||
|
**Kartenraster:** `grid gap-4 lg:grid-cols-2`. Sortierung: down, warn, ok, idle, orphan, darin
|
||||||
|
`position`. (Die Einstellungsseite behält ihre Reihenfolge.)
|
||||||
|
|
||||||
|
**Karte (Tiefe über Status, nicht Einheits-Schatten):**
|
||||||
|
- Karte `rounded-xl bg-card` mit Rand `border-border`, links eine 4 px breite Statusleiste
|
||||||
|
(absolut positioniert, volle Höhe, Farbe = Status).
|
||||||
|
- Schatten zweischichtig und im Statuston getönt, z. B.
|
||||||
|
`shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-x)]` — bei `orphan` KEIN
|
||||||
|
farbiger Schatten, stattdessen gestrichelter Rand (`border-dashed`), Inhalt
|
||||||
|
`opacity-70 saturate-50`.
|
||||||
|
- Kopfzeile: Produktsymbol (kleines inline-SVG je Typ: PVE = Server-Einschübe, PBS =
|
||||||
|
Archivkiste/Datenträger, PMG = Briefumschlag) in einem 32 px Feld mit `bg-well`, daneben Name
|
||||||
|
(fett) und darunter Produktname ausgeschrieben („Virtualisierung“, „Datensicherung“,
|
||||||
|
„Mail-Gateway“) plus Adresse klein und gedämpft. Rechts eine Statuspille: Punkt + Wort
|
||||||
|
(„In Ordnung“, „Warnung“, „Nicht erreichbar“, „Noch nicht abgefragt“, „Offline & verwaist“),
|
||||||
|
Hintergrund Statusfarbe /12, Text in Statusfarbe. Bei `ok` pulsiert der Punkt EINMAL beim
|
||||||
|
Laden nicht — keine Dauer-Animation.
|
||||||
|
- Fuß der Karte: „Letzte Abfrage vor 4 Min.“ (relativ, `title` mit exaktem Zeitpunkt).
|
||||||
|
|
||||||
|
**Messbereich je Produkt, als eingelassene Felder** (`bg-well`, `rounded-lg`,
|
||||||
|
`shadow-[inset_0_1px_2px_oklch(0_0_0/0.06)]`):
|
||||||
|
- **PVE:** oben zwei Kennzahlen nebeneinander: laufende Gäste (groß, `tabular-nums`) und
|
||||||
|
gestoppte Gäste (gedämpft), Knotenzahl klein. Darunter je Knoten ein „Einschub“: Knotenname
|
||||||
|
links, rechts zwei schmale Balken „Prozessor“ und „Arbeitsspeicher“ mit Prozentzahl
|
||||||
|
(`tabular-nums`) und bei Speicher „12,0 / 64,0 GB“ als `title`/kleine Zeile. Balkenfarbe
|
||||||
|
nach `meterLevel`. Unbekannter Wert: Balken schraffiert/leer + Text „unbekannt“, NIE 0 %.
|
||||||
|
Deutsche Zahlformate (Komma) über `Intl.NumberFormat('de-DE')`.
|
||||||
|
- **PBS:** je Datenspeicher ein Einschub: Name, Füllstandsbalken mit „1,2 / 4,0 TB“, darunter
|
||||||
|
„Letzte Sicherung vor 5 Std.“ (warn-Farbe, wenn > 26 h; „noch keine Sicherung“ gedämpft) und
|
||||||
|
Prüfstatus als kleine Pille (ok grün, sonst warn, null „unbekannt“ grau).
|
||||||
|
- **PMG:** vier Zahlfelder im 2×2-Raster: Eingehend, Ausgehend, Spam, Viren; Viren > 0 in
|
||||||
|
down-Farbe, sonst normal. `null` → „unbekannt“.
|
||||||
|
|
||||||
|
**Zustände ohne Messwerte:**
|
||||||
|
- `down`: roter Hinweisblock im Well (`bg-status-down/8`, Rand links in Statusfarbe) mit der
|
||||||
|
bekannten Fehlermeldung (`errors.*`) und darunter gedämpft „Zuletzt erreichbar: vor 2 Tagen“
|
||||||
|
bzw. nichts, wenn nie. `errorDetail` klein und gedämpft in eigener Zeile, nicht in Klammern
|
||||||
|
an den Satz gehängt.
|
||||||
|
- `idle`: ruhiger Text wie heute (Admin-/Nicht-Admin-Variante bleibt, 260923-le6).
|
||||||
|
- `orphan`: Text „Dieser Server ist deaktiviert und wird nicht mehr abgefragt.“ plus für Admins
|
||||||
|
der Hinweis, dass er in den Einstellungen wieder aktiviert werden kann (Link). Alte Messwerte
|
||||||
|
werden bei `orphan` NICHT angezeigt.
|
||||||
|
|
||||||
|
**Leer-, Lade-, Fehlerzustand der Seite:** Leerzustand als Well mit Serversymbol und Satz +
|
||||||
|
Link (Admins); Laden als zwei Skelett-Karten (`animate-pulse`, `motion-reduce:animate-none`).
|
||||||
|
|
||||||
|
Tests: bestehende ServerCard-/Seitentests anpassen (Texte/Strukturen), neue Tests für
|
||||||
|
orphan (Vorrang, keine alten Werte), Sortierung, Gesundheitsbalken-Zusammenfassung, unbekannte
|
||||||
|
Werte als „unbekannt“. Die Nur-Admin-Regeln aus 260923-le6 müssen weiter getestet sein.
|
||||||
|
|
||||||
|
## Task 3 — Dashboard-Reiter in die Kopfzeile
|
||||||
|
|
||||||
|
Heute belegt `DashboardTabs` eine eigene Zeile über dem Raster (`app/(portal)/page.tsx`), die
|
||||||
|
Kopfzeilenmitte (`components/layout/header.tsx`) zeigt nur den Text „Startseite“.
|
||||||
|
|
||||||
|
- Kopfzeile bekommt in der Mitte einen Einhängepunkt `<div id="header-center-slot">`. Auf der
|
||||||
|
Startseite (`pathname === '/'`) entfällt der Text „Startseite“; auf allen anderen Seiten bleibt
|
||||||
|
alles wie heute.
|
||||||
|
- `DashboardTabs` rendert per `createPortal` in diesen Einhängepunkt (sobald er im DOM ist —
|
||||||
|
`useEffect` + State; Rückfall: solange kein Einhängepunkt da ist, nichts rendern). Die eigene
|
||||||
|
Zeile über dem Raster entfällt ersatzlos.
|
||||||
|
- Gestaltung als kompakter Umschalter mit Tiefe: eine eingelassene Spur (`bg-muted`, `rounded-lg`,
|
||||||
|
`p-0.5`, `shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)]`, Höhe 32 px), darin die Reiter als
|
||||||
|
Textknöpfe (`text-sm`, `px-3`, `h-7`); der aktive Reiter liegt erhaben darauf (`bg-card`,
|
||||||
|
`shadow-sm`, `font-medium`, Text `foreground`), inaktive gedämpft mit Hover. Keine
|
||||||
|
Unterstreichung, kein Gelb-Flächen-Reiter.
|
||||||
|
- Viele Reiter: Spur höchstens `max-w-[min(56vw,720px)]`, waagrecht scrollbar ohne sichtbare
|
||||||
|
Leiste, an den Rändern weich ausgeblendet (`mask-image` nur wenn überläuft), aktiver Reiter
|
||||||
|
wird ins Bild gescrollt.
|
||||||
|
- Bearbeitungsmodus: Umbenennen/Löschen/Ziehen/Anlegen bleiben funktional gleich (alle
|
||||||
|
bestehenden Tests grün halten bzw. nur Selektoren anpassen). „+“ als 28-px-Rundknopf am Ende
|
||||||
|
der Spur (immer sichtbar, wie heute der Anlegen-Knopf sichtbar ist — Verhalten beibehalten).
|
||||||
|
Löschen-Kreuz erscheint im Bearbeitungsmodus klein im Reiter.
|
||||||
|
- Genau EIN Reiter: Umschalter trotzdem zeigen (sonst weiß niemand, dass es Reiter gibt), aber
|
||||||
|
nur mit „+“ daneben.
|
||||||
|
- Mobil (< md): die Spur darf die Kopfzeilenmitte füllen; Logo-Schriftzug bleibt, Aktionen rechts
|
||||||
|
bleiben erreichbar — nichts darf die Kopfzeile sprengen (overflow prüfen).
|
||||||
|
- Tastatur: Pfeiltasten links/rechts zwischen Reitern (`role="tablist"`/`tab`, `aria-selected`),
|
||||||
|
sichtbarer Fokusring.
|
||||||
|
- `navigation "Dashboard-Reiter"`-Beschriftung bleibt als `aria-label`.
|
||||||
|
|
||||||
|
Tests: dashboard-tabs.test.tsx an Portal anpassen (Einhängepunkt im Test anlegen), neuer Test:
|
||||||
|
Kopfzeile zeigt „Startseite“ nicht auf `/`, aber auf anderen Pfaden; Pfeiltasten.
|
||||||
|
|
||||||
|
## Tore
|
||||||
|
|
||||||
|
- `pnpm --filter web exec vitest run` komplett grün
|
||||||
|
- `pnpm turbo run type-check lint` grün, Biome-Warnungen web nicht mehr als 53
|
||||||
|
- CHANGELOG „Unveröffentlicht“: je ein Eintrag unter „Neu“/„Geändert“ in Nutzersprache
|
||||||
|
- Browser-Nachweis macht der Orchestrator (hell UND dunkel, 1400 px und 390 px Breite)
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260924-h7x
|
||||||
|
phase: quick
|
||||||
|
plan: 260924-h7x
|
||||||
|
subsystem: web / proxmox-modul, dashboard, kopfzeile
|
||||||
|
status: complete
|
||||||
|
tags: [proxmox, design, statusfarben, dashboard-reiter, kopfzeile, a11y]
|
||||||
|
requires: [quick-260923-dhh (Proxmox-Modul), quick-260923-le6 (Nur-Admin-Regeln), quick-260923-ad9 (Dashboard-Reiter)]
|
||||||
|
provides:
|
||||||
|
- Statuslogik proxmox-status.ts (serverHealth, THRESHOLDS, meterLevel, formatAge, Sortierung, Zusammenfassung)
|
||||||
|
- Statusfarben-Tokens --status-* / --status-*-fg / --well in globals.css
|
||||||
|
- Einhaengepunkt header-center-slot in der Kopfzeile
|
||||||
|
affects:
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/*
|
||||||
|
- apps/web/src/components/dashboard/dashboard-tabs.tsx
|
||||||
|
- apps/web/src/components/layout/header.tsx
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Statusklassen als statische Tailwind-Zeichenketten (status-styles.ts), damit Tailwind sie findet
|
||||||
|
- Tests mit echtem NextIntlClientProvider + de.json statt Uebersetzungs-Attrappe (ICU-Plural, Zahlformate mitgeprueft)
|
||||||
|
- createPortal in einen Kopfzeilen-Einhaengepunkt, Rueckfall = nichts rendern
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.test.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/status-styles.ts
|
||||||
|
- apps/web/src/components/layout/header-slot.ts
|
||||||
|
modified:
|
||||||
|
- apps/web/src/app/globals.css
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/dashboard-tabs.tsx
|
||||||
|
- apps/web/src/components/dashboard/dashboard-tabs.test.tsx
|
||||||
|
- apps/web/src/components/layout/header.tsx
|
||||||
|
- apps/web/src/components/layout/header.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/messages/umlaut-dictionary.ts
|
||||||
|
- CHANGELOG.md
|
||||||
|
decisions:
|
||||||
|
- Statusfarben bekommen eine Textvariante --status-*-fg; die Flaechentoene aus dem Plan bleiben fuer Balken, Punkte und Leisten, erreichen als Schrift auf Weiss aber nur 2,6 bis 4,9:1
|
||||||
|
- Bei „offline & verwaist“ werden nur Symbol und Name gedaempft; gedaempfte muted-Schrift fiele unter 3:1
|
||||||
|
- Der „+“-Knopf der Reiter bleibt wie bisher nur im Bearbeitungsmodus sichtbar, sitzt aber ausserhalb der scrollenden Spur („immer sichtbar“ = nie weggescrollt)
|
||||||
|
- Pfeiltasten verschieben nur den Fokus, gewaehlt wird mit Eingabe/Leertaste (ein Reiterwechsel laedt das ganze Dashboard)
|
||||||
|
- Ziehhinweis als sr-only-Beschreibung der Spur plus Tooltip statt eigener Zeile
|
||||||
|
metrics:
|
||||||
|
duration: 16min
|
||||||
|
completed: 2026-09-24
|
||||||
|
tasks: 3
|
||||||
|
files: 19
|
||||||
|
plan_head_before: 3d266418fc83f4b0e5f5240f0fcd5017aebe0d10
|
||||||
|
actuals:
|
||||||
|
tokens: 35900
|
||||||
|
tasks: 3
|
||||||
|
commits: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260924-h7x: Proxmox-Seite mit Status-Design, Dashboard-Reiter in der Kopfzeile
|
||||||
|
|
||||||
|
Die Proxmox-Seite zeigt den Zustand jetzt über Farbe und Tiefe: oben ein Gesundheitsbalken mit Legende, darunter Karten, sortiert nach Zustand, mit Statusleiste, im Statuston getöntem Schatten und Statuspille. Die Messwerte liegen in eingelassenen Feldern mit Balken, relativen Zeitangaben und deutschem Zahlformat. Deaktivierte Server erscheinen als „Offline & verwaist“, ohne veraltete Messwerte. Die Dashboard-Reiter sitzen als kompakter Umschalter per Portal in der Mitte der Kopfzeile.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Task 1 — Statuslogik (TDD, 0fa7ce0).** `proxmox-status.ts` enthält nur reine Funktionen:
|
||||||
|
- `serverHealth`: `orphan` hat Vorrang vor allem anderen, dann `idle`, `down`, `warn`, `ok`.
|
||||||
|
- `THRESHOLDS` ist die einzige Quelle für die Schwellen: 0,80 Warnung, 0,92 kritisch, 26 h Sicherungsalter.
|
||||||
|
- `meterLevel`, `ratio`, `isBackupStale`, `sortServersByHealth`, `summarizeHealth`.
|
||||||
|
- `formatAge` nutzt `Intl.RelativeTimeFormat` und liefert „vor 3 Min.“, „vor 5 Std.“, „vor 2 Tagen“ sowie „jetzt“ unter einer Minute.
|
||||||
|
|
||||||
|
Ein `null` löst nirgends eine Warnung aus. 24 Tests. Die RED-Phase war belegt, weil das Modul fehlte.
|
||||||
|
|
||||||
|
**Task 2 — Seite und Karte (57c338f, Nachbesserung 0b659d6).**
|
||||||
|
- **Tokens:** `--status-ok/warn/down/idle/orphan` und `--well` nach der Tabelle im Plan, jeweils hell und dunkel. Dazu kommt eine Textvariante `--status-*-fg` (Begründung unter Kontrast). Alle Tokens sind in `@theme inline` abgebildet. Mit einem Tailwind-Probelauf ist nachgewiesen, dass `bg-status-ok/12`, `bg-status-down/8`, `bg-well`, die getönten Schatten und `motion-reduce:animate-none` wirklich erzeugt werden.
|
||||||
|
- **Kopf:** Titel und Beschreibung stehen links. Rechts steht „Einstellungen“ als ruhiger Textlink, danach „Jetzt aktualisieren“ als Hauptknopf mit Kreispfeil. Der Kreispfeil dreht sich während des Laufs, bei reduzierter Bewegung nicht. Beides ist nur für Admins sichtbar. Die Seitenbreite ist `max-w-5xl`.
|
||||||
|
- **Gesundheitsbalken:** 8 px hoch, anteilig geteilt in der Reihenfolge down, warn, ok, idle, orphan. Leere Zustände entfallen. Der Balken ist `role="img"` und liest die Zusammenfassung vor, zum Beispiel „Zustand der Server: 1 nicht erreichbar, 2 in Ordnung“. Die Legende zeigt Punkt, fette Zahl und Wort über ICU-Plural.
|
||||||
|
- **Karte:** Statusleiste links, 4 px breit. Zweischichtiger, im Statuston getönter Schatten; bei `orphan` gibt es keinen farbigen Schatten, sondern einen gestrichelten Rand. Das Produktsymbol steht in einem 32-px-Well (Server-Einschübe, Archivkiste, Briefumschlag). Darunter stehen Name, ausgeschriebener Produktname und Adresse. Die Statuspille hat keine Animation. Im Fuß steht „Letzte Abfrage vor 4 Min.“, der exakte Zeitpunkt liegt im `title`.
|
||||||
|
- **PVE:** Laufende Gäste groß, gestoppte gedämpft, dazu die Knotenzahl. Je Knoten ein Einschub mit Balken für Prozessor und Arbeitsspeicher und „12,0 / 64,0 GB“. Unbekannte Werte erscheinen als schraffierte Spur mit „unbekannt“, nie als 0 %.
|
||||||
|
- **PBS:** Je Datenspeicher der Füllstand mit „1,2 / 4,0 TB“ und „Letzte Sicherung vor 5 Std.“; bei mehr als 26 h in Warnfarbe. Dazu eine Prüfpille: grün bei ok, Warnfarbe bei anderem Ergebnis, grau bei „unbekannt“.
|
||||||
|
- **PMG:** Ein 2×2-Raster aus Zahlfeldern. Viren über 0 stehen in der Fehlerfarbe.
|
||||||
|
- **Zustände:**
|
||||||
|
- `down`: Hinweisblock mit linkem Rand. Die Fehlermeldung steht im Klartext, `errorDetail` in einer eigenen Zeile, darunter „Zuletzt erreichbar vor 2 Tagen“.
|
||||||
|
- `idle`: Admin- und Nicht-Admin-Text wie bisher.
|
||||||
|
- `orphan`: Hinweissatz; Admins bekommen zusätzlich einen Verweis auf die Einstellungen.
|
||||||
|
- Laden: zwei Skelettkarten.
|
||||||
|
- Keine Server: Well mit Symbol.
|
||||||
|
- **Aktuelle Zeitangaben:** Ein Minutentakt hält die relativen Zeitangaben aktuell.
|
||||||
|
|
||||||
|
**Task 3 — Reiter in der Kopfzeile (7416a92).**
|
||||||
|
- **Einhängepunkt:** Die Kopfzeile rendert einen leeren `#header-center-slot`. Die Kennung steht in `components/layout/header-slot.ts`. Auf `/` entfällt „Startseite“, auf allen anderen Seiten bleibt alles wie bisher. Die Kopfzeilenmitte ist `min-w-0`, Logo und Aktionen rechts sind `shrink-0`.
|
||||||
|
- **Portal:** `DashboardTabs` rendert per `createPortal` in den Einhängepunkt. Ohne Einhängepunkt rendert die Leiste nichts. Die eigene Zeile über dem Raster ist entfallen.
|
||||||
|
- **Gestaltung:** Eine eingelassene Spur (`bg-muted`, inset-Schatten, 32 px), darauf liegt der aktive Reiter erhaben (`bg-card`, `shadow-sm`, `font-medium`).
|
||||||
|
- **Überlauf:** Die Spur ist `max-w-[min(56vw,720px)]` breit, auf Mobilgeräten volle Breite. Sie scrollt ohne sichtbare Leiste. Die Ränder werden über `mask-image` weich ausgeblendet, aber nur bei Überlauf. Der aktive Reiter wird ins Bild gescrollt.
|
||||||
|
- **Tastatur:** `role="tablist"`/`tab` mit `aria-selected` und wanderndem `tabIndex`. Pfeil links/rechts, Pos1 und Ende bewegen den Fokus, mit Umlauf. Der Fokusring ist sichtbar.
|
||||||
|
- **Bearbeitungsmodus:** Der „+“-Knopf ist ein 28-px-Rundknopf außerhalb der Spur. Umbenennen und Löschen sitzen klein im Reiter. Der Löschdialog hängt am Dokumentkörper statt im Stapelkontext der Kopfzeile. Die Beschriftung `navigation "Dashboard-Reiter"` bleibt.
|
||||||
|
|
||||||
|
**CHANGELOG** unter „Unveröffentlicht“: ein Eintrag unter „Neu“ (Proxmox-Seite) und ein neuer Abschnitt „Geändert“ (Reiter in der Kopfzeile).
|
||||||
|
|
||||||
|
## Kontrast (gemessen, WCAG-Formel, OKLCH → sRGB)
|
||||||
|
|
||||||
|
Die Flächentöne aus dem Plan erreichen als Schrift auf `--card` (hell) nur diese Werte:
|
||||||
|
|
||||||
|
| Token | hell | dunkel |
|
||||||
|
|---|---|---|
|
||||||
|
| ok | 3,41 | 6,46 |
|
||||||
|
| warn | 2,55 | 7,92 |
|
||||||
|
| down | 4,76 | 4,80 |
|
||||||
|
| idle | 3,64 | 3,82 |
|
||||||
|
| orphan | 4,85 | 2,74 |
|
||||||
|
|
||||||
|
Nach der Planregel wurde deshalb die Helligkeit angepasst, und zwar als eigene Textvariante `--status-*-fg`. Die Flächentöne bleiben wie geplant.
|
||||||
|
|
||||||
|
| Textvariante | hell auf card | hell auf Pille (12 %) | dunkel auf card | dunkel auf Pille |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| ok `0.50 0.13 152` / `0.76 0.15 152` | 5,64 | 4,94 | 7,44 | 6,02 |
|
||||||
|
| warn `0.52 0.12 60` / `0.82 0.14 75` | 5,72 | 5,15 | 8,47 | 6,63 |
|
||||||
|
| down `0.52 0.20 27` / `0.74 0.16 27` | 6,11 | 5,10 | 6,09 | 5,24 |
|
||||||
|
| idle `0.50 0.02 260` / `0.72 0.02 260` | 6,00 | 5,28 | 6,08 | 5,24 |
|
||||||
|
| orphan `0.50 0.01 260` / `0.70 0.01 260` | 6,00 | 5,17 | 5,64 | 5,06 |
|
||||||
|
|
||||||
|
Alle Werte liegen bei mindestens 4,5:1. Gedämpfte Schrift auf verwaisten Karten (`opacity-70`) hätte bei `muted-foreground` nur 2,75:1 (hell) bzw. 3,02:1 (dunkel). Deshalb wird dort nur der Name in Vordergrundfarbe gedämpft (7,54 bzw. 7,13).
|
||||||
|
|
||||||
|
Die Fläche `warn` hat im hellen Modus als Grafik 2,55:1 zu Weiß. Balken und Punkte in Warnfarbe stehen aber immer neben einer Zahl oder einem Wort, die Farbe trägt die Aussage also nicht allein.
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
**1. [Rule 2 – Kontrast] Textvariante der Statusfarben.** Der Plan erlaubt ausdrücklich, die Helligkeit anzupassen. Umgesetzt ist das als zusätzliches `-fg`-Token und nicht als Änderung der Flächentöne, damit Balken und Leisten die geplante Leuchtkraft behalten. Betrifft `globals.css` und `status-styles.ts`.
|
||||||
|
|
||||||
|
**2. [Rule 2 – Kontrast] Dämpfung bei „verwaist“ eingegrenzt.** Der Plan sieht `Inhalt opacity-70 saturate-50` vor. Umgesetzt ist das nur an Symbol und Name. Pille, Produktname, Adresse, Hinweis und Fuß bleiben voll lesbar, weil gedämpfte Schrift unter 3:1 fiele. Commit 0b659d6.
|
||||||
|
|
||||||
|
**3. Auslegung „+“ „immer sichtbar“.** Der Knopf bleibt wie bisher nur im Bearbeitungsmodus sichtbar, so verlangt es „Verhalten beibehalten“ und bestehender Test 4. Er sitzt jedoch außerhalb der scrollenden Spur, damit er nie weggescrollt wird.
|
||||||
|
|
||||||
|
**4. Zusätzliche Dateien:**
|
||||||
|
- `HealthBar.tsx` und `status-styles.ts` (Proxmox): die Klassen werden an zwei Stellen gebraucht.
|
||||||
|
- `components/layout/header-slot.ts`: die Kopfzeile soll nicht die Reiter-Komponente importieren.
|
||||||
|
- `umlaut-dictionary.ts`: „Prozessor“ und „Arbeitsspeicher“ sind korrektes Deutsch mit „ss“ und stehen jetzt auf der Freigabeliste der Umlaut-Wache.
|
||||||
|
|
||||||
|
**5. Übersetzungsschlüssel.** Nicht mehr genutzte Schlüssel wurden ersetzt:
|
||||||
|
- aus `card.pve.*`: `nodeCount`, `guests`
|
||||||
|
- aus `card.pbs.*`: `lastBackup`, `verifyState`
|
||||||
|
- aus `card.*`: `lastPolledLabel`, `lastOkLabel`
|
||||||
|
|
||||||
|
Neu sind `health.*`, `legend.*`, `card.product.*` und weitere. Die Änderungen stehen in de.json und en.json gleichermaßen.
|
||||||
|
|
||||||
|
**6. Formatierung.** `biome format` wurde auf die geänderten Proxmox-Dateien angewendet. Dadurch ist auch `proxmox-status.ts` aus Task 1 in Commit 57c338f rein umformatiert.
|
||||||
|
|
||||||
|
## Tore
|
||||||
|
|
||||||
|
- `pnpm --filter web exec vitest run`: 87 Dateien, 789 Tests grün. Proxmox allein: 65.
|
||||||
|
- `pnpm turbo run type-check lint`: 9/9 erfolgreich.
|
||||||
|
- Biome-Warnungen web: 53, nicht mehr als vorher (53).
|
||||||
|
- Verbotene Muster: kein `uppercase`, kein `tracking-widest`, keine Mittelpunkt-Ketten und kein „→“ in den neuen Dateien (per grep geprüft).
|
||||||
|
- Den Browser-Nachweis (hell/dunkel, 1400 px/390 px) macht wie vereinbart der Orchestrator. Docker wurde nicht neu gebaut.
|
||||||
|
|
||||||
|
## Hinweise für die Browser-Prüfung
|
||||||
|
|
||||||
|
- **Mobile Kopfzeile bei 390 px:** Rechnerisch bleiben für die Mitte etwa 80 px, also etwa ein Reiter sichtbar, der Rest ist scrollbar. Im Bearbeitungsmodus kommen 28 px für „+“ dazu. Bitte prüfen, dass nichts überläuft.
|
||||||
|
- **Ränder der Spur:** Die weiche Ausblendung erscheint nur bei Überlauf. Mit drei oder mehr langen Reiternamen kann man das prüfen.
|
||||||
|
- **Verwaiste Karte:** gestrichelter Rand, graue Leiste, kein farbiger Schatten, keine alten Messwerte.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: proxmox-status.ts, proxmox-status.test.ts, HealthBar.tsx, status-styles.ts, header-slot.ts
|
||||||
|
- FOUND: 0fa7ce0, 57c338f, 7416a92, 0b659d6
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260924-i8v
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260924-i8v
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-260924-i8v]
|
||||||
|
files_modified:
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/src/dashboard/widget-module-map.spec.ts
|
||||||
|
- apps/web/src/components/proxmox/proxmox-status.ts (git mv aus app/(portal)/modules/proxmox/components/)
|
||||||
|
- apps/web/src/components/proxmox/proxmox-status.test.ts (git mv)
|
||||||
|
- apps/web/src/components/proxmox/HealthBar.tsx (git mv)
|
||||||
|
- apps/web/src/components/proxmox/status-styles.ts (git mv)
|
||||||
|
- apps/web/src/components/proxmox/proxmox-server-picker.tsx (neu)
|
||||||
|
- apps/web/src/components/proxmox/proxmox-server-picker.test.tsx (neu)
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/proxmox-widget.tsx (neu)
|
||||||
|
- apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx (neu)
|
||||||
|
- apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts (neu)
|
||||||
|
- apps/web/src/components/dashboard/widgets/proxmox-widget-model.test.ts (neu)
|
||||||
|
- apps/web/src/components/settings/proxmox-widget-config-form.tsx (neu)
|
||||||
|
- apps/web/src/components/settings/proxmox-widget-config-form.test.tsx (neu)
|
||||||
|
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||||
|
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 60000
|
||||||
|
raw_tokens: 60000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Wer das Proxmox-Modul nutzen darf, findet im Katalog „Widget hinzufügen“ die Kachel „Proxmox“ (als letzte, die übrigen neun in unveränderter Reihenfolge) und kann sie anlegen; die API akzeptiert den Typ proxmox"
|
||||||
|
- "Wer das Modul nicht nutzen darf, sieht die Kachel weder im Katalog noch auf dem Dashboard (Katalogfilter als Komfort, verbindlich serverseitig in DashboardService.getWidgets, fail-closed)"
|
||||||
|
- "Oben in der Kachel steht ein 6 px hoher Gesundheitsbalken und eine Zeile in Worten: „Alles in Ordnung“ in der Ok-Farbe, sonst z. B. „1 nicht erreichbar, 1 mit Warnung“ in der Farbe des schlimmsten Zustands"
|
||||||
|
- "Darunter stehen die Server sortiert nach down, warn, ok, idle, orphan, je mit Statuspunkt, Name und genau einer rechtsbündigen Kennzahl; ein unbekannter Wert heißt „unbekannt“, nie 0"
|
||||||
|
- "Ein Klick auf eine Serverzeile öffnet /modules/proxmox; im Bearbeitungsmodus führt keine Zeile irgendwohin und die ganze Kachel bleibt ziehbar"
|
||||||
|
- "Die Kachel liest alle 60 s neu aus dem Zwischenlager (GET servers), pausiert bei verborgenem Browser-Tab und löst niemals eine Abfrage bei Proxmox aus"
|
||||||
|
- "Titel und Serverauswahl lassen sich im Bearbeitungsmodus direkt an der Kachel und unter Einstellungen > Dashboard festlegen; keine Auswahl bedeutet alle Server"
|
||||||
|
- "Schmale Kachel (unter 15rem): nur Punkte und Namen; sehr kleine Kachel (unter 7.5rem hoch oder unter 8rem breit): nur Balken und Zusammenfassung"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/web/src/components/dashboard/widgets/proxmox-widget.tsx"
|
||||||
|
provides: "ProxmoxWidget (WidgetProps) — Balken, Zusammenfassung, Serverliste, Minutentakt, Bearbeitungsmodus"
|
||||||
|
- path: "apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts"
|
||||||
|
provides: "reine Funktionen: resolveProxmoxWidgetConfig, selectServers, healthSummary, widgetKeyFigure"
|
||||||
|
- path: "apps/web/src/components/proxmox/"
|
||||||
|
provides: "gemeinsamer Ort für proxmox-status.ts, HealthBar.tsx (mit variant compact), status-styles.ts, proxmox-server-picker.tsx"
|
||||||
|
- path: "apps/web/src/components/settings/proxmox-widget-config-form.tsx"
|
||||||
|
provides: "Einstellungsformular der Kachel (Titel + Serverauswahl) für Einstellungen > Dashboard"
|
||||||
|
- path: "packages/shared/src/index.ts"
|
||||||
|
provides: "WIDGET_TYPES enthält 'proxmox', WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }"
|
||||||
|
key_links:
|
||||||
|
- from: "packages/shared/src/index.ts WIDGET_MODULE_SLUGS"
|
||||||
|
to: "apps/api/src/dashboard/dashboard.service.ts getWidgets (über widget-module-map.ts)"
|
||||||
|
via: "getModuleSlugForWidgetType('proxmox') === 'proxmox'"
|
||||||
|
pattern: "proxmox: 'proxmox'"
|
||||||
|
- from: "apps/web/src/app/(portal)/page.tsx"
|
||||||
|
to: "proxmox-widget.tsx"
|
||||||
|
via: "registerWidget('proxmox', ProxmoxWidget)"
|
||||||
|
pattern: "registerWidget\\('proxmox'"
|
||||||
|
- from: "proxmox-widget.tsx"
|
||||||
|
to: "apps/web/src/lib/proxmox-api.ts listServers"
|
||||||
|
via: "einziger Import aus proxmox-api; Intervall 60 s + visibilitychange"
|
||||||
|
pattern: "listServers"
|
||||||
|
- from: "proxmox-widget.tsx"
|
||||||
|
to: "apps/web/src/components/proxmox/HealthBar.tsx"
|
||||||
|
via: "<HealthBar variant=\"compact\" counts=... />"
|
||||||
|
pattern: "variant=\"compact\""
|
||||||
|
- from: "apps/web/src/components/settings/widget-settings-panel.tsx"
|
||||||
|
to: "proxmox-widget-config-form.tsx"
|
||||||
|
via: "widget.widgetType === 'proxmox'"
|
||||||
|
pattern: "ProxmoxWidgetConfigForm"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260924-i8v — Proxmox-Kachel fürs Dashboard
|
||||||
|
|
||||||
|
Nutzerauftrag (24.09.): „Jetzt die Proxmox-Kachel fürs Dashboard bauen.“
|
||||||
|
|
||||||
|
Grundlagen, auf denen dieser Plan steht (nicht neu erfinden):
|
||||||
|
- **quick-260922-m1h** hat den Weg „ein Modul bringt seine Kachel mit“ gebaut: `WIDGET_TYPES` +
|
||||||
|
`WIDGET_MODULE_SLUGS` in `packages/shared/src/index.ts` (die API validiert per `@IsIn` gegen genau
|
||||||
|
diese Liste), `registerWidget()` in `(portal)/page.tsx`, `visibleWidgetTypes()` als Katalogfilter.
|
||||||
|
Eine Kachel mit `moduleSlug` verschwindet für Benutzer ohne Modulzugriff automatisch aus Katalog
|
||||||
|
**und** Dashboard (serverseitig `DashboardService.getWidgets`, fail-closed). Für „gesperrt“ ist
|
||||||
|
deshalb **nichts** zu bauen — die Kachel erscheint schlicht nicht; `widgets.unavailable` im
|
||||||
|
Wrapper bleibt der Rückfall.
|
||||||
|
- **quick-260924-h7x** hat die Statussprache der Proxmox-Seite festgelegt: `serverHealth`,
|
||||||
|
`THRESHOLDS`, `meterLevel`, `formatAge`, `sortServersByHealth`, `summarizeHealth`, `HEALTH_ORDER`,
|
||||||
|
`HealthBar`, `HEALTH_STYLE`/`METER_TEXT`/`WELL`, Tokens `--status-*` und `--status-*-fg` in
|
||||||
|
`globals.css`. Diese Teile werden **wiederverwendet, nicht kopiert** — dafür ziehen sie an einen
|
||||||
|
neutralen Ort `apps/web/src/components/proxmox/`.
|
||||||
|
|
||||||
|
Verbindliche Gestaltungsregeln (wie h7x): Zustand steuert die Optik; keine Großbuchstaben-Etiketten,
|
||||||
|
keine Mittelpunkt-Ketten, kein Pfeilzeichen an Knöpfen oder in Texten; App-Texte deutsch in
|
||||||
|
Sie-Form, jeder neue Schlüssel in `de.json` **und** `en.json`. Keine neuen Pakete. Schriftfarbe in
|
||||||
|
Statusfarbe immer über die `-fg`-Variante (`HEALTH_STYLE[h].text`), Flächen über `fill` — so bleibt
|
||||||
|
der in h7x gemessene Kontrast von mindestens 4,5:1 erhalten.
|
||||||
|
|
||||||
|
Rasterrechnung (aus `dashboard-grid.tsx`: 24 Spalten, `rowHeight` 20, `margin` 8): Höhe h Zeilen =
|
||||||
|
20h + 8(h−1) px, also 4 Zeilen = 104 px, 8 Zeilen = 216 px. Breite bei rund 1400 px Inhalt: eine
|
||||||
|
Spalte ≈ 50 px, 3 Spalten ≈ 166 px, 8 Spalten ≈ 456 px.
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Die erste echte Modul-Kachel: „Proxmox“ zeigt auf dem Dashboard den Zustand der Proxmox-Server in der
|
||||||
|
Statussprache der Modulseite — kompakter Gesundheitsbalken, Zusammenfassung in Worten, darunter die
|
||||||
|
Server nach Dringlichkeit mit je einer Kennzahl, Klick führt zur Modulseite. Sie liest nur das
|
||||||
|
Zwischenlager, frischt sich minütlich auf, lässt sich auf Titel und Serverauswahl einstellen und
|
||||||
|
passt sich per Container-Query an kleine Kachelgrößen an.
|
||||||
|
|
||||||
|
Purpose: Der Nutzer sieht den Zustand seiner Proxmox-Umgebung, ohne die Modulseite zu öffnen.
|
||||||
|
Output: Kachel-Komponente samt reiner Modell-Funktionen, gemeinsamer Proxmox-Ordner, Einstellungsformular,
|
||||||
|
Registry-/Katalog-/API-Tests angepasst, Doku und Changelog.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
@.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md
|
||||||
|
@.planning/quick/260924-h7x-proxmox-seite-status-design-und-dashboar/260924-h7x-SUMMARY.md
|
||||||
|
@apps/web/src/components/dashboard/widget-registry.tsx
|
||||||
|
@apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||||
|
@apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||||
|
@apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts
|
||||||
|
@apps/web/src/app/(portal)/modules/proxmox/components/HealthBar.tsx
|
||||||
|
@apps/web/src/app/(portal)/modules/proxmox/components/status-styles.ts
|
||||||
|
@apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||||
|
@apps/web/src/lib/proxmox-api.ts
|
||||||
|
@apps/web/src/components/settings/widget-settings-panel.tsx
|
||||||
|
|
||||||
|
Schnittstellen, die der Executor braucht (aus dem Code gelesen, Stand 8bfa4fc):
|
||||||
|
|
||||||
|
- `WidgetProps = { instanceId: string; config: Record<string, unknown>; isEditMode: boolean }`
|
||||||
|
- `WIDGET_CONSTRAINTS: Record<WidgetType, { minW; minH; defaultW; defaultH }>`
|
||||||
|
- `registerWidget(type: WidgetType, component: ComponentType<WidgetProps>)`
|
||||||
|
- `visibleWidgetTypes(registry, accessibleModuleSlugs: readonly string[] | null)`
|
||||||
|
- `listServers(): Promise<ProxmoxServer[]>` — `GET /modules/proxmox/servers`, `@UseModule('proxmox')`,
|
||||||
|
für alle Rollen mit Modulzugriff lesbar (nur Schreib-/Abfrage-Endpunkte sind Admin-only).
|
||||||
|
- `ProxmoxServer`: `id, name, productType ('pve'|'pbs'|'pmg'), isActive, position, status: ProxmoxServerStatus | null`;
|
||||||
|
`status.metrics`: `pve { guestsRunning, guestsStopped, nodes[{cpu, mem, maxmem}], storages[{disk, maxdisk}] }`,
|
||||||
|
`pbs { datastores[{ used, total, lastBackupAt (Unix-Sekunden), lastVerifyState }] }`,
|
||||||
|
`pmg { countIn, countOut, spamCount, virusCount }` — alle Messwerte `number | null`.
|
||||||
|
- `serverHealth(server, now) → 'ok'|'warn'|'down'|'idle'|'orphan'`, `HEALTH_ORDER = ['down','warn','ok','idle','orphan']`,
|
||||||
|
`summarizeHealth(servers, now) → Record<ServerHealth, number>`, `sortServersByHealth(servers, now)`,
|
||||||
|
`ratio(used, total)`, `meterLevel(fraction)`, `isBackupStale(lastBackupAt, now)`, `toEpochMs`,
|
||||||
|
`formatAge(value, now, locale) → 'vor 5 Std.' | null`.
|
||||||
|
- `HEALTH_STYLE[h] = { fill, pill, text, shadow }`, `METER_TEXT[level]`.
|
||||||
|
- `updateWidgetConfig(instanceId, partialConfig)` aus `@/lib/dashboard-api` (Muster Favoriten-Titel).
|
||||||
|
- Admin-Erkennung wie auf der Modulseite: `useAuthStore((s) => s.user)`, Rolle `ADMIN` oder `SUPER_ADMIN`.
|
||||||
|
- Wiederverwendbare Übersetzungen (Namensraum `proxmox`): `legend.<h>` (ICU-Plural, „nicht erreichbar“,
|
||||||
|
„mit Warnung“ …), `health.<h>` („In Ordnung“, „Warnung“ …), `loadError`, `loading`,
|
||||||
|
`card.unknownValue` („unbekannt“), `card.pbs.noBackupYet`, `card.settingsLink`, `card.product.<typ>`.
|
||||||
|
- Tailwind 4.3.1, geprüft per Probelauf in der Planung: `@max-[15rem]:hidden` erzeugt
|
||||||
|
`@container (width < 15rem)`, `[@container(max-height:7.5rem)]:hidden` erzeugt
|
||||||
|
`@container (max-height:7.5rem)`. Der Container ist der Kachelrumpf im Wrapper (`@container-size`).
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Aufgabe 1: Durchstich — Kacheltyp „proxmox“ von der Typliste über API-Whitelist, Registry und Katalog bis zur Anzeige von Balken und Zusammenfassung</name>
|
||||||
|
<files>apps/web/src/components/proxmox/proxmox-status.ts, apps/web/src/components/proxmox/proxmox-status.test.ts, apps/web/src/components/proxmox/HealthBar.tsx, apps/web/src/components/proxmox/status-styles.ts, apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts, apps/web/src/components/dashboard/widgets/proxmox-widget.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</files>
|
||||||
|
<action>
|
||||||
|
**1. Gemeinsame Teile an neutralen Ort ziehen (Git-Historie erhalten).** Mit `git mv` die vier Dateien
|
||||||
|
`proxmox-status.ts`, `proxmox-status.test.ts`, `HealthBar.tsx`, `status-styles.ts` aus
|
||||||
|
`apps/web/src/app/(portal)/modules/proxmox/components/` nach `apps/web/src/components/proxmox/` verschieben.
|
||||||
|
Ihre gegenseitigen relativen Importe bleiben gültig. In `modules/proxmox/page.tsx` und
|
||||||
|
`components/ServerCard.tsx` (und in Tests, die direkt importieren) auf `@/components/proxmox/...`
|
||||||
|
umstellen. `ServerCard.tsx` bleibt am Modulort, nur die Seite braucht sie. Keine Logikänderung;
|
||||||
|
Kopfkommentare um einen Satz ergänzen („seit 260924-i8v gemeinsam für Modulseite und Dashboard-Kachel“).
|
||||||
|
Grund: eine Kachel unter `components/` soll nicht in einen Routenordner unter `app/` greifen.
|
||||||
|
|
||||||
|
**2. HealthBar bekommt eine kompakte Variante.** Optionale Prop `variant: 'full' | 'compact'`,
|
||||||
|
Standard `full` (Modulseite unverändert). `compact`: Balken `h-1.5` (6 px) statt `h-2`, **ohne**
|
||||||
|
Legende; der Balken ist dann `aria-hidden`, weil die Kachel darunter die Zusammenfassung als
|
||||||
|
sichtbaren Text zeigt (sonst würde ein Vorleser sie doppelt vorlesen). `data-testid="health-bar"`
|
||||||
|
bleibt; zusätzlich `data-variant` für Tests.
|
||||||
|
|
||||||
|
**3. Typ und Modulbindung eintragen** in `packages/shared/src/index.ts`: `'proxmox'` **ans Ende** von
|
||||||
|
`WIDGET_TYPES` (die neun bisherigen behalten Reihenfolge, der Katalog zeigt Proxmox zuletzt);
|
||||||
|
`WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }` — der Slug ist derselbe wie `@UseModule('proxmox')` im
|
||||||
|
Controller und `slug: 'proxmox'` in `proxmox.seed.ts`. Den Kommentar „heute bewusst leer“
|
||||||
|
richtigstellen. Nur löschbare TypeScript-Syntax (Warnung in der Datei beachten, m1h).
|
||||||
|
|
||||||
|
**4. Registry** (`widget-registry.tsx`): `WIDGET_CONSTRAINTS.proxmox = { minW: 3, minH: 4, defaultW: 8, defaultH: 8 }`
|
||||||
|
mit Kommentar im Stil der Nachbarn: 4 Zeilen = 104 px reichen genau für Balken und Zusammenfassung
|
||||||
|
(die Liste blendet sich darunter per Container-Query aus); 8×8 ≈ 456×216 px bei 1400 px Breite zeigt
|
||||||
|
rund sechs Serverzeilen; 3 Spalten ≈ 166 px = Punkte und Namen. Inline-SVG `ProxmoxIcon` im
|
||||||
|
Projektmuster (Server-Einschübe wie das Leersymbol der Modulseite: zwei abgerundete Rechtecke mit je
|
||||||
|
einem Punkt). Registry-Eintrag `proxmox` mit `nameKey: 'proxmox.name'`, `descriptionKey:
|
||||||
|
'proxmox.description'` (Namensraum `widgets`), `moduleSlug: WIDGET_MODULE_SLUGS.proxmox`,
|
||||||
|
`component: PlaceholderWidget`.
|
||||||
|
|
||||||
|
**5. Modell-Datei anlegen** `widgets/proxmox-widget-model.ts` (rein, ohne React), zunächst mit
|
||||||
|
`healthSummary(counts)` => `{ allOk: boolean; entries: Array<{ health; count }>; worst: ServerHealth | null }`:
|
||||||
|
`entries` = alle Zustände außer `ok` mit Anzahl > 0, in `HEALTH_ORDER`; `allOk` = es gibt Server und
|
||||||
|
`entries` ist leer; `worst` = erster Zustand in `HEALTH_ORDER` mit Anzahl > 0. Aufgabe 2 erweitert die Datei.
|
||||||
|
|
||||||
|
**6. Kachel, erster Schnitt** `widgets/proxmox-widget.tsx`, `'use client'`, exportiert
|
||||||
|
`ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps)`. Aus `@/lib/proxmox-api` wird
|
||||||
|
**ausschließlich** `listServers` (und Typen) importiert — die Funktion für die manuelle Abfrage, die
|
||||||
|
`POST …/poll` auslöst, darf in keiner Kachel-Datei vorkommen (T-I8V-02). Beim Einhängen einmal laden
|
||||||
|
(Abbruch-Flag gegen setState nach dem Aushängen); Zustand `servers: ProxmoxServer[] | null`,
|
||||||
|
`loadFailed: boolean` (Fehler als Flag, nicht als Text — `t` gehört nicht in Effekt-Abhängigkeiten,
|
||||||
|
Befund 14 aus favorites-widget), `now` beim erfolgreichen Laden setzen. Darstellung, Wurzel
|
||||||
|
`flex h-full flex-col overflow-hidden`, Innenabstand `p-2.5`:
|
||||||
|
- Laden: 6 px hohe Leiste `bg-muted`, pulsierend mit `motion-reduce:animate-none`, dazu sr-only `proxmox.loading`.
|
||||||
|
- Laden fehlgeschlagen und noch nie eine Liste: Satz `proxmox.loadError`, gedämpft, zentriert.
|
||||||
|
- Leere Liste: Satz `widgets.proxmox.empty`; für Admins darunter `proxmox.card.settingsLink` als
|
||||||
|
Link auf `/modules/proxmox/settings` — im Bearbeitungsmodus nur als Text, ohne Link.
|
||||||
|
- Sonst: `HealthBar variant="compact"` mit `summarizeHealth(servers, now)` und darunter die
|
||||||
|
Zusammenfassung als `p` (`text-sm font-medium truncate`, `title` = voller Text): bei `allOk`
|
||||||
|
`widgets.proxmox.allOk` in `HEALTH_STYLE.ok.text`; sonst die Einträge als „Anzahl + Wort aus
|
||||||
|
`proxmox.legend.<h>` (mit `count`)“, mit Komma und Leerzeichen verbunden, in
|
||||||
|
`HEALTH_STYLE[worst].text`. Die Anzahl `ok` wird nicht genannt.
|
||||||
|
|
||||||
|
**7. Anmelden**: in `(portal)/page.tsx` Import und `registerWidget('proxmox', ProxmoxWidget)` nach
|
||||||
|
`xframe`; in `(portal)/page.test.tsx` ein `vi.mock` für `proxmox-widget` wie für die übrigen Kacheln.
|
||||||
|
|
||||||
|
**8. Texte** (de/en) unter `widgets.proxmox`: `name` „Proxmox“/„Proxmox“, `description` „Zustand Ihrer
|
||||||
|
Proxmox-Server auf einen Blick“/„Health of your Proxmox servers at a glance“, `allOk` „Alles in
|
||||||
|
Ordnung“/„All good“, `empty` „Noch kein Proxmox-Server eingetragen.“/„No Proxmox server added yet.“
|
||||||
|
|
||||||
|
**9. Bestehende Tests nachziehen** — sie benutzen „proxmox“ bisher als Beispiel für einen
|
||||||
|
*unbekannten* Typ, das stimmt jetzt nicht mehr:
|
||||||
|
- `widget-registry.test.tsx`: `ALL_WIDGET_TYPES` um `'proxmox'` am Ende ergänzen, „neun“ in den
|
||||||
|
Testnamen zu „zehn“; der Test „keine Kachel trägt einen moduleSlug“ wird zu „nur proxmox trägt
|
||||||
|
moduleSlug 'proxmox', alle anderen keinen“; die beiden Unbekannt-Typ-Tests nehmen
|
||||||
|
`'gibt-es-nicht'`; `visibleWidgetTypes(WIDGET_REGISTRY, [])` erwartet alle Typen außer proxmox,
|
||||||
|
neu dazu `['proxmox']` => alle zehn; Constraints proxmox = 3/4/8/8.
|
||||||
|
- `widget-catalog-modal.test.tsx`: Reihenfolgetest mit `accessibleModuleSlugs={['proxmox']}` => alle
|
||||||
|
zehn in `WIDGET_TYPES`-Reihenfolge; mit `[]` => neun ohne Proxmox. Die beiden Tests, die bisher
|
||||||
|
vorübergehend `clock` zur Modul-Kachel gemacht haben, prüfen jetzt die echte Kachel „Proxmox“
|
||||||
|
(fehlt bei `[]`, erscheint bei `['proxmox']`, fehlt bei `null` während „Notizen“ bleibt).
|
||||||
|
- `apps/api/src/dashboard/widget-module-map.spec.ts`: „die neun Kacheln sind Plattform-Kacheln“ wird
|
||||||
|
zu „alle außer proxmox ohne Modul, `getModuleSlugForWidgetType('proxmox') === 'proxmox'`“; die
|
||||||
|
DTO-Whitelist deckt `'proxmox'` über `it.each([...WIDGET_TYPES])` von selbst ab.
|
||||||
|
|
||||||
|
**10. Neuer Test** `widgets/proxmox-widget.test.tsx` mit echtem `NextIntlClientProvider` +
|
||||||
|
`de.json` (Muster `proxmox-page-roles.test.tsx`), `vi.mock('@/lib/proxmox-api')` mit `listServers`
|
||||||
|
**und** einem Spion für die manuelle Abfragefunktion, Attrappe für `@/lib/stores/auth-store` und
|
||||||
|
`next/link`, Baukasten `makeServer(overrides)` mit Zwischenlager je Zustand. Fälle: alle ok =>
|
||||||
|
„Alles in Ordnung“ mit Klasse `text-status-ok-fg`; je ein down, warn, ok => „1 nicht erreichbar, 1 mit
|
||||||
|
Warnung“ mit `text-status-down-fg`; kompakter Balken vorhanden (`data-variant="compact"`, `h-1.5`),
|
||||||
|
keine Legende; leere Liste => Satz, Admin sieht Link auf `/modules/proxmox/settings`, Rolle USER
|
||||||
|
nicht; `listServers` lehnt ab => „Die Serverliste konnte nicht geladen werden.“; der Abfrage-Spion
|
||||||
|
wird nie aufgerufen.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/components/proxmox "src/app/(portal)/page.test.tsx" "src/app/(portal)/modules/proxmox" && pnpm --filter @tessera/api exec vitest run src/dashboard && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api exec tsc --noEmit && test ! -e "apps/web/src/app/(portal)/modules/proxmox/components/proxmox-status.ts" && grep -q "proxmox: 'proxmox'" packages/shared/src/index.ts && grep -q "registerWidget('proxmox'" "apps/web/src/app/(portal)/page.tsx"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Typ `proxmox` steht in `WIDGET_TYPES` (zuletzt) und in `WIDGET_MODULE_SLUGS`; die API-Whitelist akzeptiert ihn, `getModuleSlugForWidgetType('proxmox')` liefert `'proxmox'`; Registry, Katalog und Seite kennen die Kachel; die Kachel lädt die Serverliste und zeigt kompakten Balken plus Zusammenfassung bzw. Lade-, Fehler- und Leerzustand. Die vier gemeinsamen Dateien liegen unter `components/proxmox/`, die Modulseite verhält sich unverändert (ihre Tests grün). Web- und API-Tests der betroffenen Bereiche sowie beide type-checks grün. Commit `feat(260924-i8v): …`.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Aufgabe 2: Serverliste mit Kennzahl je Zeile, Sortierung, Serverfilter, Links, Größenstufen per Container-Query und Minutentakt</name>
|
||||||
|
<files>apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts, apps/web/src/components/dashboard/widgets/proxmox-widget-model.test.ts, apps/web/src/components/dashboard/widgets/proxmox-widget.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx, apps/web/src/components/proxmox/proxmox-status.ts, apps/web/src/components/proxmox/proxmox-status.test.ts, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||||
|
<behavior>
|
||||||
|
- Modell: resolveProxmoxWidgetConfig — Titel kein String wird zu leer; serverIds kein Array wird zu leer; Nicht-Strings und leere Strings fallen weg; doppelte Kennungen einmal
|
||||||
|
- Modell: selectServers — leere Auswahl liefert alle Server; Auswahl filtert nach Kennung; nur noch gelöschte Kennungen ausgewählt liefert leere Liste mit selectionGone true
|
||||||
|
- Modell: widgetKeyFigure — down/idle/orphan liefern kind status; PVE ok liefert guests (running, total = running + stopped), total 0 liefert noGuests; PVE warn liefert load mit dem höchsten bekannten Anteil aus Knoten-CPU, Knoten-RAM und Speicher; PBS liefert backup mit der ÄLTESTEN bekannten letzten Sicherung samt stale-Flag, Datenspeicher ohne jede Sicherung liefern noBackup, keine Datenspeicher liefern unknown; PMG liefert mailIn aus countIn, null liefert unknown; metrics null bei erreichbarem Server liefert unknown
|
||||||
|
- proxmox-status: formatPercent(0.87, 'de') passt auf /87\s%/; formatCount(12904, 'de') ergibt „12.904“
|
||||||
|
- Kachel: Zeilen erscheinen in der Reihenfolge down, warn, ok, idle, orphan
|
||||||
|
- Kachel: Kennzahlen „3/4 Gäste laufen“, „Auslastung 87 %“ (warn), „Sicherung vor 5 Std.“, „12.904 eingehend“, „nicht erreichbar“, „offline & verwaist“, „noch nicht abgefragt“; unbekannte Werte zeigen „unbekannt“ und nie eine 0 (kein „0 eingehend“, kein „0 %“)
|
||||||
|
- Kachel: config.serverIds beschränkt Zeilen UND Balken/Zusammenfassung auf die Auswahl; nur gelöschte Kennungen → Satz selectionGone
|
||||||
|
- Kachel: Ansichtsmodus — jede Zeile ist ein Link auf /modules/proxmox; Bearbeitungsmodus — keine Links in der Kachel
|
||||||
|
- Kachel: config.title nicht leer → Überschrift h2; leer → keine Kopfzeile
|
||||||
|
- Kachel mit falschen Zeitgebern: nach 60 s zweiter Aufruf von listServers; bei document.visibilityState hidden kein Aufruf im Takt; visibilitychange zurück auf visible lädt sofort; nach dem Aushängen keine weiteren Aufrufe; die manuelle Abfragefunktion wird in keinem Fall aufgerufen
|
||||||
|
- Kachel: scheitert ein späteres Nachladen, bleibt die zuletzt geladene Liste stehen (kein Fehlersatz)
|
||||||
|
- Kachel: Kennzahl trägt die Klasse @max-[15rem]:hidden, die Liste [@container(max-height:7.5rem)]:hidden und @max-[8rem]:hidden
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**RED zuerst**: `proxmox-widget-model.test.ts` neu und die Fälle in `proxmox-widget.test.tsx` gemäß
|
||||||
|
`<behavior>` schreiben, laufen lassen, Fehlschlag belegen, dann umsetzen.
|
||||||
|
|
||||||
|
**1. Zahlformat nicht verdoppeln.** `formatPercent(fraction, locale)` und `formatCount(value, locale)`
|
||||||
|
aus `ServerCard.tsx` unverändert als Exporte nach `components/proxmox/proxmox-status.ts` ziehen;
|
||||||
|
`ServerCard.tsx` importiert sie von dort. Zwei Tests in `proxmox-status.test.ts` (deutsches
|
||||||
|
Prozentformat hat ein geschütztes Leerzeichen — per Regex mit `\s` prüfen).
|
||||||
|
|
||||||
|
**2. Modell erweitern** (`proxmox-widget-model.ts`, rein):
|
||||||
|
- `resolveProxmoxWidgetConfig(config)` => `{ title: string; serverIds: string[] }`, abwehrend wie
|
||||||
|
`picture-frame-config.ts` (siehe `<behavior>`).
|
||||||
|
- `selectServers(servers, serverIds)` => `{ servers: ProxmoxServer[]; selectionGone: boolean }`.
|
||||||
|
- `widgetKeyFigure(server, now)` => Unterscheidungstyp `KeyFigure`:
|
||||||
|
`{ kind: 'status'; health: 'down'|'idle'|'orphan' }`, `{ kind: 'guests'; running; total }`,
|
||||||
|
`{ kind: 'noGuests' }`, `{ kind: 'load'; fraction; level: MeterLevel }`,
|
||||||
|
`{ kind: 'backup'; at: number; stale: boolean }`, `{ kind: 'noBackup' }`,
|
||||||
|
`{ kind: 'mailIn'; count }`, `{ kind: 'unknown' }`. Regeln: zuerst `serverHealth` — bei down, idle,
|
||||||
|
orphan immer `status` (ein verwaister Server zeigt keine alten Messwerte, wie in h7x). Bei ok/warn
|
||||||
|
und `metrics === null` => `unknown`. PVE: im Zustand warn der höchste **bekannte** Anteil aus
|
||||||
|
`nodes[].cpu`, `ratio(mem, maxmem)` und `ratio(disk, maxdisk)` der Speicher (`level =
|
||||||
|
meterLevel(fraction)`), sonst Gäste; ist `guestsRunning`/`guestsStopped` keine Zahl => `unknown`.
|
||||||
|
PBS: die älteste bekannte `lastBackupAt` über alle Datenspeicher (sie ist der Grund für eine
|
||||||
|
Warnung „Sicherung zu alt“), `stale = isBackupStale(at, now)`. PMG: `countIn`. Ein unbekannter
|
||||||
|
Wert ist nie 0 (Grundregel aus proxmox-status.ts).
|
||||||
|
|
||||||
|
**3. Kachel ausbauen** (`proxmox-widget.tsx`):
|
||||||
|
- Konfiguration über `resolveProxmoxWidgetConfig(config)`; Kopfzeile nur bei nicht leerem Titel,
|
||||||
|
Markup wie die Favoriten-Kopfzeile (Rand unten, `truncate text-sm font-semibold` als `h2`).
|
||||||
|
Die Eingabe im Bearbeitungsmodus folgt in Aufgabe 3.
|
||||||
|
- Ablauf: `selectServers` => Balken und Zusammenfassung aus der **gefilterten** Liste =>
|
||||||
|
`sortServersByHealth(gefiltert, now)` => Zeilen. `selectionGone` => Satz `widgets.proxmox.selectionGone`
|
||||||
|
statt Balken und Liste.
|
||||||
|
- Liste als `ul`, `min-h-0 flex-1 overflow-y-auto`, dazu `[@container(max-height:7.5rem)]:hidden`
|
||||||
|
und `@max-[8rem]:hidden` (sehr kleine Kachel: nur Balken und Zusammenfassung). Keinen weiteren
|
||||||
|
Container in der Kachel setzen — der Rumpf im Wrapper ist bereits `@container-size`, ein innerer
|
||||||
|
Container würde die Abfragen umlenken.
|
||||||
|
- Zeile: `flex items-center gap-2 rounded-md px-1.5 py-1 text-sm`; Punkt `h-2 w-2 shrink-0
|
||||||
|
rounded-full` + `HEALTH_STYLE[h].fill`, `aria-hidden`; Name `min-w-0 flex-1 truncate` (bei orphan
|
||||||
|
`opacity-70`, wie h7x nur den Namen dämpfen); sr-only das Zustandswort `proxmox.health.<h>`, damit
|
||||||
|
der Zustand nie nur an der Farbe hängt; Kennzahl `shrink-0 tabular-nums text-xs` rechtsbündig mit
|
||||||
|
`@max-[15rem]:hidden` (schmale Kachel: nur Punkte und Namen). Wiederholt die Kennzahl nur das
|
||||||
|
Zustandswort (`kind: 'status'`), ist sie `aria-hidden`.
|
||||||
|
- Kennzahltexte über `useLocale()`: guests => `widgets.proxmox.guests`; noGuests =>
|
||||||
|
`widgets.proxmox.noGuests`; load => `widgets.proxmox.load` mit `formatPercent`; backup =>
|
||||||
|
`widgets.proxmox.backupAgo` mit `formatAge(at, now, locale)`; noBackup => `proxmox.card.pbs.noBackupYet`;
|
||||||
|
mailIn => `widgets.proxmox.mailIn` mit `formatCount`; status => `proxmox.legend.<h>` mit `count: 1`;
|
||||||
|
unknown => `proxmox.card.unknownValue`. Farben: status => `HEALTH_STYLE[h].text`; load =>
|
||||||
|
`METER_TEXT[level]`; backup mit `stale` => `HEALTH_STYLE.warn.text`; unknown und alle übrigen =>
|
||||||
|
`text-muted-foreground`.
|
||||||
|
- Ansichtsmodus: jede Zeile ist ein `next/link` auf `/modules/proxmox` mit `hover:bg-muted/60` und
|
||||||
|
sichtbarem Fokusring. Bearbeitungsmodus: dieselbe Zeile als `div` ohne Ziel und ohne Tabstopp.
|
||||||
|
Bewusste Abweichung vom Favoriten-Muster (dort Anker mit verhindertem Klick): Links stehen im
|
||||||
|
Abbruch-Selektor von `dashboard-grid.tsx`, Anker-Zeilen würden das Ziehen über fast die ganze
|
||||||
|
Kachel blockieren. Das gilt auch für den Admin-Link im Leerzustand.
|
||||||
|
- Minutentakt: Konstante `REFRESH_MS = 60_000`; `setInterval` ruft nur dann `listServers` auf, wenn
|
||||||
|
`document.visibilityState !== 'hidden'`; ein `visibilitychange`-Hörer lädt sofort, sobald die Seite
|
||||||
|
wieder sichtbar ist. Aufräumen beim Aushängen: Intervall, Hörer, Abbruch-Flag. Scheitert ein
|
||||||
|
Nachladen, nachdem schon eine Liste da war, bleibt sie stehen; den Fehlersatz gibt es nur ohne
|
||||||
|
jede Liste. `now` wird bei jedem erfolgreichen Laden gesetzt (relative Zeitangaben).
|
||||||
|
|
||||||
|
**4. Texte** (de/en) unter `widgets.proxmox`: `guests` „{running}/{total} {total, plural, one {Gast läuft} other {Gäste laufen}}“ /
|
||||||
|
„{running}/{total} {total, plural, one {guest running} other {guests running}}“; `noGuests` „keine Gäste“/„no guests“;
|
||||||
|
`load` „Auslastung {percent}“/„Load {percent}“; `backupAgo` „Sicherung {age}“/„Backup {age}“;
|
||||||
|
`mailIn` „{count} eingehend“/„{count} incoming“; `selectionGone` „Die ausgewählten Server gibt es nicht
|
||||||
|
mehr. Wählen Sie im Bearbeitungsmodus andere aus.“/„The selected servers no longer exist. Choose others in edit mode.“
|
||||||
|
Umlaute echt schreiben; die Umlaut-Wache (`umlaut-guard.spec.ts`) muss grün bleiben.
|
||||||
|
|
||||||
|
**5. Tests für die Zeitgeber**: `vi.useFakeTimers()`, Zeitvorschub mit
|
||||||
|
`await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })`, `document.visibilityState`
|
||||||
|
per `Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => … })`
|
||||||
|
umschalten und `visibilitychange` auf `document` auslösen; nach jedem Test echte Zeitgeber zurück
|
||||||
|
und die Eigenschaft wiederherstellen. Die Container-Query-Stufen sind in jsdom nicht auswertbar —
|
||||||
|
dort nur die Klassen prüfen; die tatsächliche Wirkung prüft der Browser-Nachweis.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/proxmox-widget src/components/proxmox "src/app/(portal)/modules/proxmox" src/messages && test "$(grep -vE '^\s*(//|\*|/\*|\{/\*)' apps/web/src/components/dashboard/widgets/proxmox-widget.tsx apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts | grep -c 'pollServer')" -eq 0 && test "$(grep -c 'function formatPercent' "apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx")" -eq 0</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Alle `<behavior>`-Fälle grün, RED-Phase im Commit-Verlauf belegt (Test-Commit vor Umsetzungs-Commit). Die Kachel zeigt die sortierte, gefilterte Serverliste mit genau einer Kennzahl je Zeile, verlinkt im Ansichtsmodus, bleibt im Bearbeitungsmodus ziehbar, stuft sich per Container-Query ab und lädt minütlich nur aus dem Zwischenlager nach. Kein Zahlformat doppelt (ServerCard nutzt die gemeinsamen Funktionen), Modulseiten-Tests weiter grün.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Aufgabe 3: Titel und Serverauswahl einstellen (an der Kachel im Bearbeitungsmodus und unter Einstellungen > Dashboard), Doku, Changelog, Gesamt-Tore</name>
|
||||||
|
<files>apps/web/src/components/proxmox/proxmox-server-picker.tsx, apps/web/src/components/proxmox/proxmox-server-picker.test.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget.tsx, apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx, apps/web/src/components/settings/proxmox-widget-config-form.tsx, apps/web/src/components/settings/proxmox-widget-config-form.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/settings/widget-settings-panel.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md, CHANGELOG.md</files>
|
||||||
|
<behavior>
|
||||||
|
- Auswahl-Bauteil: zeigt je Server ein Kästchen, sortiert nach position, Beschriftung Name plus Produktwort (Virtualisierung/Datensicherung/Mail-Gateway); ausgewählte Kennungen sind angehakt
|
||||||
|
- Auswahl-Bauteil: Anhaken/Abhaken ruft onChange mit der neuen Kennungsliste in Listenreihenfolge; Kennungen gelöschter Server fallen dabei heraus; alles abhaken ergibt eine leere Liste
|
||||||
|
- Auswahl-Bauteil: der Hinweis „Ohne Auswahl zeigt die Kachel alle Server.“ steht sichtbar da
|
||||||
|
- Kachel im Bearbeitungsmodus: Titelfeld (Beschriftung „Titel“) speichert entprellt nach 1500 ms per updateWidgetConfig mit { title }
|
||||||
|
- Kachel im Bearbeitungsmodus: Knopf „Server auswählen“ mit aria-expanded öffnet die Auswahl an Stelle der Liste; eine Änderung speichert sofort per updateWidgetConfig mit { serverIds } und filtert die Anzeige
|
||||||
|
- Kachel: Verlassen des Bearbeitungsmodus schließt die Auswahl
|
||||||
|
- Einstellungsformular: lädt die Server einmal per listServers, zeigt Lade-, Fehler- und Leersatz; Titeländerung ruft onChange({ title }), Auswahländerung onChange({ serverIds })
|
||||||
|
- Einstellungsbereich: Kachel-Typ proxmox rendert das Formular; ein gesetzter Titel erscheint hinter „Proxmox #1“
|
||||||
|
- Weder Auswahl-Bauteil noch Formular noch Kachel rufen die manuelle Abfragefunktion auf
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**RED zuerst** für Auswahl-Bauteil, Formular, Einstellungsbereich und die neuen Kachel-Fälle gemäß
|
||||||
|
`<behavior>`, dann umsetzen.
|
||||||
|
|
||||||
|
**1. Gemeinsames Auswahl-Bauteil** `components/proxmox/proxmox-server-picker.tsx`:
|
||||||
|
`ProxmoxServerPicker({ servers, selectedIds, onChange })` als `fieldset` mit `legend`
|
||||||
|
`widgets.proxmox.serversLabel` („Angezeigte Server“), Hinweis `widgets.proxmox.serversHint` („Ohne
|
||||||
|
Auswahl zeigt die Kachel alle Server.“), je Server ein Kästchen, Reihenfolge nach `position`,
|
||||||
|
Beschriftung Name plus gedämpftes Produktwort `proxmox.card.product.<typ>`. Eindeutige
|
||||||
|
Feldkennungen je Instanz über `useId`. `onChange(next)`: Kennungen in Listenreihenfolge, nur
|
||||||
|
vorhandene Server — so räumt jede Änderung Kennungen gelöschter Server mit auf. Keine eigene
|
||||||
|
Datenabfrage im Bauteil; es bekommt die Liste hereingereicht.
|
||||||
|
|
||||||
|
**2. Kachel im Bearbeitungsmodus** (`proxmox-widget.tsx`): Innenabstand oben `pt-5`, damit die
|
||||||
|
20 px hohe Griffleiste des Wrappers das Titelfeld nicht verdeckt (vgl. `top-6` im XFrame). Die
|
||||||
|
Kopfzeile erscheint im Bearbeitungsmodus immer: Titelfeld im Favoriten-Muster (Klasse
|
||||||
|
`widgetNoDrag`, Beschriftung `widgets.proxmox.titleLabel`, Platzhalter `widgets.proxmox.titlePlaceholder`,
|
||||||
|
Entprellung 1500 ms, `updateWidgetConfig(instanceId, { title })`, Zeitgeber beim Aushängen löschen),
|
||||||
|
daneben ein kleiner Textknopf `widgets.proxmox.chooseServers` („Server auswählen“) mit
|
||||||
|
`aria-expanded` und `data-no-drag`. Offen ersetzt die Auswahl den Listenbereich (scrollbar, Hülle
|
||||||
|
mit `widgetNoDrag`, damit Klicks auf Beschriftungen kein Ziehen starten); sie bekommt die bereits
|
||||||
|
geladene, **ungefilterte** Liste — kein zusätzlicher Abruf. Eine Änderung setzt den lokalen Zustand
|
||||||
|
und speichert sofort mit `updateWidgetConfig(instanceId, { serverIds })` (Muster Ansichtswechsel der
|
||||||
|
Favoriten); die Anzeige filtert sofort mit. Verlässt der Nutzer den Bearbeitungsmodus, schließt
|
||||||
|
die Auswahl. Lokaler Zustand für Titel und Auswahl wird aus `config` initialisiert (wie `viewMode`
|
||||||
|
bei den Favoriten).
|
||||||
|
Grund für die Auswahl direkt an der Kachel: die Seite Einstellungen > Dashboard zeigt nur die
|
||||||
|
Kacheln des ersten Reiters (bekannte Grenze aus quick-260923-ad9); eine Proxmox-Kachel auf einem
|
||||||
|
zweiten Reiter wäre sonst nicht einstellbar. Die Einstellungsseite selbst wird **nicht** geändert.
|
||||||
|
|
||||||
|
**3. Einstellungsformular** `components/settings/proxmox-widget-config-form.tsx`:
|
||||||
|
`ProxmoxWidgetConfigForm({ config, onChange })` — Titelfeld (Kennung `proxmox-widget-title`,
|
||||||
|
Aufbau wie `FavoritesConfig`, sendet den rohen Tippwert), darunter nach einmaligem `listServers()`
|
||||||
|
das Auswahl-Bauteil; Ladesatz `proxmox.loading`, Fehlersatz `proxmox.loadError`, ohne Server
|
||||||
|
`widgets.proxmox.empty`. Aus `@/lib/proxmox-api` nur `listServers` und Typen importieren.
|
||||||
|
In `widget-settings-panel.tsx` einen Zweig `widget.widgetType === 'proxmox'` mit dem Formular
|
||||||
|
ergänzen und `'proxmox'` in die Bedingung für den Titel-Zusatz in der Instanz-Kopfzeile aufnehmen.
|
||||||
|
|
||||||
|
**4. Texte** (de/en) unter `widgets.proxmox`: `titleLabel` „Titel“/„Title“, `titlePlaceholder`
|
||||||
|
„Titel (optional)“/„Title (optional)“, `serversLabel` „Angezeigte Server“/„Servers shown“,
|
||||||
|
`serversHint` „Ohne Auswahl zeigt die Kachel alle Server.“/„With nothing selected, the tile shows all servers.“,
|
||||||
|
`chooseServers` „Server auswählen“/„Choose servers“.
|
||||||
|
|
||||||
|
**5. Doku** in Alltagssprache:
|
||||||
|
- `docs/anleitung-anwender.md`: neue Zeile „Proxmox“ in der Tabelle „Verfügbare Widgets“ (was die
|
||||||
|
Kachel zeigt, Klick führt zur Proxmox-Seite, nur mit Zugriff auf das Modul sichtbar, aktualisiert
|
||||||
|
sich jede Minute aus dem zuletzt gespeicherten Stand und fragt die Server dabei nicht neu ab,
|
||||||
|
Titel und Serverauswahl im Bearbeitungsmodus oder unter Einstellungen > Dashboard); Proxmox in
|
||||||
|
die Aufzählung „Für Uhr, Suchleiste, … gibt es zusätzliche Einstellungen“ und in den Absatz
|
||||||
|
„Dashboard > Widgets“ aufnehmen; im Abschnitt „### Proxmox“ ein kurzer Absatz zur Kachel.
|
||||||
|
- `docs/anleitung-entwicklung.md`, Abschnitt „Eine Kachel zum Modul“: Proxmox als erstes echtes
|
||||||
|
Beispiel nennen (`proxmox-widget.tsx`, Modellfunktionen in `proxmox-widget-model.ts`) und dass die
|
||||||
|
gemeinsame Statuslogik seit 260924-i8v unter `apps/web/src/components/proxmox/` liegt.
|
||||||
|
|
||||||
|
**6. CHANGELOG.md** unter „## Unveröffentlicht“, Abschnitt „### Neu“, ein Stichpunkt in Nutzersprache, im
|
||||||
|
Stil der Nachbarn (Aufzählung mit Semikolon oder Gedankenstrich, keine Mittelpunkte): Dashboard-Kachel
|
||||||
|
„Proxmox“ — farbiger Balken mit „Alles in Ordnung“ oder z. B. „1 nicht erreichbar“, darunter die
|
||||||
|
Server, auffällige zuerst, mit je einer Kennzahl (laufende Gäste, letzte Sicherung, eingehende
|
||||||
|
Mails); Klick öffnet die Proxmox-Seite; eigener Titel und Auswahl einzelner Server; aktualisiert
|
||||||
|
sich jede Minute, ohne die Server neu abzufragen; nur für Benutzer mit Zugriff auf das Modul.
|
||||||
|
|
||||||
|
**7. Gesamt-Tore** (alle Befehle aus `<verify>`): Web-Tests vollständig, API-Tests vollständig,
|
||||||
|
`pnpm turbo run type-check lint`, Biome-Warnungen Web höchstens 53 (Stand vorher: 53), Stilprüfung
|
||||||
|
der neuen Dateien und der neuen Texte, Tailwind-Probelauf: ein Wegwerf-Skript im Scratchpad, das
|
||||||
|
im Ordner `apps/web` `@tailwindcss/postcss` mit `@import "tailwindcss" source(none);` und `@source`
|
||||||
|
auf `proxmox-widget.tsx` laufen lässt und prüft, dass die Ausgabe `@container (width < 15rem)`,
|
||||||
|
`@container (width < 8rem)` und `@container (max-height:7.5rem)` enthält; das Skript danach löschen,
|
||||||
|
nichts davon committen. Keine neuen `any`, `!`-Nicht-null-Behauptungen oder Biome-Ausnahmen in den
|
||||||
|
neuen Dateien. Docker wird nicht neu gebaut — den Browser-Nachweis macht der Orchestrator.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api exec vitest run && pnpm turbo run type-check lint && W=$(pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${W:-0}" -le 53 && test -z "$(grep -nE 'uppercase|tracking-widest|·|→|dangerouslySetInnerHTML' apps/web/src/components/dashboard/widgets/proxmox-widget.tsx apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts apps/web/src/components/proxmox/proxmox-server-picker.tsx apps/web/src/components/settings/proxmox-widget-config-form.tsx)" && test "$(grep -vE '^\s*(//|\*|/\*|\{/\*)' apps/web/src/components/dashboard/widgets/proxmox-widget.tsx apps/web/src/components/proxmox/proxmox-server-picker.tsx apps/web/src/components/settings/proxmox-widget-config-form.tsx | grep -c 'pollServer')" -eq 0 && node -e 'for (const f of ["de","en"]) { const m = require("./apps/web/src/messages/" + f + ".json"); const s = JSON.stringify(m.widgets.proxmox); for (const k of ["name","description","allOk","empty","guests","noGuests","load","backupAgo","mailIn","selectionGone","titleLabel","titlePlaceholder","serversLabel","serversHint","chooseServers"]) if (!(k in m.widgets.proxmox)) throw new Error(f + ": fehlt widgets.proxmox." + k); if (/·|→/.test(s)) throw new Error(f + ": verbotenes Zeichen"); }' && grep -q "Proxmox" CHANGELOG.md && grep -q "| Proxmox |" docs/anleitung-anwender.md && grep -q "components/proxmox" docs/anleitung-entwicklung.md</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Titel und Serverauswahl lassen sich an der Kachel im Bearbeitungsmodus (jeder Reiter) und unter Einstellungen > Dashboard setzen; keine Auswahl = alle Server; Kennungen gelöschter Server räumen sich bei der nächsten Änderung selbst auf. Anwender- und Entwicklerdoku sowie CHANGELOG ergänzt. Web-Tests vollständig, API-Tests vollständig, type-check und lint grün, Biome-Warnungen Web ≤ 53, Tailwind erzeugt alle drei Container-Stufen, Stil- und Abfrage-Grep leer. Commits je Aufgabe mit `feat(260924-i8v)`/`test(260924-i8v)`/`docs(260924-i8v)`; `.planning/**` wird vom Executor nicht committet.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → API `GET /modules/proxmox/servers` | Kachel und Formular lesen Serverdaten; Zugriff entscheidet `@UseModule('proxmox')` serverseitig |
|
||||||
|
| Browser → API `PATCH /dashboard/widgets/:id` (updateWidgetConfig) | `config.title`/`config.serverIds` kommen vom Client und sind nicht vertrauenswürdig |
|
||||||
|
| API → Proxmox-Hosts | Abfragen laufen nur über Zeitplaner bzw. Admin-Knopf der Modulseite; die Kachel darf diese Grenze nie auslösen |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-I8V-01 | Information Disclosure | Proxmox-Kachel für Benutzer ohne Modulzugriff | high | mitigate | `WIDGET_MODULE_SLUGS.proxmox = 'proxmox'` → `DashboardService.getWidgets` filtert fail-closed; `GET servers` bleibt hinter `@UseModule('proxmox')`; Katalogfilter nur Komfort (T-M1H-01). API-Spec prüft die Zuordnung. |
|
||||||
|
| T-I8V-02 | Denial of Service | Kachel/Formular lösen Live-Abfragen bei Proxmox aus (je Kachel und Benutzer alle 60 s) | high | mitigate | Nur `listServers` (Zwischenlager) importiert; Test mit Spion: manuelle Abfragefunktion nie aufgerufen; Grep-Tor in Aufgabe 2 und 3; Takt pausiert bei verborgenem Tab. |
|
||||||
|
| T-I8V-03 | Tampering | `config.serverIds` / `config.title` vom Client | low | mitigate | Nur als clientseitiger Anzeigefilter über die vom Server gelieferte Liste genutzt; `resolveProxmoxWidgetConfig` verwirft Nicht-Strings; kein Zugriff auf Server außerhalb der eigenen Liste möglich. |
|
||||||
|
| T-I8V-04 | Tampering (XSS) | Servername und Titel in der Kachel | medium | mitigate | Ausgabe nur als React-Text, kein Roh-HTML-Einschub (Grep-Tor in Aufgabe 3). |
|
||||||
|
| T-I8V-05 | Information Disclosure | Fehlerdetails auf gemeinsam sichtbaren Dashboards | low | mitigate | Die Kachel zeigt bei down nur „nicht erreichbar“, weder `errorDetail` noch `rawSample` noch Adresse; Details bleiben auf der Modulseite. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Executor (in Aufgabe 3 gebündelt):
|
||||||
|
- `pnpm --filter @tessera/web exec vitest run` — vollständig grün
|
||||||
|
- `pnpm --filter @tessera/api exec vitest run` — vollständig grün (API-Spec wurde angefasst)
|
||||||
|
- `pnpm turbo run type-check lint` — grün; Biome-Warnungen Web ≤ 53
|
||||||
|
- Stil- und Abfrage-Grep leer; Tailwind-Probelauf erzeugt alle drei Container-Stufen
|
||||||
|
|
||||||
|
**Browser-Nachweis — führt der Orchestrator durch, kein Executor-Task** (Playwright, lokaler Stack
|
||||||
|
mit `--build`, hell UND dunkel, 1400 px Breite):
|
||||||
|
1. Admin: „Widget hinzufügen“ zeigt „Proxmox“ als zehnte Kachel; anlegen → 8×8, kein 400.
|
||||||
|
2. Kachel zeigt 6-px-Balken und „Alles in Ordnung“ (grün) bzw. die Zusammenfassung in der Farbe des
|
||||||
|
schlimmsten Zustands; Zeilen in der Reihenfolge down, warn, ok, idle, orphan, Kennzahlen rechts.
|
||||||
|
3. Klick auf eine Zeile öffnet `/modules/proxmox`; im Bearbeitungsmodus nicht, die Kachel lässt sich
|
||||||
|
über den Zeilen ziehen und vergrößern.
|
||||||
|
4. Größenstufen: auf 4 Spalten verkleinern → nur Punkte und Namen; auf 3×4 → nur Balken und
|
||||||
|
Zusammenfassung.
|
||||||
|
5. Bearbeitungsmodus: Titel setzen, „Server auswählen“ → einen Server abwählen → Kachel filtert,
|
||||||
|
nach Neuladen bleibt es so; Einstellungen > Dashboard zeigt dieselbe Auswahl.
|
||||||
|
6. Netzwerk über gut 60 s: nur `GET /modules/proxmox/servers` im Minutentakt, **kein**
|
||||||
|
`POST …/poll`; Tab verbergen → kein Abruf.
|
||||||
|
7. Benutzer ohne Proxmox-Zugriff: Katalog ohne „Proxmox“, vorhandene Kachel erscheint nicht.
|
||||||
|
8. Englisch umschalten: keine rohen Schlüssel.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Die Kachel „Proxmox“ ist im Katalog für berechtigte Benutzer anlegbar und zeigt den Zustand in der
|
||||||
|
Statussprache der Modulseite (Balken, Zusammenfassung, sortierte Liste, eine Kennzahl je Server).
|
||||||
|
- Gemeinsame Proxmox-Teile liegen einmal unter `apps/web/src/components/proxmox/`; keine kopierte
|
||||||
|
Statuslogik, kein doppeltes Zahlformat.
|
||||||
|
- Kein Aufruf der manuellen Abfrage aus Kachel, Auswahl oder Formular; minütliches Nachladen nur aus
|
||||||
|
dem Zwischenlager, pausiert bei verborgenem Tab.
|
||||||
|
- Titel und Serverauswahl an der Kachel und in den Einstellungen; unbekannte Werte heißen „unbekannt“.
|
||||||
|
- Alle Tore grün, Biome-Warnungen Web ≤ 53, Doku und CHANGELOG in Nutzersprache ergänzt.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260924-i8v-proxmox-kachel-fuers-dashboard/260924-i8v-SUMMARY.md` when done
|
||||||
|
(deutsch, Muster der h7x-Summary: Was gebaut wurde, Abweichungen, Tore mit gemessenen Zahlen,
|
||||||
|
Hinweise für den Browser-Nachweis, „Bewusst offen“: Einstellungsseite zeigt weiterhin nur den ersten
|
||||||
|
Reiter).
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260924-i8v
|
||||||
|
phase: quick
|
||||||
|
plan: 260924-i8v
|
||||||
|
subsystem: web / dashboard, proxmox-modul; shared (Kacheltypen); api (Kachel-Modul-Zuordnung)
|
||||||
|
status: complete
|
||||||
|
tags: [proxmox, dashboard, modul-kachel, container-query, statusfarben, a11y]
|
||||||
|
requires: [quick-260922-m1h (Modul bringt Kachel mit), quick-260924-h7x (Statussprache Proxmox), quick-260923-dhh (Proxmox-Modul)]
|
||||||
|
provides:
|
||||||
|
- Kacheltyp proxmox (WIDGET_TYPES zuletzt, WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' })
|
||||||
|
- ProxmoxWidget mit Balken, Zusammenfassung, Serverliste, Minutentakt, Bearbeitungsmodus
|
||||||
|
- gemeinsamer Ordner apps/web/src/components/proxmox/ (Statuslogik, HealthBar compact, Stile, Zahlformat, Serverauswahl)
|
||||||
|
- ProxmoxWidgetConfigForm fuer Einstellungen > Dashboard
|
||||||
|
affects:
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/* (Importpfade, Zahlformat aus gemeinsamer Datei)
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.tsx, (portal)/page.tsx
|
||||||
|
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||||
|
- apps/api/src/dashboard/widget-module-map.* (Zuordnung jetzt nicht mehr leer)
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Modul-Kachel liest nur das Zwischenlager des Moduls, nie eine Live-Abfrage (Grep-Tor + Spion im Test)
|
||||||
|
- Zeilen im Ansichtsmodus Links, im Bearbeitungsmodus schlichte Elemente (Abbruch-Selektor des Rasters)
|
||||||
|
- Groessenstufen per Container-Query am Wrapper-Rumpf, kein innerer Container
|
||||||
|
- Zeitgeber-Tests faelschen nur setInterval bzw. setTimeout, damit die Warte-Helfer der Testing Library laufen
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/web/src/components/dashboard/widgets/proxmox-widget.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/proxmox-widget-model.ts
|
||||||
|
- apps/web/src/components/dashboard/widgets/proxmox-widget-model.test.ts
|
||||||
|
- apps/web/src/components/proxmox/proxmox-server-picker.tsx
|
||||||
|
- apps/web/src/components/proxmox/proxmox-server-picker.test.tsx
|
||||||
|
- apps/web/src/components/settings/proxmox-widget-config-form.tsx
|
||||||
|
- apps/web/src/components/settings/proxmox-widget-config-form.test.tsx
|
||||||
|
moved:
|
||||||
|
- apps/web/src/components/proxmox/proxmox-status.ts (git mv aus app/(portal)/modules/proxmox/components/)
|
||||||
|
- apps/web/src/components/proxmox/proxmox-status.test.ts (git mv)
|
||||||
|
- apps/web/src/components/proxmox/HealthBar.tsx (git mv)
|
||||||
|
- apps/web/src/components/proxmox/status-styles.ts (git mv)
|
||||||
|
modified:
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/src/dashboard/widget-module-map.ts
|
||||||
|
- apps/api/src/dashboard/widget-module-map.spec.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||||
|
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||||
|
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
decisions:
|
||||||
|
- Die Kachel zeigt im Zustand „ausgewählte Server gibt es nicht mehr“ bei offener Auswahl direkt die Auswahl statt des Satzes, damit sich der Zustand an Ort und Stelle beheben lässt
|
||||||
|
- „Server auswählen“ erscheint nur, wenn eine Serverliste geladen und nicht leer ist
|
||||||
|
- Speichern aus der Kachel (Titel, Auswahl) fängt Fehler still ab; der nächste Ladevorgang zeigt den gespeicherten Stand
|
||||||
|
- Die Kennzahl bei PVE-Warnung fällt auf die Gästezahl zurück, falls kein einziger Auslastungswert bekannt ist
|
||||||
|
metrics:
|
||||||
|
duration: 22min
|
||||||
|
completed: 2026-09-24
|
||||||
|
tasks: 3
|
||||||
|
files: 29
|
||||||
|
plan_head_before: 8bfa4fc46737b91ed6860089721a67f17014c3d1
|
||||||
|
actuals:
|
||||||
|
tokens: 29800
|
||||||
|
tasks: 3
|
||||||
|
commits: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260924-i8v: Proxmox-Kachel fürs Dashboard
|
||||||
|
|
||||||
|
Die erste echte Modul-Kachel: „Proxmox“ zeigt oben einen 6 px hohen Gesundheitsbalken und darunter in Worten „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar, 1 mit Warnung“ in der Farbe des schlimmsten Zustands. Darunter stehen die Server nach Dringlichkeit, jeder mit Statuspunkt, Name und genau einer Kennzahl. Ein Klick führt zur Modulseite. Die Kachel liest nur das Zwischenlager, frischt sich minütlich auf, lässt sich auf Titel und Serverauswahl einstellen und stuft sich per Container-Query ab.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Aufgabe 1: Durchstich (a906c67)**
|
||||||
|
- Die vier gemeinsamen Dateien (`proxmox-status.ts` samt Test, `HealthBar.tsx`, `status-styles.ts`) liegen jetzt per `git mv` unter `apps/web/src/components/proxmox/`. Die Git-Historie bleibt erhalten. Modulseite und `ServerCard` importieren sie über `@/components/proxmox/...`. An der Logik hat sich nichts geändert.
|
||||||
|
- `HealthBar` hat eine neue Variante `variant="compact"`: 6 px hoch (`h-1.5`), ohne Legende und mit `aria-hidden`. Die Variante `full` ist Standard, die Modulseite sieht unverändert aus. Beide tragen `data-variant`.
|
||||||
|
- `packages/shared`: `'proxmox'` steht am Ende von `WIDGET_TYPES`, dazu `WIDGET_MODULE_SLUGS = { proxmox: 'proxmox' }`. Der Kommentar „bewusst leer“ ist in beiden Dateien korrigiert, in `shared` und in `widget-module-map.ts`.
|
||||||
|
- Registry: 3/4/8/8 mit Rechenkommentar, Inline-Symbol mit Server-Einschüben, `moduleSlug: WIDGET_MODULE_SLUGS.proxmox`.
|
||||||
|
- `registerWidget('proxmox', ProxmoxWidget)` steht in `(portal)/page.tsx`, das passende `vi.mock` im Seitentest.
|
||||||
|
- Die bestehenden Tests sind nachgezogen:
|
||||||
|
- Die Registry kennt zehn Typen. Nur proxmox trägt einen Modul-Slug. Die Tests für einen unbekannten Typ nutzen jetzt `'gibt-es-nicht'`.
|
||||||
|
- Der Katalog zeigt zehn Kacheln bei `['proxmox']`, neun bei `[]`. Mit `null` fehlt Proxmox, und zwar an der echten Kachel statt an der vorher nur vorübergehend umgebauten Uhr.
|
||||||
|
- Die API-Spec prüft `getModuleSlugForWidgetType('proxmox') === 'proxmox'`.
|
||||||
|
|
||||||
|
**Aufgabe 2: Serverliste (RED 92bf130, GREEN a217d60)**
|
||||||
|
- `formatPercent` und `formatCount` sind unverändert aus `ServerCard.tsx` nach `proxmox-status.ts` gezogen. ServerCard importiert sie von dort, damit das Zahlformat nicht doppelt existiert.
|
||||||
|
- Die reinen Funktionen in `proxmox-widget-model.ts`:
|
||||||
|
- `resolveProxmoxWidgetConfig` liest die Konfiguration abwehrend: Nicht-Strings und leere Kennungen fallen weg, doppelte zählen einmal.
|
||||||
|
- `selectServers`: leere Auswahl bedeutet alle Server; bleiben nur gelöschte Kennungen, wird `selectionGone` gesetzt.
|
||||||
|
- `widgetKeyFigure` wählt die eine Kennzahl je Zeile:
|
||||||
|
- Status bei down, idle und orphan
|
||||||
|
- Gäste bei PVE im Normalzustand, bei PVE-Warnung der höchste bekannte Anteil aus CPU, RAM und Speicher
|
||||||
|
- bei PBS die älteste bekannte Sicherung samt Hinweis, ob sie veraltet ist
|
||||||
|
- bei PMG `countIn`
|
||||||
|
- sonst `unknown`, nie 0
|
||||||
|
- Kachel:
|
||||||
|
- Die gefilterte Liste speist Balken, Zusammenfassung und Zeilen, sortiert nach `sortServersByHealth`.
|
||||||
|
- Je Zeile ein Punkt, der Name (bei orphan nur der Name mit `opacity-70`) und das Zustandswort als `sr-only`.
|
||||||
|
- Die Kennzahl steht rechts mit `@max-[15rem]:hidden`. Wiederholt sie nur den Zustand, ist sie `aria-hidden`.
|
||||||
|
- Die Liste trägt `[@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden`.
|
||||||
|
- Im Ansichtsmodus ist jede Zeile ein `next/link` auf `/modules/proxmox`. Im Bearbeitungsmodus ist sie ein `div` ohne Tabstopp, das gilt auch für den Admin-Link im Leerzustand.
|
||||||
|
- Takt: `REFRESH_MS = 60_000`, bei verborgenem Tab kein Abruf, `visibilitychange` zurück auf sichtbar lädt sofort. Beim Aushängen wird aufgeräumt.
|
||||||
|
- Scheitert ein späteres Nachladen, bleibt die zuletzt geladene Liste stehen.
|
||||||
|
|
||||||
|
**Aufgabe 3: Einstellen, Doku, Tore (RED 377b6e3, GREEN 586da44, Doku 602a45c)**
|
||||||
|
- `ProxmoxServerPicker`:
|
||||||
|
- ein `fieldset` mit der Legende „Angezeigte Server“ und dem sichtbaren Hinweis „Ohne Auswahl zeigt die Kachel alle Server.“
|
||||||
|
- je Server ein Kästchen, sortiert nach `position`, Name plus Produktwort, eindeutige Kennungen per `useId`
|
||||||
|
- `onChange` liefert die Kennungen in Listenreihenfolge und nur für vorhandene Server. Kennungen gelöschter Server räumen sich dabei selbst auf.
|
||||||
|
- Kachel im Bearbeitungsmodus:
|
||||||
|
- `pt-5` unter der Griffleiste. Die Kopfzeile erscheint immer, mit Titelfeld (`widgetNoDrag`, 1500 ms entprellt, `updateWidgetConfig({ title })`) und dem Textknopf „Server auswählen“ (`aria-expanded`, `data-no-drag`).
|
||||||
|
- Die offene Auswahl ersetzt den Listenbereich in einer Hülle mit `widgetNoDrag`. Sie bekommt die ungefilterte, schon geladene Liste; ein zusätzlicher Abruf findet nicht statt.
|
||||||
|
- Eine Änderung filtert sofort und speichert `{ serverIds }`. Beim Verlassen des Bearbeitungsmodus schließt die Auswahl.
|
||||||
|
- `ProxmoxWidgetConfigForm` (Titel `proxmox-widget-title` plus Auswahl) ist unter Einstellungen > Dashboard eingehängt. Die Instanz-Kopfzeile zeigt „Proxmox #1 — Titel“.
|
||||||
|
- Doku:
|
||||||
|
- Anwenderdoku: Tabellenzeile „Proxmox“, Aufzählung der zusätzlichen Einstellungen, Absatz „Dashboard > Widgets“, Absatz im Abschnitt „### Proxmox“.
|
||||||
|
- Entwicklerdoku: Proxmox als erstes Beispiel unter „Eine Kachel zum Modul“, mit dem Ort `components/proxmox/`.
|
||||||
|
- CHANGELOG: ein Punkt unter „Unveröffentlicht > Neu“.
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
1. **[Rule 1, Kommentar] `apps/api/src/dashboard/widget-module-map.ts`** stand nicht in der Dateiliste. Sein Kopfkommentar behauptete aber weiterhin „Die Tabelle ist bewusst leer“. Korrigiert ist nur der Kommentar, der Code ist unverändert (a906c67).
|
||||||
|
2. **Zeitgeber-Tests:** Statt `vi.useFakeTimers()` ohne Einschränkung werden nur `setInterval`/`clearInterval` gefälscht, beim Entprell-Test nur `setTimeout`/`clearTimeout`. Grund: `findBy`/`waitFor` der Testing Library laufen über `setTimeout` und blieben mit vollständig gefälschten Zeitgebern unter Vitest hängen. Die geforderten Nachweise sind unverändert erbracht (60 s, verborgen, sichtbar, Aushängen).
|
||||||
|
3. **Auswahl im Zustand „Server gibt es nicht mehr“:** Ist die Auswahl im Bearbeitungsmodus offen, zeigt die Kachel dort die Auswahl statt des Satzes. Der Satz selbst fordert dazu auf, im Bearbeitungsmodus andere Server zu wählen, und das ist so an Ort und Stelle möglich.
|
||||||
|
4. **Biome:** Importreihenfolge in `ServerCard.tsx` und Formatierung der neuen Dateien mit `biome check --write` angeglichen. Die Reihenfolge verschob sich durch den neuen `@/components`-Pfad. Keine Logikänderung.
|
||||||
|
|
||||||
|
Keine Architekturfragen, keine neuen Pakete, keine Anmelde-Sperren.
|
||||||
|
|
||||||
|
## Tore (gemessen)
|
||||||
|
|
||||||
|
| Tor | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| Web-Tests vollständig (`vitest run`) | 91 Dateien, 864 Tests grün |
|
||||||
|
| API-Tests vollständig (`vitest run`) | 84 Dateien, 1370 Tests grün, darunter `src/prisma/rls-access-inventory.spec.ts` (30 Tests, einzeln nachgeprüft) |
|
||||||
|
| `pnpm turbo run type-check lint` | 9 von 9 Aufgaben erfolgreich |
|
||||||
|
| Biome-Warnungen Web | 53 (Grenze 53); die neuen Dateien sind warnungsfrei |
|
||||||
|
| Stil-Grep (uppercase, tracking-widest, Mittelpunkt, Pfeil, dangerouslySetInnerHTML) | leer |
|
||||||
|
| Abfrage-Grep `pollServer` in Kachel, Modell, Auswahl, Formular | 0 Treffer außerhalb von Kommentaren |
|
||||||
|
| Übersetzungen `widgets.proxmox.*` (15 Schlüssel) in de und en | vollständig; die Umlaut-Wache ist grün |
|
||||||
|
| Tailwind-Probelauf (Wegwerfskript im Scratchpad, danach gelöscht) | `@container (width < 15rem)`, `@container (width < 8rem)` und `@container (max-height:7.5rem)` werden erzeugt |
|
||||||
|
| `any`, `!`, `biome-ignore` in neuen Dateien | keine |
|
||||||
|
|
||||||
|
**TDD:** Die RED-Phase ist im Verlauf belegt: Test-Commit 92bf130 vor a217d60 und Test-Commit 377b6e3 vor 586da44. Die Tests schlugen aus dem richtigen Grund fehl: fehlende Exporte, Zeilen und Module.
|
||||||
|
|
||||||
|
## Hinweise für den Browser-Nachweis
|
||||||
|
|
||||||
|
- Die Kachel braucht einen neu gebauten Web-Container, also `--build`. Docker wurde vom Executor nicht angefasst.
|
||||||
|
- Zeilen tragen `data-testid="proxmox-row"` und `data-server-name`, die Kennzahl `data-testid="proxmox-key-figure"`, die Liste `data-testid="proxmox-list"`, die Zusammenfassung `data-testid="proxmox-summary"`, der Balken `data-testid="health-bar"` mit `data-variant="compact"`.
|
||||||
|
- Größenstufen: bei 4 Spalten (rund 216 px, also unter 15rem = 240 px) nur Punkte und Namen. Bei 3×4 (104 px hoch, unter 7.5rem = 120 px) nur Balken und Zusammenfassung.
|
||||||
|
- Netzwerk: im Minutentakt nur `GET /modules/proxmox/servers`. Beim Öffnen von „Server auswählen“ gibt es keinen weiteren Abruf.
|
||||||
|
- Im Bearbeitungsmodus sind Titelfeld, Knopf und Auswahlhülle vom Ziehen ausgenommen. Die Zeilen sind keine Links, die Kachel bleibt über ihnen ziehbar.
|
||||||
|
|
||||||
|
## Bewusst offen
|
||||||
|
|
||||||
|
- Einstellungen > Dashboard zeigt weiterhin nur die Kacheln des ersten Reiters (bekannte Grenze aus quick-260923-ad9). Deshalb gibt es Titel und Serverauswahl zusätzlich direkt an der Kachel.
|
||||||
|
- Lokaler Titel- und Auswahlzustand der Kachel wird wie bei den Favoriten nur beim Einhängen aus `config` gelesen. Eine Änderung unter Einstellungen > Dashboard wirkt auf eine gleichzeitig offene Dashboard-Seite erst nach dem Neuladen.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Alle angelegten Dateien vorhanden: proxmox-widget.tsx, proxmox-widget-model.ts, beide Tests, proxmox-server-picker.tsx samt Test, proxmox-widget-config-form.tsx samt Test, die vier verschobenen Dateien unter components/proxmox/.
|
||||||
|
- Alle Commits vorhanden: a906c67, 92bf130, a217d60, 377b6e3, 586da44, 602a45c.
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260924-m4n
|
||||||
|
type: quick
|
||||||
|
wave: 1
|
||||||
|
autonomous: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260924-m4n — Flackernden Test entschaerfen; DashboardImage Stufe 2 (Spalte `data` entfernen)
|
||||||
|
|
||||||
|
Nutzerfreigabe 24.09.: beide offenen Punkte erledigen. Keine Freigabe/kein Tag.
|
||||||
|
|
||||||
|
## Task 1 — Flackernder Test `TenantContextSelector`
|
||||||
|
|
||||||
|
Todo: `.planning/todos/pending/2026-09-23-flackernder-test-tenant-selector-zeitueberschreitung.md`
|
||||||
|
(lesen, dort steht die Analyse). Test: `apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx:100`.
|
||||||
|
|
||||||
|
- Ursache ansehen (warum nahe 5 s?). Den Doppelfall (SUPER_ADMIN + ADMIN in EINEM `it`) in zwei
|
||||||
|
`it` auftrennen; langsame Stellen (unnoetige echte Wartezeiten, schwere Importe je Test)
|
||||||
|
beseitigen. KEIN globales Hochsetzen von `testTimeout`.
|
||||||
|
- Messen: `vitest run --reporter=verbose` fuer die ganze Web-Suite, die 10 langsamsten Tests
|
||||||
|
auflisten (Dauer). Jeder Test ueber 2 s wird in der SUMMARY genannt; wenn eine Ursache offensichtlich
|
||||||
|
und klein ist, gleich beheben, sonst nur auflisten.
|
||||||
|
- Nebenbei (klein, gleiche Datei-Gruppe erlaubt): die `act(...)`-Warnungen aus
|
||||||
|
`apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx` beseitigen (auf das Ende der
|
||||||
|
Zustandsaenderung warten statt sie ins Leere laufen zu lassen) — sie blaehen das CI-Protokoll auf.
|
||||||
|
- Todo-Datei nach `.planning/todos/done/` verschieben (git mv), mit kurzem Nachtrag „erledigt in 260924-m4n“.
|
||||||
|
|
||||||
|
## Task 2 — DashboardImage Stufe 2
|
||||||
|
|
||||||
|
Todo: `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md` — die dort
|
||||||
|
genannten Schritte 2–4 umsetzen. Vorbedingung geprueft vom Orchestrator: alpha
|
||||||
|
`count(storagePath IS NULL) = 0` (3 Zeilen). Live ist von hier nicht pruefbar (Live laeuft 1.3.1, die den
|
||||||
|
Bootstrap-Umzug enthaelt).
|
||||||
|
|
||||||
|
- Neue Migration mit aktuellem Zeitstempel (NACH allen vorhandenen, `ls apps/api/prisma/migrations`),
|
||||||
|
Name `..._dashboard_image_drop_data`. ZUERST ein Schutz, der den Datenverlust ausschliesst:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM "DashboardImage" WHERE "storagePath" IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'DashboardImage: es gibt noch Zeilen ohne storagePath — Umzug (quick-260922-hk4) zuerst mit einer Version >= 1.3.1 laufen lassen, dann erneut deployen';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;
|
||||||
|
ALTER TABLE "DashboardImage" DROP COLUMN "data";
|
||||||
|
```
|
||||||
|
|
||||||
|
Achtung RLS: die Migration laeuft als Eigentuemer; pruefen, dass der `EXISTS`-Check nicht von einer
|
||||||
|
Zeilenregel auf 0 gefiltert wird (FORCE ROW LEVEL SECURITY?). Wenn ja, den Check so formulieren, dass
|
||||||
|
er alle Zeilen sieht (z. B. `SET LOCAL row_security = off` falls als Eigentuemer erlaubt, oder ueber die
|
||||||
|
vorhandene `system_read_policy`). Das Ergebnis der Pruefung in die SUMMARY.
|
||||||
|
- Schema, Dienst (Bootstrap-Umzug + `forSystem()` raus), `FORSYSTEM_ALLOWED_CALL_SITES`, Tests
|
||||||
|
18/21/22/23, Zugriffsklassifikation (Zahlen per Gate-Schleife neu messen, nicht abschreiben),
|
||||||
|
`system_read_policy` auf `DashboardImage` per `DROP POLICY IF EXISTS` in derselben Migration entfernen
|
||||||
|
und die Klassifikation/Aufzaehlung nachziehen.
|
||||||
|
- Lokal anwenden (DB ohne Host-Port: Container-IP, `tessera:tessera_dev`), vorher/nachher
|
||||||
|
`pg_total_relation_size` messen, danach `VACUUM FULL "DashboardImage";` lokal.
|
||||||
|
- Negativtest der Schutzklausel lokal nachweisen: in einer Wegwerf-Datenbank oder Transaktion eine Zeile
|
||||||
|
mit `storagePath NULL` anlegen → Migration bricht mit der Meldung ab (Protokoll in die SUMMARY).
|
||||||
|
- CHANGELOG „Unveröffentlicht“ nur, wenn für Nutzer sichtbar (eher nicht) — sonst weglassen.
|
||||||
|
- `docs/anleitung-betrieb.md`: kurzer Hinweis im Abschnitt Aktualisieren/Freigabe, dass die nächste
|
||||||
|
Version die alte Bildspalte entfernt und bei Abbruch mit der Meldung zuerst 1.3.1 laufen muss.
|
||||||
|
- Todo-Datei nach `.planning/todos/done/` verschieben.
|
||||||
|
|
||||||
|
## Tore
|
||||||
|
|
||||||
|
- API-Tests KOMPLETT (inkl. `src/prisma/rls-access-inventory.spec.ts`), Web-Tests komplett,
|
||||||
|
`pnpm turbo run type-check lint` gruen, Biome-Warnungen web ≤ 53, api ≤ 82.
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260924-m4n
|
||||||
|
type: quick
|
||||||
|
status: complete
|
||||||
|
subsystem: apps/web-tests, apps/api/dashboard, apps/api/prisma
|
||||||
|
tags: [flake, vitest, act, prisma-migration, rls, bilderrahmen]
|
||||||
|
requires: [quick-260922-hk4]
|
||||||
|
provides:
|
||||||
|
- Marktplatz-Tests ohne dynamischen Import im Test (Flake der Freigabe 1.3.1 entschärft)
|
||||||
|
- Proxmox-Kachel-Tests ohne act-Warnungen
|
||||||
|
- Migration 20260924120000_dashboard_image_drop_data (Stufe 2, mit Schutzprüfung)
|
||||||
|
affects: [Freigabe der nächsten Version nach 1.3.1, docs/anleitung-betrieb.md Kap. 4]
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260924120000_dashboard_image_drop_data/migration.sql
|
||||||
|
modified:
|
||||||
|
- apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/marketplace/marketplace.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/proxmox-widget.test.tsx
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||||
|
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- apps/api/src/proxmox/proxmox.service.ts
|
||||||
|
- docs/anleitung-betrieb.md
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
decisions:
|
||||||
|
- "Flake-Ursache: Komponenten wurden per await import() IM Test geladen, das Laden zählte in die 5-s-Frist; Lösung statische Importe + Doppelfall aufgetrennt, kein globales testTimeout"
|
||||||
|
- "Migration heißt 20260924120000_dashboard_image_drop_data statt der im Todo vorgemerkten 20260922120100, weil sie hinter allen vorhandenen Migrationen liegen muss"
|
||||||
|
- "Schutzprüfung der Migration schaltet row_security für die Transaktion ab: ein Eigentümer ohne BYPASSRLS würde sonst still 0 Zeilen sehen, jetzt scheitert er laut"
|
||||||
|
- "Upload vergibt die UUID selbst (randomUUID) und legt die Zeile gleich mit storagePath an, weil storagePath Pflicht ist"
|
||||||
|
- "system_read_policy auf DashboardImage in derselben Migration entfernt (kein Leser mehr)"
|
||||||
|
metrics:
|
||||||
|
duration: ~45 min
|
||||||
|
completed: 2026-09-24
|
||||||
|
actuals:
|
||||||
|
tokens: 21900
|
||||||
|
tasks: 2
|
||||||
|
commits: 2
|
||||||
|
plan_head_before: dd09c0831142b7068f060f09ece471eb1e27560b
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260924-m4n: Flackernden Test entschärft, alte Bildspalte des Bilderrahmens entfernt – Zusammenfassung
|
||||||
|
|
||||||
|
Der Marktplatz-Test, der die Freigabe 1.3.1 blockiert hat, lädt seine Komponenten jetzt beim Einlesen der Testdatei statt im Test selbst und ist in zwei Einzelfälle aufgeteilt. Die Proxmox-Kachel-Tests erzeugen keine act-Warnungen mehr (81 weniger im CI-Protokoll). Außerdem ist Stufe 2 der Bilderrahmen-Umstellung umgesetzt: Die Spalte `data` ist weg, `storagePath` ist Pflicht. Eine Schutzprüfung bricht die Migration ab, bevor Daten verloren gehen könnten.
|
||||||
|
|
||||||
|
## Aufgabe 1 – Flackernder Test `TenantContextSelector` (Commit b10734f)
|
||||||
|
|
||||||
|
**Ursache:** `tenant-selector.test.tsx` hat `TenantContextSelector` und die Marktplatzseite per `await import(...)` **innerhalb** der Tests geladen. Der erste Test einer Datei hat damit das Laden und Umwandeln der Module in seiner 5-s-Frist mitbezahlt. Lokal waren das nur rund 115 ms. Bei jeder Freigabe laufen aber drei Pipelines gleichzeitig, und unter dieser Last reißt der Test die Frist. `waitFor` selbst war nicht der Grund: Es bricht schon nach 1 s mit einer eigenen Meldung ab, nicht erst nach 5 s.
|
||||||
|
|
||||||
|
**Behoben:**
|
||||||
|
- Statische Importe: `vi.mock` wird über die Importe gehoben, das Laden fällt damit in die Einlesephase der Datei.
|
||||||
|
- Der Doppelfall steckt jetzt in zwei `it`: „renders tenant options for SUPER_ADMIN“ sowie „renders nothing for ADMIN and does not load the tenant list“. Der ADMIN-Fall prüft zusätzlich, dass kein Abruf stattfindet.
|
||||||
|
- Die gleiche Umstellung gilt für `marketplace.test.tsx` und `marketplace-filters.test.tsx`, die aus demselben Ordner stammen. In `marketplace-filters` ist `userEvent` jetzt per `userEvent.setup({ advanceTimers: vi.advanceTimersByTime })` an die simulierte Uhr gekoppelt. Vorher hat jede Eingabe auf das langsame Nachschieben in Echtzeit durch `shouldAdvanceTime` gewartet. Der Test „typing in search … after debounce“ läuft allein jetzt in 358 ms statt vorher rund 1,1 s in der Gesamtsuite.
|
||||||
|
- `testTimeout` wurde nicht global erhöht.
|
||||||
|
|
||||||
|
**Proxmox-Kachel (`proxmox-widget.test.tsx`):** `renderWidget()` rendert jetzt innerhalb von `await act(async () => …)`. Dadurch laufen die drei Zustandsänderungen nach dem ersten Laden (`setServers`, `setLoadFailed`, `setNow`) innerhalb von act() und nicht mehr danach ins Leere. Den Test „Verlassen des Bearbeitungsmodus“ habe ich genauso umgestellt, und die Komponente wird ebenfalls statisch importiert.
|
||||||
|
- act-Warnungen in der ganzen Web-Suite: **vorher 102, davon 81 von ProxmoxWidget. Nachher 21, davon 0 von ProxmoxWidget.** Die restlichen 21 stammen aus VehicleTable (8), WidgetSettingsPanel (8), DashboardPage (3), CalendarWidget (1) und Header (1). Sie lagen außerhalb des Auftrags und sind nicht angefasst.
|
||||||
|
|
||||||
|
**Messung:** `vitest run --reporter=verbose --reporter=json` über die ganze Web-Suite mit 91 Dateien und 865 Tests, alle grün.
|
||||||
|
|
||||||
|
Die 10 langsamsten Tests nach der Änderung im normalen Lauf mit 12 Kernen:
|
||||||
|
|
||||||
|
| # | Dauer | Datei | Test |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 1136 ms | components/settings/smtp-settings-form.test.tsx | Test 2: PUT-Payload trägt den Wert; leeres Feld -> null |
|
||||||
|
| 2 | 1044 ms | components/bug-report/bug-report-button.test.tsx | Test 1: Bild VOR dem Dialog … |
|
||||||
|
| 3 | 882 ms | components/settings/proxmox-widget-config-form.test.tsx | lädt die Server einmal und zeigt die Auswahl … |
|
||||||
|
| 4 | 839 ms | app/(portal)/marketplace/marketplace-filters.test.tsx | typing in search filters the grid … after debounce |
|
||||||
|
| 5 | 799 ms | components/proxmox/proxmox-server-picker.test.tsx | zeigt je Server ein Kästchen … |
|
||||||
|
| 6 | 798 ms | modules/tender-radar/settings/components/SourceConfigForm.test.tsx | editing the interval and clicking Speichern … |
|
||||||
|
| 7 | 749 ms | modules/dkv-fleet/settings/components/VehicleTable.test.tsx | clicking trash icon opens confirm dialog … |
|
||||||
|
| 8 | 746 ms | components/settings/picture-frame-config-form.test.tsx | Test 4: Pfeile … |
|
||||||
|
| 9 | 742 ms | app/(portal)/admin/users/user-access-modal.test.tsx | rolls back the direct checkbox … |
|
||||||
|
| 10 | 741 ms | app/(portal)/admin/users/users-page.test.tsx | zeigt den Servertext im offenen Löschdialog … |
|
||||||
|
|
||||||
|
Zusätzlich lief die ganze Suite gedrosselt auf 2 Kerne (`taskset -c 0-1`, 6 Worker), um die Last beim Freigeben nachzustellen. Alle 865 Tests waren grün, der langsamste brauchte 1302 ms (smtp-settings-form Test 2). Die Tests von `tenant-selector` lagen dort bei höchstens 950 ms.
|
||||||
|
|
||||||
|
**Kein Test lag über 2 s**, weder im normalen noch im gedrosselten Lauf. Damit gibt es nichts weiter zu beheben oder aufzulisten.
|
||||||
|
|
||||||
|
**Beobachtung, nicht behoben:** Das Muster „Komponente per `await import()` im Test laden“ steckt noch in 37 weiteren Web-Testdateien. Es macht jeweils den ersten Test einer Datei unter Last anfälliger. Keiner dieser Tests liegt heute nahe am Limit. Umgestellt sind nur der Marktplatz-Ordner und die Proxmox-Kachel.
|
||||||
|
|
||||||
|
## Aufgabe 2 – DashboardImage Stufe 2 (Commit dd54ec5)
|
||||||
|
|
||||||
|
**Migration `20260924120000_dashboard_image_drop_data`:**
|
||||||
|
1. Eine Schutzprüfung im `DO`-Block: Existiert noch eine Zeile mit `storagePath IS NULL`, bricht die Migration mit `RAISE EXCEPTION` ab, und zwar mit der Meldung aus dem Plan.
|
||||||
|
2. `ALTER COLUMN "storagePath" SET NOT NULL`, danach `DROP COLUMN "data"`.
|
||||||
|
3. `DROP POLICY IF EXISTS system_read_policy ON "DashboardImage"`.
|
||||||
|
|
||||||
|
Die Migration musste einen anderen Namen bekommen als im Todo vorgemerkt (`20260922120100`), weil sie hinter allen vorhandenen Migrationen liegen muss (die letzte war `20260923160000`).
|
||||||
|
|
||||||
|
**Prüfung des Zeilenschutzes (RLS), Ergebnis:**
|
||||||
|
- `DashboardImage` hat `FORCE ROW LEVEL SECURITY`, das gilt auch für den Eigentümer. Heute läuft `migrate deploy` als `tessera`. Die Rolle ist lokal gemessen Superuser mit BYPASSRLS und Eigentümerin der Tabelle, sieht also alle Zeilen.
|
||||||
|
- **Die Falle ist real, lokal nachgewiesen:** Ein Eigentümer **ohne** BYPASSRLS bekommt bei `SELECT EXISTS (… storagePath IS NULL)` das Ergebnis `f`, obwohl eine solche Zeile existiert. Die Schutzprüfung wäre dann stumm wirkungslos. Getestet habe ich das mit einer Wegwerf-Rolle `m4n_owner` in einer Transaktion, die danach zurückgerollt wurde.
|
||||||
|
- **Lösung:** Die Prüfung setzt `set_config('row_security', 'off', true)`. Sie sieht damit entweder alle Zeilen, oder PostgreSQL bricht laut ab. Die Meldung „ERROR: query would be affected by row-level security policy for table "DashboardImage"“ ist unter derselben Wegwerf-Rolle nachgewiesen. Als `tessera` greift die Schutzprüfung wie gewollt, auch das ist nachgewiesen. Die Datenbank sieht also nie „0 Zeilen, weiter“.
|
||||||
|
|
||||||
|
**Negativtest der Schutzprüfung** in einer Wegwerf-Datenbank `tessera_m4n_neg`, die danach gelöscht wurde. Alle Migrationen bis `20260923160000` wurden angewendet, dann eine Zeile mit Pfad und eine ohne Pfad angelegt:
|
||||||
|
```
|
||||||
|
Applying migration `20260924120000_dashboard_image_drop_data`
|
||||||
|
Error: P3018
|
||||||
|
Database error code: P0001
|
||||||
|
ERROR: DashboardImage: es gibt noch Zeilen ohne storagePath — Umzug (quick-260922-hk4) zuerst mit einer Version >= 1.3.1 laufen lassen, dann erneut deployen
|
||||||
|
```
|
||||||
|
Danach war nichts geändert: `data` und `storagePath` waren weiterhin NULLbar, und `system_read_policy` stand noch.
|
||||||
|
|
||||||
|
**Nebenbefund, im Betriebshandbuch beschrieben:** Nach dem Abbruch verweigert auch 1.3.1 den Start mit `P3009` („failed migrations in the target database“). Den Ausweg habe ich vollständig durchgespielt. Zuerst wird der fehlgeschlagene Eintrag in `_prisma_migrations` als zurückgenommen markiert: `UPDATE … SET rolled_back_at = now() WHERE migration_name = '…' AND finished_at IS NULL`. Danach meldet der Stand von 1.3.1 „No pending migrations“. Anschließend habe ich den Umzug nachgestellt, und die neue Version lief sauber durch: NOT NULL gesetzt, Spalte entfernt, nur noch `tenant_isolation_policy` vorhanden. Der Ablauf steht in drei Schritten in `docs/anleitung-betrieb.md` Kapitel 4.
|
||||||
|
|
||||||
|
**Lokal angewendet** über die Container-IP 172.19.0.2 mit `tessera:tessera_dev`:
|
||||||
|
|
||||||
|
| Zeitpunkt | `pg_total_relation_size` | Zeilen |
|
||||||
|
|---|---|---|
|
||||||
|
| vorher (0 ohne Pfad, 1 Zeile mit 502 Bytes in `data`) | 65536 (64 kB) | 2 |
|
||||||
|
| nach DROP | 65536 (64 kB) | 2 |
|
||||||
|
| nach `VACUUM FULL "DashboardImage"` | 65536 (64 kB) | 2 |
|
||||||
|
|
||||||
|
Lokal ist keine Verkleinerung messbar. In `data` standen nur 502 Bytes, und die Tabelle belegt schon mit Tabelle, TOAST und zwei Indizes die Mindestgröße ganzer 8-kB-Seiten. Auf alpha und live mit echten Bildern ist der Gewinn größer. Dort gilt ebenfalls, dass PostgreSQL den Platz erst nach `VACUUM FULL` an das Dateisystem zurückgibt. Ob man das dort ausführt, entscheidest du; ich habe keinen Server angefasst.
|
||||||
|
|
||||||
|
**Dienst (`dashboard-images.service.ts`):**
|
||||||
|
- `onApplicationBootstrap()` samt `forSystem()` ist entfernt, ebenso die Selbstheilung aus `data` in `getBytes`.
|
||||||
|
- `upload` vergibt die UUID selbst mit `randomUUID()` und legt die Zeile gleich mit `storagePath` an, weil das Feld jetzt Pflicht ist. Das nachträgliche `update` entfällt. Das Verhalten bei halb fertigen Zuständen bleibt gleich: erst die Zeile, dann die Datei, und scheitert das Schreiben, wird die Zeile wieder gelöscht und 500 zurückgegeben.
|
||||||
|
- Schema: `data Bytes?` ist gestrichen, `storagePath String?` wird zu `storagePath String`.
|
||||||
|
|
||||||
|
**Tests:** Die Fälle 18 und 21–23 entfallen wie geplant. Dazu fallen **10b und 10c** weg, weil sie die Selbstheilung aus `data` geprüft haben, die es nicht mehr gibt. Test 16 liest jetzt eine JPEG-Datei statt „Datei schlägt Zeilenbytes“, Test 13 prüft die UUID und den Pfad im `create`, und Test 12 prüft die neue Aufrufreihenfolge ohne `update` und ohne `forSystem`. Die Dienst-Spec hat damit 19 statt 25 Tests.
|
||||||
|
|
||||||
|
**Erlaubnisliste und Klassifikation:**
|
||||||
|
- `FORSYSTEM_ALLOWED_CALL_SITES`: Der Eintrag `dashboard-images.service.ts` ist entfernt, jetzt 5 Dateien mit 6 Aufrufen.
|
||||||
|
- `docs/mandantentrennung-zugriffsklassifikation.md`: Der Stand des Paars `dashboard-images.service.ts`/`dashboardImage` geht von `system-gebunden` zurück auf `gebunden`. Die Zahlen habe ich mit der Gate-Schleife (`for d in apps/api/src/*/`) **neu gemessen**:
|
||||||
|
- Bereich `dashboard` geht auf **1/29/0**. Im Dokument stand 1/28/1, gemessen waren vor dieser Änderung aber schon 1/31/1, weil quick-260923-lrr zwei Treffer (favoriteLink) hinzugefügt hatte, ohne die Zeile nachzuziehen. Diese Änderung selbst zieht 2 gebundene Treffer und 1 System-Treffer ab.
|
||||||
|
- Bereich `favorites` geht auf **0/12/0**. Im Dokument stand 0/8/0, auch hier war die Zeile nach quick-260923-lrr nicht nachgezogen worden.
|
||||||
|
- Die Summe geht auf **61/213/6**, vorher 61/208/7.
|
||||||
|
- Die Aufzählung der Tabellen mit `system_read_policy` ist korrigiert. Es sind jetzt sechs Tabellen (lokal gemessen), `ProxmoxServer` fehlte bisher in der Aufzählung, und `DashboardImage` ist nur noch als vorübergehender Fall erwähnt.
|
||||||
|
- Gegenprobe: Den Stand habe ich absichtlich auf `system-gebunden` zurückgedreht. `rls-access-inventory.spec.ts` wurde rot („dokumentiert=system-gebunden, gemessen=gebunden“), danach habe ich die Datei zurückgesetzt.
|
||||||
|
- Einen veralteten Kommentarverweis auf `DashboardImagesService` als Vorbild habe ich in `proxmox.service.ts` entfernt.
|
||||||
|
|
||||||
|
**CHANGELOG:** bewusst nicht ergänzt, weil die Änderung für Nutzer nicht sichtbar ist.
|
||||||
|
**Betriebshandbuch:** Kapitel 4 hat einen Hinweis für die erste Version nach 1.3.1 bekommen: was die Meldung bedeutet und die drei Schritte zur Wiederherstellung.
|
||||||
|
|
||||||
|
## Tore
|
||||||
|
|
||||||
|
- API-Tests komplett: **84 Dateien, 1364 Tests, grün**, einschließlich `rls-access-inventory.spec.ts` mit 30 Tests.
|
||||||
|
- Web-Tests komplett: **91 Dateien, 865 Tests, grün**, auch gedrosselt auf 2 Kerne.
|
||||||
|
- `pnpm turbo run type-check lint`: **9/9 Aufgaben erfolgreich**.
|
||||||
|
- Biome-Warnungen: **web 53** (Grenze 53), **api 82** (Grenze 82).
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
1. **[Rule 3 – blockierend] Todo-Ordner:** Den Ordner `.planning/todos/done/` aus dem Plan gibt es nicht, die Ablage im Projekt heißt `.planning/todos/completed/`. Beide Todos liegen deshalb dort, jeweils mit einem Nachtrag „Erledigt in quick-260924-m4n“.
|
||||||
|
2. **[Rule 1 – Bug] Weg zurück nach einem Abbruch:** Die Meldung der Schutzprüfung rät, zuerst 1.3.1 laufen zu lassen. Allein das hätte aber nicht funktioniert, weil 1.3.1 danach mit P3009 abbricht. Der fehlende Zwischenschritt steht jetzt im Betriebshandbuch und im Kopfkommentar der Migration, und ich habe ihn durchgespielt.
|
||||||
|
3. **[Rule 1 – Bug] Upload-Ablauf:** Mit `storagePath` als Pflichtfeld hätte der bisherige Ablauf (Zeile ohne Pfad anlegen, Pfad nachtragen) an der Datenbank scheitern müssen. Deshalb vergibt der Dienst die UUID jetzt selbst.
|
||||||
|
4. **Zusätzliche Tests entfallen:** Neben 18 und 21–23 sind auch 10b und 10c weg (siehe oben).
|
||||||
|
5. **Mehr Marktplatz-Dateien umgestellt:** Neben `tenant-selector` sind auch `marketplace.test.tsx` und `marketplace-filters.test.tsx` umgestellt. Es ist dasselbe Muster im selben Ordner.
|
||||||
|
6. **Drift in der Klassifikation nachgeholt:** Die Zeilen `favorites` und `dashboard` stimmten schon vorher nicht mit der Messung überein. Beides ist jetzt nachgeholt und im Dokument begründet.
|
||||||
|
|
||||||
|
## Offen / für dich
|
||||||
|
|
||||||
|
- Live konnte ich von hier aus nicht prüfen. Die nächste Freigabe nach 1.3.1 setzt voraus, dass live einmal auf 1.3.1 gelaufen ist. Wenn nicht, bricht die Migration mit einer klaren Meldung ab, der Weg zurück steht in Kapitel 4.
|
||||||
|
- Optional nach dem Einspielen auf alpha und live: `VACUUM FULL "DashboardImage";`, damit PostgreSQL den Platz der alten Bilddaten tatsächlich an das Dateisystem zurückgibt.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: apps/api/prisma/migrations/20260924120000_dashboard_image_drop_data/migration.sql
|
||||||
|
- FOUND: .planning/todos/completed/2026-09-23-flackernder-test-tenant-selector-zeitueberschreitung.md
|
||||||
|
- FOUND: .planning/todos/completed/2026-09-22-dashboard-image-data-spalte-entfernen.md
|
||||||
|
- FOUND: b10734f, dd54ec5 (gemessen: `git rev-list --count dd09c08..HEAD` = 2)
|
||||||
+326
@@ -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>
|
||||||
+167
@@ -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).
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260928-ujj
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260928-ujj
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-260928-ujj]
|
||||||
|
files_modified:
|
||||||
|
- apps/web/** (Merge design/mosaik, 115 Dateien, nur apps/web)
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql (neu)
|
||||||
|
- apps/api/src/auth/auth.service.ts
|
||||||
|
- apps/api/src/auth/auth.service.spec.ts
|
||||||
|
- apps/api/src/user/user.controller.ts
|
||||||
|
- apps/api/src/user/user.controller.spec.ts
|
||||||
|
- apps/web/src/lib/auth-actions.ts
|
||||||
|
- apps/web/src/lib/stores/auth-store.ts
|
||||||
|
- apps/web/src/components/layout/header.tsx
|
||||||
|
- apps/web/src/components/layout/header.test.tsx
|
||||||
|
- apps/web/src/lib/dashboard-background.ts
|
||||||
|
- apps/web/src/lib/dashboard-background.test.ts
|
||||||
|
- apps/web/src/components/dashboard/dashboard-background.tsx
|
||||||
|
- apps/web/src/components/dashboard/dashboard-background.test.tsx (neu)
|
||||||
|
- "apps/web/src/app/(portal)/page.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/page.test.tsx"
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 95000
|
||||||
|
raw_tokens: 95000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "main enthaelt das freigegebene Design Mosaik als Merge-Commit mit design/mosaik (76d17fe) als zweitem Elternteil; apps/web-Tests sind gruen"
|
||||||
|
- "Widgets lassen sich schmaler ziehen, auch wenn die Maus dabei leicht wackelt (RESIZE_AXIS_FALLBACK in dashboard-grid.tsx, Test in dashboard-grid.test.tsx gruen)"
|
||||||
|
- "Der gewaehlte Dashboard-Hintergrund steht pro Benutzer in der Datenbank (User.dashboardBackground) und kommt ueber dieselbe Anmelde-/Sitzungsantwort zurueck wie accentColor — im zweiten Browser erscheint derselbe Hintergrund"
|
||||||
|
- "PATCH /users/me/dashboard-background nimmt nur 'none', bekannte Preset-Kennungen oder eine UUID-Bildkennung an; alles andere ergibt 400 und schreibt nichts"
|
||||||
|
- "Eine bereits im localStorage gespeicherte Wahl wird einmalig in die Datenbank uebernommen und der alte Schluessel entfernt"
|
||||||
|
- "CHANGELOG.md 'Unveroeffentlicht' beschreibt das neue Aussehen (Neu/Geaendert) und den Resize-Fehler (Behoben); die Anwender-Anleitung beschreibt die Hintergrundwahl"
|
||||||
|
artifacts:
|
||||||
|
- path: apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
|
||||||
|
provides: "Spalte User.dashboardBackground (JSONB, nullable)"
|
||||||
|
contains: "dashboardBackground"
|
||||||
|
- path: packages/shared/src/index.ts
|
||||||
|
provides: "DASHBOARD_BACKGROUND_PRESET_IDS, Typ DashboardBackground, parseDashboardBackground() — eine Pruefregel fuer API und Web"
|
||||||
|
contains: "parseDashboardBackground"
|
||||||
|
- path: apps/api/src/user/user.controller.ts
|
||||||
|
provides: "PATCH me/dashboard-background"
|
||||||
|
contains: "me/dashboard-background"
|
||||||
|
- path: apps/web/src/components/dashboard/dashboard-background.tsx
|
||||||
|
provides: "useDashboardBackground liest aus dem Auth-Store und speichert ueber die Server-Aktion"
|
||||||
|
key_links:
|
||||||
|
- from: apps/web/src/components/dashboard/dashboard-background.tsx
|
||||||
|
to: "PATCH /users/me/dashboard-background"
|
||||||
|
via: "updateDashboardBackgroundAction in apps/web/src/lib/auth-actions.ts"
|
||||||
|
pattern: "updateDashboardBackgroundAction"
|
||||||
|
- from: apps/api/src/auth/auth.service.ts
|
||||||
|
to: "User.dashboardBackground"
|
||||||
|
via: "select neben accentColor, Ausgabe durch parseDashboardBackground normalisiert"
|
||||||
|
pattern: "dashboardBackground: true"
|
||||||
|
- from: apps/web/src/components/layout/header.tsx
|
||||||
|
to: apps/web/src/lib/stores/auth-store.ts
|
||||||
|
via: "setUser-Abbildung uebernimmt dashboardBackground aus der Sitzung"
|
||||||
|
pattern: "dashboardBackground"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Das vom Nutzer abgenommene Design „Mosaik“ (Zweig design/mosaik, nur apps/web) in main uebernehmen, die Hintergrundwahl des Dashboards von localStorage auf ein Datenbankfeld pro Benutzer umstellen (Muster accentColor) und CHANGELOG sowie Anleitungen fuer Version 1.5.0 vorbereiten.
|
||||||
|
|
||||||
|
Purpose: Das neue Aussehen samt Resize-Fix soll als 1.5.0 ausgeliefert werden; der Hintergrund soll dem Benutzer auf jedem Geraet folgen statt an einem Browser zu kleben.
|
||||||
|
Output: Merge-Commit, Migration + API-Weg + Web-Anbindung mit Tests, CHANGELOG-/Doku-Eintraege. Release (Tag, live-Zweig, Push) ist NICHT Teil dieses Plans — nicht pushen.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@CLAUDE.md
|
||||||
|
@.planning/HANDOFF.json
|
||||||
|
|
||||||
|
Fakten (nicht neu herleiten):
|
||||||
|
- Zweig design/mosaik liegt lokal (Spitze 76d17fe, 15 Commits auf 3fc33e3), aendert nur apps/web (115 Dateien, keine package.json/Lockfile). Merge ist konfliktfrei. Die nicht committete Aenderung an .planning/HANDOFF.json beruehrt der Merge nicht — NICHT stagen, NICHT verwerfen.
|
||||||
|
- Hintergrund-Datentyp heute in apps/web/src/lib/dashboard-background.ts (Stand design/mosaik): kind 'none' | 'preset' (id aus mist, pebble, bloom, dunes, mosaic) | 'image' (imageId = Kennung eines Bilderrahmen-Bildes, DashboardImage.id ist uuid()). Speicherung per localStorage-Schluessel tessera.dashboardBackground.<userId>.
|
||||||
|
- Vorbild accentColor: schema.prisma Zeile ~45; Auswahl in apps/api/src/auth/auth.service.ts (~Zeile 320-345, select mit accentColor, speist die Sitzungsantwort); PATCH me/accent-color in apps/api/src/user/user.controller.ts (~Zeile 463-484, forTenant + where id currentUser.id, Inline-Body-Typ); Web: AuthUser in auth-actions.ts und stores/auth-store.ts, updateAccentColorAction in auth-actions.ts (~Zeile 220), setUser-Abbildung in components/layout/header.tsx (~Zeile 47-55).
|
||||||
|
- Vorbild Migration: apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql (deutscher Kopfkommentar, Hinweis auf auth_lookup_*-Funktionen mit fester Spaltenliste).
|
||||||
|
- NestJS-Routenreihenfolge: @Patch(':id') steht bei Zeile ~273; zweisegmentige Pfade wie me/accent-color werden davon nicht verschattet — der neue Pfad me/dashboard-background ist ebenfalls zweisegmentig.
|
||||||
|
- GET /dashboard/images/:id prueft den Besitz (dashboard-images.service.ts) — eine fremde Bildkennung liefert nur 404, deshalb reicht serverseitig die Formatpruefung.
|
||||||
|
- RLS-Inventar-Test apps/api/src/prisma/rls-access-inventory.spec.ts vergleicht Paare (Datei, Modell) gegen docs/mandantentrennung-zugriffsklassifikation.md; user.controller.ts + user existiert schon gebunden, also kein neues Paar — nur den Zeilentext fortschreiben.
|
||||||
|
- Lokale DB hat keinen Host-Port: Prisma vom Host ueber die Container-IP (172.19.x, docker inspect) mit tessera:tessera_dev.
|
||||||
|
- CHANGELOG.md wird vom Was-ist-neu-Fenster geparst: Ueberschriftenformat „## Unveröffentlicht“ / „### Neu|Geändert|Behoben“ exakt beibehalten.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Design Mosaik in main mergen</name>
|
||||||
|
<files>apps/web/** (aus design/mosaik)</files>
|
||||||
|
<action>Auf main (HEAD a8a910f) pruefen, dass design/mosaik auf 76d17fe steht und `git diff --name-only 3fc33e3 design/mosaik` ausschliesslich apps/web-Pfade zeigt. Dann `git merge --no-ff design/mosaik -m "feat(260928-ujj): Design Mosaik uebernehmen"` ausfuehren (Nachricht kurz, deutsch ohne Umlaute; im Rumpf eine Zeile, dass der Merge den Resize-Achsen-Fallback fuer schmaler gezogene Widgets mitbringt). .planning/HANDOFF.json bleibt unangetastet und ungestaged. Kein pnpm install noetig (keine Abhaengigkeitsaenderung). Danach Web-Tests und Typpruefung laufen lassen; schlaegt etwas fehl, das im Klon gruen war, Ursache beheben und als eigener Commit fix(260928-ujj) nachziehen — nicht in den Merge-Commit falten.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && git merge-base --is-ancestor 76d17fe HEAD && grep -q RESIZE_AXIS_FALLBACK apps/web/src/components/dashboard/dashboard-grid.tsx && pnpm --filter web test && pnpm --filter web type-check</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Merge-Commit auf main mit 76d17fe als Elternteil; dashboard-grid.tsx enthaelt RESIZE_AXIS_FALLBACK; `pnpm --filter web test` und `pnpm --filter web type-check` gruen; HANDOFF.json weiterhin nur als lokale Aenderung vorhanden.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Dashboard-Hintergrund pro Benutzer in der Datenbank</name>
|
||||||
|
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql, apps/api/src/auth/auth.service.ts, apps/api/src/auth/auth.service.spec.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.controller.spec.ts, apps/web/src/lib/auth-actions.ts, apps/web/src/lib/stores/auth-store.ts, apps/web/src/components/layout/header.tsx, apps/web/src/components/layout/header.test.tsx, apps/web/src/lib/dashboard-background.ts, apps/web/src/lib/dashboard-background.test.ts, apps/web/src/components/dashboard/dashboard-background.tsx, apps/web/src/components/dashboard/dashboard-background.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<behavior>
|
||||||
|
- API: PATCH me/dashboard-background mit background {kind:'none'} / {kind:'preset', id:'dunes'} / {kind:'image', imageId:<uuid>} schreibt genau das normalisierte Objekt (Zusatzschluessel entfernt) per forTenant mit where id = currentUser.id und liefert {success:true, dashboardBackground}
|
||||||
|
- API: unbekannte Preset-Kennung, unbekanntes kind, imageId kein UUID (z. B. 'img-1', mit Anfuehrungszeichen/Klammern, laenger als 36), background null/fehlend/kein Objekt/Array ergibt BadRequestException und kein update-Aufruf
|
||||||
|
- API: auth.service liefert dashboardBackground neben accentColor; gespeichertes gueltiges Objekt kommt normalisiert zurueck, NULL oder ungueltiger Inhalt kommt als null zurueck
|
||||||
|
- Web-Lib: takeLegacyDashboardBackground(userId) liefert eine gueltige alte localStorage-Wahl und entfernt den Schluessel; ungueltiger Wert ergibt null und entfernt ebenfalls; gesperrter Speicher ergibt null ohne Ausnahme
|
||||||
|
- Web-Lib: Preset-Kennungen in BACKGROUND_PRESETS sind deckungsgleich mit DASHBOARD_BACKGROUND_PRESET_IDS aus @tessera/shared
|
||||||
|
- Web-Hook: background kommt aus user.dashboardBackground im Auth-Store (null ergibt 'none'); choose() setzt den Store sofort und ruft updateDashboardBackgroundAction; bei Fehlschlag wird der vorige Wert zurueckgesetzt
|
||||||
|
- Web-Hook: ist der Server-Wert null und liegt eine alte localStorage-Wahl vor, wird sie genau einmal gespeichert; ist der Server-Wert gesetzt, passiert keine Uebernahme
|
||||||
|
</behavior>
|
||||||
|
<action>Zuerst die Tests aus dem behavior-Block schreiben (rot), dann umsetzen.
|
||||||
|
|
||||||
|
Gemeinsame Pruefregel: In packages/shared/src/index.ts DASHBOARD_BACKGROUND_PRESET_IDS (mist, pebble, bloom, dunes, mosaic als const-Tupel), Typ DashboardBackgroundPresetId, Typ DashboardBackground (drei Faelle wie im Web heute) und parseDashboardBackground(value: unknown): DashboardBackground | null ergaenzen. Die Funktion baut immer ein frisches Objekt nur aus den erlaubten Feldern; imageId muss eine UUID (8-4-4-4-12 Hex, Gross/Klein egal) sein — das haelt auch jede CSS-Einschleusung in den spaeteren url("...")-Stil fern. Deutscher Kommentar mit Verweis quick-260928-ujj im Stil der Datei.
|
||||||
|
|
||||||
|
API: In schema.prisma am User nach lastSeenReleaseVersion das Feld dashboardBackground Json? mit Kommentar (quick-260928-ujj, null = nie gewaehlt, sonst normalisiertes Objekt inkl. kind none). Migration 20260928120000_user_dashboard_background/migration.sql von Hand im Stil der Vorlage: deutscher Kopfkommentar (Zweck, NULL-Bedeutung, kein Backfill, auth_lookup_* unberuehrt) und ALTER TABLE "User" ADD COLUMN "dashboardBackground" JSONB. Danach `pnpm --filter api exec prisma generate`. Laeuft der lokale Stack, die Migration zusaetzlich per prisma migrate deploy gegen die Container-IP der db einspielen (tessera:tessera_dev, DB-Name aus .env/Compose) — kein Gate. In auth.service.ts im select neben accentColor dashboardBackground aufnehmen und in der Rueckgabe durch parseDashboardBackground normalisieren; auth.service.spec.ts Fixture/Erwartungen (~Zeile 500-540) ergaenzen. In user.controller.ts direkt nach updateAccentColor eine Methode updateDashboardBackground mit @Patch('me/dashboard-background'), Inline-Body-Typ mit background: unknown (wie accent-color, bewusst keine DTO-Klasse — die globale ValidationPipe mit whitelist wuerde verschachtelte Felder sonst nicht pruefen), parseDashboardBackground, bei null BadRequestException('Invalid dashboard background.'), sonst forTenant(...).user.update mit where id currentUser.id und data dashboardBackground; JSDoc mit Bedrohungsverweis T-ujj-01/02. Tests als neuer describe-Block „Dashboard-Hintergrund (quick-260928-ujj)“ in user.controller.spec.ts nach dem Muster des Was-ist-neu-Blocks. In docs/mandantentrennung-zugriffsklassifikation.md die Zeile zu apps/api/src/user/user.controller.ts fortschreiben: seit quick-260928-ujj schreibt der Selbstbedienungsweg PATCH me/dashboard-background dashboardBackground, ebenfalls forTenant mit where id currentUser.id, ohne Kennungsparameter.
|
||||||
|
|
||||||
|
Web: AuthUser in auth-actions.ts und stores/auth-store.ts um dashboardBackground?: DashboardBackground | null (Typ aus @tessera/shared) erweitern; updateDashboardBackgroundAction(background) als Server-Aktion nach dem Muster updateAccentColorAction (PATCH /users/me/dashboard-background, Body mit background). header.tsx setUser-Abbildung um dashboardBackground erweitern, header.test.tsx-Fixture nachziehen. apps/web/src/lib/dashboard-background.ts: Typ und Preset-Kennungen aus @tessera/shared beziehen (BackgroundPresetId als Alias behalten, falls genutzt), BACKGROUND_PRESETS/presetBackground bleiben; loadDashboardBackground und saveDashboardBackground ersetzen durch takeLegacyDashboardBackground(userId) (liest, prueft mit parseDashboardBackground, entfernt Schluessel); Kopfkommentar aktualisieren (Speicherung jetzt in der Datenbank, „Prototyp“ entfernen). components/dashboard/dashboard-background.tsx: useDashboardBackground liest user aus useAuthStore statt userId-Parameter; choose() wie im behavior-Block (optimistisch, bei Fehlschlag zuruecksetzen); einmalige Uebernahme der alten Wahl per useRef je Benutzerkennung. Aufrufstelle in app/(portal)/page.tsx anpassen (Kommentar „Prototyp mit localStorage“ ersetzen), page.test.tsx bei Bedarf nachziehen. Hook-Tests in neuer Datei components/dashboard/dashboard-background.test.tsx mit gemocktem @/lib/auth-actions. Hinweistext dashboard.background.hint in de.json auf „Gilt nur für Sie – auf jedem Gerät, auf dem Sie sich anmelden.“ und in en.json sinngemaess („Applies only to you – on every device you sign in on.“) aendern; bestehende Tests, die den alten Text pruefen, anpassen.
|
||||||
|
|
||||||
|
Commit(s): test(260928-ujj) fuer die roten Tests, feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank — deutsch ohne Umlaute.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/shared type-check && pnpm --filter api type-check && pnpm --filter api test && pnpm --filter web type-check && pnpm --filter web test && grep -q '"dashboardBackground" JSONB' apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql && ! grep -v '^\s*//' apps/web/src/lib/dashboard-background.ts | grep -q 'setItem'</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Migration und Schemafeld vorhanden, Prisma-Client generiert; PATCH me/dashboard-background prueft und speichert, Sitzungsantwort liefert dashboardBackground; Web liest aus dem Store und speichert ueber die API, alte localStorage-Wahl wird einmalig uebernommen; api- und web-Tests inkl. rls-access-inventory.spec.ts gruen, Typpruefung aller drei Pakete sauber.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: CHANGELOG, Anleitungen, Abschlusspruefung</name>
|
||||||
|
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md</files>
|
||||||
|
<action>Vorher `git log --format='%h %s%n%b' 3fc33e3..design/mosaik` lesen, um alle sichtbaren Aenderungen zu erfassen. CHANGELOG.md, Abschnitt „## Unveröffentlicht“ (bestehenden Neu-Eintrag zum Was-ist-neu-Fenster behalten), in Alltagssprache, Sie-Form, ganze Saetze, Stil der bisherigen Eintraege, keine Fachbegriffe:
|
||||||
|
- „### Neu“: ein Eintrag zum waehlbaren Dashboard-Hintergrund (Knopf „Hintergrund“: keiner, ruhige Flaechen und Motive, eigenes Bild aus den Bilderrahmen-Bildern oder neu hochgeladen; gilt nur fuer Sie und folgt Ihnen auf jedes Geraet und in die Desktop-App; eine bisher im Browser gemerkte Wahl wird automatisch uebernommen).
|
||||||
|
- „### Geändert“ (neu anlegen, zwischen Neu und Behoben): drei bis vier Eintraege — (1) neues Aussehen: dunkle App-Leiste, neu gestaltete Seitenleiste mit Modul-Kacheln, deutschen Kategorienamen und der Begruessung unten, Akzentfarbe nur beim Modul im Fokus; (2) neue Anmeldeseite, geteilt mit dunklem Markenbereich und Farbmosaik; (3) Dashboard: Kacheln mittig ausgerichtet, Widgets mit gelbem Symbol-Feld, heben sich beim Darueberfahren leicht an und blenden beim Laden sanft ein, Begruessung/Befehlsleiste ueber den Kacheln, ruhigere Kalender-, Favoriten- und Notiz-Kacheln; (4) Kalender: Terminliste einzeilig mit „Heute“/„Morgen“ statt Datum. Eintraege aus den Commit-Rumpfen ergaenzen, die fuer Anwender sichtbar sind (z. B. mobile Schublade), nichts Internes.
|
||||||
|
- „### Behoben“ (neu anlegen): Widgets liessen sich manchmal nicht schmaler ziehen, wenn die Maus dabei leicht wackelte — jetzt klappt das zuverlaessig.
|
||||||
|
docs/anleitung-anwender.md knapp nachziehen: Abschnitt „Aufbau der Oberfläche“ (dunkle App-Leiste, Seitenleiste mit Modul-Kacheln und Begruessung unten — nur was sich wirklich geaendert hat, gegen den gemergten Code pruefen), Abschnitt „Anmeldung“ falls die Seite beschrieben ist, Abschnitt „Dashboard“ um einen Absatz **Hintergrund** (Knopf, Auswahl, pro Benutzer gespeichert, im dunklen Erscheinungsbild werden eigene Bilder abgedunkelt und „Blüte“ durch „Nebel“ ersetzt), Kalender-Zeile der Widget-Tabelle (einzeilige Terminliste, „Heute“/„Morgen“). docs/anleitung-entwicklung.md: kurzer Absatz neben der Stelle zu User.lastSeenReleaseVersion (~Zeile 669) zu User.dashboardBackground, PATCH /users/me/dashboard-background und parseDashboardBackground in @tessera/shared als einzige Pruefregel.
|
||||||
|
Abschlusspruefung: web- und api-Build, Biome auf allen in dieser Aufgabe und im Merge geaenderten ts/tsx-Dateien ohne Fehler (Fehler in unveraenderten Dateien sind ausser Umfang; Biome-Fehler in gemergten Dateien beheben als fix(260928-ujj)). Laeuft der lokale Stack: api und web mit --build neu starten und per Playwright MCP pruefen — Hintergrund waehlen, Seite neu laden, in einem zweiten Browserkontext (gleicher Benutzer) erscheint derselbe Hintergrund; nie per fetch aus der Seite messen. Kein Push, kein Tag. Commit docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && awk '/^## Unver/{f=1;next} /^## [0-9]/{f=0} f' CHANGELOG.md | grep -c '^### \(Neu\|Geändert\|Behoben\)$' | grep -q '^3$' && grep -q 'Hintergrund' docs/anleitung-anwender.md && grep -q 'dashboardBackground' docs/anleitung-entwicklung.md && pnpm exec biome check $(git diff --name-only --diff-filter=AM 3fc33e3 HEAD -- '*.ts' '*.tsx') && pnpm --filter api build && pnpm --filter web build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>CHANGELOG „Unveröffentlicht“ hat Neu, Geändert und Behoben mit den beschriebenen Eintraegen; Anwender- und Entwickler-Anleitung beschreiben Hintergrundwahl und neues Aussehen; Biome ohne Fehler auf den geaenderten Dateien; api- und web-Build erfolgreich; alles committet, nichts gepusht.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser -> API PATCH /users/me/dashboard-background | Unvertrauter JSON-Body wird gespeichert und spaeter als CSS-Stil (url("...")) gerendert |
|
||||||
|
| DB -> Web (Sitzungsantwort) | Gespeicherter JSON-Wert fliesst in style-Attribut des Dashboard-Hintergrunds |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-ujj-01 | Tampering | user.controller.ts updateDashboardBackground / parseDashboardBackground | medium | mitigate | Allowlist fuer kind und Preset-Kennungen, imageId nur als UUID, frisch aufgebautes Objekt ohne Zusatzschluessel; ungueltig ergibt 400 ohne Schreibzugriff; Ausgabe in auth.service erneut durch parseDashboardBackground |
|
||||||
|
| T-ujj-02 | Elevation of Privilege | PATCH me/dashboard-background | medium | mitigate | Kein Kennungsparameter; forTenant(prisma, currentUser.tenantId).user.update mit where id = currentUser.id |
|
||||||
|
| T-ujj-03 | Information Disclosure | imageId eines fremden Bildes | low | accept | GET /dashboard/images/:id prueft Besitz; fremde Kennung ergibt nur ein fehlendes Bild beim eigenen Benutzer |
|
||||||
|
| T-ujj-04 | Denial of Service | uebergrosser Body | low | accept | Express-JSON-Grenze greift; gespeichert wird nur das normalisierte Kleinobjekt |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `git merge-base --is-ancestor 76d17fe main` ist erfolgreich (Merge-Commit mit 76d17fe als zweitem Elternteil)
|
||||||
|
- `pnpm --filter web test`, `pnpm --filter api test` gruen; type-check fuer shared, api, web sauber
|
||||||
|
- Biome ohne Fehler auf geaenderten ts/tsx-Dateien; `pnpm --filter api build` und `pnpm --filter web build` erfolgreich
|
||||||
|
- Nichts gepusht, kein Tag; .planning/HANDOFF.json unveraendert als lokale Aenderung
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
main traegt das Design Mosaik inklusive Resize-Fix, der Dashboard-Hintergrund wird pro Benutzer in der Datenbank gespeichert und geprueft, CHANGELOG und Anleitungen sind fuer 1.5.0 vorbereitet — bereit fuer die Freigabe durch den Orchestrator.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/260928-ujj-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260928-ujj
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260928-ujj
|
||||||
|
status: complete
|
||||||
|
subsystem: web, api, shared, docs
|
||||||
|
tags: [design-mosaik, dashboard-background, prisma-migration, changelog, 1.5.0]
|
||||||
|
requires:
|
||||||
|
- design/mosaik (76d17fe)
|
||||||
|
provides:
|
||||||
|
- Design Mosaik auf main (Merge 9fa0a3f)
|
||||||
|
- User.dashboardBackground (JSONB) + PATCH /users/me/dashboard-background
|
||||||
|
- parseDashboardBackground / DASHBOARD_BACKGROUND_PRESET_IDS in @tessera/shared
|
||||||
|
- CHANGELOG "Unveröffentlicht" mit Neu/Geändert/Behoben fuer 1.5.0
|
||||||
|
affects:
|
||||||
|
- apps/web (115 Dateien aus dem Merge)
|
||||||
|
- apps/api user/auth
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Gemeinsame Pruefregel in @tessera/shared, angewendet beim Schreiben (Controller) und Lesen (getMe)"
|
||||||
|
- "Hook liest Benutzerwahl aus dem Auth-Store und speichert optimistisch ueber Server-Aktion mit Ruecksetzen"
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
|
||||||
|
- apps/web/src/components/dashboard/dashboard-background.test.tsx
|
||||||
|
modified:
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/auth/auth.service.ts
|
||||||
|
- apps/api/src/auth/auth.service.spec.ts
|
||||||
|
- apps/api/src/user/user.controller.ts
|
||||||
|
- apps/api/src/user/user.controller.spec.ts
|
||||||
|
- apps/web/src/lib/auth-actions.ts
|
||||||
|
- apps/web/src/lib/stores/auth-store.ts
|
||||||
|
- apps/web/src/components/layout/header.tsx
|
||||||
|
- apps/web/src/components/layout/header.test.tsx
|
||||||
|
- apps/web/src/lib/dashboard-background.ts
|
||||||
|
- apps/web/src/lib/dashboard-background.test.ts
|
||||||
|
- apps/web/src/components/dashboard/dashboard-background.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
decisions:
|
||||||
|
- "Dashboard-Hintergrund als JSONB-Spalte User.dashboardBackground; null = nie gewaehlt, { kind: 'none' } = bewusst kein Hintergrund"
|
||||||
|
- "parseDashboardBackground in @tessera/shared ist die einzige Pruefregel (Allowlist kind/Preset, imageId nur UUID) fuer API-Schreiben, API-Lesen und Web-Altdatenuebernahme"
|
||||||
|
- "Alter localStorage-Schluessel wird immer einmal gelesen und entfernt, uebernommen nur bei Server-Wert null"
|
||||||
|
- "Biome: nur vom Merge neu eingebrachte Befunde behoben; vorbestehende Format-/Importbefunde (158 an der Basis) bleiben ausser Umfang"
|
||||||
|
metrics:
|
||||||
|
duration: 17min
|
||||||
|
completed: 2026-09-28
|
||||||
|
estimate:
|
||||||
|
tokens: 95000
|
||||||
|
tasks: 3
|
||||||
|
actuals:
|
||||||
|
tokens: 95700
|
||||||
|
tasks: 3
|
||||||
|
commits: 17
|
||||||
|
plan_head_before: a8a910fd29cf6f2e4c3b228e2a71b2af226f39da
|
||||||
|
plan_head_after: cb45d2663ac65954463e8c5a6bf859f73a555e86
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260928-ujj Plan 01: Design Mosaik uebernehmen und fuer 1.5.0 vorbereiten — Summary
|
||||||
|
|
||||||
|
Design „Mosaik“ per `--no-ff`-Merge (zweiter Elternteil 76d17fe) auf main übernommen. Der Dashboard-Hintergrund wird jetzt pro Benutzer in `User.dashboardBackground` (JSONB) gespeichert: `PATCH /users/me/dashboard-background` prüft mit dem gemeinsamen `parseDashboardBackground`, und der Wert kommt zusammen mit `accentColor` über `getMe` zurück. Die alte Wahl aus dem localStorage wird einmal übernommen. CHANGELOG und beide Anleitungen sind für 1.5.0 vorbereitet.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Task | Commit | Beschreibung |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| 1 | 9fa0a3f | feat(260928-ujj): Design Mosaik uebernehmen (Merge, Eltern a8a910f + 76d17fe; bringt 12 Commits aus design/mosaik mit) |
|
||||||
|
| 2 (RED) | 76f6d87 | test(260928-ujj): rote Tests fuer Dashboard-Hintergrund in der Datenbank |
|
||||||
|
| 2 (GREEN) | 0aaa152 | feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank |
|
||||||
|
| 3 (Biome) | 69d1730 | fix(260928-ujj): Biome-Formatierung der mit Design Mosaik eingebrachten Dateien |
|
||||||
|
| 3 | cb45d26 | docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik |
|
||||||
|
|
||||||
|
`commits: 17` wurde gemessen mit `git rev-list --count a8a910f..HEAD`. Die Zahl enthält die 12 Commits aus design/mosaik, die der Merge mitbringt. Auf der ersten Elternlinie stehen 5 eigene Commits.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
- `git merge-base --is-ancestor 76d17fe HEAD`: erfolgreich. `RESIZE_AXIS_FALLBACK` steht in `dashboard-grid.tsx`.
|
||||||
|
- Web-Tests (vitest 4.1.9): 97 Dateien, **952 Tests grün**. Direkt nach dem Merge waren es 96 Dateien und 940 Tests.
|
||||||
|
- API-Tests (vitest 3.2.6): 85 Dateien, **1462 Tests grün**, einschließlich `rls-access-inventory.spec.ts`.
|
||||||
|
- tsc: shared, api und web sind sauber.
|
||||||
|
- Builds: `pnpm --filter api build` und `pnpm --filter web build` sind erfolgreich.
|
||||||
|
- Biome bringt **keine neuen Fehler**. In den seit 3fc33e3 geänderten ts/tsx-Dateien standen an der Basis 158 Fehler, jetzt sind es 156. Alle verbleibenden Befunde bestanden schon vorher (84 format, 72 organizeImports in 93 Dateien).
|
||||||
|
- Die Migration ist lokal eingespielt (`prisma migrate deploy` gegen 172.19.0.2). Die Spalte `dashboardBackground` hat den Typ jsonb.
|
||||||
|
- Lokaler Stack neu gebaut mit `docker compose up -d --build api web`. Die API meldet die Route `Mapped {/users/me/dashboard-background, PATCH}`. Ein Aufruf ohne Anmeldung ergibt 401.
|
||||||
|
- Nichts gepusht, kein Tag gesetzt, den live-Zweig nicht angefasst. `.planning/HANDOFF.json` ist weiter nur eine lokale Änderung und wurde nicht gestaged.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blocking] Das Biome-Gate des Plans kann auf den geänderten Dateien nicht fehlerfrei werden**
|
||||||
|
- **Found during:** Task 2 und Task 3
|
||||||
|
- **Issue:** Die Verify-Zeile `biome check $(git diff --name-only --diff-filter=AM 3fc33e3 HEAD ...)` endet mit Exit 1. Die 93 betroffenen Dateien hatten schon an der Basis 3fc33e3 zusammen 158 Format- und Importbefunde.
|
||||||
|
- **Fix:** Ich habe nur die Befunde behoben, die der Merge neu eingebracht hat (12 Dateien, nur Formatierung und Importreihenfolge, eigener Commit 69d1730), dazu alle Befunde in den von mir geschriebenen Zeilen und neuen Dateien. Den Rest ganzer Dateien habe ich nicht umformatiert. Die Vorgabe des Auftraggebers („no new errors“) ist erfüllt: 158 → 156.
|
||||||
|
- **Commit:** 69d1730
|
||||||
|
|
||||||
|
**2. [Rule 2 - Missing critical] Der alte localStorage-Schlüssel wird auch bei gesetztem Server-Wert aufgeräumt**
|
||||||
|
- **Found during:** Task 2
|
||||||
|
- **Issue:** Nach Plan hätte bei gesetztem Server-Wert keine Übernahme stattgefunden, der alte Schlüssel wäre dann aber für immer liegen geblieben.
|
||||||
|
- **Fix:** `takeLegacyDashboardBackground` wird je Benutzer einmal aufgerufen und entfernt den Schlüssel immer. Übernommen wird die alte Wahl nur, wenn der Server-Wert `null` ist. Ein Test deckt das ab.
|
||||||
|
|
||||||
|
**3. [Rule 1 - Doc] Die Anwender-Anleitung war schon vor dem Merge an drei Stellen veraltet**
|
||||||
|
- Die Seitenleiste zeigte bereits vorher keine Sprachumschaltung und keine Name/Rolle-Zeile mehr. Die Reiter standen seit 1.4.0 in der Kopfzeile. Der Stift-Schalter ist mit Mosaik zu „Bearbeiten“/„Fertig“ oben rechts geworden. Diese Stellen habe ich beim Nachziehen gegen den gemergten Code korrigiert.
|
||||||
|
|
||||||
|
### Nicht ausgeführt
|
||||||
|
|
||||||
|
- **Browser-Prüfung per Playwright MCP** (Hintergrund wählen, neu laden, zweiter Browserkontext): In dieser Ausführungsumgebung gab es kein Playwright-MCP-Werkzeug. Ersatzweise habe ich den lokalen Stack neu gebaut und geprüft, dass die Route gemappt ist, ohne Anmeldung 401 liefert und die Spalte in der DB existiert. Die Prüfung über zwei Geräte im Browser steht noch aus.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Eine Übernahme der alten Wahl, die fehlschlägt (z. B. wegen eines API-Ausfalls genau in dem Moment), geht verloren, weil der Schlüssel schon beim Lesen entfernt wird. Der Benutzer wählt dann einfach neu. Das ist bewusst so, damit die Übernahme garantiert nur einmal passiert.
|
||||||
|
- Alte Prototyp-Werte mit einer Bildkennung, die keine UUID ist, werden nicht übernommen. Echte Bilderrahmen-Kennungen sind immer UUIDs.
|
||||||
|
- Das Web liest `@tessera/shared` jetzt auch in `dashboard-background.ts` zur Laufzeit. `parseDashboardBackground` enthält nur löschbare Syntax (Regel aus dem Warnkommentar über `WIDGET_TYPES`).
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine über das Threat-Register hinaus. T-ujj-01 und T-ujj-02 sind wie geplant umgesetzt: Allowlist und UUID-Prüfung beim Schreiben und Lesen, `forTenant` mit `where id = currentUser.id`, kein Kennungsparameter.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
|
||||||
|
- FOUND: apps/web/src/components/dashboard/dashboard-background.test.tsx
|
||||||
|
- FOUND commits: 9fa0a3f, 76f6d87, 0aaa152, 69d1730, cb45d26
|
||||||
+18
@@ -80,3 +80,21 @@ nichts. Der Gewinn der Umstellung (kleiner `pg_dump`) ist bereits da, weil
|
|||||||
neue Uploads keine Bytes mehr in die Zeile schreiben. Nur die Bytes der
|
neue Uploads keine Bytes mehr in die Zeile schreiben. Nur die Bytes der
|
||||||
ALTEN Bilder bleiben bis dahin doppelt vorhanden — einmal in der Datei,
|
ALTEN Bilder bleiben bis dahin doppelt vorhanden — einmal in der Datei,
|
||||||
einmal in der Spalte.
|
einmal in der Spalte.
|
||||||
|
|
||||||
|
## Erledigt in quick-260924-m4n (24.09.2026)
|
||||||
|
|
||||||
|
- Migration heißt `20260924120000_dashboard_image_drop_data` (nicht
|
||||||
|
`20260922120100`: sie muss hinter allen vorhandenen Migrationen liegen).
|
||||||
|
Sie prüft zuerst, dass keine Zeile ohne `storagePath` existiert, und bricht
|
||||||
|
sonst mit Meldung ab, bevor sie etwas ändert; `row_security` ist für die
|
||||||
|
Prüfung aus, damit ein Eigentümer ohne BYPASSRLS nicht still 0 Zeilen sieht
|
||||||
|
(lokal nachgewiesen). Danach `storagePath` NOT NULL, `DROP COLUMN "data"`,
|
||||||
|
`DROP POLICY IF EXISTS system_read_policy ON "DashboardImage"`.
|
||||||
|
- Dienst: Bootstrap-Umzug, `forSystem()` und die Selbstheilung aus `data`
|
||||||
|
entfernt; der Upload vergibt die UUID selbst und legt die Zeile gleich mit
|
||||||
|
Pfad an. Erlaubnisliste, Tests (10b, 10c, 18, 21–23 entfallen) und
|
||||||
|
Zugriffsklassifikation nachgezogen (Zahlen mit der Gate-Schleife gemessen).
|
||||||
|
- Wiederherstellungsweg nach einem Abbruch (fehlgeschlagene Migration als
|
||||||
|
zurückgenommen vermerken, 1.3.1 laufen lassen, erneut einspielen) in
|
||||||
|
`docs/anleitung-betrieb.md` Kapitel 4, in einer Wegwerf-Datenbank
|
||||||
|
durchgespielt.
|
||||||
+14
@@ -49,3 +49,17 @@ auslöst. Der Fall wiederholt sich also, nicht zufällig.
|
|||||||
Die drei parallelen Pipelines abschalten: der Lauf auf `main` und der auf dem
|
Die drei parallelen Pipelines abschalten: der Lauf auf `main` und der auf dem
|
||||||
Tag prüfen unterschiedliche Dinge, und der `live`-Lauf ist die Absicherung,
|
Tag prüfen unterschiedliche Dinge, und der `live`-Lauf ist die Absicherung,
|
||||||
dass der Zweig für sich genommen grün ist.
|
dass der Zweig für sich genommen grün ist.
|
||||||
|
|
||||||
|
## Erledigt in quick-260924-m4n (24.09.2026)
|
||||||
|
|
||||||
|
- Ursache: `TenantContextSelector` und die Marktplatzseite wurden per
|
||||||
|
`await import(...)` INNERHALB der Tests geladen. Das Laden und Umwandeln der
|
||||||
|
Module zählte damit in die 5-s-Frist des ersten Tests — unter Läuferlast
|
||||||
|
(drei Pipelines je Freigabe) reicht das, um die Frist zu reißen.
|
||||||
|
- Behoben: statische Importe (vi.mock wird darüber gehoben), der Doppelfall
|
||||||
|
SUPER_ADMIN/ADMIN in zwei `it` aufgetrennt (der ADMIN-Fall prüft zusätzlich,
|
||||||
|
dass kein Abruf passiert). Dieselbe Umstellung in `marketplace.test.tsx` und
|
||||||
|
`marketplace-filters.test.tsx`; dort ist userEvent zusätzlich an die falsche
|
||||||
|
Uhr gekoppelt (`advanceTimers`). Kein globales `testTimeout`.
|
||||||
|
- Messung (ganze Web-Suite, lokal und auf 2 Kerne gedrosselt): kein Test über
|
||||||
|
2 s; der langsamste lag gedrosselt bei rund 1,3 s.
|
||||||
@@ -4,9 +4,60 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
## Unveröffentlicht
|
## Unveröffentlicht
|
||||||
|
|
||||||
|
## 1.5.2 – 2026-09-28
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Dashboard: Den Titel eines Widgets können Sie jetzt ausblenden. Im Bearbeitungsmodus sitzt dafür oben in der Mitte jedes Widgets mit Titel ein kleines „T“; ein Klick blendet den Titel aus, ein zweiter wieder ein. Die Einstellung gilt je Widget und bleibt gespeichert. Im Bearbeitungsmodus sehen Sie den Titel weiterhin, damit Sie ihn ändern können; Knöpfe wie der Stift der Notiz bleiben auch ohne Titel oben rechts erreichbar.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Seitenleiste: Die Kategorien (etwa „Fuhrpark“ oder „Infrastruktur“) sind etwas größer beschriftet, die Module darunter etwas kleiner – so ist die Gliederung auf einen Blick erkennbar.
|
||||||
|
|
||||||
|
## 1.5.1 – 2026-09-28
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Dashboard: Der Knopf „Bearbeiten“ sitzt jetzt als Stift-Symbol rechts in der dunklen Leiste oben und nimmt über den Widgets keinen Platz mehr weg. Im Bearbeitungsmodus erscheinen dort auch „Hintergrund“, „Widget hinzufügen“ und „Fertig“.
|
||||||
|
|
||||||
|
## 1.5.0 – 2026-09-28
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|
||||||
- Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen.
|
- Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen.
|
||||||
|
- Neues Modul „Proxmox“: zeigt Zustand und Auslastung Ihrer Proxmox-Server (Virtualisierung, Datensicherung und Mail-Gateway) – nur lesend, Tessera ändert dort nichts. Die Server trägt ein Administrator in den Einstellungen ein.
|
||||||
|
- Favoriten-Widget: eigenes Symbol je Link hochladen – PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB – beim Hinzufügen und im Bearbeitungsformular; ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“ macht es rückgängig
|
||||||
|
- Proxmox-Seite neu gestaltet: ein farbiger Balken oben zeigt auf einen Blick, wie viele Server in Ordnung, mit Warnung, nicht erreichbar, noch nicht abgefragt oder offline sind; jede Karte trägt ihren Zustand in Farbe und als Wort, auffällige Server stehen vorn; Auslastung als Balken mit Prozentzahl (ab 80 % Warnung), letzte Sicherung und letzte Abfrage als „vor 5 Std.“; ein deaktivierter Server erscheint als „Offline & verwaist“ ohne veraltete Messwerte
|
||||||
|
- Dashboard-Kachel „Proxmox“: ein farbiger Balken mit „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar“, darunter die Server, auffällige zuerst, mit je einer Kennzahl (laufende Gäste, letzte Sicherung, eingehende Mails); ein Klick öffnet die Proxmox-Seite; eigener Titel und Auswahl einzelner Server, im Bearbeitungsmodus direkt an der Kachel oder unter Einstellungen > Dashboard; aktualisiert sich jede Minute, ohne die Server neu abzufragen; nur für Benutzer mit Zugriff auf das Modul
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Dashboard: die Reiter sitzen jetzt als kompakter Umschalter in der Mitte der Kopfzeile statt in einer eigenen Zeile über den Kacheln – das Dashboard gewinnt dadurch Platz nach oben; viele Reiter lassen sich waagrecht durchblättern, mit den Pfeiltasten wechseln Sie zwischen ihnen; Anlegen, Umbenennen, Löschen und Umsortieren per Ziehen funktionieren wie bisher
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Fehler melden: das Häkchen „Bildschirmfoto beifügen“ war in manchen Fällen gesperrt, weil die Aufnahme an einem einzelnen Bild einer fremden Website scheiterte; die Aufnahme gelingt jetzt trotzdem
|
||||||
|
- Favoriten-Widget: lässt sich jetzt bis auf eine Spalte schmal ziehen – bei kurzen Linknamen bleibt rechts kein leerer Platz mehr
|
||||||
|
- Favoriten-Widget: nach dem Ändern der Logo-Adresse erscheint das neue Symbol jetzt sofort, statt erst nach einem Tag; ist das Bild unter der Adresse nicht abrufbar – etwa wegen einer Cloudflare-Prüfung – meldet das Formular das jetzt beim Speichern, statt die Adresse still zu übernehmen
|
||||||
|
|
||||||
## 1.3.1 – 2026-09-23
|
## 1.3.1 – 2026-09-23
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- quick-260923-lrr — Favoriten: eigenes Symbol hochladen, Zwischenspeicher
|
||||||
|
-- nach Aenderung erneuern.
|
||||||
|
--
|
||||||
|
-- Zwei neue Spalten auf "FavoriteLink":
|
||||||
|
--
|
||||||
|
-- "uploadedIconMime" TEXT NULL — der an den Bytes ERKANNTE Typ eines
|
||||||
|
-- hochgeladenen eigenen Symbols (PNG/JPEG/GIF/WebP/ICO/SVG); NULL, wenn
|
||||||
|
-- kein eigenes Symbol hochgeladen wurde. Es wird KEIN Pfad gespeichert:
|
||||||
|
-- die Datei liegt vollstaendig ableitbar unter
|
||||||
|
-- "user-files/favorite-icons/<userId>/<id>.<ext>" (Endung aus dem
|
||||||
|
-- erkannten Typ) — damit gelangt auch bei einer vollstaendigen
|
||||||
|
-- Zeilenauslieferung (Prisma liefert die ganze Zeile an den Client) kein
|
||||||
|
-- Serverpfad in eine API-Antwort. Vorrang vor "iconUrl": ist der Wert
|
||||||
|
-- gesetzt, liefert GET /favorites/:id/icon die hochgeladene Datei statt
|
||||||
|
-- die gespeicherte Logo-Adresse abzurufen.
|
||||||
|
--
|
||||||
|
-- "iconVersion" INTEGER NOT NULL DEFAULT 0 — Zaehler fuer die ausgelieferte
|
||||||
|
-- Symbol-Adresse (?v=<iconVersion>). Steigt genau dann (Prisma
|
||||||
|
-- { increment: 1 }), wenn sich die angezeigte Symbolquelle aendert: eine
|
||||||
|
-- gespeicherte "iconUrl" weicht vom alten Wert ab, ein Symbol wird
|
||||||
|
-- hochgeladen, oder ein hochgeladenes Symbol wird entfernt. NICHT bei
|
||||||
|
-- Titel-, Link-Adress- oder Reihenfolgeaenderung ohne Symbolwechsel.
|
||||||
|
-- Bestandszeilen starten bei 0 — das unterscheidet sich von der vorher
|
||||||
|
-- unversionierten Adresse, ein bereits 24 Stunden im Browser
|
||||||
|
-- zwischengespeichertes Symbol wird dadurch beim naechsten Laden sofort
|
||||||
|
-- ungueltig.
|
||||||
|
--
|
||||||
|
-- Die bestehende RLS-Regel "tenant_isolation_policy" auf "FavoriteLink"
|
||||||
|
-- (Migration 20260911120000, zeilenbezogen ueber "tenantId"/"userId")
|
||||||
|
-- braucht fuer zwei zusaetzliche Spalten KEINE Anpassung — sie schuetzt
|
||||||
|
-- Zeilen, nicht Spalten. Kein CREATE/DROP POLICY in dieser Migration.
|
||||||
|
--
|
||||||
|
-- "migrate deploy" wendet diese Migration beim Start an
|
||||||
|
-- (apps/api/scripts/migrate-and-start.sh) — kein manueller Schritt.
|
||||||
|
|
||||||
|
ALTER TABLE "FavoriteLink" ADD COLUMN "uploadedIconMime" TEXT;
|
||||||
|
ALTER TABLE "FavoriteLink" ADD COLUMN "iconVersion" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- quick-260924-m4n — Stufe 2 der Umstellung aus quick-260922-hk4: die alte
|
||||||
|
-- Bildspalte "data" faellt, "storagePath" wird Pflicht.
|
||||||
|
--
|
||||||
|
-- Stufe 1 (20260922120000_dashboard_image_to_disk) hat "storagePath"
|
||||||
|
-- angelegt und "data" nur NULLbar gemacht, weil `prisma migrate deploy` VOR
|
||||||
|
-- dem Anwendungsstart laeuft: ein sofortiges DROP haette die Bytes
|
||||||
|
-- vernichtet, bevor der Bootstrap-Umzug (DashboardImagesService,
|
||||||
|
-- Version 1.3.1) sie auf die Platte schreiben konnte (T-HK4-03). Dieser
|
||||||
|
-- Umzug ist mit dieser Version aus dem Code entfernt.
|
||||||
|
--
|
||||||
|
-- SCHUTZ VOR DATENVERLUST: gibt es noch eine Zeile ohne "storagePath", hat
|
||||||
|
-- der Umzug auf diesem Server nie gearbeitet (der Server hat eine Version
|
||||||
|
-- < 1.3.1 uebersprungen). Dann bricht die Migration mit einer Meldung ab,
|
||||||
|
-- BEVOR irgendetwas geaendert wird (die Pruefung steht vor jeder Aenderung),
|
||||||
|
-- `migrate deploy` stoppt, die API startet nicht. Abhilfe
|
||||||
|
-- (docs/anleitung-betrieb.md, Kapitel 4, gemessen in einer Wegwerf-DB):
|
||||||
|
-- den fehlgeschlagenen Eintrag in "_prisma_migrations" als zurueckgenommen
|
||||||
|
-- vermerken (sonst verweigert auch 1.3.1 den Start mit P3009), dann eine
|
||||||
|
-- Version >= 1.3.1 einmal starten lassen (der Umzug laeuft beim Start von
|
||||||
|
-- selbst), danach erneut auf diese Version gehen.
|
||||||
|
--
|
||||||
|
-- ZEILENSCHUTZ (RLS) UND DIE PRUEFUNG: "DashboardImage" hat FORCE ROW LEVEL
|
||||||
|
-- SECURITY (20260921120000). FORCE wirkt auch auf den Tabelleneigentuemer —
|
||||||
|
-- ohne Sitzungsvariablen wuerde die Mandantenregel dem EXISTS jede Zeile
|
||||||
|
-- wegfiltern, die Pruefung saehe 0 Zeilen und der Schutz waere stumm
|
||||||
|
-- wirkungslos. Heute laeuft die Migration als `tessera` (Superuser mit
|
||||||
|
-- BYPASSRLS, gemessen 24.09.2026 lokal) und sieht alles. Fuer den Fall, dass
|
||||||
|
-- sie spaeter ueber TESSERA_MIGRATE_DATABASE_URL als Eigentuemer OHNE
|
||||||
|
-- BYPASSRLS laeuft (docs/mandantentrennung-datenbankrolle.md), schaltet die
|
||||||
|
-- Pruefung `row_security` fuer diese Transaktion ab: PostgreSQL filtert dann
|
||||||
|
-- NICHT still, sondern bricht mit "query would be affected by row-level
|
||||||
|
-- security policy" ab. Die Pruefung sieht also entweder alle Zeilen oder
|
||||||
|
-- scheitert laut — nie "0 gesehen, weiter".
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('row_security', 'off', true);
|
||||||
|
IF EXISTS (SELECT 1 FROM "DashboardImage" WHERE "storagePath" IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'DashboardImage: es gibt noch Zeilen ohne storagePath — Umzug (quick-260922-hk4) zuerst mit einer Version >= 1.3.1 laufen lassen, dann erneut deployen';
|
||||||
|
END IF;
|
||||||
|
PERFORM set_config('row_security', 'on', true);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;
|
||||||
|
ALTER TABLE "DashboardImage" DROP COLUMN "data";
|
||||||
|
|
||||||
|
-- Die Systemkontext-Leseregel aus 20260922120000 hatte genau einen Zweck:
|
||||||
|
-- den Bootstrap-Umzug, der ueber ALLE Mandanten las (`forSystem()`). Der
|
||||||
|
-- Umzug ist entfernt, niemand liest "DashboardImage" mehr systemgebunden —
|
||||||
|
-- eine offene Leseregel ohne Leser waere nur Angriffsflaeche. Es bleibt
|
||||||
|
-- allein "tenant_isolation_policy" (Mandant UND Benutzer). IF EXISTS, damit
|
||||||
|
-- die Migration auch auf einer Datenbank durchlaeuft, auf der die Regel von
|
||||||
|
-- Hand entfernt wurde.
|
||||||
|
DROP POLICY IF EXISTS system_read_policy ON "DashboardImage";
|
||||||
@@ -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;
|
||||||
@@ -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[]
|
||||||
@@ -265,14 +272,11 @@ model DashboardImage {
|
|||||||
originalName String
|
originalName String
|
||||||
mimeType String
|
mimeType String
|
||||||
size Int
|
size Int
|
||||||
// Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die
|
// Relativ zur Monorepo-Wurzel, z. B.
|
||||||
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern
|
// "user-files/dashboard-images/<userId>/<id>.png". Die Bytes liegen seit
|
||||||
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit
|
// quick-260922-hk4 im Dateibereich; die alte Spalte `data` ist mit Stufe 2
|
||||||
// 20260922120100 (vorgemerkt in .planning/todos/pending/).
|
// (Migration 20260924120000_dashboard_image_drop_data) entfernt.
|
||||||
data Bytes?
|
storagePath String
|
||||||
// Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der
|
|
||||||
// Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL.
|
|
||||||
storagePath String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -419,6 +423,16 @@ model FavoriteLink {
|
|||||||
title String
|
title String
|
||||||
url String
|
url String
|
||||||
iconUrl String?
|
iconUrl String?
|
||||||
|
// quick-260923-lrr: Typ eines hochgeladenen eigenen Symbols (erkannt an
|
||||||
|
// den Bytes, NIE aus der Anfrage uebernommen); null = kein hochgeladenes
|
||||||
|
// Symbol. Kein Pfad gespeichert — die Datei liegt ableitbar unter
|
||||||
|
// user-files/favorite-icons/<userId>/<id>.<ext>.
|
||||||
|
uploadedIconMime String?
|
||||||
|
// quick-260923-lrr: Zaehler fuer die ausgelieferte Symbol-Adresse
|
||||||
|
// (?v=<iconVersion>), steigt bei jeder Aenderung der Symbolquelle
|
||||||
|
// (neue iconUrl, Upload, Entfernen) — macht den 24-h-Browser-Zwischenspeicher
|
||||||
|
// nach einer Aenderung sofort ungueltig.
|
||||||
|
iconVersion Int @default(0)
|
||||||
position Int @default(0)
|
position Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface FakeUserRow {
|
|||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
avatarPath?: string | null;
|
avatarPath?: string | null;
|
||||||
accentColor?: string | null;
|
accentColor?: string | null;
|
||||||
|
dashboardBackground?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BoundCall {
|
interface BoundCall {
|
||||||
@@ -501,6 +502,8 @@ describe('AuthService.getMe', () => {
|
|||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
avatarPath: 'avatars/u1.png',
|
avatarPath: 'avatars/u1.png',
|
||||||
accentColor: '#3b82f6',
|
accentColor: '#3b82f6',
|
||||||
|
// quick-260928-ujj: gespeicherter Zusatzschluessel wird bei der Ausgabe verworfen.
|
||||||
|
dashboardBackground: { kind: 'preset', id: 'dunes', extra: 'weg' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ldapUserRow: FakeUserRow = {
|
const ldapUserRow: FakeUserRow = {
|
||||||
@@ -515,6 +518,7 @@ describe('AuthService.getMe', () => {
|
|||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
avatarPath: null,
|
avatarPath: null,
|
||||||
accentColor: null,
|
accentColor: null,
|
||||||
|
dashboardBackground: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -535,6 +539,7 @@ describe('AuthService.getMe', () => {
|
|||||||
tenantId: 't1',
|
tenantId: 't1',
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
accentColor: '#3b82f6',
|
accentColor: '#3b82f6',
|
||||||
|
dashboardBackground: { kind: 'preset', id: 'dunes' },
|
||||||
isLocalUser: true,
|
isLocalUser: true,
|
||||||
hasAvatar: true,
|
hasAvatar: true,
|
||||||
});
|
});
|
||||||
@@ -549,6 +554,30 @@ describe('AuthService.getMe', () => {
|
|||||||
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
|
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('quick-260928-ujj: dashboardBackground NULL (nie gewaehlt) kommt als null zurueck', async () => {
|
||||||
|
const result = await service.getMe('t1', 'u2');
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('dashboardBackground', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quick-260928-ujj: ungueltiger gespeicherter Hintergrund kommt als null zurueck (T-ujj-01)', async () => {
|
||||||
|
prisma.__users.set('u1', {
|
||||||
|
...prisma.__users.get('u1'),
|
||||||
|
dashboardBackground: { kind: 'image', imageId: '") ; background: url("x' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getMe('t1', 'u1');
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('dashboardBackground', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quick-260928-ujj: dashboardBackground steht im select neben accentColor', async () => {
|
||||||
|
await service.getMe('t1', 'u1');
|
||||||
|
|
||||||
|
const call = prisma.__boundCallLog.find((c: any) => c.method === 'findUnique');
|
||||||
|
expect(call.args.select).toMatchObject({ accentColor: true, dashboardBackground: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
|
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
|
||||||
const result = await service.getMe('t2', 'u1');
|
const result = await service.getMe('t2', 'u1');
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
|
import { parseDashboardBackground } from '@tessera/shared';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
@@ -331,6 +332,7 @@ export class AuthService {
|
|||||||
ldapDn: true,
|
ldapDn: true,
|
||||||
avatarPath: true,
|
avatarPath: true,
|
||||||
accentColor: true,
|
accentColor: true,
|
||||||
|
dashboardBackground: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,10 +340,13 @@ export class AuthService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash, ldapDn, avatarPath, ...publicFields } = user;
|
const { passwordHash, ldapDn, avatarPath, dashboardBackground, ...publicFields } = user;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...publicFields,
|
...publicFields,
|
||||||
|
// quick-260928-ujj (T-ujj-01): auch beim Lesen durch die gemeinsame
|
||||||
|
// Pruefregel — NULL oder ein ungueltiger Inhalt ergibt null.
|
||||||
|
dashboardBackground: parseDashboardBackground(dashboardBackground),
|
||||||
isLocalUser: !!passwordHash && !ldapDn,
|
isLocalUser: !!passwordHash && !ldapDn,
|
||||||
hasAvatar: !!avatarPath,
|
hasAvatar: !!avatarPath,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ import * as path from 'node:path';
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bindung an forTenant()/forSystem() — dasselbe Muster wie
|
* Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts
|
||||||
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
|
* (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma`
|
||||||
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
|
* unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert,
|
||||||
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
|
* welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt
|
||||||
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
|
* damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag).
|
||||||
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
|
* `forSystem` steht als Spion daneben: seit Stufe 2 (quick-260924-m4n,
|
||||||
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
|
* Bootstrap-Umzug entfernt) darf der Dienst ihn nie mehr rufen.
|
||||||
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
|
|
||||||
* unterscheidet beide ueber `via`.
|
|
||||||
*/
|
*/
|
||||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
||||||
prisma.__makeBoundClient(tenantId, userId),
|
prisma.__makeBoundClient(tenantId, userId),
|
||||||
),
|
),
|
||||||
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
|
forSystem: vi.fn(() => {
|
||||||
|
throw new Error('forSystem darf der Bilderdienst seit Stufe 2 nicht mehr rufen');
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
@@ -39,15 +39,18 @@ import { DashboardImagesService } from './dashboard-images.service';
|
|||||||
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
||||||
* Argument aufruft.
|
* Argument aufruft.
|
||||||
*
|
*
|
||||||
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
|
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-20): KEIN
|
||||||
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
||||||
* desktop.service.spec.ts) ueber den Testschalter
|
* desktop.service.spec.ts) ueber den Testschalter
|
||||||
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
||||||
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
||||||
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
||||||
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
||||||
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
|
* bei fehlender Datei und das Mitloeschen der Datei.
|
||||||
* Umzug beim Start (T-HK4-03).
|
*
|
||||||
|
* Stufe 2 (quick-260924-m4n): die Spalte `data` ist weg, `storagePath` ist
|
||||||
|
* Pflicht. Mit ihr entfallen die Faelle 10b/10c (Selbstheilung aus `data`),
|
||||||
|
* 18 (Zeile ohne `storagePath`) und 21-23 (Bootstrap-Umzug).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface ImageRow {
|
interface ImageRow {
|
||||||
@@ -57,13 +60,11 @@ interface ImageRow {
|
|||||||
originalName: string;
|
originalName: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
data: Uint8Array | null;
|
storagePath: string;
|
||||||
storagePath: string | null;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BoundCall {
|
interface BoundCall {
|
||||||
via: 'tenant' | 'system';
|
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
userId: string | undefined;
|
userId: string | undefined;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -77,7 +78,6 @@ interface FakePrisma {
|
|||||||
__rows: ImageRow[];
|
__rows: ImageRow[];
|
||||||
__boundCallLog: BoundCall[];
|
__boundCallLog: BoundCall[];
|
||||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||||
__makeSystemClient(): { dashboardImage: ModelMethods };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||||
@@ -102,16 +102,21 @@ afterAll(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Zeile OHNE Datei auf der Platte; der Pfad folgt der Form, die der
|
||||||
|
* Dienst selbst vergibt (`storagePath` ist seit Stufe 2 Pflicht).
|
||||||
|
*/
|
||||||
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||||
|
const id = overrides.id ?? 'img-1';
|
||||||
|
const userId = overrides.userId ?? 'user-1';
|
||||||
return {
|
return {
|
||||||
id: overrides.id ?? 'img-1',
|
id,
|
||||||
userId: overrides.userId ?? 'user-1',
|
userId,
|
||||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||||
originalName: overrides.originalName ?? 'foto.png',
|
originalName: overrides.originalName ?? 'foto.png',
|
||||||
mimeType: overrides.mimeType ?? 'image/png',
|
mimeType: overrides.mimeType ?? 'image/png',
|
||||||
size: overrides.size ?? PNG.length,
|
size: overrides.size ?? PNG.length,
|
||||||
data: overrides.data === undefined ? null : overrides.data,
|
storagePath: overrides.storagePath ?? `user-files/dashboard-images/${userId}/${id}.png`,
|
||||||
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
|
|
||||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -123,11 +128,10 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
|||||||
*/
|
*/
|
||||||
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
||||||
const row = makeRow(overrides);
|
const row = makeRow(overrides);
|
||||||
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
|
|
||||||
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
||||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||||
fs.writeFileSync(absolute, bytes);
|
fs.writeFileSync(absolute, bytes);
|
||||||
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function storedFile(userId: string, id: string, ext = 'png'): string {
|
function storedFile(userId: string, id: string, ext = 'png'): string {
|
||||||
@@ -148,7 +152,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
const dashboardImage: ModelMethods = {
|
const dashboardImage: ModelMethods = {
|
||||||
findMany: vi.fn(async (raw: unknown) => {
|
findMany: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as {
|
const args = raw as {
|
||||||
where: { tenantId?: string; userId?: string; storagePath?: string | null };
|
where: { tenantId?: string; userId?: string };
|
||||||
select?: Record<string, boolean>;
|
select?: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
const where = args.where ?? {};
|
const where = args.where ?? {};
|
||||||
@@ -156,9 +160,6 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
.filter((r) => {
|
.filter((r) => {
|
||||||
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
||||||
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||||
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.slice()
|
.slice()
|
||||||
@@ -195,11 +196,11 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
|
function wrap(tenantId: string, userId?: string) {
|
||||||
const wrapped: ModelMethods = {};
|
const wrapped: ModelMethods = {};
|
||||||
for (const method of Object.keys(dashboardImage)) {
|
for (const method of Object.keys(dashboardImage)) {
|
||||||
wrapped[method] = async (...args: unknown[]) => {
|
wrapped[method] = async (...args: unknown[]) => {
|
||||||
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
|
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
||||||
return dashboardImage[method](...args);
|
return dashboardImage[method](...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -211,10 +212,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
__rows: rows,
|
__rows: rows,
|
||||||
__boundCallLog: boundCallLog,
|
__boundCallLog: boundCallLog,
|
||||||
__makeBoundClient(tenantId: string, userId?: string) {
|
__makeBoundClient(tenantId: string, userId?: string) {
|
||||||
return wrap('tenant', tenantId, userId);
|
return wrap(tenantId, userId);
|
||||||
},
|
|
||||||
__makeSystemClient() {
|
|
||||||
return wrap('system', '', undefined);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return fake;
|
return fake;
|
||||||
@@ -340,37 +338,6 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 10b: getBytes — Datei fehlt, aber die alte Spalte `data` traegt die Bytes noch: wiederherstellen statt 404', async () => {
|
|
||||||
// Fall aus dem Browser-Rundgang 22.09.2026: ein `pg_dump` von vor dem Umzug
|
|
||||||
// traegt die Bytes noch, das Volume `user-files` wird getrennt gesichert —
|
|
||||||
// wer nur den Abzug zurueckspielt, haette sonst Zeilen ohne Datei.
|
|
||||||
const row = makeRow({
|
|
||||||
id: 'img-alt',
|
|
||||||
data: PNG,
|
|
||||||
storagePath: 'user-files/dashboard-images/user-1/img-alt.png',
|
|
||||||
});
|
|
||||||
const prisma = makeFakePrisma([row]);
|
|
||||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(false);
|
|
||||||
|
|
||||||
const result = await makeService(prisma).getBytes('img-alt', 'user-1', 'tenant-1');
|
|
||||||
|
|
||||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
|
||||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(true);
|
|
||||||
expect(fs.readFileSync(storedFile('user-1', 'img-alt')).equals(PNG)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Test 10c: getBytes — Datei fehlt UND `data` ist leer -> 404', async () => {
|
|
||||||
const row = makeRow({
|
|
||||||
id: 'img-weg',
|
|
||||||
data: null,
|
|
||||||
storagePath: 'user-files/dashboard-images/user-1/img-weg.png',
|
|
||||||
});
|
|
||||||
const prisma = makeFakePrisma([row]);
|
|
||||||
await expect(makeService(prisma).getBytes('img-weg', 'user-1', 'tenant-1')).rejects.toThrow(
|
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
|
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
|
||||||
const prisma = makeFakePrisma([
|
const prisma = makeFakePrisma([
|
||||||
makeStoredRow({ id: 'eigen' }),
|
makeStoredRow({ id: 'eigen' }),
|
||||||
@@ -399,13 +366,13 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
expect(call[1]).toBe('tenant-1');
|
expect(call[1]).toBe('tenant-1');
|
||||||
expect(call[2]).toBe('user-1');
|
expect(call[2]).toBe('user-1');
|
||||||
}
|
}
|
||||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
|
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten. Seit
|
||||||
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
|
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
||||||
// UUID der Zeile erst nach `create` feststeht (hk4).
|
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
||||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||||
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
|
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||||
|
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
||||||
for (const c of prisma.__boundCallLog) {
|
for (const c of prisma.__boundCallLog) {
|
||||||
expect(c.via).toBe('tenant');
|
|
||||||
expect(c.tenantId).toBe('tenant-1');
|
expect(c.tenantId).toBe('tenant-1');
|
||||||
expect(c.userId).toBe('user-1');
|
expect(c.userId).toBe('user-1');
|
||||||
}
|
}
|
||||||
@@ -421,10 +388,15 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
expect(fs.existsSync(onDisk)).toBe(true);
|
expect(fs.existsSync(onDisk)).toBe(true);
|
||||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||||
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
|
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
||||||
expect(prisma.__rows[0].data).toBeNull();
|
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
||||||
|
// die Zeile.
|
||||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||||
expect(createArgs.data.data).toBeUndefined();
|
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||||
|
expect(createArgs.data.id).toBe(result.id);
|
||||||
|
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||||
|
expect(createArgs.data).not.toHaveProperty('data');
|
||||||
|
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
||||||
@@ -435,7 +407,7 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
|
|
||||||
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
||||||
expect(result.mimeType).toBe('image/jpeg');
|
expect(result.mimeType).toBe('image/jpeg');
|
||||||
const stored = prisma.__rows[0].storagePath ?? '';
|
const stored = prisma.__rows[0].storagePath;
|
||||||
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
||||||
expect(stored).not.toContain('passwd');
|
expect(stored).not.toContain('passwd');
|
||||||
expect(stored).not.toContain('..');
|
expect(stored).not.toContain('..');
|
||||||
@@ -462,10 +434,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
|
it('Test 16: getBytes liest den Dateiinhalt unter dem Pfad aus der Zeile', async () => {
|
||||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-16' }, JPEG)]);
|
||||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
const result = await makeService(prisma).getBytes('img-16', 'user-1', 'tenant-1');
|
||||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
expect(Buffer.from(result.data).equals(JPEG)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
||||||
@@ -479,11 +451,6 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
|
|
||||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
|
|
||||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
||||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
||||||
const onDisk = storedFile('user-1', 'weg');
|
const onDisk = storedFile('user-1', 'weg');
|
||||||
@@ -504,69 +471,3 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
expect(prisma.__rows).toHaveLength(0);
|
expect(prisma.__rows).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
|
|
||||||
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
|
|
||||||
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
|
|
||||||
const fremderMandant = makeRow({
|
|
||||||
id: 'alt-2',
|
|
||||||
userId: 'user-9',
|
|
||||||
tenantId: 'tenant-2',
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
data: Uint8Array.from(JPEG),
|
|
||||||
});
|
|
||||||
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
|
|
||||||
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
|
|
||||||
|
|
||||||
await makeService(prisma).onApplicationBootstrap();
|
|
||||||
|
|
||||||
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
|
|
||||||
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
|
|
||||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
|
|
||||||
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
|
|
||||||
|
|
||||||
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
|
|
||||||
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
|
|
||||||
// gebunden ist.
|
|
||||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
|
||||||
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
|
|
||||||
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
|
|
||||||
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
|
|
||||||
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
|
||||||
where: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
expect(findManyArgs.where.storagePath).toBeNull();
|
|
||||||
|
|
||||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
|
|
||||||
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
|
|
||||||
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
|
|
||||||
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
|
|
||||||
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
|
|
||||||
|
|
||||||
// Die bereits umgezogene Zeile wird nicht angefasst.
|
|
||||||
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
|
|
||||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
|
|
||||||
await makeService(prisma).onApplicationBootstrap();
|
|
||||||
|
|
||||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
|
||||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
|
||||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
|
|
||||||
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
|
|
||||||
const service = makeService(prisma);
|
|
||||||
await service.onApplicationBootstrap();
|
|
||||||
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
|
|
||||||
|
|
||||||
vi.mocked(forTenant).mockClear();
|
|
||||||
await service.onApplicationBootstrap();
|
|
||||||
|
|
||||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
|
||||||
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
|
|
||||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import {
|
|||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
type OnApplicationBootstrap,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { forSystem, 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 {
|
import {
|
||||||
DASHBOARD_IMAGE_MAX_COUNT,
|
DASHBOARD_IMAGE_MAX_COUNT,
|
||||||
@@ -46,13 +46,22 @@ import {
|
|||||||
* (T-HK4-02); das Volume haengt in keinem Webserver.
|
* (T-HK4-02); das Volume haengt in keinem Webserver.
|
||||||
*
|
*
|
||||||
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
|
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
|
||||||
* die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert
|
* die Zeile (mit der vom Dienst vergebenen UUID und dem daraus gebildeten
|
||||||
* das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim
|
* Pfad), dann die Datei; scheitert das Schreiben, wird die Zeile wieder
|
||||||
|
* geloescht und 500 geworfen. Beim
|
||||||
* Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
|
* Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
|
||||||
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
|
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
|
||||||
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
|
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
|
||||||
* 404 und die Kachel zeigt „Bild nicht verfügbar".
|
* 404 und die Kachel zeigt „Bild nicht verfügbar".
|
||||||
*
|
*
|
||||||
|
* STUFE 2 DER UMSTELLUNG (quick-260924-m4n, Migration
|
||||||
|
* 20260924120000_dashboard_image_drop_data): die alte Spalte `data` ist
|
||||||
|
* weg, `storagePath` ist Pflicht. Mit ihr sind der Bootstrap-Umzug
|
||||||
|
* (`onApplicationBootstrap()` mit `forSystem()`) und die Selbstheilung aus
|
||||||
|
* `data` in `getBytes` entfallen — der Umzug hatte auf allen Servern seine
|
||||||
|
* Arbeit getan, die Migration bricht ab, falls doch noch eine Zeile ohne
|
||||||
|
* Pfad existiert.
|
||||||
|
*
|
||||||
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
||||||
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
|
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
|
||||||
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
|
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
|
||||||
@@ -145,6 +154,33 @@ function relativeStoragePath(userId: string, id: string, extension: string): str
|
|||||||
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
|
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servergenerierter relativer Pfad fuer ein neues Bild: UUID der Zeile plus
|
||||||
|
* Endung aus dem ERKANNTEN Typ (T-HK4-01).
|
||||||
|
*/
|
||||||
|
function storagePathFor(userId: string, id: string, mimeType: string): string {
|
||||||
|
const extension = extensionFor(mimeType);
|
||||||
|
if (extension === null) {
|
||||||
|
// detectImageMime liefert nur die vier bekannten Typen; ein anderer
|
||||||
|
// Wert hier waere ein Programmierfehler, kein Benutzerfehler.
|
||||||
|
throw new InternalServerErrorException(`Unbekannter Bildtyp '${mimeType}'`);
|
||||||
|
}
|
||||||
|
return relativeStoragePath(userId, id, extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt die Bytes an den servergenerierten Ort. Der Ordner je Benutzer
|
||||||
|
* entsteht dabei (`recursive: true`).
|
||||||
|
*/
|
||||||
|
async function writeImageFile(storagePath: string, bytes: Uint8Array): Promise<void> {
|
||||||
|
const absolute = absoluteImagePath(storagePath);
|
||||||
|
if (absolute === null) {
|
||||||
|
throw new Error(`Ungueltiger Ablageort '${storagePath}'`);
|
||||||
|
}
|
||||||
|
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||||
|
await fs.writeFile(absolute, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
|
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
|
||||||
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
|
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
|
||||||
@@ -162,69 +198,11 @@ function absoluteImagePath(storagePath: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardImagesService implements OnApplicationBootstrap {
|
export class DashboardImagesService {
|
||||||
private readonly logger = new Logger(DashboardImagesService.name);
|
private readonly logger = new Logger(DashboardImagesService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der
|
|
||||||
* Betreiber nichts von Hand ausfuehren muss.
|
|
||||||
*
|
|
||||||
* ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die
|
|
||||||
* SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data`
|
|
||||||
* NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges
|
|
||||||
* DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte.
|
|
||||||
* Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live
|
|
||||||
* einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in
|
|
||||||
* `.planning/todos/pending/`).
|
|
||||||
*
|
|
||||||
* GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster
|
|
||||||
* `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle
|
|
||||||
* Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben
|
|
||||||
* wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer
|
|
||||||
* dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet
|
|
||||||
* (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in
|
|
||||||
* 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim
|
|
||||||
* DKV-Planer.
|
|
||||||
*
|
|
||||||
* Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein
|
|
||||||
* zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile
|
|
||||||
* wird protokolliert und haelt den Start nicht auf.
|
|
||||||
*/
|
|
||||||
async onApplicationBootstrap(): Promise<void> {
|
|
||||||
const systemPrisma = forSystem(this.prisma);
|
|
||||||
const pending = await systemPrisma.dashboardImage.findMany({
|
|
||||||
where: { storagePath: null },
|
|
||||||
select: { id: true, userId: true, tenantId: true, mimeType: true, data: true },
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
let moved = 0;
|
|
||||||
for (const row of pending) {
|
|
||||||
if (row.data === null) continue;
|
|
||||||
try {
|
|
||||||
const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
|
||||||
const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId);
|
|
||||||
await tenantPrisma.dashboardImage.update({
|
|
||||||
where: { id: row.id },
|
|
||||||
data: { storagePath },
|
|
||||||
});
|
|
||||||
moved += 1;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moved > 0) {
|
|
||||||
this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
||||||
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -240,10 +218,12 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
|||||||
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
|
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
|
||||||
* die Zeile haelt den Pfad.
|
* die Zeile haelt den Pfad.
|
||||||
*
|
*
|
||||||
* Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der
|
* Reihenfolge (T-HK4-04): die UUID vergibt der Dienst selbst
|
||||||
* Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des
|
* (`randomUUID()`, dieselbe Form wie Prismas `@default(uuid())`), damit
|
||||||
* Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine
|
* die Zeile ihren Pfad gleich beim Anlegen traegt — `storagePath` ist seit
|
||||||
* Zeile ohne Datei.
|
* Stufe 2 Pflicht. Zeile zuerst, dann die Datei; scheitert das Schreiben,
|
||||||
|
* wird die Zeile wieder geloescht — lieber gar kein Bild als eine Zeile
|
||||||
|
* ohne Datei.
|
||||||
*/
|
*/
|
||||||
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -265,8 +245,12 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const storagePath = storagePathFor(user.id, id, mimeType);
|
||||||
const created = await tenantPrisma.dashboardImage.create({
|
const created = await tenantPrisma.dashboardImage.create({
|
||||||
data: {
|
data: {
|
||||||
|
id,
|
||||||
|
storagePath,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
tenantId: user.tenantId,
|
tenantId: user.tenantId,
|
||||||
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
||||||
@@ -277,11 +261,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer);
|
await writeImageFile(storagePath, file.buffer);
|
||||||
await tenantPrisma.dashboardImage.update({
|
|
||||||
where: { id: created.id },
|
|
||||||
data: { storagePath },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
|
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
|
||||||
@@ -297,8 +277,8 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
|
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
|
||||||
* 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad
|
* 404. Gelesen wird die Datei; ein ungueltiger Pfad und eine fehlende
|
||||||
* (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404.
|
* Datei ergeben denselben 404.
|
||||||
*/
|
*/
|
||||||
async getBytes(
|
async getBytes(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -311,7 +291,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
|||||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
const absolute = absoluteImagePath(row.storagePath);
|
||||||
if (absolute === null) {
|
if (absolute === null) {
|
||||||
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
|
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
|
||||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||||
@@ -321,26 +301,6 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
|||||||
const data = await fs.readFile(absolute);
|
const data = await fs.readFile(absolute);
|
||||||
return { mimeType: row.mimeType, data };
|
return { mimeType: row.mimeType, data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Selbstheilung waehrend der Umstellung (T-HK4-03): fehlt die Datei,
|
|
||||||
// steckt aber noch die alte Spalte `data` in der Zeile, wird die Datei
|
|
||||||
// daraus neu geschrieben und ausgeliefert. Der Fall ist real: ein
|
|
||||||
// `pg_dump` aus der Zeit vor dem Umzug traegt die Bytes noch, das
|
|
||||||
// Volume `user-files` wird getrennt gesichert — wer nur den Abzug
|
|
||||||
// zurueckspielt, haette sonst Zeilen ohne Datei. Nach dem Entfernen der
|
|
||||||
// Spalte (eigenes Todo) faellt dieser Zweig ersatzlos weg.
|
|
||||||
if (row.data !== null) {
|
|
||||||
try {
|
|
||||||
await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
|
||||||
this.logger.log(`Bilderrahmen-Bild ${id} aus der Datenbank wiederhergestellt`);
|
|
||||||
return { mimeType: row.mimeType, data: row.data };
|
|
||||||
} catch (writeError) {
|
|
||||||
this.logger.error(
|
|
||||||
`Bilderrahmen-Bild ${id} konnte nicht wiederhergestellt werden: ${
|
|
||||||
writeError instanceof Error ? writeError.message : String(writeError)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
|
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
@@ -363,7 +323,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||||
|
|
||||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
const absolute = absoluteImagePath(row.storagePath);
|
||||||
if (absolute !== null) {
|
if (absolute !== null) {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(absolute);
|
await fs.unlink(absolute);
|
||||||
@@ -378,31 +338,4 @@ export class DashboardImagesService implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Schreibt die Bytes an den servergenerierten Ort und liefert den
|
|
||||||
* relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht
|
|
||||||
* dabei (`recursive: true`).
|
|
||||||
*/
|
|
||||||
private async writeImageFile(
|
|
||||||
userId: string,
|
|
||||||
id: string,
|
|
||||||
mimeType: string,
|
|
||||||
bytes: Uint8Array,
|
|
||||||
): Promise<string> {
|
|
||||||
const extension = extensionFor(mimeType);
|
|
||||||
if (extension === null) {
|
|
||||||
throw new Error(`Unbekannter Bildtyp '${mimeType}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const storagePath = relativeStoragePath(userId, id, extension);
|
|
||||||
const absolute = absoluteImagePath(storagePath);
|
|
||||||
if (absolute === null) {
|
|
||||||
throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
||||||
await fs.writeFile(absolute, bytes);
|
|
||||||
return storagePath;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WIDGET_MODULE_MAP wird je Testfall über eine gemeinsame, gemockte
|
* WIDGET_MODULE_MAP wird je Testfall über eine gemeinsame, gemockte
|
||||||
@@ -45,6 +48,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|||||||
|
|
||||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { favoriteIconAbsolutePath } from '../favorites/favorite-icon-files';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
@@ -52,8 +56,16 @@ import { DashboardService } from './dashboard.service';
|
|||||||
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
||||||
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
|
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
|
||||||
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
|
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
|
||||||
* quick-260923-ad9 (Task 1). */
|
* quick-260923-ad9 (Task 1). `favoriteLink` ergaenzt seit quick-260923-lrr
|
||||||
const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider'];
|
* (T-LRR-07: `removeWidget`/`deleteDashboard` lesen vor der Kaskade die
|
||||||
|
* betroffenen Favoriten mit hochgeladenem Symbol). */
|
||||||
|
const BOUND_MODEL_NAMES = [
|
||||||
|
'dashboard',
|
||||||
|
'dashboardLayout',
|
||||||
|
'widgetInstance',
|
||||||
|
'searchProvider',
|
||||||
|
'favoriteLink',
|
||||||
|
];
|
||||||
|
|
||||||
/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter
|
/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter
|
||||||
* `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */
|
* `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */
|
||||||
@@ -125,6 +137,27 @@ function makeSearchProvider(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimale FavoriteLink-Fixture fuer T-LRR-07 (quick-260923-lrr) — nur die
|
||||||
|
* Felder, die `cleanUpFavoriteIconFiles` und die vorbereitenden `findMany`-
|
||||||
|
* Aufrufe in `removeWidget`/`deleteDashboard` lesen. */
|
||||||
|
function makeFavorite(
|
||||||
|
overrides: Partial<{
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
widgetId: string;
|
||||||
|
uploadedIconMime: string | null;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'fav-1',
|
||||||
|
userId: overrides.userId ?? 'user-1',
|
||||||
|
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||||
|
widgetId: overrides.widgetId ?? 'w1',
|
||||||
|
uploadedIconMime: overrides.uploadedIconMime === undefined ? null : overrides.uploadedIconMime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function makeFakePrisma(
|
function makeFakePrisma(
|
||||||
opts: {
|
opts: {
|
||||||
widgets?: ReturnType<typeof makeWidget>[];
|
widgets?: ReturnType<typeof makeWidget>[];
|
||||||
@@ -132,12 +165,14 @@ function makeFakePrisma(
|
|||||||
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
|
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
|
||||||
searchProviders?: ReturnType<typeof makeSearchProvider>[];
|
searchProviders?: ReturnType<typeof makeSearchProvider>[];
|
||||||
dashboards?: ReturnType<typeof makeDashboard>[];
|
dashboards?: ReturnType<typeof makeDashboard>[];
|
||||||
|
favorites?: ReturnType<typeof makeFavorite>[];
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const widgets = opts.widgets ?? [];
|
const widgets = opts.widgets ?? [];
|
||||||
const modules = opts.modules ?? [];
|
const modules = opts.modules ?? [];
|
||||||
let layoutRow = opts.layout ?? null;
|
let layoutRow = opts.layout ?? null;
|
||||||
const searchProviders = opts.searchProviders ?? [];
|
const searchProviders = opts.searchProviders ?? [];
|
||||||
|
const favorites = opts.favorites ?? [];
|
||||||
// Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört
|
// Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört
|
||||||
// — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern.
|
// — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern.
|
||||||
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
|
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
|
||||||
@@ -256,6 +291,24 @@ function makeFakePrisma(
|
|||||||
return removed;
|
return removed;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// T-LRR-07 (quick-260923-lrr): nur `findMany` — `removeWidget`/
|
||||||
|
// `deleteDashboard` LESEN die betroffenen Favoriten vor der Kaskade,
|
||||||
|
// sie schreiben nie auf `favoriteLink` (das Loeschen selbst passiert
|
||||||
|
// ueber die Datenbank-Kaskade auf `widgetInstance`, nicht hier).
|
||||||
|
favoriteLink: {
|
||||||
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
|
let rows = favorites.filter((f) => f.userId === where.userId);
|
||||||
|
if (where.widgetId?.in) {
|
||||||
|
rows = rows.filter((f) => where.widgetId.in.includes(f.widgetId));
|
||||||
|
} else if (where.widgetId) {
|
||||||
|
rows = rows.filter((f) => f.widgetId === where.widgetId);
|
||||||
|
}
|
||||||
|
if (where.uploadedIconMime?.not === null) {
|
||||||
|
rows = rows.filter((f) => f.uploadedIconMime !== null);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}),
|
||||||
|
},
|
||||||
module: {
|
module: {
|
||||||
findMany: vi.fn(async ({ where }: any) => {
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
const slugs: string[] = where.slug.in;
|
const slugs: string[] = where.slug.in;
|
||||||
@@ -667,6 +720,69 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('removeWidget — T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung', () => {
|
||||||
|
let iconsDir: string;
|
||||||
|
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-'));
|
||||||
|
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||||
|
if (ORIGINAL_DIR_ENV === undefined) {
|
||||||
|
delete process.env.FAVORITE_ICONS_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt die Symboldatei eines Favoriten, dessen Zeile die Datenbank-Kaskade mitloescht', async () => {
|
||||||
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||||
|
const favorite = makeFavorite({
|
||||||
|
id: 'fav-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
widgetId: 'w1',
|
||||||
|
uploadedIconMime: 'image/png',
|
||||||
|
});
|
||||||
|
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
||||||
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||||
|
|
||||||
|
const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
|
||||||
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||||
|
fs.writeFileSync(absolute, Buffer.from([1, 2, 3]));
|
||||||
|
|
||||||
|
await service.removeWidget('w1', 'user-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(fs.existsSync(absolute)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fehlende Datei wird geschluckt — removeWidget scheitert nicht daran', async () => {
|
||||||
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||||
|
const favorite = makeFavorite({
|
||||||
|
id: 'fav-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
widgetId: 'w1',
|
||||||
|
uploadedIconMime: 'image/png',
|
||||||
|
});
|
||||||
|
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
||||||
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||||
|
|
||||||
|
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Favoriten OHNE hochgeladenes Symbol loesen keinen Dateizugriff aus', async () => {
|
||||||
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||||
|
const favorite = makeFavorite({ id: 'fav-1', userId: 'user-1', widgetId: 'w1', uploadedIconMime: null });
|
||||||
|
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
||||||
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||||
|
|
||||||
|
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
|
||||||
|
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
|
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
|
||||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||||
const prisma = makeFakePrisma({
|
const prisma = makeFakePrisma({
|
||||||
@@ -1234,6 +1350,63 @@ describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung eines ganzen Reiters', () => {
|
||||||
|
let iconsDir: string;
|
||||||
|
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-tab-'));
|
||||||
|
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||||
|
if (ORIGINAL_DIR_ENV === undefined) {
|
||||||
|
delete process.env.FAVORITE_ICONS_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt die Symboldateien ALLER Favoriten der Widgets dieses Reiters', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
|
||||||
|
widgets: [
|
||||||
|
makeWidget({ id: 'w1', dashboardId: 'd1' }),
|
||||||
|
makeWidget({ id: 'w2', dashboardId: 'd1' }),
|
||||||
|
],
|
||||||
|
favorites: [
|
||||||
|
makeFavorite({ id: 'fav-1', widgetId: 'w1', uploadedIconMime: 'image/png' }),
|
||||||
|
makeFavorite({ id: 'fav-2', widgetId: 'w2', uploadedIconMime: 'image/svg+xml' }),
|
||||||
|
makeFavorite({ id: 'fav-3', widgetId: 'w2', uploadedIconMime: null }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||||
|
|
||||||
|
const abs1 = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
|
||||||
|
const abs2 = favoriteIconAbsolutePath('user-1', 'fav-2', 'image/svg+xml') as string;
|
||||||
|
for (const absolute of [abs1, abs2]) {
|
||||||
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||||
|
fs.writeFileSync(absolute, Buffer.from([1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.deleteDashboard('d1', 'user-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(fs.existsSync(abs1)).toBe(false);
|
||||||
|
expect(fs.existsSync(abs2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Reiter ohne Widgets loest keinen Dateizugriff aus', async () => {
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
|
||||||
|
});
|
||||||
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||||
|
|
||||||
|
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'd1' });
|
||||||
|
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
|
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma, Role } from '@prisma/client';
|
import { Prisma, Role } from '@prisma/client';
|
||||||
|
import { removeFavoriteIconFileBestEffort } from '../favorites/favorite-icon-files';
|
||||||
import { ModuleAccessService } from '../module-registry/module-access.service';
|
import { ModuleAccessService } from '../module-registry/module-access.service';
|
||||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -91,11 +93,40 @@ const DEFAULT_SEARCH_PROVIDERS = [
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
|
private readonly logger = new Logger(DashboardService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly moduleAccessService: ModuleAccessService,
|
private readonly moduleAccessService: ModuleAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T-LRR-07 (quick-260923-lrr, Restrisiko aus dem Favoriten-Plan
|
||||||
|
* geschlossen): loescht ein Widget seine `FavoriteLink`-Zeilen ueber die
|
||||||
|
* Datenbank-Kaskade (`onDelete: Cascade` auf `FavoriteLink.widgetId`),
|
||||||
|
* OHNE `FavoritesService` zu durchlaufen — dessen Datei-Aufraeumung in
|
||||||
|
* `remove()` greift hier also nicht. Diese Hilfsfunktion entfernt die
|
||||||
|
* Symboldateien der betroffenen Favoriten NACHTRAEGLICH, best effort
|
||||||
|
* (Muster T-HK4-04): ein Dateifehler wird protokolliert und geschluckt,
|
||||||
|
* er darf das Loeschen des Widgets/Reiters nie verhindern oder
|
||||||
|
* zuruecknehmen — deshalb laeuft dieser Aufruf immer NACH der
|
||||||
|
* erfolgreichen Datenbankoperation, nie innerhalb ihrer Transaktion.
|
||||||
|
*/
|
||||||
|
private async cleanUpFavoriteIconFiles(
|
||||||
|
userId: string,
|
||||||
|
rows: Array<{ id: string; uploadedIconMime: string | null }>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.uploadedIconMime === null) continue;
|
||||||
|
const removed = await removeFavoriteIconFileBestEffort(userId, row.id, row.uploadedIconMime);
|
||||||
|
if (!removed) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Symboldatei des kaskadiert geloeschten Favoriten ${row.id} konnte nicht entfernt werden (T-LRR-07)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
|
* Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
|
||||||
* Benutzers, nach `position` aufsteigend — Position 0 ist der Standard
|
* Benutzers, nach `position` aufsteigend — Position 0 ist der Standard
|
||||||
@@ -243,7 +274,25 @@ export class DashboardService {
|
|||||||
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
|
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
// T-LRR-07: VOR der Kaskade merken, welche Favoriten dieses Reiters ein
|
||||||
|
// eigenes hochgeladenes Symbol tragen — siehe `cleanUpFavoriteIconFiles`.
|
||||||
|
// Nur ein Lesezugriff, kein Schreiben; laeuft ausserhalb der Transaktion
|
||||||
|
// unten, weil die Dateiraeumung selbst NICHT transaktional sein muss
|
||||||
|
// (und best effort niemals einen Rollback ausloesen darf).
|
||||||
|
const widgetsOnTab = await tenantPrisma.widgetInstance.findMany({
|
||||||
|
where: { dashboardId: id, userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const widgetIds = widgetsOnTab.map((w: { id: string }) => w.id);
|
||||||
|
const iconRows =
|
||||||
|
widgetIds.length === 0
|
||||||
|
? []
|
||||||
|
: await tenantPrisma.favoriteLink.findMany({
|
||||||
|
where: { widgetId: { in: widgetIds }, userId, uploadedIconMime: { not: null } },
|
||||||
|
select: { id: true, uploadedIconMime: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||||
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
|
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
|
||||||
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
||||||
await tx.dashboard.deleteMany({ where: { id, userId } });
|
await tx.dashboard.deleteMany({ where: { id, userId } });
|
||||||
@@ -262,6 +311,10 @@ export class DashboardService {
|
|||||||
|
|
||||||
return { id };
|
return { id };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.cleanUpFavoriteIconFiles(userId, iconRows);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -505,6 +558,11 @@ export class DashboardService {
|
|||||||
* Verifies ownership by userId before deleting (T-05-01) — same real
|
* Verifies ownership by userId before deleting (T-05-01) — same real
|
||||||
* ownership check as `updateWidgetConfig` above, same reasoning: both
|
* ownership check as `updateWidgetConfig` above, same reasoning: both
|
||||||
* queries run over the SAME bound client and tenant id.
|
* queries run over the SAME bound client and tenant id.
|
||||||
|
*
|
||||||
|
* T-LRR-07 (quick-260923-lrr): dieselbe Kaskade wie in `deleteDashboard`
|
||||||
|
* trifft hier ein einzelnes Widget — vor dem Loeschen werden dessen
|
||||||
|
* Favoriten mit hochgeladenem Symbol gemerkt, danach werden ihre Dateien
|
||||||
|
* best effort entfernt (siehe `cleanUpFavoriteIconFiles`).
|
||||||
*/
|
*/
|
||||||
async removeWidget(id: string, userId: string, tenantId: string) {
|
async removeWidget(id: string, userId: string, tenantId: string) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -518,9 +576,18 @@ export class DashboardService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tenantPrisma.widgetInstance.delete({
|
const iconRows = await tenantPrisma.favoriteLink.findMany({
|
||||||
|
where: { widgetId: id, userId, uploadedIconMime: { not: null } },
|
||||||
|
select: { id: true, uploadedIconMime: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tenantPrisma.widgetInstance.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.cleanUpFavoriteIconFiles(userId, iconRows);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Search Providers (05-02, D-15) ---
|
// --- Search Providers (05-02, D-15) ---
|
||||||
|
|||||||
@@ -21,12 +21,18 @@ describe('widget-module-map (quick-260922-m1h)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => {
|
// quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel; alle
|
||||||
for (const type of WIDGET_TYPES) {
|
// uebrigen bleiben Plattform-Kacheln ohne Modulbezug.
|
||||||
|
it('nur proxmox traegt einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => {
|
||||||
|
for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox')) {
|
||||||
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
|
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("die Proxmox-Kachel gehoert zum Modul 'proxmox' (T-I8V-01)", () => {
|
||||||
|
expect(getModuleSlugForWidgetType('proxmox')).toBe('proxmox');
|
||||||
|
});
|
||||||
|
|
||||||
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
|
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
|
||||||
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,17 +23,16 @@ import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
|
|||||||
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
||||||
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||||
*
|
*
|
||||||
* Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen
|
* Seit quick-260924-i8v steht dort genau ein Eintrag: `proxmox` →
|
||||||
* (clock/search/calendar/note/calculator/favorites/stopwatch/
|
* `proxmox`. Die übrigen neun Widget-Typen (clock/search/calendar/note/
|
||||||
* picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste
|
* calculator/favorites/stopwatch/picture-frame/xframe) sind
|
||||||
* modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein.
|
* Plattform-Widgets ohne Modulbezug.
|
||||||
*/
|
*/
|
||||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
||||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle neun
|
* der Typ kein Modul-Widget ist (alle Typen außer `proxmox`). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||||
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
|
|
||||||
* damit Tests sie gezielt mocken können.
|
* damit Tests sie gezielt mocken können.
|
||||||
*/
|
*/
|
||||||
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
|
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
FAVORITE_ICON_MAX_BYTES,
|
||||||
|
detectFavoriteIconMime,
|
||||||
|
favoriteIconAbsolutePath,
|
||||||
|
favoriteIconExtension,
|
||||||
|
removeFavoriteIconFileBestEffort,
|
||||||
|
resolveFavoriteIconsDir,
|
||||||
|
} from './favorite-icon-files';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* favorite-icon-files.spec — NEU (quick-260923-lrr).
|
||||||
|
*
|
||||||
|
* Erkennung (Muster dashboard-image-rules.spec.ts): PNG/JPEG/GIF/WebP wie
|
||||||
|
* `detectImageMime`, dazu ICO (Kopfstueck, kein CUR) und SVG (Praefix-Form,
|
||||||
|
* kein `<html>` davor). Pfadbildung: Endung aus dem Typ, Segmente nur aus
|
||||||
|
* Buchstaben/Ziffern/Bindestrich, Ergebnis muss im Symbolverzeichnis liegen
|
||||||
|
* (T-LRR-01). `FAVORITE_ICONS_DIR` steuert das Verzeichnis in Tests, wie
|
||||||
|
* `DASHBOARD_IMAGES_DIR` es fuer die Bilderrahmen-Bilder tut.
|
||||||
|
*/
|
||||||
|
function bytes(...parts: (number[] | string)[]): Uint8Array {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const p of parts) {
|
||||||
|
if (typeof p === 'string') {
|
||||||
|
for (const ch of p) out.push(ch.charCodeAt(0));
|
||||||
|
} else {
|
||||||
|
out.push(...p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Uint8Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detectFavoriteIconMime (quick-260923-lrr)', () => {
|
||||||
|
it('PNG/JPEG/GIF/WebP-Signaturen ergeben dieselben Typen wie detectImageMime', () => {
|
||||||
|
expect(
|
||||||
|
detectFavoriteIconMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13])),
|
||||||
|
).toBe('image/png');
|
||||||
|
expect(detectFavoriteIconMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg');
|
||||||
|
expect(detectFavoriteIconMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif');
|
||||||
|
expect(detectFavoriteIconMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe(
|
||||||
|
'image/webp',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Bytes 00 00 01 00 (mindestens 6 Bytes) ergeben image/x-icon', () => {
|
||||||
|
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00, 0x01, 0x00]))).toBe('image/x-icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zu kurzes ICO-Kopfstueck (weniger als 6 Bytes) ergibt null', () => {
|
||||||
|
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00]))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('00 00 02 00 (CUR-Cursor-Datei) ergibt null — nur Typ 1 (ICO) wird erkannt', () => {
|
||||||
|
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gueltige SVG-Formen ergeben image/svg+xml', () => {
|
||||||
|
expect(detectFavoriteIconMime(bytes('<svg xmlns="http://www.w3.org/2000/svg"></svg>'))).toBe(
|
||||||
|
'image/svg+xml',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
detectFavoriteIconMime(bytes('<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"/>')),
|
||||||
|
).toBe('image/svg+xml');
|
||||||
|
// fuehrendes BOM
|
||||||
|
expect(
|
||||||
|
detectFavoriteIconMime(bytes([0xef, 0xbb, 0xbf], '<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
||||||
|
).toBe('image/svg+xml');
|
||||||
|
// fuehrendes Leerzeichen
|
||||||
|
expect(detectFavoriteIconMime(bytes(' <svg></svg>'))).toBe('image/svg+xml');
|
||||||
|
// Kommentar vor <svg
|
||||||
|
expect(
|
||||||
|
detectFavoriteIconMime(bytes('<!-- Kommentar -->\n<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
||||||
|
).toBe('image/svg+xml');
|
||||||
|
// DOCTYPE svg vor <svg
|
||||||
|
expect(
|
||||||
|
detectFavoriteIconMime(
|
||||||
|
bytes(
|
||||||
|
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg></svg>',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe('image/svg+xml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ungueltige Formen ergeben null', () => {
|
||||||
|
expect(detectFavoriteIconMime(bytes('<html><svg></svg></html>'))).toBeNull();
|
||||||
|
expect(detectFavoriteIconMime(bytes('<!DOCTYPE html>\n<html></html>'))).toBeNull();
|
||||||
|
expect(detectFavoriteIconMime(new Uint8Array(0))).toBeNull();
|
||||||
|
expect(detectFavoriteIconMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull();
|
||||||
|
expect(detectFavoriteIconMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull();
|
||||||
|
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('favoriteIconExtension (quick-260923-lrr)', () => {
|
||||||
|
it('bildet die sechs bekannten Typen ab, unbekannter Typ ergibt null', () => {
|
||||||
|
expect(favoriteIconExtension('image/png')).toBe('png');
|
||||||
|
expect(favoriteIconExtension('image/jpeg')).toBe('jpg');
|
||||||
|
expect(favoriteIconExtension('image/gif')).toBe('gif');
|
||||||
|
expect(favoriteIconExtension('image/webp')).toBe('webp');
|
||||||
|
expect(favoriteIconExtension('image/x-icon')).toBe('ico');
|
||||||
|
expect(favoriteIconExtension('image/svg+xml')).toBe('svg');
|
||||||
|
expect(favoriteIconExtension('application/pdf')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveFavoriteIconsDir / favoriteIconAbsolutePath (quick-260923-lrr)', () => {
|
||||||
|
let dir: string;
|
||||||
|
const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-'));
|
||||||
|
process.env.FAVORITE_ICONS_DIR = dir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
if (ORIGINAL_ENV === undefined) {
|
||||||
|
delete process.env.FAVORITE_ICONS_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveFavoriteIconsDir beachtet FAVORITE_ICONS_DIR', () => {
|
||||||
|
expect(resolveFavoriteIconsDir()).toBe(path.resolve(dir));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liegt unter resolveFavoriteIconsDir()/<userId>/<id>.<ext>', () => {
|
||||||
|
const result = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png');
|
||||||
|
expect(result).toBe(path.join(resolveFavoriteIconsDir(), 'user-1', 'fav-1.png'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbekannter Typ ergibt null', () => {
|
||||||
|
expect(favoriteIconAbsolutePath('user-1', 'fav-1', 'application/pdf')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Segmente mit .., /, \\ oder leer ergeben null', () => {
|
||||||
|
expect(favoriteIconAbsolutePath('..', 'fav-1', 'image/png')).toBeNull();
|
||||||
|
expect(favoriteIconAbsolutePath('user-1', '../etc/passwd', 'image/png')).toBeNull();
|
||||||
|
expect(favoriteIconAbsolutePath('a/b', 'fav-1', 'image/png')).toBeNull();
|
||||||
|
expect(favoriteIconAbsolutePath('a\\b', 'fav-1', 'image/png')).toBeNull();
|
||||||
|
expect(favoriteIconAbsolutePath('', 'fav-1', 'image/png')).toBeNull();
|
||||||
|
expect(favoriteIconAbsolutePath('user-1', '', 'image/png')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeFavoriteIconFileBestEffort (quick-260923-lrr, T-LRR-07)', () => {
|
||||||
|
let dir: string;
|
||||||
|
const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-rm-'));
|
||||||
|
process.env.FAVORITE_ICONS_DIR = dir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
if (ORIGINAL_ENV === undefined) {
|
||||||
|
delete process.env.FAVORITE_ICONS_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(path.join(dir, 'user-1'), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt eine vorhandene Datei und liefert true', async () => {
|
||||||
|
const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png');
|
||||||
|
expect(absolute).not.toBeNull();
|
||||||
|
fs.mkdirSync(path.dirname(absolute as string), { recursive: true });
|
||||||
|
fs.writeFileSync(absolute as string, Buffer.from([1, 2, 3]));
|
||||||
|
|
||||||
|
const result = await removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'image/png');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(fs.existsSync(absolute as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fehlende Datei -> false, wirft nicht', async () => {
|
||||||
|
await expect(removeFavoriteIconFileBestEffort('user-1', 'fehlt', 'image/png')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbekannter Typ -> false, wirft nicht', async () => {
|
||||||
|
await expect(
|
||||||
|
removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'application/pdf'),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Grenzen (quick-260923-lrr)', () => {
|
||||||
|
it('FAVORITE_ICON_MAX_BYTES ist 512 KiB', () => {
|
||||||
|
expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { detectImageMime } from '../dashboard/dashboard-image-rules';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* favorite-icon-files — reine Regeln und Ablage-Hilfen fuer ein
|
||||||
|
* hochgeladenes Favoriten-Symbol (quick-260923-lrr). Kein Nest, kein
|
||||||
|
* Prisma: Grenzen, Erkennung und Pfadbildung, damit Dienst und Controller
|
||||||
|
* dieselben Werte anwenden und die Erkennung direkt an den Bytes testbar
|
||||||
|
* ist — Muster `dashboard-image-rules.ts` (quick-260921-pi9).
|
||||||
|
*
|
||||||
|
* Warum ZUSAETZLICH ICO und SVG (ueber die vier Typen aus
|
||||||
|
* `detectImageMime` hinaus): Favicons liegen haeufig als `.ico` vor, und
|
||||||
|
* ein selbst gezeichnetes Symbol oft als `.svg`. Beide Formate haben keine
|
||||||
|
* fuehrende Signatur wie PNG/JPEG/GIF/WebP im klassischen Sinn — ICO traegt
|
||||||
|
* nur ein vier Byte langes Kopfstueck (Typ-Feld `0x0001`, NICHT `0x0002` =
|
||||||
|
* CUR-Cursor-Dateien, die deshalb bewusst NICHT erkannt werden), SVG ist
|
||||||
|
* Text und wird ueber eine Praefix-Pruefung erkannt (XML-Deklaration,
|
||||||
|
* Kommentare, ein optionales DOCTYPE mit Wurzel `svg`, dann `<svg` selbst).
|
||||||
|
*
|
||||||
|
* Wie bei `dashboard-image-rules.ts` (T-PI9-01/T-PI9-08): was hier NICHT
|
||||||
|
* erkannt wird, kommt nicht auf die Platte — und der erkannte Typ ist
|
||||||
|
* zugleich der Typ, mit dem `GET /favorites/:id/icon` spaeter antwortet.
|
||||||
|
*
|
||||||
|
* Bewusst KEIN `file-type`-Paket (Muster T-PI9-SC): sechs feste Regeln sind
|
||||||
|
* eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hoechstgroesse je Datei: 512 KiB (multer `limits.fileSize` an der Route, zweites Netz im Dienst). */
|
||||||
|
export const FAVORITE_ICON_MAX_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
export type FavoriteIconMime =
|
||||||
|
| 'image/png'
|
||||||
|
| 'image/jpeg'
|
||||||
|
| 'image/gif'
|
||||||
|
| 'image/webp'
|
||||||
|
| 'image/x-icon'
|
||||||
|
| 'image/svg+xml';
|
||||||
|
|
||||||
|
/** ICO-Kopfstueck: Reserviert=0, Typ=1 (Icon). Typ=2 waere CUR (Cursor) — bewusst NICHT erkannt. */
|
||||||
|
const ICO_HEADER = [0x00, 0x00, 0x01, 0x00];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Praefix-Form eines SVG-Dokuments: optionales BOM/Leerraum, optionale
|
||||||
|
* XML-Deklaration, beliebig viele Kommentare und/oder ein DOCTYPE mit
|
||||||
|
* Wurzel `svg` (in beliebiger Reihenfolge/Wiederholung), danach `<svg`
|
||||||
|
* direkt gefolgt von Leerraum, `>` oder `/`. Alles andere (z. B. `<html>`
|
||||||
|
* vor `<svg>`, ein DOCTYPE auf `html`) ergibt kein Treffer.
|
||||||
|
*/
|
||||||
|
const SVG_PREFIX_RE =
|
||||||
|
/^(?:<\?xml[^>]*\?>\s*)?(?:(?:<!--[\s\S]*?-->|<!DOCTYPE\s+svg\b[^>]*>)\s*)*<svg[\s>/]/i;
|
||||||
|
|
||||||
|
function startsWithIcoHeader(buffer: Uint8Array): boolean {
|
||||||
|
if (buffer.length < 6) return false;
|
||||||
|
for (let i = 0; i < ICO_HEADER.length; i++) {
|
||||||
|
if (buffer[i] !== ICO_HEADER[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die ersten 4096 Bytes als UTF-8 gegen `SVG_PREFIX_RE`. Ein
|
||||||
|
* fuehrendes BOM oder Leerraum vor der eigentlichen Deklaration wird
|
||||||
|
* entfernt, bevor die Praefix-Form geprueft wird. Wirft nie — ein Puffer,
|
||||||
|
* der sich nicht als UTF-8 lesen laesst, ist schlicht kein SVG.
|
||||||
|
*/
|
||||||
|
function looksLikeSvg(buffer: Uint8Array): boolean {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = Buffer.from(buffer.subarray(0, 4096)).toString('utf-8');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
text = text.replace(/^/, '').replace(/^\s+/, '');
|
||||||
|
return SVG_PREFIX_RE.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt PNG, JPEG, GIF, WebP (ueber `detectImageMime`), ICO und SVG an
|
||||||
|
* den Bytes; alles andere ergibt `null`. Wirft nie.
|
||||||
|
*/
|
||||||
|
export function detectFavoriteIconMime(buffer: Uint8Array): FavoriteIconMime | null {
|
||||||
|
const known = detectImageMime(buffer);
|
||||||
|
if (known !== null) return known;
|
||||||
|
if (startsWithIcoHeader(buffer)) return 'image/x-icon';
|
||||||
|
if (looksLikeSvg(buffer)) return 'image/svg+xml';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */
|
||||||
|
export function favoriteIconExtension(mime: string): string | null {
|
||||||
|
switch (mime) {
|
||||||
|
case 'image/png':
|
||||||
|
return 'png';
|
||||||
|
case 'image/jpeg':
|
||||||
|
return 'jpg';
|
||||||
|
case 'image/gif':
|
||||||
|
return 'gif';
|
||||||
|
case 'image/webp':
|
||||||
|
return 'webp';
|
||||||
|
case 'image/x-icon':
|
||||||
|
return 'ico';
|
||||||
|
case 'image/svg+xml':
|
||||||
|
return 'svg';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loest das Symbolverzeichnis relativ zur Monorepo-Wurzel auf — Muster
|
||||||
|
* `resolveDashboardImagesDir()` (dashboard-images.service.ts): zur Laufzeit
|
||||||
|
* ist `__dirname` = apps/api/dist/favorites/, also vier Ebenen hoch.
|
||||||
|
*
|
||||||
|
* `FAVORITE_ICONS_DIR` ist ein Testschalter und im Betrieb nie gesetzt; die
|
||||||
|
* Tests zeigen damit auf ein Wegwerfverzeichnis unter `os.tmpdir()`.
|
||||||
|
*/
|
||||||
|
export function resolveFavoriteIconsDir(): string {
|
||||||
|
const override = process.env.FAVORITE_ICONS_DIR;
|
||||||
|
if (override !== undefined && override !== '') {
|
||||||
|
return path.resolve(override);
|
||||||
|
}
|
||||||
|
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'favorite-icons');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur Buchstaben, Ziffern und Bindestrich — kein Segment aus der Anfrage geht ungeprueft in einen Pfad. */
|
||||||
|
const SAFE_SEGMENT_RE = /^[A-Za-z0-9-]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet den absoluten Ablagepfad `<resolveFavoriteIconsDir()>/<userId>/<id>.<ext>`.
|
||||||
|
* `null`, wenn der Typ unbekannt ist, `userId`/`id` nicht ausschliesslich aus
|
||||||
|
* Buchstaben/Ziffern/Bindestrich bestehen (schliesst `..`, `/`, `\`, leere
|
||||||
|
* Segmente aus), oder das Ergebnis nicht unter dem Symbolverzeichnis liegt
|
||||||
|
* (T-LRR-01). Kein Byte aus der Anfrage — insbesondere nicht `originalname`
|
||||||
|
* — geht je in diesen Pfad ein: `id` ist die Zeilen-UUID, `ext` kommt aus
|
||||||
|
* dem an den Bytes ERKANNTEN Typ.
|
||||||
|
*/
|
||||||
|
export function favoriteIconAbsolutePath(userId: string, id: string, mime: string): string | null {
|
||||||
|
const ext = favoriteIconExtension(mime);
|
||||||
|
if (ext === null) return null;
|
||||||
|
if (!SAFE_SEGMENT_RE.test(userId) || !SAFE_SEGMENT_RE.test(id)) return null;
|
||||||
|
|
||||||
|
const base = resolveFavoriteIconsDir();
|
||||||
|
const absolute = path.resolve(base, userId, `${id}.${ext}`);
|
||||||
|
if (absolute !== base && !absolute.startsWith(base + path.sep)) return null;
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt die Symboldatei eines hochgeladenen Favoriten-Symbols, falls sie
|
||||||
|
* existiert — best effort, wirft NIE (Muster T-HK4-04: eine Dateileiche ist
|
||||||
|
* harmloser als eine haengende Operation). Fuer Aufrufer ausserhalb von
|
||||||
|
* `FavoritesService`, deren Vorgang (Loeschen ueber Datenbank-Kaskade,
|
||||||
|
* T-LRR-07) nicht an einem Dateifehler scheitern darf: `DashboardService`
|
||||||
|
* beim Loeschen eines Widgets oder eines ganzen Reiters, siehe dortigen
|
||||||
|
* Kommentar. Liefert `true`, wenn eine Datei tatsaechlich entfernt wurde
|
||||||
|
* (fuer eine Protokollzeile beim Aufrufer), sonst `false` — auch das ist
|
||||||
|
* kein Fehlerzustand: die Datei kann bereits gefehlt haben.
|
||||||
|
*/
|
||||||
|
export async function removeFavoriteIconFileBestEffort(
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
mime: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const absolute = favoriteIconAbsolutePath(userId, id, mime);
|
||||||
|
if (absolute === null) return false;
|
||||||
|
try {
|
||||||
|
await fs.unlink(absolute);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen der
|
||||||
|
* Upload-Route (T-LRR-04) am AUFRUF pruefbar sind — Muster
|
||||||
|
* `dashboard-images.controller.spec.ts` (quick-260921-pi9).
|
||||||
|
*/
|
||||||
|
const { fileInterceptorMock } = vi.hoisted(() => ({
|
||||||
|
fileInterceptorMock: vi.fn(() => class FakeInterceptor {}),
|
||||||
|
}));
|
||||||
|
vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock }));
|
||||||
|
|
||||||
|
import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files';
|
||||||
|
import { FavoritesController } from './favorites.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* favorites.controller.spec — NEU (quick-260923-lrr).
|
||||||
|
*
|
||||||
|
* Fuenf Bereiche: Interceptor-Grenzen der Upload-Route (Feld `icon`, 512 KB,
|
||||||
|
* genau eine Datei), die Header der Symbol-Antwort (jetzt `private` statt
|
||||||
|
* `public`, 260923-lrr), Weitergabe von Mandant/Benutzer ausschliesslich aus
|
||||||
|
* dem Sitzungsnachweis (`req.tenantId` VOR `req.user.tenantId`, Muster
|
||||||
|
* `extractContext`), Abweisung ohne Mandantenkontext, und die
|
||||||
|
* Routen-Metadaten der zwei neuen Wege.
|
||||||
|
*/
|
||||||
|
function makeService() {
|
||||||
|
return {
|
||||||
|
list: vi.fn(async () => []),
|
||||||
|
create: vi.fn(async () => ({ id: 'new' })),
|
||||||
|
reorder: vi.fn(async () => []),
|
||||||
|
getIconBytes: vi.fn(async () => ({
|
||||||
|
contentType: 'image/png',
|
||||||
|
body: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||||
|
})),
|
||||||
|
uploadIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({
|
||||||
|
id,
|
||||||
|
uploadedIconMime: 'image/png',
|
||||||
|
iconVersion: 1,
|
||||||
|
})),
|
||||||
|
removeUploadedIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({
|
||||||
|
id,
|
||||||
|
uploadedIconMime: null,
|
||||||
|
iconVersion: 2,
|
||||||
|
})),
|
||||||
|
update: vi.fn(async () => ({})),
|
||||||
|
remove: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRes() {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
setHeader: vi.fn((name: string, value: string) => {
|
||||||
|
headers[name] = value;
|
||||||
|
}),
|
||||||
|
send: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReq(overrides: Partial<{ tenantId: string | null; user: any }> = {}) {
|
||||||
|
return {
|
||||||
|
tenantId: overrides.tenantId,
|
||||||
|
user: overrides.user ?? { id: 'user-1', tenantId: 'tenant-from-user' },
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FavoritesController (quick-260923-lrr)', () => {
|
||||||
|
it('Test 1: FileInterceptor wird mit dem Feld icon und { limits: { fileSize: 512 * 1024, files: 1 } } aufgerufen', () => {
|
||||||
|
expect(fileInterceptorMock).toHaveBeenCalledWith('icon', {
|
||||||
|
limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 },
|
||||||
|
});
|
||||||
|
expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2: getIcon setzt Content-Type aus dem Dienst, Cache-Control private, nosniff, CSP sandbox', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new FavoritesController(service as never);
|
||||||
|
const res = makeRes();
|
||||||
|
|
||||||
|
await controller.getIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), res as never);
|
||||||
|
|
||||||
|
expect(service.getIconBytes).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1');
|
||||||
|
expect(res.headers['Content-Type']).toBe('image/png');
|
||||||
|
expect(res.headers['Cache-Control']).toBe('private, max-age=86400');
|
||||||
|
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
|
||||||
|
expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3: uploadIcon reicht tenantId aus req.tenantId (VOR req.user.tenantId) und userId aus req.user.id an den Dienst', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new FavoritesController(service as never);
|
||||||
|
const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 };
|
||||||
|
|
||||||
|
await controller.uploadIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), file);
|
||||||
|
|
||||||
|
expect(service.uploadIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4: uploadIcon faellt auf req.user.tenantId zurueck, wenn req.tenantId fehlt', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new FavoritesController(service as never);
|
||||||
|
const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 };
|
||||||
|
|
||||||
|
await controller.uploadIcon('fav-1', makeReq({ tenantId: undefined }), file);
|
||||||
|
|
||||||
|
expect(service.uploadIcon).toHaveBeenCalledWith('tenant-from-user', 'fav-1', 'user-1', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5: removeUploadedIcon reicht tenantId/userId ebenso weiter', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new FavoritesController(service as never);
|
||||||
|
|
||||||
|
await controller.removeUploadedIcon('fav-1', makeReq({ tenantId: 'tenant-1' }));
|
||||||
|
|
||||||
|
expect(service.removeUploadedIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 6: ohne Mandantenkontext -> ForbiddenException, Dienst wird NICHT aufgerufen', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new FavoritesController(service as never);
|
||||||
|
const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.uploadIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } }), file),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
expect(service.uploadIcon).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.removeUploadedIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } })),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
expect(service.removeUploadedIcon).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 7: POST :id/icon und DELETE :id/icon sind als Routen-Metadaten vorhanden', () => {
|
||||||
|
const proto = FavoritesController.prototype;
|
||||||
|
expect(Reflect.getMetadata('path', proto.uploadIcon)).toBe(':id/icon');
|
||||||
|
expect(Reflect.getMetadata('method', proto.uploadIcon)).toBe(1); // RequestMethod.POST
|
||||||
|
expect(Reflect.getMetadata('path', proto.removeUploadedIcon)).toBe(':id/icon');
|
||||||
|
expect(Reflect.getMetadata('method', proto.removeUploadedIcon)).toBe(3); // RequestMethod.DELETE
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,12 +12,16 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||||
|
import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files';
|
||||||
import { FavoritesService } from './favorites.service';
|
import { FavoritesService } from './favorites.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,7 +43,10 @@ import { FavoritesService } from './favorites.service';
|
|||||||
* - GET /favorites?widgetId= — list favorites for a widget instance
|
* - GET /favorites?widgetId= — list favorites for a widget instance
|
||||||
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
||||||
* - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd)
|
* - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd)
|
||||||
* - GET /favorites/:id/icon — stream a favorite's stored icon bytes
|
* - GET /favorites/:id/icon — stream a favorite's stored icon bytes (append `?v=<iconVersion>`
|
||||||
|
* client-side to bust the 24h cache after any change to the icon source, 260923-lrr)
|
||||||
|
* - POST /favorites/:id/icon — upload a custom icon (multipart field `icon`, ≤512 KB, 260923-lrr)
|
||||||
|
* - DELETE /favorites/:id/icon — remove a previously uploaded icon (260923-lrr)
|
||||||
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
|
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
|
||||||
* - DELETE /favorites/:id — delete a favorite (ownership verified in service)
|
* - DELETE /favorites/:id — delete a favorite (ownership verified in service)
|
||||||
*/
|
*/
|
||||||
@@ -122,7 +129,12 @@ export class FavoritesController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
res.setHeader('Content-Type', contentType);
|
res.setHeader('Content-Type', contentType);
|
||||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
// 260923-lrr: private statt public — kein gemeinsamer Zwischenspeicher
|
||||||
|
// (Nginx Proxy Manager) haelt benutzerbezogene Symbole vor. Die Adresse
|
||||||
|
// traegt clientseitig `?v=<iconVersion>` (T-LRR-05), damit der lange
|
||||||
|
// 24h-Browser-Zwischenspeicher nach einer Aenderung trotzdem sofort
|
||||||
|
// ungueltig wird.
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=86400');
|
||||||
// 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges
|
// 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges
|
||||||
// Zertifikat. Als <img>-Unterressource ignoriert der Browser diese
|
// Zertifikat. Als <img>-Unterressource ignoriert der Browser diese
|
||||||
// Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne
|
// Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne
|
||||||
@@ -132,6 +144,42 @@ export class FavoritesController {
|
|||||||
res.send(body);
|
res.send(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /favorites/:id/icon — laedt ein eigenes Symbol fuer einen
|
||||||
|
* Favoriten hoch (260923-lrr). Groessengrenze JE ROUTE (Muster
|
||||||
|
* `dashboard-images.controller.ts` T-PI9-02): `FileInterceptor` nimmt
|
||||||
|
* genau eine Datei bis 512 KB; multers `LIMIT_FILE_SIZE` bildet Nest auf
|
||||||
|
* 413 ab. Typ und Besitzpruefung laufen im Dienst (T-LRR-01/T-LRR-03).
|
||||||
|
*/
|
||||||
|
@Post(':id/icon')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('icon', { limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 } }),
|
||||||
|
)
|
||||||
|
async uploadIcon(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@UploadedFile() file?: UploadedFileLike,
|
||||||
|
) {
|
||||||
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
|
|
||||||
|
return this.favoritesService.uploadIcon(tenantId, id, userId, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /favorites/:id/icon — entfernt ein zuvor hochgeladenes Symbol
|
||||||
|
* wieder; die Kachel faellt danach auf `iconUrl` bzw. automatische
|
||||||
|
* Erkennung zurueck (260923-lrr).
|
||||||
|
*/
|
||||||
|
@Delete(':id/icon')
|
||||||
|
async removeUploadedIcon(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
) {
|
||||||
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
|
|
||||||
|
return this.favoritesService.removeUploadedIcon(tenantId, id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async update(
|
async update(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
import * as fs from 'node:fs';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
HttpException,
|
||||||
|
NotFoundException,
|
||||||
|
PayloadTooLargeException,
|
||||||
|
UnprocessableEntityException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { FavoritesService } from './favorites.service';
|
import { FavoritesService } from './favorites.service';
|
||||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
@@ -39,6 +48,9 @@ interface FakeFavoriteRow {
|
|||||||
position: number;
|
position: number;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
|
/** 260923-lrr — Bestandszeilen im Fake bekommen die Vorgabe null/0. */
|
||||||
|
uploadedIconMime?: string | null;
|
||||||
|
iconVersion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FakeWidgetRow {
|
interface FakeWidgetRow {
|
||||||
@@ -71,7 +83,9 @@ function throwP2025(action: 'update' | 'delete'): never {
|
|||||||
* deshalb strukturell nie.
|
* deshalb strukturell nie.
|
||||||
*/
|
*/
|
||||||
function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) {
|
function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) {
|
||||||
const favorites = new Map(favoriteRows.map((f) => [f.id, { ...f }]));
|
const favorites = new Map(
|
||||||
|
favoriteRows.map((f) => [f.id, { uploadedIconMime: null, iconVersion: 0, ...f }]),
|
||||||
|
);
|
||||||
const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }]));
|
const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }]));
|
||||||
const boundCallLog: BoundCall[] = [];
|
const boundCallLog: BoundCall[] = [];
|
||||||
let autoId = favoriteRows.length;
|
let autoId = favoriteRows.length;
|
||||||
@@ -107,7 +121,16 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
|||||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
|
||||||
const id = data.id ?? `fav-${++autoId}`;
|
const id = data.id ?? `fav-${++autoId}`;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const record = { iconUrl: null, position: 0, createdAt: now, updatedAt: now, ...data, id };
|
const record = {
|
||||||
|
iconUrl: null,
|
||||||
|
position: 0,
|
||||||
|
uploadedIconMime: null,
|
||||||
|
iconVersion: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...data,
|
||||||
|
id,
|
||||||
|
};
|
||||||
favorites.set(id, record);
|
favorites.set(id, record);
|
||||||
return record;
|
return record;
|
||||||
},
|
},
|
||||||
@@ -115,7 +138,12 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
|||||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
|
||||||
const row = favorites.get(where.id);
|
const row = favorites.get(where.id);
|
||||||
if (!row || row.tenantId !== tenantId) throwP2025('update');
|
if (!row || row.tenantId !== tenantId) throwP2025('update');
|
||||||
const updated = { ...row, ...data, updatedAt: new Date() };
|
const updated: any = { ...row, ...data, updatedAt: new Date() };
|
||||||
|
// 260923-lrr: `iconVersion: { increment: n }` — Prisma's atomic
|
||||||
|
// increment form, angewendet auf den bisherigen Zaehlerstand.
|
||||||
|
if (data.iconVersion && typeof data.iconVersion === 'object' && 'increment' in data.iconVersion) {
|
||||||
|
updated.iconVersion = (row.iconVersion ?? 0) + data.iconVersion.increment;
|
||||||
|
}
|
||||||
favorites.set(where.id, updated);
|
favorites.set(where.id, updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
@@ -625,4 +653,323 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
|
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 260923-lrr: eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe ---
|
||||||
|
|
||||||
|
describe('uploadIcon/removeUploadedIcon/getIconBytes — eigenes Symbol (260923-lrr)', () => {
|
||||||
|
let iconsDir: string;
|
||||||
|
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||||
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||||
|
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
||||||
|
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-svc-'));
|
||||||
|
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||||
|
if (ORIGINAL_DIR_ENV === undefined) {
|
||||||
|
delete process.env.FAVORITE_ICONS_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseRow: FakeFavoriteRow = {
|
||||||
|
id: 'f1',
|
||||||
|
userId: 'user-a1',
|
||||||
|
tenantId: 't1',
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'X',
|
||||||
|
url: 'https://x.invalid',
|
||||||
|
iconUrl: 'https://x.invalid/icon.png',
|
||||||
|
position: 0,
|
||||||
|
uploadedIconMime: null,
|
||||||
|
iconVersion: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fileFor(userId: string, id: string, ext: string): string {
|
||||||
|
return path.join(iconsDir, userId, `${id}.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('uploadIcon', () => {
|
||||||
|
it('PNG: Datei liegt unter <dir>/<userId>/<id>.png mit genau den Bytes, Zeile hat uploadedIconMime image/png und iconVersion +1', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||||
|
|
||||||
|
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||||
|
|
||||||
|
expect(updated.uploadedIconMime).toBe('image/png');
|
||||||
|
expect(updated.iconVersion).toBe(1);
|
||||||
|
const written = fs.readFileSync(fileFor('user-a1', 'f1', 'png'));
|
||||||
|
expect(written.equals(PNG)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Datei -> BadRequestException', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(service.uploadIcon('t1', 'f1', 'user-a1', undefined)).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
const file = { buffer: TEXT, originalname: 'x.txt', mimetype: 'text/plain', size: TEXT.length };
|
||||||
|
|
||||||
|
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
|
||||||
|
expect(prisma.__favorites.get('f1').uploadedIconMime).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz)', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
const big = Buffer.concat([PNG, Buffer.alloc(513 * 1024)]);
|
||||||
|
const file = { buffer: big, originalname: 'x.png', mimetype: 'image/png', size: big.length };
|
||||||
|
|
||||||
|
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
|
||||||
|
PayloadTooLargeException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben', async () => {
|
||||||
|
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||||
|
|
||||||
|
const prismaForeignUser = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||||
|
const serviceForeignUser = new FavoritesService(
|
||||||
|
prismaForeignUser as any,
|
||||||
|
makeIconDiscovery() as any,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
serviceForeignUser.uploadIcon('t1', 'f1', 'user-a1', file),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
|
||||||
|
const prismaForeignTenant = makeFakePrisma([baseRow]);
|
||||||
|
const serviceForeignTenant = new FavoritesService(
|
||||||
|
prismaForeignTenant as any,
|
||||||
|
makeIconDiscovery() as any,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
serviceForeignTenant.uploadIcon('t2', 'f1', 'user-a1', file),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
|
||||||
|
const prismaUnknown = makeFakePrisma([]);
|
||||||
|
const serviceUnknown = new FavoritesService(prismaUnknown as any, makeIconDiscovery() as any);
|
||||||
|
await expect(
|
||||||
|
serviceUnknown.uploadIcon('t1', 'fehlt', 'user-a1', file),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(iconsDir, 'user-a2'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
const pngFile = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||||
|
const svgFile = { buffer: SVG, originalname: 'x.svg', mimetype: 'image/svg+xml', size: SVG.length };
|
||||||
|
|
||||||
|
await service.uploadIcon('t1', 'f1', 'user-a1', pngFile);
|
||||||
|
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', svgFile);
|
||||||
|
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'svg'))).toBe(true);
|
||||||
|
expect(updated.uploadedIconMime).toBe('image/svg+xml');
|
||||||
|
expect(updated.iconVersion).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getIconBytes — Vorrang des hochgeladenen Symbols', () => {
|
||||||
|
it('hochgeladenes Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||||
|
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||||
|
|
||||||
|
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
expect((result.body as Buffer).equals(PNG)).toBe(true);
|
||||||
|
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Typ gesetzt, aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck', async () => {
|
||||||
|
const prisma = makeFakePrisma([{ ...baseRow, uploadedIconMime: 'image/png' }]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl);
|
||||||
|
expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Typ gesetzt, Datei fehlt, KEINE iconUrl -> NotFoundException', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{ ...baseRow, iconUrl: null, uploadedIconMime: 'image/png' },
|
||||||
|
]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow(
|
||||||
|
'FavoriteLink not found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeUploadedIcon', () => {
|
||||||
|
it('Datei weg, uploadedIconMime null, iconVersion +1', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||||
|
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||||
|
|
||||||
|
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(updated.uploadedIconMime).toBeNull();
|
||||||
|
expect(updated.iconVersion).toBe(2);
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne vorhandenen Upload -> Zeile unveraendert, keine Erhoehung', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(updated.iconVersion).toBe(0);
|
||||||
|
expect(updated.uploadedIconMime).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremder Benutzer -> NotFoundException', async () => {
|
||||||
|
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(service.removeUploadedIcon('t1', 'f1', 'user-a1')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove() mit hochgeladenem Symbol', () => {
|
||||||
|
it('Zeile und Datei weg', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||||
|
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||||
|
|
||||||
|
await service.remove('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler beim Datei-Entfernen wird geschluckt — das Loeschen der Zeile gelingt trotzdem', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||||
|
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||||
|
// Datei vorab entfernen, damit fs.unlink() im Dienst scheitert.
|
||||||
|
fs.unlinkSync(fileFor('user-a1', 'f1', 'png'));
|
||||||
|
|
||||||
|
await expect(service.remove('t1', 'f1', 'user-a1')).resolves.toBeUndefined();
|
||||||
|
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => {
|
||||||
|
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => {
|
||||||
|
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||||
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
fetchIconBytes: vi.fn(async () => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'user-a1', {
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'X',
|
||||||
|
url: 'https://x.invalid',
|
||||||
|
iconUrl: 'https://x.invalid/logo.png',
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(UnprocessableEntityException);
|
||||||
|
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||||
|
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
|
||||||
|
expect(prisma.__favorites.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseRow: FakeFavoriteRow = {
|
||||||
|
id: 'f1',
|
||||||
|
userId: 'user-a1',
|
||||||
|
tenantId: 't1',
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'Alt',
|
||||||
|
url: 'https://alt.invalid',
|
||||||
|
iconUrl: 'https://alt.invalid/icon.png',
|
||||||
|
position: 0,
|
||||||
|
uploadedIconMime: null,
|
||||||
|
iconVersion: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
fetchIconBytes: vi.fn(async () => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any),
|
||||||
|
).rejects.toThrow(UnprocessableEntityException);
|
||||||
|
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||||
|
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||||
|
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: baseRow.iconUrl } as any);
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||||
|
expect(updated.iconVersion).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update nur Titel: keine Erhoehung', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu' } as any);
|
||||||
|
|
||||||
|
expect(updated.iconVersion).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update mit neuer, erreichbarer iconUrl: iconVersion +1', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||||
|
iconUrl: 'https://neu.invalid/icon.png',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||||
|
expect(updated.iconVersion).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
PayloadTooLargeException,
|
||||||
|
UnprocessableEntityException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||||
|
import {
|
||||||
|
FAVORITE_ICON_MAX_BYTES,
|
||||||
|
detectFavoriteIconMime,
|
||||||
|
favoriteIconAbsolutePath,
|
||||||
|
} from './favorite-icon-files';
|
||||||
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,9 +62,31 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
|||||||
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
|
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
|
||||||
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
|
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
|
||||||
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
|
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
|
||||||
|
*
|
||||||
|
* 260923-lrr — eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe:
|
||||||
|
* - Ablage nach dem Muster `dashboard-images.service.ts` (quick-260922-hk4):
|
||||||
|
* `user-files/favorite-icons/<userId>/<id>.<ext>`, Dateiname IMMER aus
|
||||||
|
* Zeilen-UUID und ERKANNTEM Typ, nie aus der Anfrage (T-LRR-01).
|
||||||
|
* - Vorrang: `getIconBytes` liefert bei gesetztem `uploadedIconMime` immer
|
||||||
|
* die Datei, nie `fetchIconBytes` — fehlt die Datei trotz gesetztem Typ,
|
||||||
|
* wird protokolliert und auf `iconUrl` zurueckgefallen.
|
||||||
|
* - `iconVersion` steigt (Prisma `{ increment: 1 }`) genau dann, wenn sich
|
||||||
|
* die angezeigte Quelle aendert (neue, abweichende `iconUrl`; Upload;
|
||||||
|
* Entfernen des Uploads) — nicht bei Titel/Position/unveraenderter URL.
|
||||||
|
* - Halbe Zustaende (T-LRR-08, Muster T-HK4-04): Upload schreibt zuerst die
|
||||||
|
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
|
||||||
|
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
|
||||||
|
* Dateifehler wird protokolliert und geschluckt.
|
||||||
|
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
|
||||||
|
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
|
||||||
|
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
|
||||||
|
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
|
||||||
|
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FavoritesService {
|
export class FavoritesService {
|
||||||
|
private readonly logger = new Logger(FavoritesService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly iconDiscovery: IconDiscoveryService,
|
private readonly iconDiscovery: IconDiscoveryService,
|
||||||
@@ -72,11 +106,31 @@ export class FavoritesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
|
||||||
|
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
|
||||||
|
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
|
||||||
|
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
|
||||||
|
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
|
||||||
|
* sich etwas ueber die gepruefte Adresse ablesen liesse.
|
||||||
|
*/
|
||||||
|
private async assertIconUrlLoadable(iconUrl: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.iconDiscovery.fetchIconBytes(iconUrl);
|
||||||
|
} catch {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new favorite link.
|
* Creates a new favorite link.
|
||||||
* Verifies the target widget belongs to the caller BEFORE any icon
|
* Verifies the target widget belongs to the caller BEFORE any icon
|
||||||
* discovery network call (T-GWH-05).
|
* discovery network call (T-GWH-05).
|
||||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||||
|
* If iconUrl IS provided (260923-lrr), it must load successfully or the
|
||||||
|
* create is rejected with 422 — nothing is written on a failed probe.
|
||||||
*/
|
*/
|
||||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -99,8 +153,11 @@ export class FavoritesService {
|
|||||||
const url = normalizeUrl(dto.url);
|
const url = normalizeUrl(dto.url);
|
||||||
let iconUrl = dto.iconUrl ?? null;
|
let iconUrl = dto.iconUrl ?? null;
|
||||||
|
|
||||||
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
if (iconUrl) {
|
||||||
if (!iconUrl) {
|
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
|
||||||
|
await this.assertIconUrlLoadable(iconUrl);
|
||||||
|
} else {
|
||||||
|
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
||||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +178,11 @@ export class FavoritesService {
|
|||||||
* Updates an existing favorite.
|
* Updates an existing favorite.
|
||||||
* Verifies userId ownership before applying changes (T-08-06).
|
* Verifies userId ownership before applying changes (T-08-06).
|
||||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||||
|
*
|
||||||
|
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||||||
|
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
|
||||||
|
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
|
||||||
|
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
|
||||||
*/
|
*/
|
||||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -142,6 +204,10 @@ export class FavoritesService {
|
|||||||
|
|
||||||
if ('iconUrl' in dto) {
|
if ('iconUrl' in dto) {
|
||||||
if (dto.iconUrl) {
|
if (dto.iconUrl) {
|
||||||
|
if (dto.iconUrl !== link.iconUrl) {
|
||||||
|
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
|
||||||
|
await this.assertIconUrlLoadable(dto.iconUrl);
|
||||||
|
}
|
||||||
// Explicit icon URL supplied — respect it as-is.
|
// Explicit icon URL supplied — respect it as-is.
|
||||||
data.iconUrl = dto.iconUrl;
|
data.iconUrl = dto.iconUrl;
|
||||||
} else {
|
} else {
|
||||||
@@ -153,6 +219,10 @@ export class FavoritesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
|
||||||
|
data.iconVersion = { increment: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
return tenantPrisma.favoriteLink.update({
|
return tenantPrisma.favoriteLink.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -162,6 +232,10 @@ export class FavoritesService {
|
|||||||
/**
|
/**
|
||||||
* Deletes a favorite link.
|
* Deletes a favorite link.
|
||||||
* Verifies userId ownership before deleting (T-08-06).
|
* Verifies userId ownership before deleting (T-08-06).
|
||||||
|
* 260923-lrr: hat die Zeile ein hochgeladenes Symbol, wird dessen Datei
|
||||||
|
* NACH dem Loeschen der Zeile entfernt — ein Dateifehler wird
|
||||||
|
* protokolliert und geschluckt (Muster T-HK4-04), das Loeschen der Zeile
|
||||||
|
* gelingt in jedem Fall.
|
||||||
*/
|
*/
|
||||||
async remove(tenantId: string, id: string, userId: string) {
|
async remove(tenantId: string, id: string, userId: string) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -172,6 +246,10 @@ export class FavoritesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||||||
|
|
||||||
|
if (link.uploadedIconMime !== null) {
|
||||||
|
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'geloeschten');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,16 +320,148 @@ export class FavoritesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt ein eigenes Symbol fuer einen Favoriten an (260923-lrr). Reihenfolge
|
||||||
|
* (Muster T-HK4-04): Groesse/Typ zuerst (kein DB-Zugriff bei offensichtlich
|
||||||
|
* ungueltiger Datei), dann Besitzpruefung, dann Datei, dann Zeile —
|
||||||
|
* scheitert die Zeile, wird eine neu geschriebene Datei zurueckgenommen.
|
||||||
|
* Hatte der Favorit vorher ein Symbol MIT ANDERER Endung, wird die alte
|
||||||
|
* Datei danach entfernt (Fehler protokolliert und geschluckt).
|
||||||
|
*/
|
||||||
|
async uploadIcon(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
file: UploadedFileLike | undefined,
|
||||||
|
) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
|
||||||
|
}
|
||||||
|
if (file.buffer.length > FAVORITE_ICON_MAX_BYTES) {
|
||||||
|
// Zweites Netz — multer (`limits.fileSize` an der Route) faengt das
|
||||||
|
// in der Regel bereits vorher ab.
|
||||||
|
throw new PayloadTooLargeException(
|
||||||
|
'Die Datei ist zu groß – erlaubt sind höchstens 512 KB.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = detectFavoriteIconMime(file.buffer);
|
||||||
|
if (mime === null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||||
|
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
|
||||||
|
throw new NotFoundException('FavoriteLink not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = favoriteIconAbsolutePath(link.userId, link.id, mime);
|
||||||
|
if (absolute === null) {
|
||||||
|
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||||
|
await fs.writeFile(absolute, file.buffer);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Symbol des Favoriten ${id} konnte nicht gespeichert werden: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousMime = link.uploadedIconMime;
|
||||||
|
let updated: typeof link;
|
||||||
|
try {
|
||||||
|
updated = await tenantPrisma.favoriteLink.update({
|
||||||
|
where: { id },
|
||||||
|
data: { uploadedIconMime: mime, iconVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Ruecknahme (T-LRR-08): die neu geschriebene Datei nur entfernen,
|
||||||
|
// wenn sie einen ANDEREN Pfad als eine vorhandene alte Datei traegt —
|
||||||
|
// sonst wuerde ein fehlgeschlagenes Update auf demselben Typ die
|
||||||
|
// weiterhin gueltige alte Datei loeschen.
|
||||||
|
if (previousMime !== mime) {
|
||||||
|
await fs.unlink(absolute).catch(() => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousMime !== null && previousMime !== mime) {
|
||||||
|
await this.removeIconFile(id, link.userId, previousMime, 'alte');
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt ein hochgeladenes Symbol wieder (260923-lrr). Ohne gesetztes
|
||||||
|
* `uploadedIconMime` liefert die Methode die Zeile unveraendert — kein
|
||||||
|
* unnoetiger Versionssprung. Die Datei wird NACH dem Update entfernt,
|
||||||
|
* ein Fehler dabei wird protokolliert und geschluckt (Muster T-HK4-04).
|
||||||
|
*/
|
||||||
|
async removeUploadedIcon(tenantId: string, id: string, userId: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||||
|
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
|
||||||
|
throw new NotFoundException('FavoriteLink not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.uploadedIconMime === null) {
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousMime = link.uploadedIconMime;
|
||||||
|
const updated = await tenantPrisma.favoriteLink.update({
|
||||||
|
where: { id },
|
||||||
|
data: { uploadedIconMime: null, iconVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.removeIconFile(id, link.userId, previousMime, 'entfernte');
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort-Entfernung einer Symboldatei — protokolliert, wirft nie (Muster T-HK4-04). */
|
||||||
|
private async removeIconFile(
|
||||||
|
favoriteId: string,
|
||||||
|
userId: string,
|
||||||
|
mime: string,
|
||||||
|
label: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const absolute = favoriteIconAbsolutePath(userId, favoriteId, mime);
|
||||||
|
if (absolute === null) return;
|
||||||
|
try {
|
||||||
|
await fs.unlink(absolute);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`${label} Symboldatei des Favoriten ${favoriteId} konnte nicht entfernt werden: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
||||||
* requesting user (T-08-06 — same ownership check as update/remove).
|
* requesting user (T-08-06 — same ownership check as update/remove).
|
||||||
* Never accepts a client-supplied URL — only the stored iconUrl on a
|
* Never accepts a client-supplied URL — only the stored iconUrl on a
|
||||||
* row the caller owns is fetched (T-QFIP-01).
|
* row the caller owns is fetched (T-QFIP-01).
|
||||||
*
|
*
|
||||||
|
* 260923-lrr: ein hochgeladenes Symbol hat VORRANG vor `iconUrl` — fehlt
|
||||||
|
* die Datei trotz gesetztem Typ (sollte praktisch nie vorkommen), wird
|
||||||
|
* protokolliert und auf `iconUrl` zurueckgefallen, statt 404 zu werfen.
|
||||||
|
*
|
||||||
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||||
* by the caller, or has no icon on record. Throws a 502 HttpException
|
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||||
* if the upstream fetch fails (unreachable, timeout, non-image, or
|
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||||
* SSRF-blocked) -- never returns a placeholder image.
|
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
||||||
*/
|
*/
|
||||||
async getIconBytes(
|
async getIconBytes(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -261,7 +471,29 @@ export class FavoritesService {
|
|||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (!link || link.userId !== userId || !link.iconUrl) {
|
if (!link || link.userId !== userId) {
|
||||||
|
throw new NotFoundException('FavoriteLink not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.uploadedIconMime !== null) {
|
||||||
|
const absolute = favoriteIconAbsolutePath(link.userId, link.id, link.uploadedIconMime);
|
||||||
|
if (absolute !== null) {
|
||||||
|
try {
|
||||||
|
const body = await fs.readFile(absolute);
|
||||||
|
return { contentType: link.uploadedIconMime, body };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Hochgeladenes Symbol des Favoriten ${id} fehlt im Dateibereich, falle auf iconUrl zurueck: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Hochgeladenes Symbol des Favoriten ${id} hat keinen gueltigen Ablageort`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!link.iconUrl) {
|
||||||
throw new NotFoundException('FavoriteLink not found');
|
throw new NotFoundException('FavoriteLink not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -173,9 +173,16 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
|||||||
* `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen
|
* `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen
|
||||||
* Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber
|
* Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber
|
||||||
* `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe.
|
* `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe.
|
||||||
|
*
|
||||||
|
* quick-260924-m4n: der SIEBTE FALL ist wieder ENTFERNT. Stufe 2 der
|
||||||
|
* Bilderrahmen-Umstellung (Migration 20260924120000_dashboard_image_drop_data)
|
||||||
|
* loescht die Spalte `data`; der Bootstrap-Umzug in
|
||||||
|
* `dashboard-images.service.ts` hat damit nichts mehr zu lesen und ist samt
|
||||||
|
* seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration
|
||||||
|
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu:
|
||||||
|
* 5 Dateien, 6 Aufrufe.
|
||||||
*/
|
*/
|
||||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||||
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
|
|
||||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||||
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ export class ProxmoxService {
|
|||||||
* `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE
|
* `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE
|
||||||
* `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst
|
* `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst
|
||||||
* keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden
|
* keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden
|
||||||
* (Muster `DkvSchedulerService`/`DashboardImagesService`, einmal lesen,
|
* (Muster `DkvSchedulerService`, einmal lesen,
|
||||||
* viele bedienen).
|
* viele bedienen).
|
||||||
*/
|
*/
|
||||||
async loadActiveServersForScheduler(): Promise<
|
async loadActiveServersForScheduler(): Promise<
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
>
|
<TesseraLogo
|
||||||
<div className="px-8 text-gray-900">
|
variant="horizontal"
|
||||||
<TesseraLogo
|
size={40}
|
||||||
variant="horizontal"
|
plateOutline="always"
|
||||||
size={104}
|
className="gap-3"
|
||||||
plateOutline={false}
|
wordmarkClassName="text-xl font-semibold tracking-normal text-appbar-foreground"
|
||||||
tagline={t('branding.tagline')}
|
/>
|
||||||
className="gap-6"
|
|
||||||
wordmarkClassName="text-5xl"
|
<div className="my-auto max-w-[30rem]">
|
||||||
taglineClassName="text-lg text-gray-800 max-w-xs"
|
<LoginMosaic />
|
||||||
/>
|
<p className="mt-12 text-[32px] font-semibold leading-10 tracking-tight">
|
||||||
|
{t('branding.claim')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-base leading-6 text-appbar-muted">
|
||||||
|
{t('branding.tagline')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Login form */}
|
{/* Rechts: Anmeldekarte zentriert auf der Arbeitsflaeche */}
|
||||||
<div className="flex flex-1 items-center justify-center bg-background px-4 py-8">
|
<div className="flex flex-1 items-center justify-center px-4 py-10">
|
||||||
<div className="w-full max-w-sm space-y-8">
|
<div className="w-full max-w-[400px]">
|
||||||
{/* Mobile-only branding */}
|
{/* Unter lg: Bildmarke ueber der Karte */}
|
||||||
<div className="text-center md:hidden">
|
<div className="mb-6 flex justify-center lg:hidden">
|
||||||
<TesseraLogo variant="horizontal" size={32} />
|
<TesseraLogo
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
variant="horizontal"
|
||||||
{t('branding.tagline')}
|
size={32}
|
||||||
</p>
|
className="gap-2.5"
|
||||||
|
wordmarkClassName="text-lg font-semibold tracking-normal"
|
||||||
|
/>
|
||||||
</div>
|
</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 gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<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 */}
|
||||||
<DesktopDownloadLinks />
|
<div className="mt-6">
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
|
||||||
{category}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
role="status"
|
|
||||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
|
||||||
isActive
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
|
||||||
: 'bg-muted text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isActive ? t('statusActive') : t('statusAvailable')}
|
|
||||||
</span>
|
|
||||||
{locked && (
|
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
'rounded-full px-2 py-0.5 text-xs',
|
|
||||||
'bg-amber-100 text-amber-700',
|
|
||||||
'dark:bg-amber-900/30 dark:text-amber-400',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{t('statusNoAccess')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Beschreibung */}
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
|
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
|
||||||
{localizedDescription}
|
{localizedDescription}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Footer: action button — only rendered for users who may activate/deactivate */}
|
{/* Fuss: Status links, Aktion rechts */}
|
||||||
{canManage && (
|
<div className="mt-auto flex items-center justify-between gap-2 pt-4">
|
||||||
<div className="relative flex items-center justify-end">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
isActive
|
||||||
|
? 'bg-status-ok/12 text-status-ok-fg'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && <span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-status-ok" />}
|
||||||
|
{isActive ? t('statusActive') : t('statusAvailable')}
|
||||||
|
</span>
|
||||||
|
{locked && (
|
||||||
|
<span className="rounded-full bg-status-warn/15 px-2 py-0.5 text-xs font-medium text-status-warn-fg">
|
||||||
|
{t('statusNoAccess')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aktion — nur fuer Benutzer, die aktivieren/deaktivieren duerfen */}
|
||||||
|
{canManage && (
|
||||||
<button
|
<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) => {
|
||||||
<button
|
const selected = value === tab.key;
|
||||||
key={tab.key}
|
return (
|
||||||
type="button"
|
<button
|
||||||
role="tab"
|
key={tab.key}
|
||||||
aria-selected={value === tab.key}
|
type="button"
|
||||||
onClick={() => onChange(tab.key)}
|
role="tab"
|
||||||
className={`px-3 py-2 text-sm font-medium transition-colors ${
|
aria-selected={selected}
|
||||||
value === tab.key
|
onClick={() => onChange(tab.key)}
|
||||||
? 'text-foreground border-b-2 border-primary'
|
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 ${
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
selected ? 'font-semibold text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(tab.label)}
|
{t(tab.label)}
|
||||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
<span
|
||||||
{counts[tab.key]}
|
className={`ml-2 rounded-full px-1.5 text-xs font-medium ${
|
||||||
</span>
|
selected ? 'bg-foreground/10 text-foreground' : 'bg-muted text-muted-foreground'
|
||||||
</button>
|
}`}
|
||||||
))}
|
>
|
||||||
|
{counts[tab.key]}
|
||||||
|
</span>
|
||||||
|
{/* Fluent-Reiter: 2 px Akzentlinie unten, beim Zeigen eine graue */}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`absolute inset-x-3 bottom-0 h-[2px] rounded-full ${
|
||||||
|
selected ? 'bg-primary-strong' : 'bg-transparent group-hover:bg-border'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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">
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
// Statisch statt je Test dynamisch importiert: Laden/Umwandeln der Seite faellt
|
||||||
|
// in die Sammelphase, nicht in die 5-s-Frist eines Tests (quick-260924-m4n).
|
||||||
|
import Page from './page';
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||||
@@ -110,14 +113,14 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importPage() {
|
|
||||||
const mod = await import('./page');
|
|
||||||
return mod.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Marketplace Filters', () => {
|
describe('Marketplace Filters', () => {
|
||||||
|
// userEvent an die falsche Uhr koppeln: sonst wartet jede Eingabe auf das
|
||||||
|
// langsame Echtzeit-Nachschieben von shouldAdvanceTime (quick-260924-m4n).
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
mockAuthStore.mockImplementation(
|
mockAuthStore.mockImplementation(
|
||||||
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
||||||
selector({
|
selector({
|
||||||
@@ -132,7 +135,6 @@ describe('Marketplace Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('typing in search filters the grid to only matching modules after debounce', async () => {
|
it('typing in search filters the grid to only matching modules after debounce', async () => {
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -142,7 +144,7 @@ describe('Marketplace Filters', () => {
|
|||||||
expect(screen.getByText('Email Tool')).toBeInTheDocument();
|
expect(screen.getByText('Email Tool')).toBeInTheDocument();
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Module suchen...');
|
const searchInput = screen.getByPlaceholderText('Module suchen...');
|
||||||
await userEvent.type(searchInput, 'Domain');
|
await user.type(searchInput, 'Domain');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(350);
|
vi.advanceTimersByTime(350);
|
||||||
@@ -155,7 +157,6 @@ describe('Marketplace Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('selecting the Aktiviert status tab shows only activated modules', async () => {
|
it('selecting the Aktiviert status tab shows only activated modules', async () => {
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -163,7 +164,7 @@ describe('Marketplace Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeTab = screen.getByRole('tab', { name: /Aktiviert/i });
|
const activeTab = screen.getByRole('tab', { name: /Aktiviert/i });
|
||||||
await userEvent.click(activeTab);
|
await user.click(activeTab);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
||||||
@@ -173,7 +174,6 @@ describe('Marketplace Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('selecting a category chip shows only modules of that category; Alle shows all', async () => {
|
it('selecting a category chip shows only modules of that category; Alle shows all', async () => {
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -181,7 +181,7 @@ describe('Marketplace Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const utilitiesChip = screen.getByRole('button', { name: 'Utilities' });
|
const utilitiesChip = screen.getByRole('button', { name: 'Utilities' });
|
||||||
await userEvent.click(utilitiesChip);
|
await user.click(utilitiesChip);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Converter')).toBeInTheDocument();
|
expect(screen.getByText('Converter')).toBeInTheDocument();
|
||||||
@@ -189,7 +189,7 @@ describe('Marketplace Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const allChip = screen.getByRole('button', { name: 'Alle' });
|
const allChip = screen.getByRole('button', { name: 'Alle' });
|
||||||
await userEvent.click(allChip);
|
await user.click(allChip);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
||||||
@@ -198,7 +198,6 @@ describe('Marketplace Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('search + status + category filters compose together (AND)', async () => {
|
it('search + status + category filters compose together (AND)', async () => {
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -207,11 +206,11 @@ describe('Marketplace Filters', () => {
|
|||||||
|
|
||||||
// Filter to Domain-Tools category
|
// Filter to Domain-Tools category
|
||||||
const domainChip = screen.getByRole('button', { name: 'Domain-Tools' });
|
const domainChip = screen.getByRole('button', { name: 'Domain-Tools' });
|
||||||
await userEvent.click(domainChip);
|
await user.click(domainChip);
|
||||||
|
|
||||||
// Filter to available only
|
// Filter to available only
|
||||||
const availableTab = screen.getByRole('tab', { name: /Verfuegbar/i });
|
const availableTab = screen.getByRole('tab', { name: /Verfuegbar/i });
|
||||||
await userEvent.click(availableTab);
|
await user.click(availableTab);
|
||||||
|
|
||||||
// Domain-Tools + Available = only Email Tool (Domaincheck is active)
|
// Domain-Tools + Available = only Email Tool (Domaincheck is active)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -222,7 +221,6 @@ describe('Marketplace Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders filtered-empty heading when filters produce zero results', async () => {
|
it('renders filtered-empty heading when filters produce zero results', async () => {
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -230,7 +228,7 @@ describe('Marketplace Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Module suchen...');
|
const searchInput = screen.getByPlaceholderText('Module suchen...');
|
||||||
await userEvent.type(searchInput, 'zzz-nonexistent');
|
await user.type(searchInput, 'zzz-nonexistent');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(350);
|
vi.advanceTimersByTime(350);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
// Statisch statt je Test dynamisch importiert: Laden/Umwandeln der Seite faellt
|
||||||
|
// in die Sammelphase, nicht in die 5-s-Frist eines Tests (quick-260924-m4n).
|
||||||
|
import Page from './page';
|
||||||
|
|
||||||
// Mock next-intl
|
// Mock next-intl
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
@@ -79,12 +82,6 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lazy import after mocks
|
|
||||||
async function importPage() {
|
|
||||||
const mod = await import('./page');
|
|
||||||
return mod.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('MarketplacePage', () => {
|
describe('MarketplacePage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Default: admin user
|
// Default: admin user
|
||||||
@@ -106,7 +103,6 @@ describe('MarketplacePage', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -126,7 +122,6 @@ describe('MarketplacePage', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -156,7 +151,6 @@ describe('MarketplacePage', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -176,7 +170,6 @@ describe('MarketplacePage', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
// Komponenten statisch importiert (vi.mock wird ueber die Importe gehoben): das
|
||||||
|
// Laden und Umwandeln faellt so in die Sammelphase der Datei und nicht in die
|
||||||
|
// 5-s-Frist des ersten Tests. Frueher lag der dynamische Import im Test und hat
|
||||||
|
// unter Laeuferlast (drei parallele Pipelines bei jeder Freigabe) die Frist
|
||||||
|
// gerissen (Todo 2026-09-23, quick-260924-m4n).
|
||||||
|
import { TenantContextSelector } from './components/TenantContextSelector';
|
||||||
|
import Page from './page';
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||||
@@ -86,19 +93,8 @@ afterEach(() => {
|
|||||||
mockBumpSidebarRefresh.mockClear();
|
mockBumpSidebarRefresh.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importPage() {
|
|
||||||
const mod = await import('./page');
|
|
||||||
return mod.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importTenantSelector() {
|
|
||||||
const mod = await import('./components/TenantContextSelector');
|
|
||||||
return mod.TenantContextSelector;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TenantContextSelector', () => {
|
describe('TenantContextSelector', () => {
|
||||||
it('renders tenant options for SUPER_ADMIN; renders nothing for ADMIN', async () => {
|
it('renders tenant options for SUPER_ADMIN', async () => {
|
||||||
// SUPER_ADMIN case
|
|
||||||
mockAuthStore.mockImplementation(
|
mockAuthStore.mockImplementation(
|
||||||
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
||||||
selector({
|
selector({
|
||||||
@@ -116,16 +112,13 @@ describe('TenantContextSelector', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const TenantContextSelector = await importTenantSelector();
|
render(<TenantContextSelector />);
|
||||||
const { unmount } = render(<TenantContextSelector />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(await screen.findByText('Tenant Alpha')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Tenant Alpha')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.getByText('Tenant Beta')).toBeInTheDocument();
|
expect(screen.getByText('Tenant Beta')).toBeInTheDocument();
|
||||||
unmount();
|
});
|
||||||
|
|
||||||
// ADMIN case
|
it('renders nothing for ADMIN and does not load the tenant list', () => {
|
||||||
mockAuthStore.mockImplementation(
|
mockAuthStore.mockImplementation(
|
||||||
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
||||||
selector({
|
selector({
|
||||||
@@ -133,8 +126,12 @@ describe('TenantContextSelector', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
const { container } = render(<TenantContextSelector />);
|
const { container } = render(<TenantContextSelector />);
|
||||||
expect(container.innerHTML).toBe('');
|
expect(container.innerHTML).toBe('');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls setSelectedTenantId when selector value changes', async () => {
|
it('calls setSelectedTenantId when selector value changes', async () => {
|
||||||
@@ -155,7 +152,6 @@ describe('TenantContextSelector', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const TenantContextSelector = await importTenantSelector();
|
|
||||||
render(<TenantContextSelector />);
|
render(<TenantContextSelector />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -190,7 +186,6 @@ describe('ActivationDialog', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -224,7 +219,6 @@ describe('ActivationDialog', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const Page = await importPage();
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -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,51 +76,47 @@ 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">
|
? t('lastChecked', { time: formatLastChecked(lastChecked) })
|
||||||
{lastChecked
|
: t('neverChecked')
|
||||||
? t('lastChecked', { time: formatLastChecked(lastChecked) })
|
}
|
||||||
: t('neverChecked')}
|
actions={
|
||||||
</p>
|
<>
|
||||||
</div>
|
<Link
|
||||||
|
href={`${basePath}/settings`}
|
||||||
{/* Einstellungen-Link */}
|
className="btn btn-secondary btn-icon"
|
||||||
<Link
|
title={t('settingsTitle')}
|
||||||
href={`${basePath}/settings`}
|
aria-label={t('settingsTitle')}
|
||||||
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
>
|
||||||
title={t('settingsTitle')}
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
>
|
|
||||||
<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>
|
||||||
|
<button
|
||||||
{/* "Jetzt prüfen" button per 07-UI-SPEC button states */}
|
type="button"
|
||||||
<button
|
onClick={handleCheckNow}
|
||||||
type="button"
|
disabled={isChecking}
|
||||||
onClick={handleCheckNow}
|
className="btn btn-primary"
|
||||||
disabled={isChecking}
|
>
|
||||||
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
|
{isChecking ? (
|
||||||
isChecking ? ' opacity-80 cursor-not-allowed' : ''
|
<>
|
||||||
}`}
|
<SpinnerIcon />
|
||||||
>
|
{t('checking')}
|
||||||
{isChecking ? (
|
</>
|
||||||
<>
|
) : (
|
||||||
<SpinnerIcon />
|
t('checkNow')
|
||||||
{t('checking')}
|
)}
|
||||||
</>
|
</button>
|
||||||
) : (
|
</>
|
||||||
t('checkNow')
|
}
|
||||||
)}
|
/>
|
||||||
</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} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,56 @@
|
|||||||
import { cleanup, render, screen } from '@testing-library/react';
|
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
import type { ProxmoxServer, ProxmoxServerStatus } from '@/lib/proxmox-api';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
/**
|
||||||
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
|
* ServerCard (quick-260924-h7x) — mit dem ECHTEN next-intl-Provider und den
|
||||||
const translations: Record<string, string> = {
|
* echten deutschen Texten, damit ICU-Plural und Zahlformate mitgeprueft
|
||||||
'card.unknownValue': 'unbekannt',
|
* werden.
|
||||||
'card.lastPolledLabel': 'Letzte Abfrage',
|
*/
|
||||||
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
|
|
||||||
'card.notPolledYetAutomatic':
|
vi.mock('next/link', () => ({
|
||||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||||
'card.refresh': 'Jetzt aktualisieren',
|
<a href={href} {...rest}>
|
||||||
'card.lastOkLabel': 'Letzte erfolgreiche Messung',
|
{children}
|
||||||
'card.pve.nodeCount': 'Knoten',
|
</a>
|
||||||
'card.pve.guests': '{running} laufend / {stopped} gestoppt',
|
),
|
||||||
'card.pve.cpu': 'Prozessorlast',
|
|
||||||
'card.pve.mem': 'Speicher',
|
|
||||||
'card.pbs.used': 'Belegt',
|
|
||||||
'card.pbs.lastBackup': 'Letzte Sicherung',
|
|
||||||
'card.pbs.noBackupYet': 'noch keine Sicherung',
|
|
||||||
'card.pbs.verifyState': 'Letzte Prüfung',
|
|
||||||
'card.pmg.countIn': 'Eingehend',
|
|
||||||
'card.pmg.countOut': 'Ausgehend',
|
|
||||||
'card.pmg.spamCount': 'Spam',
|
|
||||||
'card.pmg.virusCount': 'Viren',
|
|
||||||
'errors.netz': 'Der Server ist nicht erreichbar.',
|
|
||||||
'errors.zugang': 'Der Zugang wurde abgelehnt.',
|
|
||||||
'errors.rechte': 'Die Rechte reichen nicht aus.',
|
|
||||||
'errors.zertifikat': 'Das Zertifikat wurde abgelehnt.',
|
|
||||||
'errors.antwortform': 'Unerwartete Antwortform.',
|
|
||||||
'errors.server': 'Serverfehler.',
|
|
||||||
'errors.unbekannt': 'Unerwarteter Fehler.',
|
|
||||||
};
|
|
||||||
let result = translations[key] ?? key;
|
|
||||||
if (params) {
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
result = result.replace(`{${k}}`, String(v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Fester Bezugszeitpunkt: 24.09.2026, 12:00 UTC. */
|
||||||
|
const NOW = Date.parse('2026-09-24T12:00:00.000Z');
|
||||||
|
const NOW_S = NOW / 1000;
|
||||||
|
|
||||||
|
function renderDe(ui: ReactElement) {
|
||||||
|
return render(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
{ui}
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStatus(overrides: Partial<ProxmoxServerStatus> = {}): ProxmoxServerStatus {
|
||||||
|
return {
|
||||||
|
id: 's1',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
lastPolledAt: '2026-09-24T11:56:00.000Z',
|
||||||
|
lastOkAt: '2026-09-24T11:56:00.000Z',
|
||||||
|
reachable: true,
|
||||||
|
errorKind: null,
|
||||||
|
errorDetail: null,
|
||||||
|
rawSample: null,
|
||||||
|
updatedAt: '2026-09-24T11:56:00.000Z',
|
||||||
|
metrics: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
||||||
return {
|
return {
|
||||||
id: 'srv-1',
|
id: 'srv-1',
|
||||||
@@ -67,290 +72,397 @@ function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ServerCard', () => {
|
const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
|
||||||
it('PVE-Server zeigt Knotenzahl, laufende/gestoppte Gaeste sowie je Knoten Prozessorlast und Speicherbelegung', async () => {
|
|
||||||
const { ServerCard } = await import('./ServerCard');
|
|
||||||
const server = makeServer({
|
|
||||||
status: {
|
|
||||||
id: 's1',
|
|
||||||
serverId: 'srv-1',
|
|
||||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
reachable: true,
|
|
||||||
errorKind: null,
|
|
||||||
errorDetail: null,
|
|
||||||
rawSample: null,
|
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
metrics: {
|
|
||||||
productType: 'pve',
|
|
||||||
nodeCount: 2,
|
|
||||||
guestsRunning: 3,
|
|
||||||
guestsStopped: 1,
|
|
||||||
nodes: [
|
|
||||||
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_294_967_296, maxmem: 8_589_934_592 },
|
|
||||||
],
|
|
||||||
storages: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ServerCard server={server} />);
|
async function card(server: ProxmoxServer, isAdmin?: boolean) {
|
||||||
|
const { ServerCard } = await import('./ServerCard');
|
||||||
|
renderDe(<ServerCard server={server} isAdmin={isAdmin} now={NOW} />);
|
||||||
|
return screen.getByTestId('server-card');
|
||||||
|
}
|
||||||
|
|
||||||
expect(screen.getByText(/Knoten:/)).toHaveTextContent('Knoten: 2');
|
describe('ServerCard — Kopf und Status', () => {
|
||||||
expect(screen.getByText(/laufend/)).toHaveTextContent('3 laufend / 1 gestoppt');
|
it('zeigt Name, ausgeschriebenen Produktnamen und die Adresse unveraendert (keine Grossschrift)', async () => {
|
||||||
expect(screen.getByText(/Prozessorlast/)).toHaveTextContent('Prozessorlast: 25%');
|
const el = await card(makeServer({ baseUrl: 'https://172.21.0.1:8006', status: makeStatus() }));
|
||||||
expect(screen.getByText(/Speicher:/)).toHaveTextContent('4.0 GB / 8.0 GB');
|
|
||||||
|
expect(within(el).getByText('pve-1')).toBeInTheDocument();
|
||||||
|
expect(within(el).getByText('Virtualisierung')).toBeInTheDocument();
|
||||||
|
const address = within(el).getByText('https://172.21.0.1:8006');
|
||||||
|
expect(address).not.toHaveClass('uppercase');
|
||||||
|
expect(el.querySelector('.uppercase')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PBS-Server zeigt je Datenspeicher Belegung, letzte Sicherung und Ergebnis der letzten Pruefung', async () => {
|
it('PBS und PMG tragen ihre ausgeschriebenen Produktnamen', async () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
await card(makeServer({ id: 'b', productType: 'pbs', status: makeStatus() }));
|
||||||
const server = makeServer({
|
expect(screen.getByText('Datensicherung')).toBeInTheDocument();
|
||||||
productType: 'pbs',
|
cleanup();
|
||||||
status: {
|
await card(makeServer({ id: 'm', productType: 'pmg', status: makeStatus() }));
|
||||||
id: 's1',
|
expect(screen.getByText('Mail-Gateway')).toBeInTheDocument();
|
||||||
serverId: 'srv-1',
|
|
||||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
reachable: true,
|
|
||||||
errorKind: null,
|
|
||||||
errorDetail: null,
|
|
||||||
rawSample: null,
|
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
metrics: {
|
|
||||||
productType: 'pbs',
|
|
||||||
datastores: [
|
|
||||||
{ name: 'backup-store', total: 1000, used: 400, free: 600, lastBackupAt: 1758000000, lastVerifyState: 'ok' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ServerCard server={server} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('backup-store')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Belegt:/)).toHaveTextContent('400 B / 1000 B');
|
|
||||||
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('ok');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PBS-Datenspeicher ohne Sicherung zeigt "noch keine Sicherung"', async () => {
|
it('erreichbar ohne Auffaelligkeiten: Pille „In Ordnung“, data-health ok', async () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
const el = await card(makeServer({ status: makeStatus() }));
|
||||||
const server = makeServer({
|
expect(el).toHaveAttribute('data-health', 'ok');
|
||||||
productType: 'pbs',
|
expect(within(el).getByTestId('status-pill')).toHaveTextContent('In Ordnung');
|
||||||
status: {
|
|
||||||
id: 's1',
|
|
||||||
serverId: 'srv-1',
|
|
||||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
lastOkAt: null,
|
|
||||||
reachable: true,
|
|
||||||
errorKind: null,
|
|
||||||
errorDetail: null,
|
|
||||||
rawSample: null,
|
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
metrics: {
|
|
||||||
productType: 'pbs',
|
|
||||||
datastores: [
|
|
||||||
{ name: 'leer', total: 10, used: 0, free: 10, lastBackupAt: null, lastVerifyState: null },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ServerCard server={server} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Letzte Sicherung/)).toHaveTextContent('noch keine Sicherung');
|
|
||||||
expect(screen.getByText(/Letzte Prüfung/)).toHaveTextContent('unbekannt');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PMG-Server zeigt die Tageszahlen eingehend, ausgehend, Spam und Viren', async () => {
|
it('Fuss: „Letzte Abfrage vor 4 Min.“ relativ, exakter Zeitpunkt im title', async () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
const el = await card(makeServer({ status: makeStatus() }));
|
||||||
const server = makeServer({
|
const footer = within(el).getByTestId('last-polled');
|
||||||
productType: 'pmg',
|
expect(footer).toHaveTextContent('Letzte Abfrage vor 4 Min.');
|
||||||
status: {
|
expect(footer.getAttribute('title')).toBeTruthy();
|
||||||
id: 's1',
|
});
|
||||||
serverId: 'srv-1',
|
});
|
||||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
lastOkAt: '2026-09-23T10:00:00.000Z',
|
describe('ServerCard — PVE', () => {
|
||||||
reachable: true,
|
it('laufende und gestoppte Gaeste, Knotenzahl, je Knoten Prozessor und Arbeitsspeicher mit deutschem Zahlformat', async () => {
|
||||||
errorKind: null,
|
const el = await card(
|
||||||
errorDetail: null,
|
makeServer({
|
||||||
rawSample: null,
|
status: makeStatus({
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
metrics: {
|
||||||
metrics: { productType: 'pmg', countIn: 120, countOut: 45, spamCount: 12, virusCount: 0 },
|
productType: 'pve',
|
||||||
},
|
nodeCount: 2,
|
||||||
});
|
guestsRunning: 3,
|
||||||
|
guestsStopped: 1,
|
||||||
render(<ServerCard server={server} />);
|
nodes: [
|
||||||
|
{ node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 12 * 1024 ** 3, maxmem: 64 * 1024 ** 3 },
|
||||||
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('120');
|
],
|
||||||
expect(screen.getByText(/Ausgehend:/)).toHaveTextContent('45');
|
storages: [],
|
||||||
expect(screen.getByText(/Spam:/)).toHaveTextContent('12');
|
},
|
||||||
expect(screen.getByText(/Viren:/)).toHaveTextContent('0');
|
}),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
it('ein Messwert, der null ist, erscheint als "unbekannt" — nie als 0, leer oder NaN', async () => {
|
|
||||||
const { ServerCard } = await import('./ServerCard');
|
expect(within(el).getByTestId('guests-running')).toHaveTextContent('3');
|
||||||
const server = makeServer({
|
expect(within(el).getByText('Gäste laufen')).toBeInTheDocument();
|
||||||
productType: 'pmg',
|
expect(within(el).getByTestId('guests-stopped')).toHaveTextContent('1');
|
||||||
status: {
|
expect(within(el).getByText('2 Knoten')).toBeInTheDocument();
|
||||||
id: 's1',
|
|
||||||
serverId: 'srv-1',
|
const node = within(el).getByTestId('pve-node-pve1');
|
||||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
const [cpu, mem] = within(node).getAllByTestId('meter');
|
||||||
lastOkAt: null,
|
expect(cpu).toHaveTextContent('Prozessor');
|
||||||
reachable: true,
|
expect(cpu).toHaveTextContent(/25\s%/);
|
||||||
errorKind: null,
|
expect(cpu).toHaveAttribute('data-level', 'ok');
|
||||||
errorDetail: null,
|
expect(mem).toHaveTextContent('Arbeitsspeicher');
|
||||||
rawSample: null,
|
expect(mem).toHaveTextContent(/19\s%/);
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
expect(mem).toHaveTextContent('12,0 / 64,0 GB');
|
||||||
metrics: { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null },
|
});
|
||||||
},
|
|
||||||
});
|
it('Auslastung ab 80 % faerbt den Balken als Warnung, ab 92 % kritisch, und die Karte wird „Warnung“', async () => {
|
||||||
|
const el = await card(
|
||||||
render(<ServerCard server={server} />);
|
makeServer({
|
||||||
|
status: makeStatus({
|
||||||
expect(screen.getByText(/Eingehend:/)).toHaveTextContent('unbekannt');
|
metrics: {
|
||||||
expect(screen.queryByText(/Eingehend: 0/)).not.toBeInTheDocument();
|
productType: 'pve',
|
||||||
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
|
nodeCount: 1,
|
||||||
});
|
guestsRunning: 1,
|
||||||
|
guestsStopped: 0,
|
||||||
it('ein Server mit reachable:false zeigt den Klartext der Ursache und den Zeitpunkt der letzten erfolgreichen Messung', async () => {
|
nodes: [{ node: 'pve1', cpu: 0.85, maxcpu: 8, mem: 95, maxmem: 100 }],
|
||||||
const { ServerCard } = await import('./ServerCard');
|
storages: [],
|
||||||
const server = makeServer({
|
},
|
||||||
status: {
|
}),
|
||||||
id: 's1',
|
}),
|
||||||
serverId: 'srv-1',
|
);
|
||||||
lastPolledAt: '2026-09-23T10:05:00.000Z',
|
|
||||||
lastOkAt: '2026-09-23T09:00:00.000Z',
|
expect(el).toHaveAttribute('data-health', 'warn');
|
||||||
reachable: false,
|
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Warnung');
|
||||||
errorKind: 'zugang',
|
const [cpu, mem] = within(el).getAllByTestId('meter');
|
||||||
errorDetail: null,
|
expect(cpu).toHaveAttribute('data-level', 'warn');
|
||||||
rawSample: null,
|
expect(mem).toHaveAttribute('data-level', 'crit');
|
||||||
updatedAt: '2026-09-23T10:05:00.000Z',
|
});
|
||||||
metrics: null,
|
|
||||||
},
|
it('unbekannte Knotenwerte erscheinen als „unbekannt“ — nie 0 %, nie NaN', async () => {
|
||||||
});
|
const el = await card(
|
||||||
|
makeServer({
|
||||||
render(<ServerCard server={server} />);
|
status: makeStatus({
|
||||||
|
metrics: {
|
||||||
expect(screen.getByText('Der Zugang wurde abgelehnt.')).toBeInTheDocument();
|
productType: 'pve',
|
||||||
expect(screen.getByText(/Letzte erfolgreiche Messung/)).toBeInTheDocument();
|
nodeCount: 1,
|
||||||
});
|
guestsRunning: 0,
|
||||||
|
guestsStopped: 0,
|
||||||
it('der Zeitpunkt der letzten Abfrage steht bei jedem Server', async () => {
|
nodes: [{ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null }],
|
||||||
const { ServerCard } = await import('./ServerCard');
|
storages: [],
|
||||||
const server = makeServer({
|
},
|
||||||
status: {
|
}),
|
||||||
id: 's1',
|
}),
|
||||||
serverId: 'srv-1',
|
);
|
||||||
lastPolledAt: '2026-09-23T10:00:00.000Z',
|
|
||||||
lastOkAt: null,
|
const [cpu, mem] = within(el).getAllByTestId('meter');
|
||||||
reachable: true,
|
expect(cpu).toHaveTextContent('unbekannt');
|
||||||
errorKind: null,
|
expect(mem).toHaveTextContent('unbekannt');
|
||||||
errorDetail: null,
|
expect(cpu).toHaveAttribute('data-level', 'unknown');
|
||||||
rawSample: null,
|
expect(el).not.toHaveTextContent(/0\s%/);
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
expect(el).not.toHaveTextContent('NaN');
|
||||||
metrics: null,
|
expect(el).toHaveAttribute('data-health', 'ok');
|
||||||
},
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<ServerCard server={server} />);
|
describe('ServerCard — PBS', () => {
|
||||||
|
it('je Datenspeicher Fuellstand, letzte Sicherung relativ und Pruefpille', async () => {
|
||||||
expect(screen.getByText(/Letzte Abfrage/)).toBeInTheDocument();
|
const el = await card(
|
||||||
});
|
makeServer({
|
||||||
|
productType: 'pbs',
|
||||||
it('Nachbesserung Befund 2: ein frisch angelegter, noch nie abgefragter Server zeigt Admins den ruhigen Hinweis mit Knopfverweis statt "Ein unerwarteter Fehler ist aufgetreten"', async () => {
|
status: makeStatus({
|
||||||
const { ServerCard } = await import('./ServerCard');
|
metrics: {
|
||||||
// Zustand direkt nach `createServer`: leere Zwischenlagerzeile, noch nie abgefragt.
|
productType: 'pbs',
|
||||||
const server = makeServer({
|
datastores: [
|
||||||
status: {
|
{
|
||||||
id: 's1',
|
name: 'backup-store',
|
||||||
serverId: 'srv-1',
|
total: 4 * 1024 ** 4,
|
||||||
lastPolledAt: null,
|
used: 1.2 * 1024 ** 4,
|
||||||
lastOkAt: null,
|
free: 2.8 * 1024 ** 4,
|
||||||
reachable: false,
|
lastBackupAt: NOW_S - 5 * 3600,
|
||||||
errorKind: null,
|
lastVerifyState: 'ok',
|
||||||
errorDetail: null,
|
},
|
||||||
rawSample: null,
|
],
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
},
|
||||||
metrics: null,
|
}),
|
||||||
},
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
render(<ServerCard server={server} isAdmin />);
|
const ds = within(el).getByTestId('pbs-datastore-backup-store');
|
||||||
|
expect(within(ds).getByText('backup-store')).toBeInTheDocument();
|
||||||
expect(
|
expect(within(ds).getByTestId('meter')).toHaveTextContent('1,2 / 4,0 TB');
|
||||||
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
|
expect(within(ds).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 5 Std.');
|
||||||
).toBeInTheDocument();
|
expect(within(ds).getByTestId('verify-pill')).toHaveTextContent('Prüfung in Ordnung');
|
||||||
// Ohne die Korrektur erschiene hier faelschlich die Sammelmeldung — roter Test.
|
expect(el).toHaveAttribute('data-health', 'ok');
|
||||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
});
|
||||||
});
|
|
||||||
|
it('Sicherung aelter als 26 h und fehlgeschlagene Pruefung -> Warnung', async () => {
|
||||||
it('260923-le6: isAdmin={false}, noch nie abgefragt -> automatischer Hinweis ohne Knopfverweis', async () => {
|
const el = await card(
|
||||||
const { ServerCard } = await import('./ServerCard');
|
makeServer({
|
||||||
const server = makeServer({
|
productType: 'pbs',
|
||||||
status: {
|
status: makeStatus({
|
||||||
id: 's1',
|
metrics: {
|
||||||
serverId: 'srv-1',
|
productType: 'pbs',
|
||||||
lastPolledAt: null,
|
datastores: [
|
||||||
lastOkAt: null,
|
{
|
||||||
reachable: false,
|
name: 'alt',
|
||||||
errorKind: null,
|
total: 100,
|
||||||
errorDetail: null,
|
used: 10,
|
||||||
rawSample: null,
|
free: 90,
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
lastBackupAt: NOW_S - 30 * 3600,
|
||||||
metrics: null,
|
lastVerifyState: 'failed',
|
||||||
},
|
},
|
||||||
});
|
],
|
||||||
|
},
|
||||||
render(<ServerCard server={server} isAdmin={false} />);
|
}),
|
||||||
|
}),
|
||||||
expect(
|
);
|
||||||
screen.getByText(
|
|
||||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
expect(el).toHaveAttribute('data-health', 'warn');
|
||||||
),
|
expect(within(el).getByTestId('last-backup')).toHaveTextContent('Letzte Sicherung vor 1 Tag');
|
||||||
).toBeInTheDocument();
|
expect(within(el).getByTestId('verify-pill')).toHaveTextContent('Prüfung fehlgeschlagen');
|
||||||
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
expect(within(el).getByTestId('verify-pill')).toHaveAttribute('title', 'failed');
|
||||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
});
|
||||||
});
|
|
||||||
|
it('ohne Sicherung „noch keine Sicherung“, Pruefung null „Prüfung unbekannt“, keine Warnung', async () => {
|
||||||
it('260923-le6: isAdmin weggelassen, noch nie abgefragt -> verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
|
const el = await card(
|
||||||
const { ServerCard } = await import('./ServerCard');
|
makeServer({
|
||||||
const server = makeServer({
|
productType: 'pbs',
|
||||||
status: {
|
status: makeStatus({
|
||||||
id: 's1',
|
metrics: {
|
||||||
serverId: 'srv-1',
|
productType: 'pbs',
|
||||||
lastPolledAt: null,
|
datastores: [
|
||||||
lastOkAt: null,
|
{
|
||||||
reachable: false,
|
name: 'leer',
|
||||||
errorKind: null,
|
total: 10,
|
||||||
errorDetail: null,
|
used: 0,
|
||||||
rawSample: null,
|
free: 10,
|
||||||
updatedAt: '2026-09-23T10:00:00.000Z',
|
lastBackupAt: null,
|
||||||
metrics: null,
|
lastVerifyState: null,
|
||||||
},
|
},
|
||||||
});
|
],
|
||||||
|
},
|
||||||
render(<ServerCard server={server} />);
|
}),
|
||||||
|
}),
|
||||||
expect(
|
);
|
||||||
screen.getByText(
|
|
||||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
expect(within(el).getByText('noch keine Sicherung')).toBeInTheDocument();
|
||||||
),
|
expect(within(el).getByTestId('verify-pill')).toHaveTextContent('Prüfung unbekannt');
|
||||||
).toBeInTheDocument();
|
expect(el).toHaveAttribute('data-health', 'ok');
|
||||||
expect(screen.queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
});
|
||||||
expect(screen.queryByText('Unerwarteter Fehler.')).not.toBeInTheDocument();
|
});
|
||||||
});
|
|
||||||
|
describe('ServerCard — PMG', () => {
|
||||||
it('Nachbesserung Befund 3: die Adresse bleibt unveraendert dargestellt, nur das Produktkuerzel ist grossgeschrieben', async () => {
|
it('vier Zahlfelder: eingehend, ausgehend, Spam, Viren', async () => {
|
||||||
const { ServerCard } = await import('./ServerCard');
|
const el = await card(
|
||||||
const server = makeServer({ baseUrl: 'https://172.21.0.1:8006' });
|
makeServer({
|
||||||
|
productType: 'pmg',
|
||||||
render(<ServerCard server={server} />);
|
status: makeStatus({
|
||||||
|
metrics: {
|
||||||
const line = screen.getByText(/172\.21\.0\.1:8006/);
|
productType: 'pmg',
|
||||||
expect(line).toHaveTextContent('pve — https://172.21.0.1:8006');
|
countIn: 1200,
|
||||||
const productSpan = line.querySelector('span');
|
countOut: 45,
|
||||||
expect(productSpan).not.toBeNull();
|
spamCount: 12,
|
||||||
expect(productSpan).toHaveClass('uppercase');
|
virusCount: 0,
|
||||||
expect(line).not.toHaveClass('uppercase');
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(within(el).getByTestId('pmg-in')).toHaveTextContent('Eingehend1.200');
|
||||||
|
expect(within(el).getByTestId('pmg-out')).toHaveTextContent('Ausgehend45');
|
||||||
|
expect(within(el).getByTestId('pmg-spam')).toHaveTextContent('Spam12');
|
||||||
|
expect(within(el).getByTestId('pmg-virus')).toHaveTextContent('Viren0');
|
||||||
|
expect(el).toHaveAttribute('data-health', 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Viren > 0 erscheinen in der Fehlerfarbe und machen die Karte zur Warnung', async () => {
|
||||||
|
const el = await card(
|
||||||
|
makeServer({
|
||||||
|
productType: 'pmg',
|
||||||
|
status: makeStatus({
|
||||||
|
metrics: { productType: 'pmg', countIn: 1, countOut: 1, spamCount: 1, virusCount: 3 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(el).toHaveAttribute('data-health', 'warn');
|
||||||
|
expect(within(within(el).getByTestId('pmg-virus')).getByText('3')).toHaveClass(
|
||||||
|
'text-status-down-fg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null-Werte erscheinen als „unbekannt“ — nie 0', async () => {
|
||||||
|
const el = await card(
|
||||||
|
makeServer({
|
||||||
|
productType: 'pmg',
|
||||||
|
status: makeStatus({
|
||||||
|
metrics: {
|
||||||
|
productType: 'pmg',
|
||||||
|
countIn: null,
|
||||||
|
countOut: null,
|
||||||
|
spamCount: null,
|
||||||
|
virusCount: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(within(el).getByTestId('pmg-in')).toHaveTextContent('Eingehendunbekannt');
|
||||||
|
expect(within(el).getByTestId('pmg-virus')).toHaveTextContent('Virenunbekannt');
|
||||||
|
expect(el).not.toHaveTextContent('NaN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ServerCard — nicht erreichbar', () => {
|
||||||
|
it('Klartext der Ursache, Detail in eigener Zeile (nicht in Klammern), „Zuletzt erreichbar vor 2 Tagen“', async () => {
|
||||||
|
const el = await card(
|
||||||
|
makeServer({
|
||||||
|
status: makeStatus({
|
||||||
|
lastOkAt: '2026-09-22T12:00:00.000Z',
|
||||||
|
reachable: false,
|
||||||
|
errorKind: 'zugang',
|
||||||
|
errorDetail: 'HTTP 401',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(el).toHaveAttribute('data-health', 'down');
|
||||||
|
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Nicht erreichbar');
|
||||||
|
const notice = within(el).getByTestId('down-notice');
|
||||||
|
expect(within(notice).getByText(/Der Zugang wurde abgelehnt\./)).toBeInTheDocument();
|
||||||
|
expect(within(notice).getByText('HTTP 401')).toBeInTheDocument();
|
||||||
|
expect(notice).not.toHaveTextContent('(HTTP 401)');
|
||||||
|
expect(within(notice).getByText('Zuletzt erreichbar vor 2 Tagen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nie erreichbar gewesen: keine „Zuletzt erreichbar“-Zeile', async () => {
|
||||||
|
const el = await card(
|
||||||
|
makeServer({ status: makeStatus({ lastOkAt: null, reachable: false, errorKind: 'netz' }) }),
|
||||||
|
);
|
||||||
|
expect(within(el).queryByText(/Zuletzt erreichbar/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
|
||||||
|
it('Admin: ruhiger Hinweis mit Knopfverweis statt Fehlermeldung', async () => {
|
||||||
|
const el = await card(makeServer({ status: UNPOLLED }), true);
|
||||||
|
|
||||||
|
expect(el).toHaveAttribute('data-health', 'idle');
|
||||||
|
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Noch nicht abgefragt');
|
||||||
|
expect(
|
||||||
|
within(el).getByText(
|
||||||
|
'Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(el).queryByText('Ein unerwarteter Fehler ist aufgetreten.'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(within(el).queryByTestId('last-polled')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => {
|
||||||
|
const el = await card(makeServer({ status: UNPOLLED }), false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(el).getByText(
|
||||||
|
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isAdmin weggelassen: verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
|
||||||
|
const el = await card(makeServer({ status: UNPOLLED }));
|
||||||
|
|
||||||
|
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||||
|
expect(within(el).getByText(/automatischen Abfrage/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ServerCard — offline & verwaist', () => {
|
||||||
|
const staleMetrics = makeStatus({
|
||||||
|
metrics: {
|
||||||
|
productType: 'pve',
|
||||||
|
nodeCount: 1,
|
||||||
|
guestsRunning: 7,
|
||||||
|
guestsStopped: 2,
|
||||||
|
nodes: [{ node: 'altknoten', cpu: 0.99, maxcpu: 8, mem: 99, maxmem: 100 }],
|
||||||
|
storages: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deaktiviert hat Vorrang: Pille „Offline & verwaist“, gestrichelter Rand, KEINE alten Messwerte', async () => {
|
||||||
|
const el = await card(makeServer({ isActive: false, status: staleMetrics }));
|
||||||
|
|
||||||
|
expect(el).toHaveAttribute('data-health', 'orphan');
|
||||||
|
expect(el).toHaveClass('border-dashed');
|
||||||
|
// gedaempft wird nur der Name (Vordergrundfarbe haelt 7,5:1), die Adresse bleibt lesbar
|
||||||
|
expect(within(el).getByText('pve-1')).toHaveClass('opacity-70', 'saturate-50');
|
||||||
|
expect(within(el).getByText('https://pve.intern')).not.toHaveClass('opacity-70');
|
||||||
|
expect(within(el).getByTestId('status-pill')).toHaveTextContent('Offline & verwaist');
|
||||||
|
expect(
|
||||||
|
within(el).getByText('Dieser Server ist deaktiviert und wird nicht mehr abgefragt.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(el).queryByTestId('guests-running')).not.toBeInTheDocument();
|
||||||
|
expect(within(el).queryByText('altknoten')).not.toBeInTheDocument();
|
||||||
|
expect(within(el).queryAllByTestId('meter')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deaktiviert und nicht erreichbar: keine Fehlermeldung, sondern der Verwaist-Hinweis', async () => {
|
||||||
|
const el = await card(
|
||||||
|
makeServer({ isActive: false, status: makeStatus({ reachable: false, errorKind: 'netz' }) }),
|
||||||
|
);
|
||||||
|
expect(within(el).queryByTestId('down-notice')).not.toBeInTheDocument();
|
||||||
|
expect(within(el).getByTestId('orphan-notice')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Admins bekommen den Hinweis auf die Einstellungen mit Verweis, andere nicht', async () => {
|
||||||
|
const admin = await card(makeServer({ isActive: false, status: staleMetrics }), true);
|
||||||
|
expect(
|
||||||
|
within(admin).getByText('Sie können ihn in den Einstellungen wieder aktivieren.', {
|
||||||
|
exact: false,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(admin).getByRole('link', { name: 'Zu den Einstellungen' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/modules/proxmox/settings',
|
||||||
|
);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const user = await card(makeServer({ isActive: false, status: staleMetrics }), false);
|
||||||
|
expect(within(user).queryByRole('link')).not.toBeInTheDocument();
|
||||||
|
expect(within(user).queryByText(/wieder aktivieren/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,88 +1,230 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import Link from 'next/link';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
formatAge,
|
||||||
|
formatCount,
|
||||||
|
formatPercent,
|
||||||
|
isBackupStale,
|
||||||
|
meterLevel,
|
||||||
|
ratio,
|
||||||
|
type ServerHealth,
|
||||||
|
serverHealth,
|
||||||
|
toEpochMs,
|
||||||
|
} from '@/components/proxmox/proxmox-status';
|
||||||
|
import { HEALTH_STYLE, METER_FILL, METER_TEXT, WELL } from '@/components/proxmox/status-styles';
|
||||||
import type {
|
import type {
|
||||||
ProxmoxErrorKind,
|
ProxmoxErrorKind,
|
||||||
ProxmoxPbsMetrics,
|
ProxmoxPbsMetrics,
|
||||||
ProxmoxPmgMetrics,
|
ProxmoxPmgMetrics,
|
||||||
|
ProxmoxProductType,
|
||||||
ProxmoxPveMetrics,
|
ProxmoxPveMetrics,
|
||||||
ProxmoxServer,
|
ProxmoxServer,
|
||||||
} from '@/lib/proxmox-api';
|
} from '@/lib/proxmox-api';
|
||||||
|
|
||||||
/**
|
type Translator = ReturnType<typeof useTranslations>;
|
||||||
* Wandelt einen rohen Byte-Wert in eine lesbare Form (B/KB/MB/GB/TB).
|
|
||||||
*/
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
||||||
let value = bytes;
|
|
||||||
let unitIndex = -1;
|
|
||||||
do {
|
|
||||||
value /= 1024;
|
|
||||||
unitIndex++;
|
|
||||||
} while (value >= 1024 && unitIndex < units.length - 1);
|
|
||||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prozentwert einer Auslastung (0..1) gerundet, z. B. `25%`. */
|
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
function formatPercent(fraction: number): string {
|
|
||||||
return `${Math.round(fraction * 100)}%`;
|
function isKnown(value: number | null | undefined): value is number {
|
||||||
|
return typeof value === 'number' && !Number.isNaN(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die EINZIGE Stelle, die einen Messwert in Text verwandelt (Aufgabe 6):
|
* „12,0 / 64,0 GB“ — beide Werte in DERSELBEN Einheit, gewaehlt nach der
|
||||||
* ist der Wert `null`/`undefined`, erscheint der uebersetzte Text
|
* Gesamtgroesse, Zahlformat nach Sprache (deutsch mit Komma). `null`, wenn
|
||||||
* "unbekannt" — sonst der Wert mit seiner Einheit. Dadurch kann kein Zweig
|
* einer der Werte unbekannt ist — dann steht an der Stelle nichts statt
|
||||||
* versehentlich eine `0` anzeigen, wo nichts gemessen wurde. Die Feldnamen
|
* einer erfundenen Zahl.
|
||||||
* von PBS und PMG sind bis zur Pruefung am echten Server nur abgeleitet
|
|
||||||
* (Recherche, Annahmen A2/A3/A5), und ein still falscher Wert waere
|
|
||||||
* schlimmer als ein ehrliches "unbekannt".
|
|
||||||
*/
|
*/
|
||||||
function formatMetric(
|
function formatBytePair(used: number | null, total: number | null, locale: string): string | null {
|
||||||
value: number | string | null | undefined,
|
if (!isKnown(used) || !isKnown(total) || total <= 0) return null;
|
||||||
unknownLabel: string,
|
const exponent = Math.min(
|
||||||
formatter?: (v: number) => string,
|
BYTE_UNITS.length - 1,
|
||||||
): string {
|
Math.max(0, Math.floor(Math.log(total) / Math.log(1024))),
|
||||||
if (value === null || value === undefined) return unknownLabel;
|
);
|
||||||
if (typeof value === 'number') {
|
const divisor = 1024 ** exponent;
|
||||||
if (Number.isNaN(value)) return unknownLabel;
|
const digits = exponent === 0 ? 0 : 1;
|
||||||
return formatter ? formatter(value) : String(value);
|
const nf = new Intl.NumberFormat(locale, {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
});
|
||||||
|
return `${nf.format(used / divisor)} / ${nf.format(total / divisor)} ${BYTE_UNITS[exponent]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exakter Zeitpunkt fuer das `title`-Attribut der relativen Angaben. */
|
||||||
|
function exactTime(value: number | string | null | undefined, locale: string): string | undefined {
|
||||||
|
const ms = toEpochMs(value);
|
||||||
|
if (ms === null) return undefined;
|
||||||
|
return new Date(ms).toLocaleString(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleines Produktsymbol je Typ: Server-Einschuebe, Archivkiste, Briefumschlag. */
|
||||||
|
function ProductIcon({ productType }: { productType: ProxmoxProductType }) {
|
||||||
|
const common = {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.75,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
} as const;
|
||||||
|
if (productType === 'pve') {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" {...common}>
|
||||||
|
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||||
|
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
||||||
|
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
|
||||||
|
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
|
||||||
|
<line x1="11" y1="7.5" x2="17" y2="7.5" />
|
||||||
|
<line x1="11" y1="16.5" x2="17" y2="16.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (productType === 'pbs') {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" {...common}>
|
||||||
|
<rect x="3" y="4" width="18" height="4" rx="1" />
|
||||||
|
<path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8" />
|
||||||
|
<line x1="10" y1="12" x2="14" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(value: string | number | null | undefined, unknownLabel: string): string {
|
|
||||||
if (value === null || value === undefined) return unknownLabel;
|
|
||||||
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return unknownLabel;
|
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetricsViewProps {
|
|
||||||
metrics: ProxmoxPveMetrics | ProxmoxPbsMetrics | ProxmoxPmgMetrics;
|
|
||||||
unknown: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unknown: string }) {
|
|
||||||
const t = useTranslations('proxmox');
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 text-sm">
|
<svg aria-hidden="true" {...common}>
|
||||||
<div>
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
{t('card.pve.nodeCount')}: {metrics.nodeCount}
|
<polyline points="3 7 12 13 21 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statuspille: Punkt + Wort, Flaeche in Statusfarbe mit 12 %. Keine Daueranimation. */
|
||||||
|
function StatusPill({ health, label }: { health: ServerHealth; label: string }) {
|
||||||
|
const style = HEALTH_STYLE[health];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="status-pill"
|
||||||
|
className={`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium ${style.pill}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${style.fill}`} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schmaler Balken mit Prozentzahl. Ein unbekannter Wert zeigt eine
|
||||||
|
* schraffierte Spur und das Wort „unbekannt“ — NIE 0 %.
|
||||||
|
*/
|
||||||
|
function Meter({
|
||||||
|
label,
|
||||||
|
fraction,
|
||||||
|
detail,
|
||||||
|
unknownLabel,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
fraction: number | null;
|
||||||
|
detail?: string | null;
|
||||||
|
unknownLabel: string;
|
||||||
|
locale: string;
|
||||||
|
}) {
|
||||||
|
const level = meterLevel(fraction);
|
||||||
|
const known = level !== 'unknown' && fraction !== null;
|
||||||
|
const width = known ? `${Math.min(100, Math.max(0, fraction * 100))}%` : '100%';
|
||||||
|
return (
|
||||||
|
<div data-testid="meter" data-level={level} className="min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className={`tabular-nums font-medium ${METER_TEXT[level]}`}>
|
||||||
|
{known ? formatPercent(fraction, locale) : unknownLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div aria-hidden="true" className="mt-1 h-1.5 overflow-hidden rounded-full bg-foreground/10">
|
||||||
{t('card.pve.guests', { running: metrics.guestsRunning, stopped: metrics.guestsStopped })}
|
{known ? (
|
||||||
|
<div className={`h-full rounded-full ${METER_FILL[level]}`} style={{ width }} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(135deg, var(--border) 0 4px, transparent 4px 8px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{detail && (
|
||||||
|
<div className="mt-1 text-[11px] tabular-nums text-muted-foreground">{detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PveMetricsView({
|
||||||
|
metrics,
|
||||||
|
t,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
metrics: ProxmoxPveMetrics;
|
||||||
|
t: Translator;
|
||||||
|
locale: string;
|
||||||
|
}) {
|
||||||
|
const unknown = t('card.unknownValue');
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className={`${WELL} flex items-end justify-between gap-4 px-4 py-3`}>
|
||||||
|
<div className="flex items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="text-2xl font-semibold leading-none tabular-nums"
|
||||||
|
data-testid="guests-running"
|
||||||
|
>
|
||||||
|
{formatCount(metrics.guestsRunning, locale)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t('card.pve.running', { count: metrics.guestsRunning })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="text-2xl font-semibold leading-none tabular-nums text-muted-foreground"
|
||||||
|
data-testid="guests-stopped"
|
||||||
|
>
|
||||||
|
{formatCount(metrics.guestsStopped, locale)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{t('card.pve.stopped')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('card.pve.nodes', { count: metrics.nodeCount })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{metrics.nodes.map((node) => (
|
{metrics.nodes.map((node) => (
|
||||||
<div key={node.node} className="rounded border border-border/60 px-2 py-1 text-xs">
|
<div
|
||||||
<div className="font-medium">{node.node}</div>
|
key={node.node}
|
||||||
<div>
|
data-testid={`pve-node-${node.node}`}
|
||||||
{t('card.pve.cpu')}: {formatMetric(node.cpu, unknown, formatPercent)}
|
className={`${WELL} grid grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] items-start gap-4 px-4 py-3`}
|
||||||
{' · '}
|
>
|
||||||
{t('card.pve.mem')}: {formatMetric(node.mem, unknown, formatBytes)}
|
<div className="truncate pt-0.5 text-sm font-medium" title={node.node}>
|
||||||
{node.maxmem !== null && node.mem !== null
|
{node.node}
|
||||||
? ` / ${formatMetric(node.maxmem, unknown, formatBytes)}`
|
</div>
|
||||||
: ''}
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Meter
|
||||||
|
label={t('card.pve.cpu')}
|
||||||
|
fraction={isKnown(node.cpu) ? node.cpu : null}
|
||||||
|
unknownLabel={unknown}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
<Meter
|
||||||
|
label={t('card.pve.mem')}
|
||||||
|
fraction={ratio(node.mem, node.maxmem)}
|
||||||
|
detail={formatBytePair(node.mem, node.maxmem, locale)}
|
||||||
|
unknownLabel={unknown}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -90,124 +232,258 @@ function PveMetricsView({ metrics, unknown }: { metrics: ProxmoxPveMetrics; unkn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PbsMetricsView({ metrics, unknown }: { metrics: ProxmoxPbsMetrics; unknown: string }) {
|
function PbsMetricsView({
|
||||||
const t = useTranslations('proxmox');
|
metrics,
|
||||||
|
t,
|
||||||
|
locale,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
metrics: ProxmoxPbsMetrics;
|
||||||
|
t: Translator;
|
||||||
|
locale: string;
|
||||||
|
now: number;
|
||||||
|
}) {
|
||||||
|
const unknown = t('card.unknownValue');
|
||||||
|
if (metrics.datastores.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">{t('card.pbs.noDatastores')}</p>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2">
|
||||||
{metrics.datastores.map((ds) => (
|
{metrics.datastores.map((ds) => {
|
||||||
<div key={ds.name} className="rounded border border-border/60 px-2 py-1 text-xs">
|
const stale = isBackupStale(ds.lastBackupAt, now);
|
||||||
<div className="font-medium">{ds.name}</div>
|
const backupAge = formatAge(ds.lastBackupAt, now, locale);
|
||||||
<div>
|
const verify =
|
||||||
{t('card.pbs.used')}: {formatMetric(ds.used, unknown, formatBytes)}
|
ds.lastVerifyState === null
|
||||||
{ds.total !== null && ds.used !== null ? ` / ${formatMetric(ds.total, unknown, formatBytes)}` : ''}
|
? { cls: HEALTH_STYLE.idle.pill, text: t('card.pbs.verifyUnknown') }
|
||||||
</div>
|
: ds.lastVerifyState === 'ok'
|
||||||
<div>
|
? { cls: HEALTH_STYLE.ok.pill, text: t('card.pbs.verifyOk') }
|
||||||
{t('card.pbs.lastBackup')}:{' '}
|
: {
|
||||||
{ds.lastBackupAt === null ? t('card.pbs.noBackupYet') : formatTimestamp(ds.lastBackupAt, unknown)}
|
cls: HEALTH_STYLE.warn.pill,
|
||||||
</div>
|
text: t('card.pbs.verifyFailed', { state: ds.lastVerifyState }),
|
||||||
<div>
|
};
|
||||||
{t('card.pbs.verifyState')}: {formatMetric(ds.lastVerifyState, unknown)}
|
return (
|
||||||
|
<div
|
||||||
|
key={ds.name}
|
||||||
|
data-testid={`pbs-datastore-${ds.name}`}
|
||||||
|
className={`${WELL} space-y-2.5 px-4 py-3`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate text-sm font-medium" title={ds.name}>
|
||||||
|
{ds.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testid="verify-pill"
|
||||||
|
title={ds.lastVerifyState ?? undefined}
|
||||||
|
className={`shrink-0 whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium ${verify.cls}`}
|
||||||
|
>
|
||||||
|
{verify.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Meter
|
||||||
|
label={t('card.pbs.used')}
|
||||||
|
fraction={ratio(ds.used, ds.total)}
|
||||||
|
detail={formatBytePair(ds.used, ds.total, locale)}
|
||||||
|
unknownLabel={unknown}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
{ds.lastBackupAt === null || backupAge === null ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('card.pbs.noBackupYet')}</p>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
data-testid="last-backup"
|
||||||
|
className={`text-xs ${stale ? `font-medium ${HEALTH_STYLE.warn.text}` : 'text-muted-foreground'}`}
|
||||||
|
title={exactTime(ds.lastBackupAt, locale)}
|
||||||
|
>
|
||||||
|
{t('card.pbs.lastBackupAgo', { age: backupAge })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PmgMetricsView({
|
||||||
|
metrics,
|
||||||
|
t,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
metrics: ProxmoxPmgMetrics;
|
||||||
|
t: Translator;
|
||||||
|
locale: string;
|
||||||
|
}) {
|
||||||
|
const unknown = t('card.unknownValue');
|
||||||
|
const tiles: Array<{ key: string; label: string; value: number | null; alarm: boolean }> = [
|
||||||
|
{ key: 'in', label: t('card.pmg.countIn'), value: metrics.countIn, alarm: false },
|
||||||
|
{ key: 'out', label: t('card.pmg.countOut'), value: metrics.countOut, alarm: false },
|
||||||
|
{ key: 'spam', label: t('card.pmg.spamCount'), value: metrics.spamCount, alarm: false },
|
||||||
|
{
|
||||||
|
key: 'virus',
|
||||||
|
label: t('card.pmg.virusCount'),
|
||||||
|
value: metrics.virusCount,
|
||||||
|
alarm: isKnown(metrics.virusCount) && metrics.virusCount > 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{tiles.map((tile) => (
|
||||||
|
<div key={tile.key} data-testid={`pmg-${tile.key}`} className={`${WELL} px-4 py-3`}>
|
||||||
|
<div className="text-xs text-muted-foreground">{tile.label}</div>
|
||||||
|
{isKnown(tile.value) ? (
|
||||||
|
<div
|
||||||
|
className={`mt-1 text-xl font-semibold leading-tight tabular-nums ${tile.alarm ? HEALTH_STYLE.down.text : ''}`}
|
||||||
|
>
|
||||||
|
{formatCount(tile.value, locale)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 text-sm leading-7 text-muted-foreground">{unknown}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PmgMetricsView({ metrics, unknown }: { metrics: ProxmoxPmgMetrics; unknown: string }) {
|
function errorMessage(t: Translator, kind: ProxmoxErrorKind | null): string {
|
||||||
const t = useTranslations('proxmox');
|
|
||||||
return (
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<div>
|
|
||||||
{t('card.pmg.countIn')}: {formatMetric(metrics.countIn, unknown)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('card.pmg.countOut')}: {formatMetric(metrics.countOut, unknown)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('card.pmg.spamCount')}: {formatMetric(metrics.spamCount, unknown)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('card.pmg.virusCount')}: {formatMetric(metrics.virusCount, unknown)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricsView({ metrics, unknown }: MetricsViewProps) {
|
|
||||||
if (metrics.productType === 'pve') return <PveMetricsView metrics={metrics} unknown={unknown} />;
|
|
||||||
if (metrics.productType === 'pbs') return <PbsMetricsView metrics={metrics} unknown={unknown} />;
|
|
||||||
return <PmgMetricsView metrics={metrics} unknown={unknown} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(t: ReturnType<typeof useTranslations>, kind: ProxmoxErrorKind | null): string {
|
|
||||||
return t(`errors.${kind ?? 'unbekannt'}`);
|
return t(`errors.${kind ?? 'unbekannt'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServerCardProps {
|
interface ServerCardProps {
|
||||||
server: ProxmoxServer;
|
server: ProxmoxServer;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
|
||||||
|
now?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anzeige EINES Servers (Aufgabe 6) — verzweigt ueber `productType` auf der
|
* Anzeige EINES Servers (quick-260924-h7x). Die Tiefe entsteht ueber den
|
||||||
* unterscheidbaren Union aus Aufgabe 3.
|
* Status: Leiste links, im Statuston getoenter Schatten, Pille oben rechts.
|
||||||
|
* Messwerte liegen in eingelassenen Feldern. Ein deaktivierter Server
|
||||||
|
* („offline & verwaist“) zeigt KEINE alten Messwerte mehr — auch wenn das
|
||||||
|
* Zwischenlager noch welche hat.
|
||||||
*/
|
*/
|
||||||
export function ServerCard({ server, isAdmin = false }: ServerCardProps) {
|
export function ServerCard({ server, isAdmin = false, now = Date.now() }: ServerCardProps) {
|
||||||
const t = useTranslations('proxmox');
|
const t = useTranslations('proxmox');
|
||||||
const unknown = t('card.unknownValue');
|
const locale = useLocale();
|
||||||
|
const health = serverHealth(server, now);
|
||||||
|
const style = HEALTH_STYLE[health];
|
||||||
const status = server.status;
|
const status = server.status;
|
||||||
|
const isOrphan = health === 'orphan';
|
||||||
|
const polledAge = formatAge(status?.lastPolledAt ?? null, now, locale);
|
||||||
|
|
||||||
|
let body: React.ReactNode = null;
|
||||||
|
if (health === 'orphan') {
|
||||||
|
body = (
|
||||||
|
<div className="space-y-1 text-sm" data-testid="orphan-notice">
|
||||||
|
<p className="text-foreground">{t('card.orphanText')}</p>
|
||||||
|
{isAdmin && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t('card.orphanAdminHint')}{' '}
|
||||||
|
<Link
|
||||||
|
href="/modules/proxmox/settings"
|
||||||
|
className="font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{t('card.settingsLink')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (health === 'idle') {
|
||||||
|
// Nicht-Admins sehen den Knopf nicht (der Poll-Endpunkt verlangt
|
||||||
|
// ADMIN/SUPER_ADMIN) und bekommen deshalb den Text ohne Knopfverweis
|
||||||
|
// (260923-le6).
|
||||||
|
body = (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isAdmin
|
||||||
|
? t('card.notPolledYet', { refreshLabel: t('card.refresh') })
|
||||||
|
: t('card.notPolledYetAutomatic')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
} else if (health === 'down' && status) {
|
||||||
|
const lastOkAge = formatAge(status.lastOkAt, now, locale);
|
||||||
|
body = (
|
||||||
|
<div
|
||||||
|
data-testid="down-notice"
|
||||||
|
className="space-y-1 rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-foreground">{errorMessage(t, status.errorKind)}</p>
|
||||||
|
{status.errorDetail && (
|
||||||
|
<p className="break-words text-xs text-muted-foreground">{status.errorDetail}</p>
|
||||||
|
)}
|
||||||
|
{lastOkAge && (
|
||||||
|
<p className="text-xs text-muted-foreground" title={exactTime(status.lastOkAt, locale)}>
|
||||||
|
{t('card.lastOkAgo', { age: lastOkAge })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (status?.metrics) {
|
||||||
|
const metrics = status.metrics;
|
||||||
|
body =
|
||||||
|
metrics.productType === 'pve' ? (
|
||||||
|
<PveMetricsView metrics={metrics} t={t} locale={locale} />
|
||||||
|
) : metrics.productType === 'pbs' ? (
|
||||||
|
<PbsMetricsView metrics={metrics} t={t} locale={locale} now={now} />
|
||||||
|
) : (
|
||||||
|
<PmgMetricsView metrics={metrics} t={t} locale={locale} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gedaempft (`opacity-70 saturate-50`) wird bei „verwaist“ nur Symbol und
|
||||||
|
// Name. Gedaempfte Schrift in `muted-foreground` fiele auf 2,75:1 (hell)
|
||||||
|
// bzw. 3,0:1 (dunkel) — gemessen — und damit unter 4,5:1; Produktname,
|
||||||
|
// Adresse, Fussangabe, Pille und Hinweistext bleiben deshalb voll lesbar.
|
||||||
|
// Der Name in Vordergrundfarbe haelt gedaempft noch 7,5:1 bzw. 7,1:1.
|
||||||
|
const dim = isOrphan ? 'opacity-70 saturate-50' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
<article
|
||||||
<div className="flex items-center justify-between">
|
data-testid="server-card"
|
||||||
<div>
|
data-health={health}
|
||||||
<div className="font-medium">{server.name}</div>
|
aria-label={`${server.name}: ${t(`health.${health}`)}`}
|
||||||
{/* Nachbesserung Befund 3: `uppercase` gilt nur dem Produktkuerzel, nicht der Adresse. */}
|
className={`relative flex h-full flex-col overflow-hidden rounded-lg bg-card ${
|
||||||
<div className="text-xs text-muted-foreground">
|
isOrphan ? 'border border-dashed border-border' : 'dark:border dark:border-border'
|
||||||
<span className="uppercase">{server.productType}</span> — {server.baseUrl}
|
} ${style.shadow}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 py-4 pl-5 pr-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={`${WELL} flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground ${dim}`}
|
||||||
|
>
|
||||||
|
<ProductIcon productType={server.productType} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className={`truncate font-semibold leading-tight ${dim}`} title={server.name}>
|
||||||
|
{server.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{t(`card.product.${server.productType}`)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground" title={server.baseUrl}>
|
||||||
|
{server.baseUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<StatusPill health={health} label={t(`health.${health}`)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{body && <div className="flex-1">{body}</div>}
|
||||||
|
|
||||||
|
{polledAge && (
|
||||||
|
<div
|
||||||
|
data-testid="last-polled"
|
||||||
|
className="mt-auto border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||||
|
title={exactTime(status?.lastPolledAt, locale)}
|
||||||
|
>
|
||||||
|
{t('card.lastPolledAgo', { age: polledAge })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{t('card.lastPolledLabel')}: {formatTimestamp(status?.lastPolledAt ?? null, unknown)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
Nachbesserung Befund 2: ein Server, der noch nie abgefragt wurde
|
|
||||||
(`lastPolledAt === null`), zeigt einen ruhigen Hinweis statt der
|
|
||||||
Fehlermeldung — die leere Zwischenlagerzeile aus `createServer` hat
|
|
||||||
`reachable: false` und `errorKind: null`, was sonst faelschlich als
|
|
||||||
"unbekannter Fehler" erschien. Nicht-Admins sehen den Knopf nicht
|
|
||||||
(der Poll-Endpunkt verlangt ADMIN/SUPER_ADMIN) und bekommen deshalb
|
|
||||||
den Text ohne Knopfverweis (260923-le6).
|
|
||||||
*/}
|
|
||||||
{status && !status.lastPolledAt && (
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{isAdmin ? t('card.notPolledYet', { refreshLabel: t('card.refresh') }) : t('card.notPolledYetAutomatic')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status?.lastPolledAt && !status.reachable && (
|
|
||||||
<div className="mt-2 space-y-1 text-sm">
|
|
||||||
<p className="text-destructive">
|
|
||||||
{errorMessage(t, status.errorKind)}
|
|
||||||
{status.errorDetail ? ` (${status.errorDetail})` : ''}
|
|
||||||
</p>
|
|
||||||
{status.lastOkAt && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('card.lastOkLabel')}: {formatTimestamp(status.lastOkAt, unknown)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status?.reachable && status.metrics && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<MetricsView metrics={status.metrics} unknown={unknown} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,67 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, 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 { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status';
|
||||||
import { ServerCard } from './components/ServerCard';
|
import { ServerCard } from './components/ServerCard';
|
||||||
|
import { WELL } from '@/components/proxmox/status-styles';
|
||||||
|
|
||||||
|
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
|
||||||
|
const CLOCK_TICK_MS = 60_000;
|
||||||
|
|
||||||
|
function RefreshIcon({ spinning }: { spinning: boolean }) {
|
||||||
|
return (
|
||||||
|
<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={spinning ? 'animate-spin motion-reduce:animate-none' : ''}
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||||
|
<polyline points="21 3 21 8 16 8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-card p-4 pl-5 shadow-sm dark:border dark:border-border">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-muted" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-3.5 w-1/3 rounded bg-muted" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-20 rounded-full bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-16 rounded-lg bg-muted" />
|
||||||
|
<div className="mt-2 h-12 rounded-lg bg-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modulseite (Aufgabe 6) — liest ausschliesslich aus dem Zwischenlager, das
|
* Modulseite (quick-260924-h7x, vorher 260923-dhh Aufgabe 6) — liest
|
||||||
* `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05).
|
* ausschliesslich aus dem Zwischenlager, das `GET servers` liefert; kein
|
||||||
* "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die
|
* Live-Zugriff bei Proxmox von hier aus (D-05).
|
||||||
* Liste danach neu — waehrend des Laufs ist der Knopf gesperrt. Der Knopf
|
*
|
||||||
* erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN,
|
* Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand
|
||||||
* SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6).
|
* (down, warn, ok, idle, orphan, darin `position`). „Jetzt aktualisieren“
|
||||||
|
* loest je Server eine Abfrage aus und laedt danach neu; der Knopf erscheint
|
||||||
|
* nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN, SUPER_ADMIN)`
|
||||||
|
* verlangt (260923-le6).
|
||||||
*/
|
*/
|
||||||
export default function ProxmoxPage() {
|
export default function ProxmoxPage() {
|
||||||
const t = useTranslations('proxmox');
|
const t = useTranslations('proxmox');
|
||||||
@@ -23,10 +71,14 @@ export default function ProxmoxPage() {
|
|||||||
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
listServers()
|
listServers()
|
||||||
.then(setServers)
|
.then((list) => {
|
||||||
|
setServers(list);
|
||||||
|
setNow(Date.now());
|
||||||
|
})
|
||||||
.catch(() => setError(t('loadError')));
|
.catch(() => setError(t('loadError')));
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
@@ -34,6 +86,11 @@ export default function ProxmoxPage() {
|
|||||||
reload();
|
reload();
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
if (!servers || servers.length === 0) return;
|
if (!servers || servers.length === 0) return;
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
@@ -45,63 +102,100 @@ export default function ProxmoxPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sorted = useMemo(() => (servers ? sortServersByHealth(servers, now) : []), [servers, now]);
|
||||||
|
const counts = useMemo(() => summarizeHealth(servers ?? [], now), [servers, now]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
moduleSlug="proxmox"
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
title={t('title')}
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
description={t('description')}
|
||||||
|
actions={
|
||||||
|
isAdmin ? (
|
||||||
|
<>
|
||||||
|
<Link href="/modules/proxmox/settings" className="btn btn-subtle">
|
||||||
|
{t('card.settingsNav')}
|
||||||
|
</Link>
|
||||||
|
{servers !== null && servers.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<RefreshIcon spinning={isRefreshing} />
|
||||||
|
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && servers === null && (
|
||||||
|
<div
|
||||||
|
aria-busy="true"
|
||||||
|
className="grid animate-pulse gap-4 motion-reduce:animate-none lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
<span className="sr-only">{t('loading')}</span>
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
)}
|
||||||
{isAdmin && servers !== null && servers.length > 0 && (
|
|
||||||
<button
|
{!error && servers !== null && servers.length === 0 && (
|
||||||
type="button"
|
<div className={`${WELL} flex flex-col items-center gap-3 px-6 py-10 text-center`}>
|
||||||
onClick={handleRefresh}
|
<svg
|
||||||
disabled={isRefreshing}
|
aria-hidden="true"
|
||||||
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
width="28"
|
||||||
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
|
height="28"
|
||||||
</button>
|
viewBox="0 0 24 24"
|
||||||
)}
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||||
|
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
||||||
|
<line x1="7" y1="7.5" x2="7.01" y2="7.5" />
|
||||||
|
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
|
||||||
|
</svg>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">{t('emptyState')}</p>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link
|
<Link
|
||||||
href="/modules/proxmox/settings"
|
href="/modules/proxmox/settings"
|
||||||
className="text-sm text-primary hover:underline"
|
className="text-sm font-medium text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
{t('card.settingsLink')}
|
{t('card.settingsLink')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
{!error && servers === null && (
|
|
||||||
<p className="text-sm text-muted-foreground">{t('loading')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!error && servers !== null && servers.length === 0 && (
|
|
||||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
|
||||||
{t('emptyState')}
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<Link href="/modules/proxmox/settings" className="text-primary hover:underline">
|
|
||||||
{t('card.settingsLink')}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && servers !== null && servers.length > 0 && (
|
{!error && servers !== null && servers.length > 0 && (
|
||||||
<ul className="space-y-3">
|
<>
|
||||||
{servers.map((server) => (
|
<HealthBar counts={counts} />
|
||||||
<li key={server.id}>
|
{/* Spaltenfluss statt Zeilenraster: eine kurze Karte (z. B. "nicht
|
||||||
<ServerCard server={server} isAdmin={isAdmin} />
|
erreichbar") liesse im Raster eine Luecke bis zur hoeheren
|
||||||
</li>
|
Nachbarkarte; so rueckt die naechste Karte direkt nach. */}
|
||||||
))}
|
<ul className="gap-4 lg:columns-2">
|
||||||
</ul>
|
{sorted.map((server) => (
|
||||||
|
<li key={server.id} className="mb-4 min-w-0 break-inside-avoid">
|
||||||
|
<ServerCard server={server} isAdmin={isAdmin} now={now} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
|
||||||
|
// Echter next-intl-Provider mit den deutschen Texten (quick-260924-h7x) —
|
||||||
|
// ServerCard braucht `useLocale` und ICU-Plural.
|
||||||
|
function render(ui: ReactElement) {
|
||||||
|
return rtlRender(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
{ui}
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Mock @/lib/proxmox-api — die Seite ruft `listServers` beim Laden auf,
|
// Mock @/lib/proxmox-api — die Seite ruft `listServers` beim Laden auf,
|
||||||
// "Jetzt aktualisieren" ruft `pollServer` je Server.
|
// "Jetzt aktualisieren" ruft `pollServer` je Server.
|
||||||
@@ -26,34 +39,6 @@ vi.mock('next/link', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
|
||||||
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
|
|
||||||
const translations: Record<string, string> = {
|
|
||||||
title: 'Proxmox',
|
|
||||||
description: 'Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick.',
|
|
||||||
loading: 'Lade Serverliste...',
|
|
||||||
loadError: 'Die Serverliste konnte nicht geladen werden.',
|
|
||||||
emptyState:
|
|
||||||
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
|
|
||||||
'card.refresh': 'Jetzt aktualisieren',
|
|
||||||
'card.refreshing': 'Wird aktualisiert...',
|
|
||||||
'card.settingsLink': 'Zu den Einstellungen',
|
|
||||||
'card.unknownValue': 'unbekannt',
|
|
||||||
'card.lastPolledLabel': 'Letzte Abfrage',
|
|
||||||
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
|
|
||||||
'card.notPolledYetAutomatic':
|
|
||||||
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
|
|
||||||
};
|
|
||||||
let result = translations[key] ?? key;
|
|
||||||
if (params) {
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
result = result.replace(`{${k}}`, String(v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
|
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
|
||||||
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
|
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
|
||||||
selector({ user }),
|
selector({ user }),
|
||||||
@@ -176,3 +161,119 @@ describe('ProxmoxPage role gating (260923-le6)', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ein erreichbarer, abgefragter Server; Zustand ueber die Ueberschreibungen.
|
||||||
|
function makePolledServer(
|
||||||
|
overrides: Partial<ProxmoxServer> = {},
|
||||||
|
statusOverrides: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const base = makeUnpolledServer(overrides);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: {
|
||||||
|
...base.status,
|
||||||
|
lastPolledAt: new Date(Date.now() - 3 * 60_000).toISOString(),
|
||||||
|
lastOkAt: new Date(Date.now() - 3 * 60_000).toISOString(),
|
||||||
|
reachable: true,
|
||||||
|
...statusOverrides,
|
||||||
|
},
|
||||||
|
} as ProxmoxServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProxmoxPage — Gesundheitsbalken und Sortierung (quick-260924-h7x)', () => {
|
||||||
|
const servers = [
|
||||||
|
makePolledServer({ id: 'a', name: 'ok-server', position: 0 }),
|
||||||
|
makePolledServer(
|
||||||
|
{ id: 'b', name: 'down-server', position: 1 },
|
||||||
|
{ reachable: false, errorKind: 'netz' },
|
||||||
|
),
|
||||||
|
makePolledServer({ id: 'c', name: 'orphan-server', position: 2, isActive: false }),
|
||||||
|
makePolledServer(
|
||||||
|
{ id: 'd', name: 'warn-server', position: 3, productType: 'pmg' },
|
||||||
|
{ metrics: { productType: 'pmg', countIn: 1, countOut: 1, spamCount: 0, virusCount: 2 } },
|
||||||
|
),
|
||||||
|
makeUnpolledServer({ id: 'e', name: 'idle-server', position: 4 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
it('Karten stehen in der Reihenfolge down, warn, ok, idle, orphan', async () => {
|
||||||
|
mockUser({ role: 'USER' });
|
||||||
|
mockListServers.mockResolvedValue(servers);
|
||||||
|
|
||||||
|
const { default: ProxmoxPage } = await import('./page');
|
||||||
|
render(<ProxmoxPage />);
|
||||||
|
await screen.findByText('ok-server');
|
||||||
|
|
||||||
|
const order = screen.getAllByTestId('server-card').map((c) => c.getAttribute('data-health'));
|
||||||
|
expect(order).toEqual(['down', 'warn', 'ok', 'idle', 'orphan']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Balken liest die Zusammenfassung vor und hat je belegtem Zustand ein Segment', async () => {
|
||||||
|
mockUser({ role: 'USER' });
|
||||||
|
mockListServers.mockResolvedValue(servers);
|
||||||
|
|
||||||
|
const { default: ProxmoxPage } = await import('./page');
|
||||||
|
render(<ProxmoxPage />);
|
||||||
|
await screen.findByText('ok-server');
|
||||||
|
|
||||||
|
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
|
||||||
|
expect(bar).toHaveAccessibleName(
|
||||||
|
'Zustand der Server: 1 nicht erreichbar, 1 mit Warnung, 1 in Ordnung, 1 noch nicht abgefragt, 1 offline & verwaist',
|
||||||
|
);
|
||||||
|
expect(Array.from(bar.children).map((s) => s.getAttribute('data-segment'))).toEqual([
|
||||||
|
'down',
|
||||||
|
'warn',
|
||||||
|
'ok',
|
||||||
|
'idle',
|
||||||
|
'orphan',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leere Zustaende entfallen in Balken und Legende', async () => {
|
||||||
|
mockUser({ role: 'USER' });
|
||||||
|
mockListServers.mockResolvedValue([
|
||||||
|
makePolledServer({ id: 'a', name: 'eins', position: 0 }),
|
||||||
|
makePolledServer({ id: 'b', name: 'zwei', position: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { default: ProxmoxPage } = await import('./page');
|
||||||
|
render(<ProxmoxPage />);
|
||||||
|
await screen.findByText('eins');
|
||||||
|
|
||||||
|
const bar = screen.getByRole('img', { name: /Zustand der Server/ });
|
||||||
|
expect(bar).toHaveAccessibleName('Zustand der Server: 2 in Ordnung');
|
||||||
|
expect(bar.children).toHaveLength(1);
|
||||||
|
const legend = within(screen.getByTestId('health-bar')).getByRole('list', { hidden: true });
|
||||||
|
expect(legend.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waehrend des Ladens erscheinen Skelett-Karten ohne Daueranimation bei reduzierter Bewegung', async () => {
|
||||||
|
mockUser({ role: 'USER' });
|
||||||
|
mockListServers.mockReturnValue(new Promise(() => undefined));
|
||||||
|
|
||||||
|
const { default: ProxmoxPage } = await import('./page');
|
||||||
|
const { container } = render(<ProxmoxPage />);
|
||||||
|
|
||||||
|
const busy = container.querySelector('[aria-busy="true"]');
|
||||||
|
expect(busy).not.toBeNull();
|
||||||
|
expect(busy).toHaveClass('animate-pulse', 'motion-reduce:animate-none');
|
||||||
|
expect(screen.getByText('Lade Serverliste...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Einstellungen-Verweis nur fuer Admins', async () => {
|
||||||
|
mockUser({ role: 'USER' });
|
||||||
|
mockListServers.mockResolvedValue([makePolledServer()]);
|
||||||
|
const { default: ProxmoxPage } = await import('./page');
|
||||||
|
render(<ProxmoxPage />);
|
||||||
|
await screen.findByText('pve-1');
|
||||||
|
expect(screen.queryByRole('link', { name: 'Einstellungen' })).not.toBeInTheDocument();
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
mockUser({ role: 'ADMIN' });
|
||||||
|
render(<ProxmoxPage />);
|
||||||
|
await screen.findByText('pve-1');
|
||||||
|
expect(screen.getByRole('link', { name: 'Einstellungen' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/modules/proxmox/settings',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user