16 Commits

Author SHA1 Message Date
schalli af78157536 docs(changelog): Version 1.8.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m2s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m21s
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:48:47 +02:00
schalli 7e70fc4d32 feat(custom-modules): besuchte eigene Module offen halten (Keep-Alive, hoechstens 5)
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m37s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m12s
iframes liegen dauerhaft in einem Behaelter im Portal-Rahmen und werden per
position: fixed deckungsgleich ueber den Platzhalter der Modulseite gelegt;
beim Wegnavigieren nur versteckt (visibility), nie neu eingehaengt. Name/Adresse
je Modul im Sitzungsspeicher, Nachladen im Hintergrund, 404 verwirft. Abmelden
und Loeschen leeren. Im Browser nachgewiesen: gleiches iframe-Element und kein
neuer Seitenabruf nach Dashboard -> Modul B -> Modul A; folgt Seitenleiste
ein-/ausgeklappt und Handybreite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:02:50 +02:00
schalli f78b422abf fix(dashboard): nie scrollen – Inhalt hoeher als die Leinwand wird eingepasst
Tessera CI/CD / Lint & Type Check (push) Successful in 1m29s
Tessera CI/CD / Tests (push) Successful in 1m43s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 22s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m45s
Die Einpassung nimmt max(Leinwandhoehe, Rasterhoehe) (fitCanvasToContent).
Anlass: Dashboard des Nutzers 940 px hoch bei Leinwand 849 px, dadurch
scrollte es ueberall. Im Bearbeitungsmodus bleibt die Hoehe vom Beginn des
Bearbeitens stehen, damit der Massstab beim Ziehen nicht wandert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:55:50 +02:00
schalli c846eb49cb style(favorites): Kachelansicht enger, lange Namen kleiner und zweizeilig
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m58s
Spaltenmindestbreite 76 -> 52 px (Abstand zwischen den Symbolen ~59 -> ~27 px
bei einer 373 px breiten Kachel). LauncherLabel misst den Titel einzeilig in
12 px; passt er nicht, 10 px, zweizeilig mit Silbentrennung und Auslassung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:33:20 +02:00
schalli af87c2c112 feat(dashboard): auf jedem Bildschirm dasselbe Bild – Leinwand je Reiter, massstaeblich skaliert
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m54s
Je Reiter wird die Flaeche des ersten Desktop-Bildschirms als __canvas im
Layout-JSON gespeichert (ohne API/DB-Aenderung, GRID_VERSION bleibt 3).
Das Raster rendert in Leinwandbreite und wird per transform: scale(min(bw/cw, bh/ch))
eingepasst, Schrift eingeschlossen, ohne Scrollen; unter 768 px wie bisher.
Ziehen/Groesse aendern unter Skalierung ueber eine eigene Positionsstrategie
(createScaledStrategy aus react-grid-layout 2.2.3 rechnet den Rasterversatz falsch).
Im Browser nachgewiesen: 1920x1080 -> 1366x768 (Faktor 0,66), Ziehen +200 px
folgt der Maus, 700 px ohne Skalierung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:19:27 +02:00
schalli bd73fa5e74 docs(proxmox): Lesezugang anlegen – API-Token fuer PVE/PBS, eigener Auditor-Benutzer fuer PMG
PMG kennt keine API-Token (Proxmox-Bugzilla 5849, offen); Schritt-fuer-Schritt
fuer Oberflaeche und Kommandozeile, Rolle an Benutzer UND Token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:12:13 +02:00
schalli 86ad95f74e docs: Windows-Test bestanden, Review-Fixes seit 26.09., Uebergabe verbraucht
Tessera CI/CD / Lint & Type Check (push) Successful in 46s
Tessera CI/CD / Tests (push) Successful in 1m19s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m30s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m12s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:23:59 +02:00
schalli 12214a948e fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund
- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis
- Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt
- optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht
- Titel-Schalter mit fester Beschriftung + aria-pressed
- Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog
- Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck
- Uebersetzungen und CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli 071082983b fix(desktop,favorites): keine zweite Update-Installation, Lesegrenzen bei der Symbolsuche
- Desktop: Merker "Installation laeuft" sperrt Pruefschleife und Klick; ein angebotenes Update bleibt nach fehlgeschlagener Pruefung per Klick installierbar
- Desktop: Benachrichtigungsrecht erst nach erfolgreichem add_capability vermerken
- Favoriten: HTML nur bis MAX_HTML_CHARS und hoechstens 4 s lesen, Nicht-HTML-Antworten verwerfen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli c2e4467dd8 fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung
- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung)
- Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck
- UpdateReminderDto lehnt null ab (400 statt 500)
- keine Mails an deaktivierte Benutzer
- Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt
- Bearbeiten schickt dueAt nur bei geaenderter Zeit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli be1e0035e0 fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt
- PATCH mit null fuer name/url/category ergibt 400 statt 500
- Modulansicht unterscheidet Ladefehler von "nicht gefunden"
- Formular/Loeschdialog nennen 403 und 400 eigens
- eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten
- neue Eintraege sind mit "Eigene Module" vorbelegt
- Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli f7213f5e45 wip: pausiert nach 1.7.0-Folgearbeiten, Windows-Test offen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:49:15 +02:00
schalli e72814abd9 docs(quick-260929-lh3): Favoriten-Symbole, Browser-Pruefung
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m21s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:48:39 +02:00
schalli 0e72ad45f8 docs(changelog): Favoriten-Symbole
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:47:23 +02:00
schalli b15c74632b fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann
- API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft
  und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr
- Erkennung: Seite mit Fehlerstatus, aber HTML mit <link rel=icon>, liefert
  diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht
- Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) ->
  Origin-Favicon -> Buchstabe
- Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:34:58 +02:00
schalli 7188c5b958 fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt
- eine neue, ausdruecklich eingetragene Logo-Adresse verdraengt ein frueher
  hochgeladenes Symbol (Vorrang der Datei liess die Adresse unsichtbar)
- iconVersion steigt mit, die Kachel laedt das Bild neu
- Regressionstests: Upload wird ersetzt, unveraenderte Adresse laesst Upload stehen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:32:30 +02:00
68 changed files with 4611 additions and 1018 deletions
+3 -2
View File
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
status: verified 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-30
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0 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:
@@ -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-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9)
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -482,6 +482,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) | | 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) | | 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) | | 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
## Deferred Items ## Deferred Items
@@ -0,0 +1,67 @@
---
quick_id: 260929-lh3
type: quick
wave: 1
autonomous: true
---
# Quick 260929-lh3: Favoriten — eigene Symbol-Adresse wirkt nicht
## User reports (29.09.2026, alpha 8c644de)
1. Favorite with URL https://docuvita.ctl.local/server/services/web/ shows a black circle with a white "V"
instead of the page's favicon (visible in the browser tab).
2. Setting an explicit icon URL ("Symbol-Adresse", field `iconUrl`) to
https://nextcloud.com/c/uploads/2025/10/Nextcloud_01-standard-logo.png on a favorite does not change the shown icon.
## Measured facts (orchestrator, from inside the alpha api container)
- docuvita.ctl.local resolves (172.16.0.46). Server-side GET of the page returns **400** but the HTML contains
`<link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" />`.
Server-side GET of that icon returns **404 text/html** (also with a Chrome User-Agent). Root /favicon.ico → 404.
→ docuvita refuses the files to the server; the browser can load them (user sees the icon in the tab).
- Discovery (`apps/api/src/favorites/icon-discovery.service.ts` `discoverFavoriteIconUrl`) ignores non-2xx HTML
(`fetchHtml` returns null) → falls back to `{origin}/favicon.ico`; proxy fails → browser direct
`{origin}/favicon.ico` shows the "V" (a real icon served to browsers at the root).
- Explicit `iconUrl` that the server cannot fetch → API answers 422 `iconUrlUnreachable` (quick 260923-lrr),
so the user cannot set the docuvita icon URL at all.
## Task 1: Explicit icon URL change must show immediately (bug 2)
- files: apps/api/src/favorites/*, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/lib/favorites-api.ts (+ tests)
- action: Reproduce locally (admin/admin123, favorites widget): set/change `iconUrl` to a reachable PNG (e.g. the Nextcloud URL,
and a second different one). Find why the tile keeps the old image — likely the icon proxy URL
(`/favorites/:id/icon?...`) does not change when `iconUrl` changes (browser/HTTP cache, Cache-Control on the proxy
response, `iconVersion` only bumped on upload, or the server returns a cached/discovered icon instead of the explicit one).
Fix at the root: the explicit `iconUrl` wins over discovery, and any change of `iconUrl` changes the image URL
(e.g. cache-buster from `iconVersion` bumped on every iconUrl change, or a hash of iconUrl). Add regression tests
(API: PATCH iconUrl bumps version / proxy serves new bytes; web: tile src changes when iconUrl changes).
- verify: api + web tests for favorites green.
- done: commit `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt`.
## Task 2: Accept icon URLs the server cannot fetch; browser loads them directly (bug 1)
- action:
- API: an explicit `iconUrl` that is a valid http/https URL is stored even if the server cannot fetch it
(no more 422 for "unreachable"; keep validation of scheme/length and keep rejecting non-image responses only
when the server DID get a response with a non-image content type — decide and document). Keep SSRF guard for
server-side fetches unchanged.
- Web tile: chain for an explicit `iconUrl`: proxy image → on error the browser loads `iconUrl` directly
(`<img>` with referrerPolicy="no-referrer", only http/https) → letter fallback. Existing chain for discovered icons unchanged.
- Discovery improvement (small, safe): if the page answers non-2xx but returns HTML with a `<link rel=icon>`,
still use that icon URL (so docuvita-like servers yield `/webclient/.../favicon.ico`, which the browser can then load directly).
- Remove/adjust the now-unused `iconUrlUnreachable` error text (de/en) if no longer reachable.
- verify: api + web favorites tests green; type-check/lint green; biome web ≤ 55, api ≤ 82.
- done: commit `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann`.
## Task 3: CHANGELOG + rebuild
- CHANGELOG `## Unveröffentlicht` → `### Behoben`: two plain-German bullets (Sie-Form) for both fixes.
- `docker compose up -d --build web api`.
- Commit `docs(changelog): Favoriten-Symbole`.
## Constraints
- Commit locally only, NEVER git push. Commits end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
- Commit with explicit paths only.
- Browser check is done by the orchestrator.
@@ -0,0 +1,82 @@
---
quick_id: 260929-lh3
phase: quick
plan: 260929-lh3
subsystem: favorites
tags: [favorites, icons, icon-discovery, browser-fallback]
status: complete
commits: 3
plan_head_before: 8c644de5dad56a0394a14a00180a125919565246
plan_head_after: 0e72ad45f8833cb93ae9a8c0afa1f6d4e948e3e0
actuals:
tasks: 3
commits: 3
key-files:
modified:
- apps/api/src/favorites/favorites.service.ts
- apps/api/src/favorites/icon-discovery.service.ts
- apps/api/src/favorites/dto/create-favorite.dto.ts
- apps/api/src/favorites/dto/update-favorite.dto.ts
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
- apps/web/src/lib/favorites-api.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- CHANGELOG.md
---
# Quick 260929-lh3: Favoriten-Symbol-Adresse Summary
Explicit icon URLs are now stored even when the server cannot fetch them, the tile loads them directly in the browser, discovery uses `<link rel=icon>` from non-2xx HTML pages, and a newly entered icon URL replaces a previously uploaded icon.
## Commits
- 7188c5b `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt` (Task 1)
- b15c746 `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann` (Task 2)
- 0e72ad4 `docs(changelog): Favoriten-Symbole` (Task 3)
## Root cause, bug 2 ("Symbol-Adresse wirkt nicht")
Measured, not assumed:
- Local reproduction (API with curl, and the real widget in headless Chromium via CDP): changing `iconUrl` on a favorite WITHOUT an uploaded icon works. `iconVersion` is bumped, the tile is remounted, the `<img>` src changes (`?v=1` -> `?v=2`), and the bytes change (naturalWidth 626 -> 48). The proxy, `Cache-Control` and Next rewrite are not the cause.
- The alpha database (read-only psql) shows the save path works there too: favorite "Medon" holds the Nextcloud URL with `iconVersion = 1`. No favorite on alpha has `uploadedIconMime` set, and the alpha web image contains the `?v=` code.
- The real defect found in the code: `getIconBytes` always serves the uploaded file when `uploadedIconMime` is set, and `update()` never cleared it. A newly entered `iconUrl` was therefore saved but invisible for any favorite with an uploaded icon (the form even said "Ein hochgeladenes Symbol hat Vorrang"). Fixed: a new, different, non-empty `iconUrl` now clears `uploadedIconMime`, removes the file, and bumps `iconVersion`. An unchanged `iconUrl` (the form resends it on every save) leaves the upload alone.
- Caveat for the orchestrator: for the exact alpha "Medon" case (no upload) I could not reproduce a stale display; the alpha row and local browser behavior are both correct. The browser check on alpha (https, NPM, basic auth) remains the only place this can still show. If it still fails there with the new build, capture the network request of `/api-proxy/favorites/<id>/icon?v=1` in the alpha browser.
## Root cause, bug 1 (black circle with "V")
`fetchHtml` dropped every non-2xx response, so docuvita (answers the server with 400 but ships `<link rel="SHORTCUT ICON" href="/webclient/.../favicon.ico">`) fell back to `{origin}/favicon.ico`; the proxy failed and the browser showed the root favicon (the "V"). And an explicit `iconUrl` was rejected by the 422 fetch probe because docuvita returns 404 HTML to the server.
## Changes
- API: `assertIconUrlLoadable` (422) replaced by `assertIconUrlWellFormed` (http/https, <= 2048 chars, else 400); DTO `@MaxLength(2048)` on both DTOs. Decision, documented in code: even a response the server DID receive with a non-image type does not reject, because "server gets no image" does not mean "browser gets none". SSRF guard for server-side fetches is unchanged.
- Discovery: `fetchWithRedirectGuard` got `allowErrorStatus` (used only by the HTML search; `fetchIconBytes` stays strict). On a non-2xx page only `<link rel=...icon>` counts, not `og:image`.
- Web tile: proxy -> `iconUrl` direct (`referrerPolicy="no-referrer"`, http/https only) -> `{origin}/favicon.ico` (skipped when identical) -> letter. Existing chain for discovered icons behaves as before.
- Removed the `iconUrlUnreachable` reason, 422 handling and de/en texts; adjusted the upload hint (a newly entered address replaces an upload).
- CHANGELOG: two plain-German bullets under Unveröffentlicht / Behoben.
- Rebuilt `web` and `api` (`docker compose up -d --build`, healthy). Smoke test against the rebuilt API: URL the server cannot fetch is saved (proxy answers 502, tile falls back to browser), `javascript:` is rejected with 400. Test favorite deleted afterwards.
## Verification
- API favorites specs: 99 tests green. Web favorites widget, favorites-api and messages tests: 47 green. `tsc --noEmit` clean for web and api.
- Biome: web 55 warnings, api 82 warnings (at the limits, not above).
## Deviations from Plan
- [Rule 2 - Missing validation] Plan said "keep validation of scheme/length", but none existed for `iconUrl`; added form validation (service + DTO length) so the SSRF-relevant direct browser load never receives non-http(s) schemes.
- Task 1 needed no web change: the existing test "Speichern mit neuer Logo-Adresse ... ?v=1" already covers the src change; API regression tests were added.
- Commits were made on `main` as instructed (no worktree).
## Known Stubs
None.
## Self-Check: PASSED
Commits 7188c5b, b15c746, 0e72ad4 exist; SUMMARY location correct; PLAN/SUMMARY/STATE not committed.
## Browser-Pruefung (Orchestrator, 29.09.)
- Favorit „Claude“: iconUrl -> Nextcloud-PNG (PATCH 200), nach Neuladen Proxy-Bild ?v=5 mit 626 px Breite geladen.
- iconUrl -> docuvita-Brand-Icon (vom Dev-Host nicht aufloesbar): PATCH 200 (frueher 422), Kachel faellt sauber zurueck. Im Firmennetz laedt der Browser direkt.
- Zurueckgesetzt auf das urspruengliche Symbol.
+17
View File
@@ -4,17 +4,34 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
## 1.8.0 – 2026-09-30
### Neu ### Neu
- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version. - Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version.
### Geändert ### Geändert
- Eigene Module: Einmal geöffnete Seiten bleiben im Hintergrund offen. Wechseln Sie zurück, ist die Seite sofort da – im selben Zustand, ohne neu zu laden. Tessera hält die fünf zuletzt benutzten offen; beim Abmelden werden sie geschlossen.
- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen.
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Ist ein Dashboard länger als der Bildschirm, wird es so weit verkleinert, dass es ganz hineinpasst. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts. - Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
### Behoben ### Behoben
- Eigene Module: Lässt sich ein Eintrag nicht laden, sagt Tessera das jetzt, statt „nicht gefunden“ zu melden. Fehlende Berechtigung und ungültige Angaben werden beim Speichern und Löschen eigens genannt.
- Seitenleiste: Eingeklappt stehen eigene Module jetzt bei ihrer Kategorie, in derselben Reihenfolge wie ausgeklappt.
- Erinnerungen: Ohne Browser-Speicher (etwa im privaten Fenster) kam dieselbe Benachrichtigung alle 10 Sekunden – jetzt nur einmal.
- Erinnerungen: Nach einer Änderung von Datum oder Uhrzeit kommt die E-Mail zuverlässig zur neuen Zeit. Deaktivierte Benutzer bekommen keine Erinnerungs-E-Mails mehr.
- Erinnerungen: „Später erinnern“ zeigt „Heute um …“, wenn die Uhrzeit heute noch kommt, statt fälschlich „Morgen um …“. Speichern ohne Zeitänderung verschiebt die Fälligkeit nicht mehr um Sekunden.
- Dashboard: Wird ein Bild gelöscht, das als Hintergrund gewählt war, gilt wieder „kein Hintergrund“.
- Dashboard: Ein noch offener Tab mit älterer Tessera-Version kann die Anordnung nicht mehr verziehen; er bittet stattdessen, die Seite neu zu laden.
- Desktop-App: Während ein Update installiert wird, bietet das Menü kein zweites mehr an. Nach einer fehlgeschlagenen Update-Prüfung genügt wieder ein Klick zum Installieren.
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet. - Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
- Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es.
- Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen.
## 1.7.0 – 2026-09-29 ## 1.7.0 – 2026-09-29
@@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => {
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category'); expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name'); expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
}); });
it.each([
'name',
'url',
'category',
])('lehnt %s: null ab statt es durchzulassen', async (field) => {
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
});
}); });
@@ -78,7 +78,11 @@ export class CreateCustomModuleDto {
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist, * `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld * aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
* dank `whitelist: true`). * dank `whitelist: true`).
* `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein
* ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst
* liefe `{"name": null}` bis in die Datenbank und endete als 500.
*/ */
export class UpdateCustomModuleDto extends PartialType( export class UpdateCustomModuleDto extends PartialType(
OmitType(CreateCustomModuleDto, ['shared'] as const), OmitType(CreateCustomModuleDto, ['shared'] as const),
{ skipNullProperties: false },
) {} ) {}
@@ -21,7 +21,11 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
}), }),
})); }));
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
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 { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardImagesService } from './dashboard-images.service'; import { DashboardImagesService } from './dashboard-images.service';
@@ -73,11 +77,21 @@ interface BoundCall {
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>; type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
interface UserRow {
id: string;
dashboardBackground: unknown;
}
interface FakePrisma { interface FakePrisma {
dashboardImage: ModelMethods; dashboardImage: ModelMethods;
user: ModelMethods;
__rows: ImageRow[]; __rows: ImageRow[];
__users: UserRow[];
__boundCallLog: BoundCall[]; __boundCallLog: BoundCall[];
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods }; __makeBoundClient(
tenantId: string,
userId?: string,
): { dashboardImage: ModelMethods; user: 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]);
@@ -147,8 +161,28 @@ function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
return out; return out;
} }
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma { function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
const boundCallLog: BoundCall[] = []; const boundCallLog: BoundCall[] = [];
// quick-260930: Hintergrund-Wahl (`User.dashboardBackground`) — bedingtes
// updateMany ueber den JSON-Pfad `imageId`, wie Prisma es auf PostgreSQL filtert.
const user: ModelMethods = {
updateMany: vi.fn(async (raw: unknown) => {
const args = raw as {
where: { id: string; dashboardBackground: { path: string[]; equals: unknown } };
data: { dashboardBackground: unknown };
};
const [key] = args.where.dashboardBackground.path;
let count = 0;
for (const u of users) {
const bg = u.dashboardBackground as Record<string, unknown> | null;
if (u.id !== args.where.id || !bg || bg[key] !== args.where.dashboardBackground.equals)
continue;
u.dashboardBackground = args.data.dashboardBackground;
count++;
}
return { count };
}),
};
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 {
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}), }),
count: vi.fn(async (raw: unknown) => { count: vi.fn(async (raw: unknown) => {
const args = raw as { where: { tenantId: string; userId: string } }; const args = raw as { where: { tenantId: string; userId: string } };
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length; return rows.filter(
(r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId,
).length;
}), }),
create: vi.fn(async (raw: unknown) => { create: vi.fn(async (raw: unknown) => {
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> }; const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') }); const created = makeRow({
id: `new-${rows.length + 1}`,
...args.data,
createdAt: new Date('2026-02-02'),
});
rows.push(created); rows.push(created);
return pick(created, args.select); return pick(created, args.select);
}), }),
@@ -196,20 +236,29 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}), }),
}; };
function wrap(tenantId: string, userId?: string) { function wrapModel(model: string, methods: ModelMethods, tenantId: string, userId?: string) {
const wrapped: ModelMethods = {}; const wrapped: ModelMethods = {};
for (const method of Object.keys(dashboardImage)) { for (const method of Object.keys(methods)) {
wrapped[method] = async (...args: unknown[]) => { wrapped[method] = async (...args: unknown[]) => {
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method }); boundCallLog.push({ tenantId, userId, model, method });
return dashboardImage[method](...args); return methods[method](...args);
}; };
} }
return { dashboardImage: wrapped }; return wrapped;
}
function wrap(tenantId: string, userId?: string) {
return {
dashboardImage: wrapModel('dashboardImage', dashboardImage, tenantId, userId),
user: wrapModel('user', user, tenantId, userId),
};
} }
const fake: FakePrisma = { const fake: FakePrisma = {
dashboardImage, dashboardImage,
user,
__rows: rows, __rows: rows,
__users: users,
__boundCallLog: boundCallLog, __boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string, userId?: string) { __makeBoundClient(tenantId: string, userId?: string) {
return wrap(tenantId, userId); return wrap(tenantId, userId);
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
const result = await makeService(prisma).list('user-1', 'tenant-1'); const result = await makeService(prisma).list('user-1', 'tenant-1');
expect(result.map((r) => r.id)).toEqual(['a', 'b']); expect(result.map((r) => r.id)).toEqual(['a', 'b']);
for (const r of result) { for (const r of result) {
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']); expect(Object.keys(r).sort()).toEqual([
'createdAt',
'id',
'mimeType',
'originalName',
'size',
]);
} }
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> }; const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
select: Record<string, boolean>;
};
expect(call.select.data).toBeUndefined(); expect(call.select.data).toBeUndefined();
expect(call.select.storagePath).toBeUndefined(); expect(call.select.storagePath).toBeUndefined();
}); });
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
expect(result.mimeType).toBe('image/png'); expect(result.mimeType).toBe('image/png');
expect(result.originalName).toBe('irgendwas.txt'); expect(result.originalName).toBe('irgendwas.txt');
expect(result.size).toBe(PNG.length); expect(result.size).toBe(PNG.length);
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']); expect(Object.keys(result).sort()).toEqual([
'createdAt',
'id',
'mimeType',
'originalName',
'size',
]);
expect(prisma.__rows[0].userId).toBe('user-1'); expect(prisma.__rows[0].userId).toBe('user-1');
expect(prisma.__rows[0].tenantId).toBe('tenant-1'); expect(prisma.__rows[0].tenantId).toBe('tenant-1');
}); });
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => { it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow( await expect(
makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png')),
).rejects.toThrow(
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'), new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
); );
expect(prisma.dashboardImage.create).not.toHaveBeenCalled(); expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
@@ -304,7 +369,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
}); });
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => { it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' })); const foreign = Array.from({ length: 30 }, (_, i) =>
makeRow({ id: `f${i}`, userId: 'user-2' }),
);
const prisma = makeFakePrisma(foreign); const prisma = makeFakePrisma(foreign);
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({ await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
mimeType: 'image/png', mimeType: 'image/png',
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => { it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]); const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
}); });
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => { it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]); const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
const service = makeService(prisma); const service = makeService(prisma);
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); NotFoundException,
);
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
}); });
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => { it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
@@ -347,11 +420,52 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
const service = makeService(prisma); const service = makeService(prisma);
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' }); await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']); expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); NotFoundException,
);
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
expect(prisma.__rows).toHaveLength(2); expect(prisma.__rows).toHaveLength(2);
}); });
it('Test 11b (quick-260930): war das geloeschte Bild der Dashboard-Hintergrund, steht die Wahl danach auf „kein Hintergrund“ — andere Wahlen und andere Benutzer bleiben', async () => {
const users: UserRow[] = [
{ id: 'user-1', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
{ id: 'user-2', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
];
const prisma = makeFakePrisma(
[makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'zweites' })],
users,
);
const service = makeService(prisma);
await service.remove('eigen', 'user-1', 'tenant-1');
expect(users[0].dashboardBackground).toEqual({ kind: 'none' });
// nur die eigene Zeile
expect(users[1].dashboardBackground).toEqual({ kind: 'image', imageId: 'eigen' });
const call = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
expect(call).toEqual({
where: { id: 'user-1', dashboardBackground: { path: ['imageId'], equals: 'eigen' } },
data: { dashboardBackground: { kind: 'none' } },
});
// Ein anderes Bild loeschen laesst eine andere Wahl stehen.
users[0].dashboardBackground = { kind: 'preset', id: 'mist' };
await service.remove('zweites', 'user-1', 'tenant-1');
expect(users[0].dashboardBackground).toEqual({ kind: 'preset', id: 'mist' });
});
it('Test 11c (quick-260930): scheitert das Zuruecksetzen der Wahl, ist das Bild trotzdem geloescht (kein Fehler nach aussen)', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'eigen' })]);
vi.mocked(prisma.user.updateMany).mockRejectedValueOnce(new Error('db weg'));
await expect(makeService(prisma).remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({
id: 'eigen',
});
expect(prisma.__rows).toHaveLength(0);
});
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => { it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
const prisma = makeFakePrisma([]); const prisma = makeFakePrisma([]);
const service = makeService(prisma); const service = makeService(prisma);
@@ -370,7 +484,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich // Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n). // 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', 'findUnique', 'findUnique', 'delete']); // quick-260930: `remove` setzt zusaetzlich die Hintergrund-Wahl zurueck (user.updateMany).
expect(methods).toEqual([
'findMany',
'count',
'create',
'findUnique',
'findUnique',
'delete',
'updateMany',
]);
expect(prisma.__boundCallLog.at(-1)?.model).toBe('user');
expect(vi.mocked(forSystem)).not.toHaveBeenCalled(); expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
for (const c of prisma.__boundCallLog) { for (const c of prisma.__boundCallLog) {
expect(c.tenantId).toBe('tenant-1'); expect(c.tenantId).toBe('tenant-1');
@@ -387,14 +511,20 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
const onDisk = storedFile('user-1', result.id); const onDisk = storedFile('user-1', result.id);
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 Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2), // Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in // die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
// die Zeile. // 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.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`); expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
expect(createArgs.data.id).toBe(result.id); 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(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(createArgs.data).not.toHaveProperty('data');
expect(prisma.dashboardImage.update).not.toHaveBeenCalled(); expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
}); });
@@ -444,7 +574,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei // Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da. // dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
const prisma = makeFakePrisma([ const prisma = makeFakePrisma([
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }), makeRow({
id: 'datei-fehlt',
storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png',
}),
]); ]);
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow( await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException, NotFoundException,
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
const onDisk = storedFile('user-1', 'weg'); const onDisk = storedFile('user-1', 'weg');
expect(fs.existsSync(onDisk)).toBe(true); expect(fs.existsSync(onDisk)).toBe(true);
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' }); await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({
id: 'weg',
});
expect(prisma.__rows).toHaveLength(0); expect(prisma.__rows).toHaveLength(0);
expect(fs.existsSync(onDisk)).toBe(false); expect(fs.existsSync(onDisk)).toBe(false);
}); });
@@ -1,3 +1,6 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
@@ -5,12 +8,9 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
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 { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { import {
DASHBOARD_IMAGE_MAX_COUNT, DASHBOARD_IMAGE_MAX_COUNT,
type DashboardImageMime, type DashboardImageMime,
@@ -314,6 +314,15 @@ export class DashboardImagesService {
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. * Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird * Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
* protokolliert und geschluckt (T-HK4-04). * protokolliert und geschluckt (T-HK4-04).
*
* quick-260930: War das Bild der Dashboard-Hintergrund des Benutzers
* (`User.dashboardBackground` = `{ kind: 'image', imageId: <diese UUID> }`),
* wird die Wahl im selben Vorgang auf „kein Hintergrund“ gesetzt — sonst
* zeigte sie auf ein Bild, das es nicht mehr gibt. Bedingtes `updateMany`
* (JSON-Pfad `imageId`), damit jede andere Wahl unberuehrt bleibt; nur die
* eigene Zeile (`id: userId`). Ein Fehler dabei wird wie beim Entfernen der
* Datei protokolliert und geschluckt: das Bild ist schon weg, und das Web
* zeigt eine Wahl mit nicht ladbarem Bild ohnehin als „kein Hintergrund“.
*/ */
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> { async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -323,6 +332,19 @@ export class DashboardImagesService {
} }
await tenantPrisma.dashboardImage.delete({ where: { id } }); await tenantPrisma.dashboardImage.delete({ where: { id } });
try {
await tenantPrisma.user.updateMany({
where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } },
data: { dashboardBackground: { kind: 'none' } },
});
} catch (error) {
this.logger.warn(
`Hintergrund-Wahl zum geloeschten Bilderrahmen-Bild ${id} konnte nicht zurueckgesetzt werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const absolute = absoluteImagePath(row.storagePath); const absolute = absoluteImagePath(row.storagePath);
if (absolute !== null) { if (absolute !== null) {
try { try {
@@ -5,6 +5,7 @@ import {
IsString, IsString,
IsUrl, IsUrl,
IsUUID, IsUUID,
MaxLength,
} from 'class-validator'; } from 'class-validator';
/** /**
@@ -24,6 +25,7 @@ export class CreateFavoriteDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2048)
iconUrl?: string; iconUrl?: string;
@IsOptional() @IsOptional()
@@ -1,4 +1,4 @@
import { IsInt, IsOptional, IsString, IsUrl } from 'class-validator'; import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
/** /**
* DTO for updating an existing FavoriteLink. * DTO for updating an existing FavoriteLink.
@@ -19,6 +19,8 @@ export class UpdateFavoriteDto {
* No strict type validation so null passes through to Prisma. * No strict type validation so null passes through to Prisma.
*/ */
@IsOptional() @IsOptional()
@IsString()
@MaxLength(2048)
iconUrl?: string | null; iconUrl?: string | null;
@IsOptional() @IsOptional()
@@ -6,7 +6,6 @@ import {
HttpException, HttpException,
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FavoritesService } from './favorites.service'; import { FavoritesService } from './favorites.service';
@@ -859,6 +858,40 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
}); });
}); });
describe('update — ausdrueckliche Logo-Adresse verdraengt ein hochgeladenes Symbol (260929-lh3)', () => {
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
it('neue, abweichende iconUrl bei vorhandenem Upload: Upload-Typ null, Datei weg, iconVersion erneut +1 — die neue Adresse wird angezeigt', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await service.uploadIcon('t1', 'f1', 'user-a1', file);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
const updated = await service.update('t1', 'f1', 'user-a1', {
iconUrl: 'https://neu.invalid/logo.png',
} as any);
expect(updated.iconUrl).toBe('https://neu.invalid/logo.png');
expect(updated.uploadedIconMime).toBeNull();
expect(updated.iconVersion).toBe(2);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
});
it('UNVERAENDERTE iconUrl bei vorhandenem Upload (das Formular schickt sie bei jedem Speichern mit): Upload bleibt', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await service.uploadIcon('t1', 'f1', 'user-a1', file);
const updated = await service.update('t1', 'f1', 'user-a1', {
iconUrl: baseRow.iconUrl,
} as any);
expect(updated.uploadedIconMime).toBe('image/png');
expect(updated.iconVersion).toBe(1);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
});
});
describe('remove() mit hochgeladenem Symbol', () => { describe('remove() mit hochgeladenem Symbol', () => {
it('Zeile und Datei weg', async () => { it('Zeile und Datei weg', async () => {
const prisma = makeFakePrisma([baseRow]); const prisma = makeFakePrisma([baseRow]);
@@ -886,26 +919,50 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
}); });
}); });
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => { describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => {
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => { const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }];
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]); const failingFetch = () =>
const iconDiscovery = makeIconDiscovery({ vi.fn(async () => {
fetchIconBytes: vi.fn(async () => { throw new Error('server bekommt 404/HTML');
throw new Error('blocked');
}),
}); });
it('create mit einer Adresse, die der SERVER nicht abrufen kann: wird gespeichert, KEIN Abruf, KEINE Erkennung', async () => {
const prisma = makeFakePrisma([], widgets);
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
const service = new FavoritesService(prisma as any, iconDiscovery as any); const service = new FavoritesService(prisma as any, iconDiscovery as any);
const created = await service.create('t1', 'user-a1', {
widgetId: 'widget-a1',
title: 'Docuvita',
url: 'https://docuvita.ctl.local/server/services/web/',
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
} as any);
expect(created.iconUrl).toBe(
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
);
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
expect(prisma.__favorites.size).toBe(1);
});
it.each([
['kein http/https', 'ftp://x.invalid/icon.png'],
['javascript-Schema', 'javascript:alert(1)'],
['keine Adresse', 'kein url'],
['laenger als 2048 Zeichen', `https://x.invalid/${'a'.repeat(2050)}`],
])('create mit ungueltiger iconUrl (%s) -> BadRequestException, nichts geschrieben', async (_label, iconUrl) => {
const prisma = makeFakePrisma([], widgets);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect( await expect(
service.create('t1', 'user-a1', { service.create('t1', 'user-a1', {
widgetId: 'widget-a1', widgetId: 'widget-a1',
title: 'X', title: 'X',
url: 'https://x.invalid', url: 'https://x.invalid',
iconUrl: 'https://x.invalid/logo.png', iconUrl,
} as any), } as any),
).rejects.toThrow(UnprocessableEntityException); ).rejects.toThrow(BadRequestException);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
expect(prisma.__favorites.size).toBe(0); expect(prisma.__favorites.size).toBe(0);
}); });
@@ -922,24 +979,31 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
iconVersion: 0, iconVersion: 0,
}; };
it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => { it('update mit neuer iconUrl, die der Server nicht abrufen kann: gespeichert, iconVersion +1, KEIN Abruf', async () => {
const prisma = makeFakePrisma([baseRow]); const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({ const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
fetchIconBytes: vi.fn(async () => {
throw new Error('blocked');
}),
});
const service = new FavoritesService(prisma as any, iconDiscovery as any); 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(updated.iconUrl).toBe('https://neu.invalid/icon.png');
expect(updated.iconVersion).toBe(1);
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
});
it('update mit ungueltiger neuer iconUrl -> BadRequestException, Zeile unveraendert', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect( await expect(
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any), service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any),
).rejects.toThrow(UnprocessableEntityException); ).rejects.toThrow(BadRequestException);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl); expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
}); });
it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => { it('update mit UNVERAENDERTER iconUrl: keine Pruefung, keine Erhoehung', async () => {
const prisma = makeFakePrisma([baseRow]); const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery(); const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any); const service = new FavoritesService(prisma as any, iconDiscovery as any);
@@ -958,18 +1022,5 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
expect(updated.iconVersion).toBe(0); 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);
});
}); });
}); });
+64 -28
View File
@@ -9,7 +9,6 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user'; import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -77,12 +76,16 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder * Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein * entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
* Dateifehler wird protokolliert und geschluckt. * Dateifehler wird protokolliert und geschluckt.
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab, * - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer * Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt * auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur * Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest. * neuen, abweichenden Adresse verdraengt (Vorrang der Datei sonst: Adresse
* gespeichert, aber unsichtbar).
*/ */
/** Hoechstlaenge einer ausdruecklichen Logo-Adresse (260929-lh3). */
const ICON_URL_MAX_LENGTH = 2048;
@Injectable() @Injectable()
export class FavoritesService { export class FavoritesService {
private readonly logger = new Logger(FavoritesService.name); private readonly logger = new Logger(FavoritesService.name);
@@ -107,19 +110,31 @@ export class FavoritesService {
} }
/** /**
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst * Prueft eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3):
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes` * gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler * serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.) * 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422,
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der * 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch
* sich etwas ueber die gepruefte Adresse ablesen liesse. * eine Antwort, die der Server sieht und die kein Bild ist, weist NICHT ab —
* "Server bekommt kein Bild" heisst nicht "Browser bekommt keins", und der
* Server kann beides nicht unterscheiden. Der SSRF-Schutz bleibt unveraendert
* dort, wo der Server tatsaechlich abruft (`getIconBytes`/Erkennung); scheitert
* der Proxy, laedt die Kachel die Adresse direkt im Browser.
*/ */
private async assertIconUrlLoadable(iconUrl: string): Promise<void> { private assertIconUrlWellFormed(iconUrl: string): void {
let parsed: URL | null = null;
try { try {
await this.iconDiscovery.fetchIconBytes(iconUrl); parsed = new URL(iconUrl);
} catch { } catch {
throw new UnprocessableEntityException( parsed = null;
'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.', }
if (
parsed === null ||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
iconUrl.length > ICON_URL_MAX_LENGTH
) {
throw new BadRequestException(
'Die Logo-Adresse muss eine gültige http- oder https-Adresse sein (höchstens 2048 Zeichen).',
); );
} }
} }
@@ -129,8 +144,8 @@ export class FavoritesService {
* 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 * If iconUrl IS provided it is stored as given after a form check only
* create is rejected with 422 — nothing is written on a failed probe. * (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch.
*/ */
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);
@@ -154,8 +169,8 @@ export class FavoritesService {
let iconUrl = dto.iconUrl ?? null; let iconUrl = dto.iconUrl ?? null;
if (iconUrl) { if (iconUrl) {
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert. // 260929-lh3: nur Formpruefung, kein serverseitiger Abruf.
await this.assertIconUrlLoadable(iconUrl); this.assertIconUrlWellFormed(iconUrl);
} else { } else {
// Server-side icon discovery (D-05) — only when caller did not supply an icon // 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);
@@ -179,10 +194,11 @@ export class FavoritesService {
* 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` * 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas * durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede * irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`. * sie nicht abrufen kann. 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);
@@ -205,8 +221,8 @@ export class FavoritesService {
if ('iconUrl' in dto) { if ('iconUrl' in dto) {
if (dto.iconUrl) { if (dto.iconUrl) {
if (dto.iconUrl !== link.iconUrl) { if (dto.iconUrl !== link.iconUrl) {
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert. // 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form).
await this.assertIconUrlLoadable(dto.iconUrl); this.assertIconUrlWellFormed(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;
@@ -219,14 +235,34 @@ export class FavoritesService {
} }
} }
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) { // 260929-lh3: eine NEUE, ausdruecklich eingetragene Logo-Adresse muss
// Vorrang vor einem frueher hochgeladenen Symbol haben. `getIconBytes`
// liefert bei gesetztem `uploadedIconMime` IMMER die Datei — ohne diesen
// Schritt blieb die neue Adresse gespeichert, aber unsichtbar (die Kachel
// zeigte weiter das alte hochgeladene Bild). Nur bei einer tatsaechlichen
// Aenderung: das Formular schickt die unveraenderte Adresse bei jedem
// Speichern mit, das darf ein hochgeladenes Symbol nicht verdraengen.
const iconUrlChanged = data.iconUrl !== undefined && data.iconUrl !== link.iconUrl;
const explicitUrlReplacesUpload =
iconUrlChanged && Boolean(dto.iconUrl) && link.uploadedIconMime !== null;
if (explicitUrlReplacesUpload) {
data.uploadedIconMime = null;
}
if (iconUrlChanged) {
data.iconVersion = { increment: 1 }; data.iconVersion = { increment: 1 };
} }
return tenantPrisma.favoriteLink.update({ const updated = await tenantPrisma.favoriteLink.update({
where: { id }, where: { id },
data, data,
}); });
if (explicitUrlReplacesUpload && link.uploadedIconMime !== null) {
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'ersetzte');
}
return updated;
} }
/** /**
@@ -18,24 +18,21 @@ vi.mock('undici', () => ({
import { Agent } from 'undici'; import { Agent } from 'undici';
import { import {
discardBody,
IconDiscoveryService, IconDiscoveryService,
isPublicHttpUrl, isPublicHttpUrl,
normalizeUrl, normalizeUrl,
readTextCapped,
} from './icon-discovery.service'; } from './icon-discovery.service';
function mockResponse(options: { function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response {
contentType?: string;
body?: ArrayBuffer;
}): Response {
const body = options.body ?? new ArrayBuffer(10); const body = options.body ?? new ArrayBuffer(10);
return { return {
ok: true, ok: true,
status: 200, status: 200,
headers: { headers: {
get: (name: string) => get: (name: string) =>
name.toLowerCase() === 'content-type' name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null,
? (options.contentType ?? 'image/png')
: null,
}, },
arrayBuffer: async () => body, arrayBuffer: async () => body,
} as unknown as Response; } as unknown as Response;
@@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
status: 200, status: 200,
headers: { headers: {
get: (n: string) => get: (n: string) =>
n.toLowerCase() === 'content-type' n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
? 'text/html; charset=utf-8'
: null,
}, },
text: async () => html, text: async () => html,
}), }),
@@ -133,6 +128,58 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
}); });
}); });
describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatus (260929-lh3)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function htmlResponse(status: number, html: string) {
return {
ok: status >= 200 && status < 300,
status,
headers: {
get: (n: string) =>
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
},
text: async () => html,
};
}
it('Seite antwortet 400, traegt aber <link rel="SHORTCUT ICON"> (docuvita) -> dieser Verweis wird genutzt', async () => {
const html =
'<html><head><link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" /></head></html>';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(400, html)));
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl(
'http://8.8.8.8/server/services/web/',
);
expect(icon).toBe('http://8.8.8.8/webclient/docuvita/resources/brandimage/favicon.ico');
});
it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
const html =
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html)));
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
expect(icon).toBe('http://8.8.8.8/favicon.ico');
});
it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }),
);
await expect(
new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'),
).rejects.toThrow();
});
});
describe('IconDiscoveryService.fetchIconBytes', () => { describe('IconDiscoveryService.fetchIconBytes', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -155,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
}); });
it('rejects when Content-Type is not an image', async () => { it('rejects when Content-Type is not an image', async () => {
vi.stubGlobal( vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
await expect( await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'), /not an image/,
).rejects.toThrow(/not an image/); );
}); });
it('rejects when the SSRF guard blocks the target', async () => { it('rejects when the SSRF guard blocks the target', async () => {
@@ -173,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
await expect( await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow(
service.fetchIconBytes('http://127.0.0.1/favicon.ico'), /blocked or failed/,
).rejects.toThrow(/blocked or failed/); );
expect(fetchSpy).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
}); });
@@ -188,9 +232,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
await expect( await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'), /size limit/,
).rejects.toThrow(/size limit/); );
}); });
}); });
@@ -210,10 +254,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
}); });
it('still returns a URL string', async () => { it('still returns a URL string', async () => {
vi.stubGlobal( vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page'); const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
@@ -287,3 +328,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher); expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
}); });
}); });
describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */
function countingStream(chunks: number, chunkChars: number) {
const state = { pulled: 0, cancelled: false };
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (state.pulled >= chunks) {
controller.close();
return;
}
state.pulled += 1;
controller.enqueue(encoder.encode('a'.repeat(chunkChars)));
},
cancel() {
state.cancelled = true;
},
});
return { body, state };
}
it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => {
const { body, state } = countingStream(1000, 1000);
const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500);
expect(text).toHaveLength(2500);
expect(state.pulled).toBeLessThan(10);
expect(state.cancelled).toBe(true);
});
it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
// danach kommt nichts mehr, der Stream bleibt offen
},
cancel() {
cancelled = true;
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50);
expect(text).toBe('<link rel="icon">');
expect(cancelled).toBe(true);
});
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
const bytes = new TextEncoder().encode('<p>Grüße</p>');
const body = new ReadableStream<Uint8Array>({
start(controller) {
// Das "ü" (2 Bytes) wird absichtlich zerteilt.
controller.enqueue(bytes.slice(0, 5));
controller.enqueue(bytes.slice(5));
controller.close();
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000);
expect(text).toBe('<p>Grüße</p>');
});
it('ohne Stream: Rueckfall auf text() mit Deckel', async () => {
const text = await readTextCapped(
{ body: null, text: async () => 'x'.repeat(50) } as never,
10,
);
expect(text).toBe('x'.repeat(10));
});
it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => {
const { body, state } = countingStream(5, 10);
discardBody({ body } as never);
expect(state.cancelled).toBe(true);
expect(() => discardBody({ body: null } as never)).not.toThrow();
});
it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => {
const { body, state } = countingStream(5, 10);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: {
get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null),
},
body,
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
expect(icon).toBe('http://8.8.8.8/favicon.ico');
expect(state.cancelled).toBe(true);
});
it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => {
const head = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
const encoder = new TextEncoder();
const state = { pulled: 0, cancelled: false };
const body = new ReadableStream<Uint8Array>({
pull(controller) {
state.pulled += 1;
controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024)));
},
cancel() {
state.cancelled = true;
},
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) },
body,
text: async () => {
throw new Error('text() darf bei vorhandenem Stream nicht laufen');
},
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/');
expect(icon).toBe('http://8.8.8.8/klein.png');
expect(state.cancelled).toBe(true);
// 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen.
expect(state.pulled).toBeLessThan(10);
});
});
+104 -36
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { lookup } from 'node:dns/promises'; import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici'; import { Injectable } from '@nestjs/common';
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
/** /**
* Server-side favicon / icon discovery with SSRF protection (T-08-05). * Server-side favicon / icon discovery with SSRF protection (T-08-05).
@@ -49,6 +49,8 @@ const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
type FetchHtmlResult = { type FetchHtmlResult = {
html: string; html: string;
finalUrl: string; finalUrl: string;
/** false = die Seite antwortete mit einem Fehlerstatus (z. B. 400/404), lieferte aber HTML (260929-lh3). */
ok: boolean;
}; };
function isPrivateIpv4(address: string): boolean { function isPrivateIpv4(address: string): boolean {
@@ -56,9 +58,7 @@ function isPrivateIpv4(address: string): boolean {
if ( if (
parts.length !== 4 || parts.length !== 4 ||
parts.some( parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
(part) => !Number.isInteger(part) || part < 0 || part > 255,
)
) { ) {
return true; return true;
} }
@@ -115,12 +115,7 @@ function isPrivateIpAddress(address: string): boolean {
function isBlockedHostname(hostname: string): boolean { function isBlockedHostname(hostname: string): boolean {
const h = hostname.trim().toLowerCase(); const h = hostname.trim().toLowerCase();
return ( return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
h === 'localhost' ||
h.endsWith('.localhost') ||
h.endsWith('.local') ||
h === '0.0.0.0'
);
} }
export async function isPublicHttpUrl(url: URL): Promise<boolean> { export async function isPublicHttpUrl(url: URL): Promise<boolean> {
@@ -202,7 +197,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
} }
} }
function extractIconFromHtml(html: string, baseUrl: string): string | null { function extractIconFromHtml(html: string, baseUrl: string, linkTagsOnly = false): string | null {
const linkTags = html.match(/<link\b[^>]*>/gi) ?? []; const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? []; const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
@@ -214,30 +209,26 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
})) }))
.filter((c) => c.href); .filter((c) => c.href);
const appleTouchIcon = linkCandidates.find((c) => const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
c.rel.includes('apple-touch-icon'),
)?.href;
if (appleTouchIcon) return appleTouchIcon; if (appleTouchIcon) return appleTouchIcon;
const icon = linkCandidates.find((c) => const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
c.rel.split(/\s+/).includes('icon'),
)?.href;
if (icon) return icon; if (icon) return icon;
const shortcutIcon = linkCandidates.find((c) => const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
c.rel.includes('shortcut icon'),
)?.href;
if (shortcutIcon) return shortcutIcon; if (shortcutIcon) return shortcutIcon;
const imageSrc = linkCandidates.find((c) => const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
c.rel.includes('image_src'),
)?.href;
if (imageSrc) return imageSrc; if (imageSrc) return imageSrc;
// 260929-lh3: eine Fehlerseite (Status != 2xx) traegt kein Vorschaubild der
// Seite — nur die ausdruecklichen Symbol-Verweise (<link rel=...icon>) zaehlen.
if (linkTagsOnly) return null;
const metaImage = metaTags const metaImage = metaTags
.map((tag) => parseAttributes(tag)) .map((tag) => parseAttributes(tag))
.map((a) => ({ .map((a) => ({
@@ -247,9 +238,7 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
.find( .find(
(c) => (c) =>
c.content && c.content &&
(c.property === 'og:image' || (c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
c.property === 'og:logo' ||
c.property === 'twitter:image'),
)?.content; )?.content;
return metaImage ?? null; return metaImage ?? null;
@@ -266,7 +255,13 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
*/ */
async function fetchWithRedirectGuard( async function fetchWithRedirectGuard(
pageUrl: URL, pageUrl: URL,
options: { accept: string; timeoutMs: number; userAgent?: string }, options: {
accept: string;
timeoutMs: number;
userAgent?: string;
/** 260929-lh3: auch eine 4xx/5xx-Antwort zurueckgeben (nur fuer die HTML-Suche). */
allowErrorStatus?: boolean;
},
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> { ): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
let currentUrl = pageUrl; let currentUrl = pageUrl;
@@ -298,7 +293,7 @@ async function fetchWithRedirectGuard(
continue; continue;
} }
if (!response.ok) return null; if (!response.ok && !options.allowErrorStatus) return null;
return { response, finalUrl: currentUrl }; return { response, finalUrl: currentUrl };
} catch { } catch {
@@ -311,23 +306,98 @@ async function fetchWithRedirectGuard(
return null; return null;
} }
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
/**
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
* gelesen/abgebrochen) sind egal.
*/
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
try {
response.body?.cancel().catch(() => {});
} catch {
// Body gesperrt oder schon verbraucht — nichts zu tun.
}
}
/**
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
*/
export async function readTextCapped(
response: BodyResponse,
maxChars: number,
timeoutMs = HTML_FETCH_TIMEOUT_MS,
): Promise<string> {
if (!response.body) {
return (await response.text()).slice(0, maxChars);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = '';
// cancel() beendet ein haengendes read() mit done: true.
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
text += decoder.decode();
break;
}
text += decoder.decode(value, { stream: true });
if (text.length >= maxChars) {
await reader.cancel().catch(() => {});
break;
}
}
} finally {
clearTimeout(deadline);
}
return text.slice(0, maxChars);
}
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> { async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const result = await fetchWithRedirectGuard(pageUrl, { const result = await fetchWithRedirectGuard(pageUrl, {
accept: 'text/html,application/xhtml+xml,*/*', accept: 'text/html,application/xhtml+xml,*/*',
timeoutMs: HTML_FETCH_TIMEOUT_MS, timeoutMs: HTML_FETCH_TIMEOUT_MS,
// 260929-lh3: Server wie docuvita antworten dem Server mit 400, tragen im
// HTML aber trotzdem den <link rel=icon> — den Verweis wollen wir haben.
allowErrorStatus: true,
}); });
if (!result) return null; if (!result) return null;
const contentType = result.response.headers.get('content-type') ?? ''; const contentType = result.response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('text/html')) return null; if (!contentType.toLowerCase().includes('text/html')) {
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
discardBody(result.response);
return null;
}
const html = await result.response.text(); // T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
return { return {
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap html,
finalUrl: result.finalUrl.toString(), finalUrl: result.finalUrl.toString(),
ok: result.response.ok,
}; };
} }
@@ -350,7 +420,7 @@ export class IconDiscoveryService {
if (!htmlResult) return fallback; if (!htmlResult) return fallback;
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallback; return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
} catch { } catch {
return fallback; return fallback;
} }
@@ -365,9 +435,7 @@ export class IconDiscoveryService {
* or an oversized body. Callers must not return a placeholder image; let * or an oversized body. Callers must not return a placeholder image; let
* the caller map the failure to an HTTP error status instead. * the caller map the failure to an HTTP error status instead.
*/ */
async fetchIconBytes( async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
iconUrl: string,
): Promise<{ contentType: string; body: Buffer }> {
const url = new URL(iconUrl); const url = new URL(iconUrl);
const result = await fetchWithRedirectGuard(url, { const result = await fetchWithRedirectGuard(url, {
+30 -2
View File
@@ -1,4 +1,3 @@
import { PartialType } from '@nestjs/mapped-types';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@@ -7,6 +6,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
ValidateIf,
} from 'class-validator'; } from 'class-validator';
const trimString = ({ value }: { value: unknown }) => const trimString = ({ value }: { value: unknown }) =>
@@ -44,12 +44,40 @@ export class CreateReminderDto {
emailEnabled?: boolean; emailEnabled?: boolean;
} }
/** Nur ein FEHLENDES Feld wird uebersprungen — `null` wird geprueft und damit abgelehnt. */
const whenPresent = ValidateIf((_obj: object, value: unknown) => value !== undefined);
/** /**
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. * Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
* Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer * Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer
* gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`). * gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`).
*
* WARUM KEIN `PartialType`: das setzt `@IsOptional()`, und das laesst auch
* `null` ungeprueft durch — `{"title": null}` kaeme dann als 500 aus der
* Datenbank statt als 400 aus der Pruefung. `whenPresent` ueberspringt nur
* fehlende Felder.
*/ */
export class UpdateReminderDto extends PartialType(CreateReminderDto) {} export class UpdateReminderDto {
@whenPresent
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(200)
title?: string;
@whenPresent
@IsString()
@MaxLength(2000)
description?: string;
@whenPresent
@IsISO8601({ strict: true })
dueAt?: string;
@whenPresent
@IsBoolean()
emailEnabled?: boolean;
}
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */ /** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
export class SnoozeReminderDto { export class SnoozeReminderDto {
@@ -48,7 +48,11 @@ function row(over: Partial<Row> & { id: string }): Row {
const sameTime = (a: Date | null, b: Date | null) => const sameTime = (a: Date | null, b: Date | null) =>
a === null || b === null ? a === b : a.getTime() === b.getTime(); a === null || b === null ? a === b : a.getTime() === b.getTime();
function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u1@example.invalid' }) { function makeStore(
rows: Row[],
emails: Record<string, string | null> = { u1: 'u1@example.invalid' },
inactive: string[] = [],
) {
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => { const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
let list = rows.filter( let list = rows.filter(
(r) => (r) =>
@@ -58,8 +62,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
r.dueAt.getTime() <= where.dueAt.lte.getTime() && r.dueAt.getTime() <= where.dueAt.lte.getTime() &&
r.dueAt.getTime() >= where.dueAt.gte.getTime(), r.dueAt.getTime() >= where.dueAt.gte.getTime(),
); );
if (orderBy?.dueAt === 'asc') list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime()); if (orderBy?.dueAt === 'asc')
return list.slice(0, take).map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt })); list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
return list
.slice(0, take)
.map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
}); });
const boundLog: string[] = []; const boundLog: string[] = [];
@@ -69,11 +76,13 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
boundLog.push(`updateMany:${tenantId}`); boundLog.push(`updateMany:${tenantId}`);
let count = 0; let count = 0;
for (const r of rows) { for (const r of rows) {
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId) continue; if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId)
continue;
if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue; if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue;
if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue; if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue;
if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue; if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue;
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt)) continue; if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt))
continue;
if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt; if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt;
if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment; if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment;
count++; count++;
@@ -89,7 +98,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
}), }),
}, },
user: { user: {
findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })), findFirst: vi.fn(async ({ where }: any) => {
const isActive = !inactive.includes(where.id);
if ('isActive' in where && where.isActive !== isActive) return null;
return { email: emails[where.id] ?? null };
}),
}, },
}); });
@@ -100,10 +113,7 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
return { prisma, rows, systemFindMany, boundLog }; return { prisma, rows, systemFindMany, boundLog };
} }
function makeScheduler( function makeScheduler(prisma: any, opts: { smtp?: unknown; sendResult?: boolean | Error } = {}) {
prisma: any,
opts: { smtp?: unknown; sendResult?: boolean | Error } = {},
) {
const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() }; const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() };
const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) }; const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) };
const mail = { const mail = {
@@ -112,7 +122,12 @@ function makeScheduler(
return opts.sendResult ?? true; return opts.sendResult ?? true;
}), }),
}; };
const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any); const scheduler = new ReminderMailScheduler(
registry as any,
prisma,
settings as any,
mail as any,
);
return { scheduler, registry, settings, mail }; return { scheduler, registry, settings, mail };
} }
@@ -126,7 +141,9 @@ describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
const one = makeScheduler(store.prisma); const one = makeScheduler(store.prisma);
const two = makeScheduler(store.prisma); const two = makeScheduler(store.prisma);
await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]); await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]);
expect(one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length).toBe(1); expect(
one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length,
).toBe(1);
expect(store.rows[0].emailSentAt).toEqual(NOW); expect(store.rows[0].emailSentAt).toEqual(NOW);
expect(store.rows[0].emailAttempts).toBe(1); expect(store.rows[0].emailAttempts).toBe(1);
}); });
@@ -223,6 +240,23 @@ describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
expect(mail.sendReminderEmail).not.toHaveBeenCalled(); expect(mail.sendReminderEmail).not.toHaveBeenCalled();
expect(store.rows[0].emailSentAt).toEqual(NOW); expect(store.rows[0].emailSentAt).toEqual(NOW);
}); });
it('deaktivierter Benutzer: kein Versand, Anspruch bleibt wie ohne Adresse', async () => {
const store = makeStore([row({ id: 'a' })], { u1: 'u1@example.invalid' }, ['u1']);
const { scheduler, mail } = makeScheduler(store.prisma);
const spy = vi.spyOn(store.prisma, '__tenantClient');
await scheduler.runTick(NOW);
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
expect(store.rows[0].emailSentAt).toEqual(NOW);
expect(store.rows[0].emailAttempts).toBe(1);
const client = spy.mock.results[0].value;
expect(client.user.findFirst.mock.calls[0][0].where).toEqual({
id: 'u1',
tenantId: 't1',
isActive: true,
});
});
}); });
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => { describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
@@ -26,7 +26,7 @@ const BATCH = 200;
* Scheitert der Transport, gibt der Planer den Anspruch wieder frei * Scheitert der Transport, gibt der Planer den Anspruch wieder frei
* (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht — * (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht —
* hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse * hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse
* des Benutzers, bleibt der Anspruch: die Faelligkeit gilt als erledigt und * des Benutzers oder ist sein Konto deaktiviert (`isActive = false`), bleibt der Anspruch: die Faelligkeit gilt als erledigt und
* wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben * wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben
* („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`). * („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`).
* *
@@ -104,9 +104,7 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
await this.processCandidate(candidate, now); await this.processCandidate(candidate, now);
} catch (err) { } catch (err) {
// Eine kaputte Zeile darf die uebrigen nicht anhalten. // Eine kaputte Zeile darf die uebrigen nicht anhalten.
this.logger.error( this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`);
`Reminder email for ${candidate.id} failed: ${(err as Error).message}`,
);
} }
} }
} finally { } finally {
@@ -140,14 +138,15 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt }, where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
select: { title: true, description: true, dueAt: true }, select: { title: true, description: true, dueAt: true },
}); });
// Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse.
const user = await tenantPrisma.user.findFirst({ const user = await tenantPrisma.user.findFirst({
where: { id: c.userId, tenantId: c.tenantId }, where: { id: c.userId, tenantId: c.tenantId, isActive: true },
select: { email: true }, select: { email: true },
}); });
const smtp = await this.settingsService.getSmtpConfig(c.tenantId); const smtp = await this.settingsService.getSmtpConfig(c.tenantId);
if (!row || !user?.email || smtp === null) { if (!row || !user?.email || smtp === null) {
this.logger.log( this.logger.log(
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user?.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`, `Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user ? 'Benutzer deaktiviert' : !user.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
); );
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04) return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
} }
@@ -1,5 +1,5 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
@@ -24,7 +24,14 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
const proto = RemindersController.prototype as any; const proto = RemindersController.prototype as any;
describe('RemindersController — Rollen', () => { describe('RemindersController — Rollen', () => {
it.each(['list', 'emailStatus', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => { it.each([
'list',
'emailStatus',
'create',
'update',
'snooze',
'remove',
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
}); });
@@ -55,7 +62,9 @@ describe('RemindersController — Mandant', () => {
const controller = new RemindersController(makeService() as any); const controller = new RemindersController(makeService() as any);
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(
ForbiddenException,
);
await expect( await expect(
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }), controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
@@ -96,6 +105,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
expect(out).toEqual({ title: 'b' }); expect(out).toEqual({ title: 'b' });
}); });
it.each([
'title',
'description',
'dueAt',
'emailEnabled',
])('Aendern mit %s: null ergibt 400 statt eines Datenbankfehlers', async (field) => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ [field]: null }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('Aendern: ein leerer Titel wird abgelehnt, ein leeres Objekt ist erlaubt', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ title: ' ' }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
pipe.transform({}, { type: 'body', metatype: UpdateReminderDto }),
).resolves.toEqual({});
});
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => { it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true }); const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform( const out: any = await pipe.transform(
@@ -16,7 +16,13 @@ function makeFakePrisma() {
const reminder = { const reminder = {
create: vi.fn(async ({ data }: { data: any }) => { create: vi.fn(async ({ data }: { data: any }) => {
const id = `r-${++seq}`; const id = `r-${++seq}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data }; const row = {
id,
createdAt: new Date(),
updatedAt: new Date(),
emailEnabled: false,
...data,
};
rows.set(id, row); rows.set(id, row);
return row; return row;
}), }),
@@ -37,6 +43,14 @@ function makeFakePrisma() {
rows.set(where.id, row); rows.set(where.id, row);
return row; return row;
}), }),
// Wie die eine SQL-Anweisung: Bedingung pruefen und schreiben ohne `await` dazwischen.
updateMany: vi.fn(async ({ where, data }: { where: any; data: any }) => {
const r = rows.get(where.id);
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return { count: 0 };
if (where.dueAt?.gt && !(r.dueAt.getTime() > where.dueAt.gt.getTime())) return { count: 0 };
rows.set(where.id, { ...r, ...data });
return { count: 1 };
}),
delete: vi.fn(async ({ where }: { where: any }) => { delete: vi.fn(async ({ where }: { where: any }) => {
rows.delete(where.id); rows.delete(where.id);
}), }),
@@ -106,7 +120,9 @@ describe('RemindersService — anlegen', () => {
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }), service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
// ein anderer Benutzer ist davon nicht betroffen // ein anderer Benutzer ist davon nicht betroffen
await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy(); await expect(
service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) }),
).resolves.toBeTruthy();
}); });
}); });
@@ -153,15 +169,21 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => { ])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
const { prisma, service } = setup(); const { prisma, service } = setup();
seed(prisma, 'x', past(), owner); seed(prisma, 'x', past(), owner);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException); await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException); NotFoundException,
);
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException); await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.rows.has('x')).toBe(true); expect(prisma.rows.has('x')).toBe(true);
}); });
it('eine unbekannte Kennung ergibt 404', async () => { it('eine unbekannte Kennung ergibt 404', async () => {
const { service } = setup(); const { service } = setup();
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(NotFoundException); await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(
NotFoundException,
);
}); });
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => { it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
@@ -173,10 +195,11 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
tenantId: 't1', tenantId: 't1',
userId: 'u1', userId: 'u1',
}); });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({ expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({
id: 'x', id: 'x',
tenantId: 't1', tenantId: 't1',
userId: 'u1', userId: 'u1',
dueAt: { gt: expect.any(Date) },
}); });
}); });
}); });
@@ -185,7 +208,9 @@ describe('RemindersService — aendern', () => {
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => { it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
const { prisma, service } = setup(); const { prisma, service } = setup();
seed(prisma, 'x', past()); seed(prisma, 'x', past());
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(ConflictException); await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
ConflictException,
);
}); });
it('eine vergangene neue Faelligkeit ergibt 400', async () => { it('eine vergangene neue Faelligkeit ergibt 400', async () => {
@@ -200,14 +225,61 @@ describe('RemindersService — aendern', () => {
const { prisma, service } = setup(); const { prisma, service } = setup();
seed(prisma, 'x', future()); seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' }); await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' }); expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
const newDue = inHours(9); const newDue = inHours(9);
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' }); await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({ expect(prisma.reminder.updateMany.mock.calls[1]?.[0]?.data).toEqual({
description: '', description: '',
dueAt: new Date(newDue), dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
}); });
}); });
it('eine neue Faelligkeit setzt emailSentAt und emailAttempts zurueck, sonst bleiben sie', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.rows.get('x').emailAttempts).toBe(2);
expect(prisma.rows.get('x').emailSentAt).not.toBeNull();
const newDue = inHours(5);
const out: any = await service.update('t1', 'u1', 'x', { dueAt: newDue });
expect(prisma.rows.get('x')).toMatchObject({
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
expect(out.dueAt).toEqual(new Date(newDue));
});
it('wird die Erinnerung zwischen Pruefung und Schreiben faellig, gibt es 409 und keine Aenderung', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
// Nach dem Laden (Pruefung bestanden) ist die Zeile inzwischen faellig.
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const r = prisma.rows.get('x');
const loaded = { ...r };
prisma.rows.set('x', { ...r, dueAt: past() });
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.rows.get('x').title).toBe('alt');
});
it('wird die Erinnerung zwischen Pruefung und Schreiben geloescht, gibt es 404', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const loaded = { ...prisma.rows.get('x') };
prisma.rows.delete('x');
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
}); });
describe('RemindersService — spaeter erinnern (D-03)', () => { describe('RemindersService — spaeter erinnern (D-03)', () => {
@@ -276,7 +348,10 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
hasEmail: false, hasEmail: false,
}); });
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail'); expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({ id: 'u-ohne-mail', tenantId: 't1' }); expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({
id: 'u-ohne-mail',
tenantId: 't1',
});
}); });
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => { it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
@@ -298,7 +373,11 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
const noMail = setup(); const noMail = setup();
await expect( await expect(
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }), noMail.service.create('t1', 'u-ohne-mail', {
title: 'a',
dueAt: inHours(1),
emailEnabled: true,
}),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(noSmtp.prisma.rows.size).toBe(0); expect(noSmtp.prisma.rows.size).toBe(0);
expect(noMail.prisma.rows.size).toBe(0); expect(noMail.prisma.rows.size).toBe(0);
@@ -311,6 +390,6 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
BadRequestException, BadRequestException,
); );
await service.update('t1', 'u1', 'x', { emailEnabled: false }); await service.update('t1', 'u1', 'x', { emailEnabled: false });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false }); expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
}); });
}); });
+38 -6
View File
@@ -5,8 +5,8 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SettingsService } from '../settings/settings.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { SettingsService } from '../settings/settings.service';
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */ /** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
@@ -107,26 +107,58 @@ export class RemindersService {
/** /**
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu * Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03). * (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
*
* WARUM `updateMany` MIT `dueAt > jetzt`: Pruefung und Schreiben sind sonst
* getrennt — wird die Erinnerung dazwischen faellig (und der E-Mail-Planer
* hat sie womoeglich schon beansprucht), wuerde trotzdem geschrieben. Die
* Bedingung im selben Schreibzugriff schliesst das aus; `count === 0` heisst
* dann 409 (oder 404, falls die Zeile inzwischen geloescht ist).
*
* Eine neue Faelligkeit setzt wie beim Verschieben die Spuren des
* E-Mail-Planers zurueck, damit zur neuen Faelligkeit eine Mail geht.
*/ */
async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) { async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id); const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
if (row.dueAt.getTime() <= Date.now()) { const now = Date.now();
if (row.dueAt.getTime() <= now) {
throw new ConflictException('Die Erinnerung ist bereits fällig'); throw new ConflictException('Die Erinnerung ist bereits fällig');
} }
const data: { title?: string; description?: string; dueAt?: Date; emailEnabled?: boolean } = {}; const data: {
title?: string;
description?: string;
dueAt?: Date;
emailEnabled?: boolean;
emailSentAt?: null;
emailAttempts?: number;
} = {};
if (dto.title !== undefined) data.title = dto.title; if (dto.title !== undefined) data.title = dto.title;
if (dto.description !== undefined) data.description = dto.description; if (dto.description !== undefined) data.description = dto.description;
if (dto.dueAt !== undefined) data.dueAt = this.assertValidDueAt(dto.dueAt); if (dto.dueAt !== undefined) {
data.dueAt = this.assertValidDueAt(dto.dueAt);
data.emailSentAt = null;
data.emailAttempts = 0;
}
if (dto.emailEnabled !== undefined) { if (dto.emailEnabled !== undefined) {
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId); if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
data.emailEnabled = dto.emailEnabled; data.emailEnabled = dto.emailEnabled;
} }
return tenantPrisma.reminder.update({ const result = await tenantPrisma.reminder.updateMany({
where: { id, tenantId, userId }, where: { id, tenantId, userId, dueAt: { gt: new Date() } },
data, data,
});
if (result.count !== 1) {
await this.loadOwn(tenantPrisma, tenantId, userId, id); // inzwischen geloescht: 404
throw new ConflictException('Die Erinnerung ist bereits fällig');
}
const updated = await tenantPrisma.reminder.findFirst({
where: { id, tenantId, userId },
select: REMINDER_SELECT, select: REMINDER_SELECT,
}); });
if (!updated) {
throw new NotFoundException('Erinnerung nicht gefunden');
}
return updated;
} }
/** /**
+202 -21
View File
@@ -1,4 +1,5 @@
use semver::Version; use semver::Version;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
use tauri::{ use tauri::{
@@ -48,6 +49,59 @@ struct VersionResponse {
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das. /// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
struct PendingUpdate(Mutex<Option<(Update, String)>>); struct PendingUpdate(Mutex<Option<(Update, String)>>);
/// Ablauf-Merker des Updaters neben `PendingUpdate`.
///
/// `installing`: von `spawn_update_install` bis zum Fehlerfall gesetzt (im
/// Erfolgsfall startet die App neu bzw. beendet sich, der Merker bleibt).
/// Solange er gesetzt ist, tun Tray-Klick, `spawn_version_check` und die
/// 4-h-Schleife nichts -- vorher konnte eine Pruefung waehrend des Downloads
/// ein neues Update ablegen und den Eintrag wieder aktiv schalten, ein
/// zweiter Klick startete dann einen parallelen Download samt Installer.
///
/// `offered`: eine Pruefung hat ein Update angeboten ("Auf Version …
/// aktualisieren"). Der Klick darauf prueft frisch (`install_after`); schlug
/// diese Pruefung fehl, war der abgelegte Stand weg und der naechste Klick
/// bot nur wieder an -- zwei weitere Klicks bis zur Installation. Der Merker
/// ueberlebt die fehlgeschlagene Pruefung, der naechste erfolgreiche Klick
/// installiert direkt. Geleert, wenn eine Pruefung "kein Update" ergibt oder
/// der Server wechselt.
#[derive(Default)]
struct UpdateFlow {
installing: AtomicBool,
offered: AtomicBool,
}
impl UpdateFlow {
/// Beansprucht die Installation. `false`, wenn bereits eine laeuft --
/// dann darf der Aufrufer nichts starten.
fn try_begin_install(&self) -> bool {
self.installing
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
/// Gibt die Installation nach einem Fehler wieder frei.
fn install_failed(&self) {
self.installing.store(false, Ordering::SeqCst);
}
fn is_installing(&self) -> bool {
self.installing.load(Ordering::SeqCst)
}
fn set_offered(&self, offered: bool) {
self.offered.store(offered, Ordering::SeqCst);
}
/// Soll der Tray-Klick nach der frischen Pruefung installieren? Ja, wenn
/// ein Stand abgelegt war ODER vorher schon ein Update angeboten wurde
/// (Klick auf "Update-Prüfung fehlgeschlagen … – erneut prüfen" nach
/// einem Angebot).
fn install_on_click(&self, had_pending: bool) -> bool {
had_pending || self.offered.load(Ordering::SeqCst)
}
}
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen /// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut; /// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden /// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
@@ -429,28 +483,44 @@ fn grant_server_notifications(app: &AppHandle, url: &str) {
); );
return; return;
}; };
{ let result = grant_origin_once(&GRANTED_SERVER_ORIGINS, &pattern, |pattern| {
let mut granted = match GRANTED_SERVER_ORIGINS.lock() { let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
Ok(guard) => guard, .remote(pattern.to_string())
Err(poisoned) => poisoned.into_inner(), .local(false)
}; .window("main");
if granted.contains(&pattern) { for permission in SERVER_NOTIFICATION_PERMISSIONS {
return; capability = capability.permission(permission);
} }
granted.push(pattern.clone()); app.add_capability(capability)
} });
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications") if let Err(e) = result {
.remote(pattern)
.local(false)
.window("main");
for permission in SERVER_NOTIFICATION_PERMISSIONS {
capability = capability.permission(permission);
}
if let Err(e) = app.add_capability(capability) {
eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e); eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e);
} }
} }
/// Fuehrt `grant` fuer `pattern` hoechstens einmal erfolgreich aus. Die Sperre
/// von `granted` bleibt ueber den Aufruf gehalten, damit zwei gleichzeitige
/// Aufrufe nicht doppelt berechtigen; vermerkt wird der Ursprung erst NACH
/// dem Erfolg. Frueher stand er schon vorher in der Liste -- schlug
/// `add_capability` fehl, gab es bis zum Neustart keinen neuen Versuch.
/// Bereits vermerkt: `Ok(())` ohne Aufruf.
fn grant_origin_once<E>(
granted: &Mutex<Vec<String>>,
pattern: &str,
grant: impl FnOnce(&str) -> Result<(), E>,
) -> Result<(), E> {
let mut granted = match granted.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if granted.iter().any(|p| p == pattern) {
return Ok(());
}
grant(pattern)?;
granted.push(pattern.to_string());
Ok(())
}
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide /// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in /// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
/// `update_labels`. /// `update_labels`.
@@ -596,7 +666,13 @@ fn clear_check_notice(app: &AppHandle) {
/// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein /// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein
/// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem /// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem
/// Server-Update passten Datei und Signatur nicht mehr zusammen. /// Server-Update passten Datei und Signatur nicht mehr zusammen.
///
/// Waehrend einer Installation (`UpdateFlow::installing`) tut die Funktion
/// nichts: kein Menuetext, kein Leeren, keine Ablage.
fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) { fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) {
if app.state::<UpdateFlow>().is_installing() {
return;
}
let update_item = app.state::<TrayItems>().update.clone(); let update_item = app.state::<TrayItems>().update.clone();
let _ = update_item.set_text(UPDATE_ITEM_CHECKING); let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
let _ = update_item.set_enabled(false); let _ = update_item.set_enabled(false);
@@ -615,7 +691,14 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
.expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein"); .expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein");
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
match check_for_update(&app, endpoint).await { let result = check_for_update(&app, endpoint).await;
// Eine parallel gestartete Pruefung (Klick waehrend der 4-h-Schleife)
// kann inzwischen eine Installation ausgeloest haben: dann weder
// Menuetext noch Ablage anfassen.
if app.state::<UpdateFlow>().is_installing() {
return;
}
match result {
Ok(Some(mut update)) => { Ok(Some(mut update)) => {
let (menu_text, body) = Version::parse(&update.version) let (menu_text, body) = Version::parse(&update.version)
.map(|release| release_labels(&current, &release)) .map(|release| release_labels(&current, &release))
@@ -640,10 +723,12 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() { if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text)); *pending = Some((update, menu_text));
} }
app.state::<UpdateFlow>().set_offered(true);
} }
Ok(None) => { Ok(None) => {
let _ = update_item.set_text(UPDATE_ITEM_NONE); let _ = update_item.set_text(UPDATE_ITEM_NONE);
let _ = update_item.set_enabled(true); let _ = update_item.set_enabled(true);
app.state::<UpdateFlow>().set_offered(false);
clear_check_notice(&app); clear_check_notice(&app);
} }
// Der Release-Bau verweigert http-Endpunkte (config.rs // Der Release-Bau verweigert http-Endpunkte (config.rs
@@ -692,7 +777,13 @@ fn open_download_page(app: &AppHandle) {
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder /// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als /// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
/// Rueckfall. /// Rueckfall.
///
/// Beansprucht `UpdateFlow::installing`; laeuft bereits eine Installation,
/// passiert nichts. Freigegeben wird der Merker nur im Fehlerfall.
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) { fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
if !app.state::<UpdateFlow>().try_begin_install() {
return;
}
let item = app.state::<TrayItems>().update.clone(); let item = app.state::<TrayItems>().update.clone();
let _ = item.set_enabled(false); let _ = item.set_enabled(false);
let _ = item.set_text("Update wird heruntergeladen…"); let _ = item.set_text("Update wird heruntergeladen…");
@@ -738,6 +829,7 @@ fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() { if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text.clone())); *pending = Some((update, menu_text.clone()));
} }
app.state::<UpdateFlow>().install_failed();
let _ = item.set_enabled(true); let _ = item.set_enabled(true);
let _ = app let _ = app
.notification() .notification()
@@ -796,6 +888,8 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
// Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen. // Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen.
grant_server_notifications(&app, &normalized); grant_server_notifications(&app, &normalized);
apply_server(&app, Some(&normalized)); apply_server(&app, Some(&normalized));
// Ein Angebot des alten Servers gilt nicht fuer den neuen.
app.state::<UpdateFlow>().set_offered(false);
spawn_version_check(app.clone(), normalized.clone(), false); spawn_version_check(app.clone(), normalized.clone(), false);
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
@@ -993,6 +1087,7 @@ pub fn run() {
update: update.clone(), update: update.clone(),
}); });
app.manage(PendingUpdate(Mutex::new(None))); app.manage(PendingUpdate(Mutex::new(None)));
app.manage(UpdateFlow::default());
app.manage(LastCheckNotice(Mutex::new(String::new()))); app.manage(LastCheckNotice(Mutex::new(String::new())));
let autostart_for_menu = autostart.clone(); let autostart_for_menu = autostart.clone();
@@ -1021,6 +1116,14 @@ pub fn run() {
// nur erneut. Der Browser-Weg (`open_download_page`) // nur erneut. Der Browser-Weg (`open_download_page`)
// bleibt Rueckfall einer fehlgeschlagenen Installation. // bleibt Rueckfall einer fehlgeschlagenen Installation.
// Ohne gespeicherte Adresse gibt es nichts zu pruefen. // Ohne gespeicherte Adresse gibt es nichts zu pruefen.
// Waehrend einer Installation tut der Klick nichts
// (`UpdateFlow`). War vorher schon ein Update
// angeboten (Stand nach fehlgeschlagener Pruefung
// weg), installiert der Klick trotzdem direkt.
let flow = app.state::<UpdateFlow>();
if flow.is_installing() {
return;
}
let had_pending = app let had_pending = app
.state::<PendingUpdate>() .state::<PendingUpdate>()
.0 .0
@@ -1029,7 +1132,8 @@ pub fn run() {
.and_then(|mut guard| guard.take()) .and_then(|mut guard| guard.take())
.is_some(); .is_some();
if let Some(url) = stored_server_url(app) { if let Some(url) = stored_server_url(app) {
spawn_version_check(app.clone(), url, had_pending); let install_after = flow.install_on_click(had_pending);
spawn_version_check(app.clone(), url, install_after);
} }
} }
"autostart" => { "autostart" => {
@@ -1081,11 +1185,16 @@ pub fn run() {
// ein Update, wird der Durchlauf uebersprungen: die Pruefung // ein Update, wird der Durchlauf uebersprungen: die Pruefung
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden. // wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
// Waehrend einer Installation ist der Stand per `take()` leer; // Waehrend einer Installation ist der Stand per `take()` leer;
// faellt die 4-h-Marke genau in den Download, wird nur der // darum prueft die Schleife zusaetzlich `UpdateFlow::installing`
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert). // und ueberspringt den Durchlauf (vorher: Pruefung mitten im
// Download legte ein neues Update ab und schaltete den Eintrag
// wieder aktiv -- zweiter paralleler Download moeglich).
let handle = app.handle().clone(); let handle = app.handle().clone();
std::thread::spawn(move || loop { std::thread::spawn(move || loop {
std::thread::sleep(UPDATE_CHECK_INTERVAL); std::thread::sleep(UPDATE_CHECK_INTERVAL);
if handle.state::<UpdateFlow>().is_installing() {
continue;
}
let pending = handle let pending = handle
.state::<PendingUpdate>() .state::<PendingUpdate>()
.0 .0
@@ -1493,6 +1602,78 @@ mod tests {
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich"); assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600)); assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
} }
// --- UpdateFlow ---
#[test]
fn update_flow_nur_eine_installation_gleichzeitig() {
let flow = UpdateFlow::default();
assert!(!flow.is_installing());
assert!(flow.try_begin_install());
assert!(flow.is_installing());
assert!(
!flow.try_begin_install(),
"zweite Installation muss abgewiesen werden"
);
}
#[test]
fn update_flow_fehler_gibt_installation_frei() {
let flow = UpdateFlow::default();
assert!(flow.try_begin_install());
flow.install_failed();
assert!(!flow.is_installing());
assert!(flow.try_begin_install());
}
#[test]
fn update_flow_klick_installiert_bei_abgelegtem_stand() {
let flow = UpdateFlow::default();
assert!(flow.install_on_click(true));
assert!(!flow.install_on_click(false));
}
#[test]
fn update_flow_klick_nach_angebot_und_fehlgeschlagener_pruefung_installiert() {
let flow = UpdateFlow::default();
flow.set_offered(true);
// Stand wurde vom ersten Klick entnommen, frische Pruefung schlug fehl.
assert!(flow.install_on_click(false));
// "Kein Update" oder Serverwechsel leert das Angebot.
flow.set_offered(false);
assert!(!flow.install_on_click(false));
}
// --- grant_origin_once ---
#[test]
fn grant_origin_once_vermerkt_erst_nach_erfolg() {
let granted = Mutex::new(Vec::new());
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |_| Err("kaputt"));
assert_eq!(r, Err("kaputt"));
assert!(granted.lock().unwrap().is_empty());
let mut calls = 0;
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |p| {
calls += 1;
assert_eq!(p, "https://a");
Ok(())
});
assert_eq!(r, Ok(()));
assert_eq!(calls, 1, "nach einem Fehler muss ein neuer Versuch laufen");
assert_eq!(*granted.lock().unwrap(), vec!["https://a".to_string()]);
}
#[test]
fn grant_origin_once_ruft_bei_vermerktem_ursprung_nicht_erneut() {
let granted = Mutex::new(vec!["https://a".to_string()]);
let r: Result<(), &str> =
grant_origin_once(&granted, "https://a", |_| panic!("darf nicht laufen"));
assert_eq!(r, Ok(()));
let r: Result<(), &str> = grant_origin_once(&granted, "https://b", |_| Ok(()));
assert_eq!(r, Ok(()));
assert_eq!(granted.lock().unwrap().len(), 2);
}
// --- server_origin_* (quick-260929-if2, E-01) --- // --- server_origin_* (quick-260929-if2, E-01) ---
fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern { fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern {
@@ -28,6 +28,8 @@ const messages: Record<string, Record<string, unknown>> = {
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.', urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
saveError: 'Das Modul konnte nicht gespeichert werden.', saveError: 'Das Modul konnte nicht gespeichert werden.',
deleteError: 'Das Modul konnte nicht gelöscht werden.', deleteError: 'Das Modul konnte nicht gelöscht werden.',
forbidden: 'Sie haben keine Berechtigung für diese Aktion.',
invalidInput: 'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
deleteConfirm: { deleteConfirm: {
title: 'Eigenes Modul löschen', title: 'Eigenes Modul löschen',
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.', bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
@@ -58,8 +60,18 @@ vi.mock('next-intl', () => ({
})); }));
vi.mock('next/link', () => ({ vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( default: ({
<a href={href} className={className}>{children}</a> children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
), ),
})); }));
@@ -68,13 +80,7 @@ vi.mock('@/lib/use-category-label', () => ({
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug, ({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
})); }));
const { const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
mockList,
mockCreate,
mockUpdate,
mockDelete,
mockBump,
} = vi.hoisted(() => ({
mockList: vi.fn(), mockList: vi.fn(),
mockCreate: vi.fn(), mockCreate: vi.fn(),
mockUpdate: vi.fn(), mockUpdate: vi.fn(),
@@ -106,6 +112,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
selector({ user: { role: mockRole } }), selector({ user: { role: mockRole } }),
})); }));
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
import AdminCustomModulesPage from './page'; import AdminCustomModulesPage from './page';
const wiki = { const wiki = {
@@ -136,7 +143,9 @@ describe('AdminCustomModulesPage', () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument(); expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument(); expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length).toBeGreaterThan(0); expect(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length,
).toBeGreaterThan(0);
}); });
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => { it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
@@ -154,13 +163,17 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => { it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.')).toBeInTheDocument(); expect(
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
).toBeInTheDocument();
expect(mockCreate).not.toHaveBeenCalled(); expect(mockCreate).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -168,7 +181,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => { it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
@@ -183,7 +198,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: leerer Name zeigt die Meldung', async () => { it('Anlegen: leerer Name zeigt die Meldung', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), ' '); await userEvent.type(screen.getByLabelText('Name'), ' ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -196,7 +213,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => { it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite '); await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -217,11 +236,62 @@ describe('AdminCustomModulesPage', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
}); });
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => { it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure'); screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
expect(screen.getByLabelText('Kategorie')).toHaveValue('custom-modules');
});
it('Anlegen ohne Kategoriewahl sendet die Kategorie „Eigene Module“', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ category: 'custom-modules' }),
);
});
});
it('Speichern mit 403 zeigt „keine Berechtigung“, nicht den allgemeinen Fehler', async () => {
mockCreate.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Sie haben keine Berechtigung für diese Aktion.',
);
expect(screen.queryByText('Das Modul konnte nicht gespeichert werden.')).toBeNull();
});
it('Speichern mit 400 zeigt die Meldung des Servers', async () => {
mockList.mockResolvedValue([wiki]);
mockUpdate.mockRejectedValue(
new CustomModuleRequestError(400, 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.'),
);
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
const dialog = screen.getByRole('dialog');
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
);
}); });
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => { it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
@@ -254,13 +324,17 @@ describe('AdminCustomModulesPage', () => {
mockCreate.mockRejectedValue(new Error('500')); mockCreate.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument(); expect(
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -271,7 +345,9 @@ describe('AdminCustomModulesPage', () => {
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole('dialog');
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.')).toBeInTheDocument(); expect(
within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.'),
).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' })); await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1')); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
@@ -283,7 +359,9 @@ describe('AdminCustomModulesPage', () => {
mockList.mockResolvedValue([wiki]); mockList.mockResolvedValue([wiki]);
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' })); await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }),
);
expect(mockDelete).not.toHaveBeenCalled(); expect(mockDelete).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
@@ -295,13 +373,38 @@ describe('AdminCustomModulesPage', () => {
mockDelete.mockRejectedValue(new Error('500')); mockDelete.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' })); await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
);
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument(); expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
it('Löschen mit 403 zeigt „keine Berechtigung“', async () => {
mockList.mockResolvedValue([wiki]);
mockDelete.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Sie haben keine Berechtigung für diese Aktion.',
);
expect(screen.queryByText('Das Modul konnte nicht gelöscht werden.')).toBeNull();
});
it('Ladefehler zeigt nur die Fehlermeldung, nicht zusätzlich den Leer-Zustand', async () => {
mockList.mockRejectedValue(new CustomModuleRequestError(500, 'Internal server error'));
render(<AdminCustomModulesPage />);
expect(await screen.findByText('Laden fehlgeschlagen.')).toBeInTheDocument();
expect(screen.queryByText('Noch keine gemeinsamen Einträge')).toBeNull();
});
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => { it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
mockList.mockResolvedValue([ mockList.mockResolvedValue([
wiki, wiki,
+18 -10
View File
@@ -1,28 +1,32 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl'; import {
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot'; BackgroundPicker,
DashboardBackdrop,
useDashboardBackground,
} from '@/components/dashboard/dashboard-background';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import type { WidgetType } from '@/components/dashboard/widget-registry';
import { registerWidget } from '@/components/dashboard/widget-registry'; import { registerWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget'; import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget'; import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget'; import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget'; import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget'; import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { useDashboardStore } from '@/lib/stores/dashboard-store'; import { useDashboardStore } from '@/lib/stores/dashboard-store';
import type { WidgetType } from '@/components/dashboard/widget-registry';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -71,6 +75,7 @@ export default function DashboardPage() {
activeDashboardId, activeDashboardId,
isSwitchingDashboard, isSwitchingDashboard,
layouts, layouts,
canvas,
widgets, widgets,
isEditMode, isEditMode,
isLoading, isLoading,
@@ -85,6 +90,7 @@ export default function DashboardPage() {
renameDashboard, renameDashboard,
deleteDashboard, deleteDashboard,
reorderDashboards, reorderDashboards,
captureCanvas,
} = useDashboardStore(); } = useDashboardStore();
// Load dashboard data on mount // Load dashboard data on mount
@@ -200,6 +206,8 @@ export default function DashboardPage() {
onRemoveWidget={removeWidget} onRemoveWidget={removeWidget}
onOpenCatalog={() => setCatalogOpen(true)} onOpenCatalog={() => setCatalogOpen(true)}
onQuickAdd={(type) => addWidget(type)} onQuickAdd={(type) => addWidget(type)}
canvas={canvas}
onCaptureCanvas={captureCanvas}
/> />
)} )}
@@ -129,9 +129,9 @@ describe('CustomModulesSettingsPage', () => {
render(<CustomModulesSettingsPage />); render(<CustomModulesSettingsPage />);
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument(); expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument(); expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
expect( expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length, 0,
).toBeGreaterThan(0); );
}); });
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => { it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
@@ -158,7 +158,7 @@ describe('CustomModulesSettingsPage', () => {
expect(mockCreate).toHaveBeenCalledWith({ expect(mockCreate).toHaveBeenCalledWith({
name: 'Mein Wiki', name: 'Mein Wiki',
url: 'https://example.com', url: 'https://example.com',
category: 'infrastructure', category: 'custom-modules',
}); });
}); });
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared'); expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
@@ -228,7 +228,9 @@ describe('CustomModulesSettingsPage', () => {
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument(); expect(
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -1,14 +1,16 @@
'use client'; 'use client';
import { useState } from 'react'; import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared'; import { useState } from 'react';
import { import {
type CustomModule, type CustomModule,
CustomModuleRequestError,
checkCustomModuleUrl, checkCustomModuleUrl,
createCustomModule, createCustomModule,
updateCustomModule, updateCustomModule,
} from '@/lib/custom-modules-api'; } from '@/lib/custom-modules-api';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
import { useCategoryLabel } from '@/lib/use-category-label'; import { useCategoryLabel } from '@/lib/use-category-label';
interface CustomModuleFormModalProps { interface CustomModuleFormModalProps {
@@ -27,6 +29,23 @@ interface CustomModuleFormModalProps {
const NAME_MAX = 100; const NAME_MAX = 100;
const URL_MAX = 2048; const URL_MAX = 2048;
/**
* Fehlertext fuer Speichern und Loeschen: 403 = keine Berechtigung, 400 = die
* API hat die Angaben abgelehnt (mit ihrer Meldung), alles andere (500,
* Netzwerk) = der allgemeine „bitte erneut versuchen“-Text `fallbackKey`.
*/
export function customModuleErrorText(
err: unknown,
t: (key: string, values?: Record<string, string>) => string,
fallbackKey: 'saveError' | 'deleteError',
): string {
if (err instanceof CustomModuleRequestError) {
if (err.status === 403) return t('forbidden');
if (err.status === 400) return t('invalidInput');
}
return t(fallbackKey);
}
/** /**
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`; * Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame, * quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
@@ -47,7 +66,9 @@ export function CustomModuleFormModal({
const [name, setName] = useState(mod?.name ?? ''); const [name, setName] = useState(mod?.name ?? '');
const [url, setUrl] = useState(mod?.url ?? ''); const [url, setUrl] = useState(mod?.url ?? '');
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure'); // Neue Eintraege landen vorbelegt in der Gruppe „Eigene Module“; beim
// Bearbeiten bleibt die gespeicherte Kategorie.
const [category, setCategory] = useState<string>(mod?.category ?? CUSTOM_MODULE_CATEGORY);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -76,13 +97,17 @@ export function CustomModuleFormModal({
const input = { name: trimmedName, url: trimmedUrl, category }; const input = { name: trimmedName, url: trimmedUrl, category };
if (mod) { if (mod) {
await updateCustomModule(mod.id, input); await updateCustomModule(mod.id, input);
// Sitzungsspeicher der offen gehaltenen Module nachziehen — nur wenn
// das Modul dort schon liegt; eine neue Adresse laedt dessen <iframe> um.
const cache = useCustomModuleCacheStore.getState();
if (cache.modules[mod.id]) cache.remember({ id: mod.id, name: input.name, url: input.url });
} else { } else {
await createCustomModule(shared ? { ...input, shared: true } : input); await createCustomModule(shared ? { ...input, shared: true } : input);
} }
onSaved(); onSaved();
onClose(); onClose();
} catch { } catch (err) {
setError(t('saveError')); setError(customModuleErrorText(err, t, 'saveError'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useCategoryLabel } from '@/lib/use-category-label'; import { useCategoryLabel } from '@/lib/use-category-label';
@@ -86,9 +86,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
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 <h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
>
{texts.title} {texts.title}
</h1> </h1>
<button type="button" onClick={openCreate} className="btn btn-primary"> <button type="button" onClick={openCreate} className="btn btn-primary">
@@ -104,9 +102,11 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
</div> </div>
)} )}
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
„keine Eintraege“ waere dann falsch. */}
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? ( ) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
<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">
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2> <h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p> <p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
@@ -119,9 +119,15 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr> <tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th> <th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th> {t('name')}
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th> </th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('url')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('category')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"> <th className="px-4 py-3 text-right font-medium text-muted-foreground">
{tManage('actions')} {tManage('actions')}
</th> </th>
@@ -1,8 +1,10 @@
'use client'; 'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api'; import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
import { customModuleErrorText } from './custom-module-form-modal';
interface DeleteCustomModuleDialogProps { interface DeleteCustomModuleDialogProps {
mod: CustomModule; mod: CustomModule;
@@ -35,10 +37,12 @@ export function DeleteCustomModuleDialog({
setError(null); setError(null);
try { try {
await deleteCustomModule(mod.id); await deleteCustomModule(mod.id);
// Offen gehaltenen <iframe> und Sitzungsspeicher des Moduls verwerfen.
useCustomModuleCacheStore.getState().forget(mod.id);
onDeleted(); onDeleted();
onClose(); onClose();
} catch { } catch (err) {
setError(t('deleteError')); setError(customModuleErrorText(err, t, 'deleteError'));
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@@ -55,7 +59,11 @@ export function DeleteCustomModuleDialog({
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground"> <h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
{t('deleteConfirm.title')} {t('deleteConfirm.title')}
</h2> </h2>
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p> <p className="mb-4 text-sm text-foreground">
{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', {
name: mod.name,
})}
</p>
{error && ( {error && (
<div <div
@@ -1,4 +1,4 @@
import { act, renderHook, waitFor } from '@testing-library/react'; import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store'; import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store';
@@ -13,6 +13,23 @@ vi.mock('@/lib/auth-actions', () => ({
updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args), updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args),
})); }));
// quick-260930: Auswahlfenster (Fokus, geloeschtes Bild) braucht Uebersetzung,
// Erscheinungsbild und die Bilderliste.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}));
const fetchDashboardImages = vi.fn();
vi.mock('@/lib/dashboard-images-api', () => ({
fetchDashboardImages: (...args: unknown[]) => fetchDashboardImages(...args),
uploadDashboardImage: vi.fn(),
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${id}`,
}));
const IMAGE_ID = '11111111-2222-3333-4444-555555555555';
const LEGACY_KEY = 'tessera.dashboardBackground.u1'; const LEGACY_KEY = 'tessera.dashboardBackground.u1';
function makeUser(overrides: Partial<AuthUser> = {}): AuthUser { function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
@@ -141,3 +158,147 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled(); expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
}); });
}); });
describe('useDashboardBackground — Rueckfall nur fuer den eigenen Wert (quick-260930)', () => {
it('scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
let rejectFirst: (e: Error) => void = () => {};
updateDashboardBackgroundAction
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockResolvedValueOnce({ success: true });
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
const { result } = await renderBackgroundHook();
act(() => result.current.choose({ kind: 'preset', id: 'mist' }));
act(() => result.current.choose({ kind: 'preset', id: 'dunes' }));
await act(async () => {
rejectFirst(new Error('offline'));
await Promise.resolve();
});
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
kind: 'preset',
id: 'dunes',
});
});
});
describe('useDashboardBackground — Bild nicht mehr vorhanden (quick-260930)', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('laesst sich das gewaehlte Bild nicht laden, gilt die Wahl als „kein Hintergrund“ (ohne zu speichern)', async () => {
class FailingImage {
onerror: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onerror?.(), 0);
}
}
vi.stubGlobal('Image', FailingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await waitFor(() => expect(result.current.background).toEqual({ kind: 'none' }));
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
});
it('ein ladbares Bild bleibt gewaehlt', async () => {
class LoadingImage {
onerror: (() => void) | null = null;
onload: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onload?.(), 0);
}
}
vi.stubGlobal('Image', LoadingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
expect(result.current.background).toEqual({ kind: 'image', imageId: IMAGE_ID });
});
});
describe('BackgroundPicker (quick-260930)', () => {
async function renderPicker(value: import('@/lib/dashboard-background').DashboardBackground) {
const { BackgroundPicker } = await import('./dashboard-background');
const onChange = vi.fn();
render(<BackgroundPicker value={value} onChange={onChange} />);
return { onChange, trigger: screen.getByRole('button', { name: 'button' }) };
}
beforeEach(() => {
fetchDashboardImages.mockReset();
fetchDashboardImages.mockResolvedValue([]);
});
it('Oeffnen setzt den Fokus auf die erste Auswahl, Escape schliesst und gibt ihn an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const first = screen.getByRole('button', { name: 'none' });
expect(dialog).toContainElement(first);
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Klick nach draussen schliesst und gibt den Fokus an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
await screen.findByRole('dialog');
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Tab bleibt im Fenster: vom letzten Knopf zum ersten, Umschalt+Tab vom ersten zum letzten', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const buttons = Array.from(dialog.querySelectorAll('button'));
const first = buttons[0];
const last = buttons[buttons.length - 1];
last.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(last).toHaveFocus();
});
it('gewaehltes Bild fehlt in der Bilderliste -> „Keiner“ ist markiert', async () => {
fetchDashboardImages.mockResolvedValue([
{ id: 'anderes', originalName: 'urlaub.jpg', mimeType: 'image/jpeg', size: 1, createdAt: '' },
]);
const { trigger } = await renderPicker({ kind: 'image', imageId: IMAGE_ID });
fireEvent.click(trigger);
await screen.findByRole('button', { name: 'urlaub.jpg' });
expect(screen.getByRole('button', { name: 'none' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'urlaub.jpg' })).toHaveAttribute(
'aria-pressed',
'false',
);
});
});
@@ -26,6 +26,23 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
useAuthStore.getState().setUser({ ...current, dashboardBackground: value }); useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
} }
/**
* Nimmt eine fehlgeschlagene Wahl zurueck — aber nur, wenn im Store noch
* GENAU dieser optimistisch gesetzte Wert steht (quick-260930). Hat der
* Benutzer inzwischen etwas anderes gewaehlt, wuerde der Fehler des ersten
* Klicks sonst die zweite Wahl ueberschreiben.
*/
function revertStoredBackground(
userId: string,
optimistic: DashboardBackground,
previous: DashboardBackground | null,
) {
const current = useAuthStore.getState().user;
if (!current || current.id !== userId) return;
if (current.dashboardBackground !== optimistic) return;
setStoredBackground(userId, previous);
}
/** /**
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj). * Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
* *
@@ -34,18 +51,43 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck. * die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor, * Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
* wird sie genau einmal je Benutzerkennung uebernommen. * wird sie genau einmal je Benutzerkennung uebernommen.
*
* quick-260930: Zeigt die Wahl auf ein eigenes Bild, das nicht (mehr) zu
* laden ist — etwa weil es im Bilderrahmen geloescht wurde (die API setzt die
* gespeicherte Wahl dabei auf „kein Hintergrund“, eine schon geladene Sitzung
* kennt aber noch den alten Wert) —, gilt sie als „kein Hintergrund“. Das
* ist nur Anzeige; gespeichert wird dabei nichts.
*/ */
export function useDashboardBackground() { export function useDashboardBackground() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const userId = user?.id ?? null; const userId = user?.id ?? null;
const stored = user?.dashboardBackground ?? null; const stored = user?.dashboardBackground ?? null;
const background = stored ?? NO_BACKGROUND; const imageId = stored?.kind === 'image' ? stored.imageId : null;
const [brokenImageId, setBrokenImageId] = useState<string | null>(null);
const background =
stored === null || (imageId !== null && imageId === brokenImageId) ? NO_BACKGROUND : stored;
const migratedFor = useRef<string | null>(null); const migratedFor = useRef<string | null>(null);
// Laedt das gewaehlte Bild einmal zur Probe; schlaegt das fehl, wird die
// Wahl als „kein Hintergrund“ angezeigt.
useEffect(() => {
if (imageId === null || typeof Image === 'undefined') return;
let active = true;
const probe = new Image();
probe.onerror = () => {
if (active) setBrokenImageId(imageId);
};
probe.src = dashboardImageSrc(imageId);
return () => {
active = false;
probe.onerror = null;
};
}, [imageId]);
const save = useCallback( const save = useCallback(
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => { (id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
setStoredBackground(id, value); setStoredBackground(id, value);
const revert = () => setStoredBackground(id, previous); const revert = () => revertStoredBackground(id, value, previous);
updateDashboardBackgroundAction(value) updateDashboardBackgroundAction(value)
.then((result) => { .then((result) => {
if (!result.success) revert(); if (!result.success) revert();
@@ -141,23 +183,88 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
const [images, setImages] = useState<DashboardImageMeta[] | null>(null); const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Liste erfolgreich geladen? Nur dann laesst sich sagen, dass ein gewaehltes
// Bild fehlt (nach einem Abruffehler ist die Liste nur ersatzweise leer).
const [imagesListed, setImagesListed] = useState(false);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
// Gewaehltes eigenes Bild, das es nicht mehr gibt -> „Keiner“ markieren
// (quick-260930).
const shown: DashboardBackground =
value.kind === 'image' &&
imagesListed &&
images !== null &&
!images.some((image) => image.id === value.imageId)
? NO_BACKGROUND
: value;
useEffect(() => { useEffect(() => {
if (!open || images !== null) return; if (!open || images !== null) return;
fetchDashboardImages() fetchDashboardImages()
.then(setImages) .then((list) => {
setImages(list);
setImagesListed(true);
})
.catch(() => setImages([])); .catch(() => setImages([]));
}, [open, images]); }, [open, images]);
// Fokus (quick-260930): beim Oeffnen auf die erste Auswahl („Keiner“),
// nach dem Schliessen zurueck auf den Knopf „Hintergrund“ — ausser der
// Benutzer hat zum Schliessen etwas anderes Fokussierbares angeklickt.
useEffect(() => {
if (open) {
wasOpen.current = true;
dialogRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])')?.focus();
return;
}
if (!wasOpen.current) return;
wasOpen.current = false;
// Einen Takt spaeter pruefen: beim Klick nach draussen setzt der Browser
// den Fokus erst NACH dem mousedown-Horcher (auf das Ziel oder den body).
const timer = window.setTimeout(() => {
const active = document.activeElement;
if (!active || active === document.body || rootRef.current?.contains(active)) {
triggerRef.current?.focus();
}
}, 0);
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onPointer(e: MouseEvent) { function onPointer(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false); if (e.key === 'Escape') {
setOpen(false);
return;
}
// Tab bleibt im Fenster (Muster ActivationDialog im Marktplatz):
// vom letzten Knopf zum ersten und mit Umschalt+Tab umgekehrt.
if (e.key === 'Tab' && dialogRef.current) {
const items = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled])'),
);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (!active || !dialogRef.current.contains(active)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
} }
document.addEventListener('mousedown', onPointer); document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
@@ -190,7 +297,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
preview: CSSProperties, preview: CSSProperties,
content?: React.ReactNode, content?: React.ReactNode,
) => { ) => {
const selected = sameChoice(value, choice); const selected = sameChoice(shown, choice);
return ( return (
<li key={key}> <li key={key}>
<button <button
@@ -223,6 +330,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
return ( return (
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-expanded={open} aria-expanded={open}
@@ -253,6 +361,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
{open && ( {open && (
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-label={t('title')} aria-label={t('title')}
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border" className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
@@ -1,8 +1,15 @@
import { Children, isValidElement } from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { Children, isValidElement } from 'react';
import { createScaledStrategy, defaultPositionStrategy } from 'react-grid-layout/core';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer'; import { stubResizeObserver } from '@/test/fake-resize-observer';
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid'; import {
computeCanvasScale,
fitCanvasToContent,
DASHBOARD_BOTTOM_GAP_PX,
RESIZE_AXIS_FALLBACK,
scaledPositionStrategy,
} from './dashboard-grid';
// Mock CSS imports that vitest cannot resolve // Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({})); vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -137,9 +144,7 @@ describe('DashboardGrid', () => {
}); });
it('renders widget instance when widgets are provided', async () => { it('renders widget instance when widgets are provided', async () => {
const widgets = [ const widgets = [{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } }];
{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } },
];
const layouts = { const layouts = {
lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }],
md: [], md: [],
@@ -167,9 +172,7 @@ describe('DashboardGrid', () => {
}); });
it('shows edit affordances (delete button) when isEditMode is true', async () => { it('shows edit affordances (delete button) when isEditMode is true', async () => {
const widgets = [ const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
{ id: 'inst-2', widgetType: 'clock', config: {} },
];
const layouts = { const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [], md: [],
@@ -312,7 +315,7 @@ describe('DashboardGrid', () => {
expect(layouts.lg[0].minH).toBe(8); expect(layouts.lg[0].minH).toBe(8);
}); });
it('quick-260929-dmx Test 9c: bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 8 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 8 angehoben', async () => { it('quick-260929-dmx Test 9c (quick-260930: Kalender-minW 11): bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 11 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 11 angehoben', async () => {
captured.props = null; captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
// So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus. // So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus.
@@ -341,25 +344,108 @@ describe('DashboardGrid', () => {
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>; const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
// Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt. // Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt.
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 8, minH: 8 }); expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 8, minH: 8 }); expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 8, h: 16, minW: 8, minH: 8 }); expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 11, h: 16, minW: 11, minH: 8 });
// Auch der data-grid-Wert der Kinder traegt das neue Minimum. // Auch der data-grid-Wert der Kinder traegt das neue Minimum.
const children = Children.toArray(captured.props?.children as React.ReactNode); const children = Children.toArray(captured.props?.children as React.ReactNode);
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props['data-grid'] as Record<string, unknown>; const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props[
expect(grid.minW).toBe(8); 'data-grid'
] as Record<string, unknown>;
expect(grid.minW).toBe(11);
}); });
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'; it('quick-260930 Test 9d: ein schmaler Kalender mit direktem Nachbarn wird nur bis zum Nachbarn angehoben (keine neue Ueberlappung), am Rasterrand nur bis zum Rand', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
lg: [
// Kalender w 8 (vor quick-260930 erlaubt), Uhr direkt rechts ab x 18.
{ i: 'cal-1', x: 10, y: 0, w: 8, h: 16 },
{ i: 'clk-1', x: 18, y: 4, w: 8, h: 4 },
// Kalender am rechten Rand: x 40 + 11 > 48.
{ i: 'cal-2', x: 40, y: 20, w: 8, h: 16 },
// Nachbar liegt tiefer (keine Zeilenueberschneidung) -> kein Hindernis.
{ i: 'cal-3', x: 0, y: 40, w: 8, h: 8 },
{ i: 'clk-2', x: 8, y: 48, w: 8, h: 4 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'cal-1', widgetType: 'calendar', config: {} },
{ id: 'clk-1', widgetType: 'clock', config: {} },
{ id: 'cal-2', widgetType: 'calendar', config: {} },
{ id: 'cal-3', widgetType: 'calendar', config: {} },
{ id: 'clk-2', widgetType: 'clock', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.lg[0]).toMatchObject({ i: 'cal-1', x: 10, w: 8, minW: 11 });
expect(passed.lg[1]).toMatchObject({ i: 'clk-1', x: 18, w: 8 });
expect(passed.lg[2]).toMatchObject({ i: 'cal-2', x: 40, w: 8, minW: 11 });
expect(passed.lg[3]).toMatchObject({ i: 'cal-3', x: 0, w: 11, minW: 11 });
});
it('quick-260930 Test 9e: minW und w werden auf die Spaltenzahl des Breakpoints begrenzt (xxs 4, xs 16)', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
xs: [{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4 }],
xxs: [
{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4, minW: 12 },
{ i: 'cal-1', x: 0, y: 4, w: 2, h: 16 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'srch-1', widgetType: 'search', config: {} },
{ id: 'cal-1', widgetType: 'calendar', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.xs[0]).toMatchObject({ w: 16, minW: 12 });
expect(passed.xxs[0]).toMatchObject({ w: 4, minW: 4 });
// Kalender (minW 11) am xxs-Breakpoint: Minimum 4, Breite auf 4 angehoben.
expect(passed.xxs[1]).toMatchObject({ w: 4, minW: 4 });
});
const CANCEL =
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => { it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
};
captured.props = null; captured.props = null;
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
expect(captured.props?.dragConfig).toEqual({ expect(captured.props?.dragConfig).toEqual({
enabled: true, enabled: true,
@@ -372,7 +458,13 @@ describe('DashboardGrid', () => {
captured.props = null; captured.props = null;
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
expect(captured.props?.dragConfig).toEqual({ expect(captured.props?.dragConfig).toEqual({
enabled: false, enabled: false,
@@ -400,7 +492,10 @@ describe('DashboardGrid', () => {
type: unknown; type: unknown;
allowOverlap: boolean; allowOverlap: boolean;
preventCollision?: boolean; preventCollision?: boolean;
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>; compact: (
layout: Array<Record<string, unknown>>,
cols: number,
) => Array<Record<string, unknown>>;
}; };
expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true }); expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true });
expect(typeof compactor.compact).toBe('function'); expect(typeof compactor.compact).toBe('function');
@@ -423,10 +518,22 @@ describe('DashboardGrid', () => {
expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL); expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL);
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
};
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement; const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement;
@@ -455,7 +562,13 @@ describe('DashboardGrid', () => {
cleanup(); cleanup();
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull(); expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
expect(document.querySelector('.widget-drag-handle')).toBeNull(); expect(document.querySelector('.widget-drag-handle')).toBeNull();
@@ -512,7 +625,9 @@ describe('DashboardGrid', () => {
expect(captured.props?.width).toBe(1000); expect(captured.props?.width).toBe(1000);
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800)); vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
new DOMRect(0, 0, 1600, 800),
);
act(() => { act(() => {
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
}); });
@@ -528,14 +643,182 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 }, { i: 'fav', x: 0, y: 0, w: 6, h: 10 },
{ i: 'below', x: 0, y: 10, w: 6, h: 8 }, { i: 'below', x: 0, y: 10, w: 6, h: 8 },
]; ];
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4]; const context = { layout } as unknown as Parameters<
NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>
>[4];
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0]; const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => { it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 }); expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({
w: 2,
h: 10,
});
}); });
it('laesst eine freie Groesse unveraendert', () => { it('laesst eine freie Groesse unveraendert', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 }); expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
}); });
}); });
describe('Leinwand: Dashboard wie ein Bild mitskalieren', () => {
const ONE = {
layouts: { lg: [{ i: 'inst-1', x: 0, y: 0, w: 8, h: 4 }], md: [], sm: [], xs: [], xxs: [] },
widgets: [{ id: 'inst-1', widgetType: 'clock', config: {} }],
};
// jsdom: innerHeight 768, getBoundingClientRect -> 0 -> verfuegbare Hoehe 748.
const JSDOM_AVAILABLE_H = 768 - DASHBOARD_BOTTOM_GAP_PX;
function setInnerHeight(value: number) {
Object.defineProperty(window, 'innerHeight', { value, configurable: true, writable: true });
}
afterEach(() => setInnerHeight(768));
async function renderGrid(props: {
canvas?: { w: number; h: number } | null;
onCaptureCanvas?: (area: { w: number; h: number }) => void;
}) {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const view = render(
<DashboardGrid
{...ONE}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
{...props}
/>,
);
const outer = view.container.firstElementChild as HTMLElement;
const inner = outer.firstElementChild as HTMLElement;
return { outer, inner };
}
describe('fitCanvasToContent', () => {
it('Inhalt passt in die Leinwand: Leinwand bleibt (1:1 am Original-PC)', () => {
expect(fitCanvasToContent({ w: 1641, h: 849 }, 700)).toEqual({ w: 1641, h: 849 });
});
it('Inhalt hoeher als die Leinwand: Inhaltshoehe zaehlt, damit nie gescrollt wird', () => {
expect(fitCanvasToContent({ w: 1641, h: 849 }, 940)).toEqual({ w: 1641, h: 940 });
// am Original-PC selbst: 849 / 940 -> alles passt hinein
expect(
computeCanvasScale({ w: 1641, h: 849 }, fitCanvasToContent({ w: 1641, h: 849 }, 940)),
).toBeCloseTo(849 / 940);
});
it('unbrauchbare Hoehe: Leinwand bleibt', () => {
expect(fitCanvasToContent({ w: 1600, h: 800 }, Number.NaN)).toEqual({ w: 1600, h: 800 });
});
});
describe('computeCanvasScale', () => {
it('breitenbegrenzt: freier Platz unten', () => {
expect(computeCanvasScale({ w: 1280, h: 900 }, { w: 1600, h: 800 })).toBe(0.8);
});
it('hoehenbegrenzt: freier Platz rechts', () => {
expect(computeCanvasScale({ w: 1920, h: 600 }, { w: 1600, h: 800 })).toBe(0.75);
});
it('Vergroesserung bei groesserem Bildschirm (s > 1)', () => {
expect(computeCanvasScale({ w: 2400, h: 1300 }, { w: 1600, h: 800 })).toBe(1.5);
});
it('gleiche Flaeche -> 1', () => {
expect(computeCanvasScale({ w: 1600, h: 800 }, { w: 1600, h: 800 })).toBe(1);
});
it('unbrauchbare Hoehe begrenzt nicht, dann zaehlt nur die Breite', () => {
expect(computeCanvasScale({ w: 800, h: 0 }, { w: 1600, h: 800 })).toBe(0.5);
expect(computeCanvasScale({ w: 800, h: Number.NaN }, { w: 1600, h: 800 })).toBe(0.5);
});
});
describe('scaledPositionStrategy (Ziehen/Groesse aendern unter transform)', () => {
it('s = 1 ist die Standardstrategie', () => {
expect(scaledPositionStrategy(1)).toBe(defaultPositionStrategy);
});
it('s != 1: transform-Strategie mit scale, OHNE calcDragPosition (RGL rechnet dann relativ zum Raster)', () => {
const strategy = scaledPositionStrategy(0.5);
expect(strategy.type).toBe('transform');
expect(strategy.scale).toBe(0.5);
expect(strategy.calcDragPosition).toBeUndefined();
expect(strategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 })).toEqual(
defaultPositionStrategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 }),
);
});
it('Grund: createScaledStrategy rechnet den Ziehstart in Fensterkoordinaten (ohne Raster-Versatz)', () => {
// Kachel sichtbar bei clientX 300 (Raster beginnt bei 250 neben der
// Seitenleiste, Kachel also 50 px im Raster), Maus 10 px in der Kachel.
const pos = createScaledStrategy(0.5).calcDragPosition?.(310, 110, 10, 10);
// Richtig waere (300 - 250) / 0.5 = 100; geliefert wird 300 / 0.5 = 600.
expect(pos?.left).toBe(600);
});
});
it('skalierter Modus: virtuelle Breite canvas.w, transform scale(s) links oben, sichtbare Hoehe = Rasterhoehe x s', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const { outer, inner } = await renderGrid({ canvas: { w: 2000, h: 800 } });
// s = min(1000 / 2000, 748 / 800) = 0.5
expect(captured.props?.width).toBe(2000);
expect(inner.style.width).toBe('2000px');
expect(inner.style.transform).toBe('scale(0.5)');
expect(inner.style.transformOrigin).toBe('top left');
expect(outer.style.height).toBe('400px');
expect(outer.style.overflow).toBe('hidden');
const strategy = captured.props?.positionStrategy as { scale: number };
expect(strategy.scale).toBe(0.5);
});
it('skalierter Modus hoehenbegrenzt und vergroessernd', async () => {
stubResizeObserver({ width: 1000, height: 800 });
setInnerHeight(420); // verfuegbar 400
const first = await renderGrid({ canvas: { w: 1000, h: 800 } });
expect(first.inner.style.transform).toBe('scale(0.5)');
cleanup();
setInnerHeight(768);
// Rasterinhalt niedriger als die Leinwand (300 < 374) — sonst zaehlte die
// Inhaltshoehe (fitCanvasToContent).
stubResizeObserver({ width: 1000, height: 300 });
const second = await renderGrid({ canvas: { w: 800, h: 374 } });
// min(1000 / 800, 748 / 374) = 1.25
expect(second.inner.style.transform).toBe('scale(1.25)');
expect(captured.props?.width).toBe(800);
});
it('Inhalt hoeher als die Leinwand: eingepasst, damit nie gescrollt wird', async () => {
stubResizeObserver({ width: 1000, height: 1600 });
setInnerHeight(820); // verfuegbar 800
const { inner } = await renderGrid({ canvas: { w: 1000, h: 800 } });
// min(1000 / 1000, 800 / 1600) = 0.5
expect(inner.style.transform).toBe('scale(0.5)');
});
it('schmal (< 768): wie bisher — Containerbreite, kein transform, keine Erfassung', async () => {
stubResizeObserver({ width: 600, height: 800 });
const onCaptureCanvas = vi.fn();
const { outer, inner } = await renderGrid({ canvas: { w: 1600, h: 800 }, onCaptureCanvas });
expect(captured.props?.width).toBe(600);
expect(inner.style.transform).toBe('');
expect(outer.style.height).toBe('');
expect(captured.props?.positionStrategy).toBe(defaultPositionStrategy);
cleanup();
await renderGrid({ canvas: null, onCaptureCanvas });
expect(onCaptureCanvas).not.toHaveBeenCalled();
});
it('ohne Leinwand am Desktop: unskaliert und die verfuegbare Flaeche wird zum Erfassen gemeldet', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const onCaptureCanvas = vi.fn();
const { inner } = await renderGrid({ canvas: null, onCaptureCanvas });
expect(captured.props?.width).toBe(1000);
expect(inner.style.transform).toBe('');
expect(onCaptureCanvas).toHaveBeenCalledWith({ w: 1000, h: JSDOM_AVAILABLE_H });
});
it('mit Leinwand wird nicht erneut erfasst', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const onCaptureCanvas = vi.fn();
await renderGrid({ canvas: { w: 1000, h: 748 }, onCaptureCanvas });
expect(onCaptureCanvas).not.toHaveBeenCalled();
});
});
@@ -1,13 +1,26 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout'; import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core'; import { noCompactor, Responsive } from 'react-grid-layout';
import {
defaultConstraints,
defaultPositionStrategy,
type LayoutConstraint,
type PositionStrategy,
transformStrategy,
} from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry'; import { CANVAS_MIN_HEIGHT, CANVAS_MIN_WIDTH, type GridCanvas } from '@/lib/grid-layout-migration';
import {
clampWidthToCols,
GRID_COLS,
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetType,
} from './widget-registry';
import { WidgetIcon } from './widgets/widget-icon'; import { WidgetIcon } from './widgets/widget-icon';
import { WidgetWrapper } from './widgets/widget-wrapper'; import { WidgetWrapper } from './widgets/widget-wrapper';
@@ -23,7 +36,9 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration // damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position. // einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }; // Spaltenzahlen stehen seit quick-260930 in widget-registry.tsx (GRID_COLS),
// weil auch der Store sie fuer neue Kacheln braucht.
const COLS = GRID_COLS;
// quick-260916-dyv: Ziehen zuverlaessig. // quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx). // - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
@@ -93,6 +108,76 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
const GRID_MARGIN = 12; const GRID_MARGIN = 12;
/**
* Dashboard wie ein Bild mitskalieren (Leinwand).
*
* Wunsch: an PC 1 eingerichtet, an PC 2 mit anderer Aufloesung GENAU gleich
* aussehen — alles (auch die Schrift) gleichmaessig kleiner oder groesser,
* nie scrollen; passt die Bildschirmform nicht, bleibt rechts oder unten
* Platz frei.
*
* Umsetzung: Jeder Reiter hat eine Leinwand `{ w, h }` (gespeichert als
* `__canvas`, siehe grid-layout-migration.ts) — die verfuegbare Flaeche, auf
* der er eingerichtet wurde (erfasst beim ersten Oeffnen am Desktop, danach
* fest). Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus. Auf
* jedem Desktop-Bildschirm zeichnet react-grid-layout das Raster mit der
* VIRTUELLEN Breite `canvas.w` — Breakpoint und Spaltenbreite sind damit
* ueberall identisch — und ein umgebendes Element verkleinert/vergroessert
* das Ergebnis per `transform: scale(s)` mit
* `s = min(Breite / canvas.w, Hoehe / canvas.h)` (computeCanvasScale),
* links oben ausgerichtet (nicht zentriert, bewusst entfernt in bc4c011).
*
* Unter CANVAS_MIN_WIDTH (Handy/schmal) bleibt alles wie zuvor: Breite =
* gemessene Containerbreite, kein transform, keine Erfassung.
*/
/** Abstand unter dem Raster bis zur Fensterkante: main p-3 (12) + Wrapper p-2 (8). */
export const DASHBOARD_BOTTOM_GAP_PX = 20;
/**
* Skalierungsfaktor der Leinwand fuer die verfuegbare Flaeche: das kleinere
* der beiden Verhaeltnisse, damit alles hineinpasst (freier Platz rechts
* ODER unten). Eine unbrauchbare Hoehe (<= 0, nicht endlich) begrenzt nicht —
* dann entscheidet nur die Breite.
*/
export function computeCanvasScale(available: GridCanvas, canvas: GridCanvas): number {
const byWidth = available.w / canvas.w;
const byHeight = available.h / canvas.h;
const heightUsable = Number.isFinite(byHeight) && byHeight > 0;
const s = heightUsable ? Math.min(byWidth, byHeight) : byWidth;
return Number.isFinite(s) && s > 0 ? s : 1;
}
/**
* Leinwand fuer die Einpassung: ist der Inhalt (unskalierte Rasterhoehe)
* hoeher als die Leinwand, gilt die Inhaltshoehe, sonst die Leinwand selbst —
* so bleibt es am Original-PC bei 1:1, solange alles draufpasst.
*/
export function fitCanvasToContent(canvas: GridCanvas, contentHeight: number): GridCanvas {
if (!Number.isFinite(contentHeight) || contentHeight <= canvas.h) return canvas;
return { w: canvas.w, h: contentHeight };
}
/**
* Positionsstrategie fuer ein per `transform: scale(s)` skaliertes Raster.
*
* Bewusst NICHT `createScaledStrategy(s)` aus react-grid-layout 2.2.3: deren
* `calcDragPosition` rechnet `(clientX - offsetX) / scale` =
* `clientRect.left / scale` (chunk-KDANGDDL.mjs:315-320) — eine FENSTER-
* Koordinate ohne Abzug der Raster-Oberkante/-Linkskante. Im Portal liegt das
* Raster rechts neben der Seitenleiste; die Kachel spraenge beim Ziehstart um
* (Seitenleiste + Abstand) / s Pixel nach rechts unten. Ohne
* `calcDragPosition` nimmt react-grid-layout den eigenen Weg
* (chunk-WGL5FSZH.mjs:146-163): `(clientRect.left - parentRect.left) /
* transformScale` — relativ zum Raster und korrekt entskaliert. Die
* Mausbewegungen teilen `DraggableCore` (Prop `scale`) bzw. `Resizable`
* (`deltaX / transformScale`) selbst durch `scale`; beide lesen ihn aus
* `positionStrategy.scale` (chunk-WGL5FSZH.mjs:675, 512, 527).
*/
export function scaledPositionStrategy(scale: number): PositionStrategy {
if (scale === 1) return defaultPositionStrategy;
return { type: 'transform', scale, calcStyle: transformStrategy.calcStyle };
}
interface LayoutItemShape { interface LayoutItemShape {
i: string; i: string;
x: number; x: number;
@@ -113,6 +198,10 @@ interface DashboardGridProps {
onOpenCatalog?: () => void; onOpenCatalog?: () => void;
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */ /** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
onQuickAdd?: (type: WidgetType) => void; onQuickAdd?: (type: WidgetType) => void;
/** Leinwand des aktiven Reiters; null = noch nicht erfasst (dann unskaliert). */
canvas?: GridCanvas | null;
/** Wird mit der verfuegbaren Flaeche gerufen, solange `canvas` fehlt (nur Desktop). */
onCaptureCanvas?: (area: GridCanvas) => void;
} }
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */ /** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
@@ -138,6 +227,17 @@ const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich — * chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten. * der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert. * Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
*
* quick-260930: minW je Breakpoint auf dessen Spaltenzahl begrenzt (Suche
* minW 12 am xxs-Breakpoint mit 4 Spalten), w ebenso. Die BREITE wird nur so
* weit angehoben, wie rechts Platz ist — innerhalb des Rasters und ohne eine
* NEUE Ueberlappung mit einem Nachbarn (freie Platzierung ohne Kompaktierung
* wuerde sie sonst stehen lassen; Anlass: Kalender-minW 8 -> 11, schmal
* gezogene Kalender mit direktem Nachbarn). Bleibt die Kachel dadurch unter
* dem Minimum, ist das harmlos: react-grid-layout zeichnet sie woertlich und
* klemmt erst beim naechsten Groessenziehen; stoesst das an den Nachbarn,
* greift RESIZE_AXIS_FALLBACK (nur die Hoehe aendert sich), bis der Nachbar
* verschoben ist.
*/ */
function applyConstraintMinima( function applyConstraintMinima(
layouts: Record<string, Array<LayoutItemShape>>, layouts: Record<string, Array<LayoutItemShape>>,
@@ -150,18 +250,42 @@ function applyConstraintMinima(
for (const key of Object.keys(layouts)) { for (const key of Object.keys(layouts)) {
const entries = layouts[key]; const entries = layouts[key];
if (!Array.isArray(entries)) continue; if (!Array.isArray(entries)) continue;
result[key] = entries.map((entry) => { const cols = GRID_COLS[key] ?? Number.POSITIVE_INFINITY;
// Zielbreite je Eintrag merken; angehoben wird im zweiten Durchgang,
// wenn alle Hoehen feststehen (die Kollisionspruefung braucht sie).
const targetW = new Map<string, number>();
const next = entries.map((entry) => {
const type = typeById.get(entry.i); const type = typeById.get(entry.i);
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined; const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
if (!constraints) return { ...entry }; if (!constraints) return { ...entry };
const minW = clampWidthToCols(constraints.minW, key);
const w = Math.min(entry.w, cols);
if (w < minW) targetW.set(entry.i, minW);
return { return {
...entry, ...entry,
w: Math.max(entry.w, constraints.minW), w,
h: Math.max(entry.h, constraints.minH), h: Math.max(entry.h, constraints.minH),
minW: constraints.minW, minW,
minH: constraints.minH, minH: constraints.minH,
}; };
}); });
for (const item of next) {
const target = targetW.get(item.i);
if (target === undefined) continue;
const others = next.filter((o) => o !== item);
// Schon bestehende Ueberlappungen zaehlen nicht als Hindernis — sie
// entstehen nicht durch das Anheben.
const blockers = others.filter((o) => !overlaps(item, o));
while (
item.w < target &&
item.x + item.w + 1 <= cols &&
!blockers.some((o) => overlaps({ ...item, w: item.w + 1 }, o))
) {
item.w += 1;
}
}
result[key] = next;
} }
return result; return result;
} }
@@ -181,9 +305,15 @@ export function DashboardGrid({
onRemoveWidget, onRemoveWidget,
onOpenCatalog, onOpenCatalog,
onQuickAdd, onQuickAdd,
canvas = null,
onCaptureCanvas,
}: DashboardGridProps) { }: DashboardGridProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [width, setWidth] = useState(1200); const [width, setWidth] = useState(1200);
// Verfuegbare Hoehe bis zur unteren Fensterkante (Leinwand). 0 = unbekannt.
const [availableHeight, setAvailableHeight] = useState(0);
// Unskalierte Hoehe des Rasters (inneres Element) fuer die sichtbare Hoehe.
const [innerHeight, setInnerHeight] = useState(0);
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf), // quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste. // nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
@@ -222,6 +352,16 @@ export function DashboardGrid({
} }
}, []); }, []);
// Leinwand: Hoehe = Fensterhoehe minus Oberkante des Rasters im DOKUMENT
// (rect.top + scrollY, also unabhaengig vom Scrollstand) minus Abstand
// unten. Das aeussere Element ist selbst nicht transformiert, seine
// Oberkante ist also die echte.
const applyHeight = useCallback((node: HTMLElement) => {
const top = node.getBoundingClientRect().top + window.scrollY;
const next = window.innerHeight - top - DASHBOARD_BOTTOM_GAP_PX;
setAvailableHeight(Number.isFinite(next) && next > 0 ? Math.floor(next) : 0);
}, []);
const measureRef = useCallback( const measureRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
// Ein eventuell laufender Beobachter zuerst trennen — auch der // Ein eventuell laufender Beobachter zuerst trennen — auch der
@@ -234,32 +374,84 @@ export function DashboardGrid({
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen. // Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
applyWidth(node.getBoundingClientRect().width); applyWidth(node.getBoundingClientRect().width);
applyHeight(node);
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
applyWidth(entries[0].contentRect.width); applyWidth(entries[0].contentRect.width);
applyHeight(node);
}); });
observer.observe(node); observer.observe(node);
observerRef.current = observer; observerRef.current = observer;
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den // Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf. // Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
}, },
[applyWidth], [applyWidth, applyHeight],
); );
useEffect(() => { useEffect(() => {
const onResize = () => { const onResize = () => {
if (nodeRef.current) { if (nodeRef.current) {
applyWidth(nodeRef.current.getBoundingClientRect().width); applyWidth(nodeRef.current.getBoundingClientRect().width);
applyHeight(nodeRef.current);
} }
}; };
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);
}, [applyWidth]); }, [applyWidth, applyHeight]);
// Unskalierte Hoehe des inneren Elements (react-grid-layout setzt die
// Rasterhoehe selbst). `contentRect`/`offsetHeight` sind Layout-Masse und
// vom transform unberuehrt — genau das Gewuenschte.
const innerObserverRef = useRef<ResizeObserver | null>(null);
const innerRef = useCallback((node: HTMLDivElement | null) => {
innerObserverRef.current?.disconnect();
innerObserverRef.current = null;
if (!node) return;
if (node.offsetHeight > 0) setInnerHeight(node.offsetHeight);
const observer = new ResizeObserver((entries) => {
const h = entries[0].contentRect.height;
if (Number.isFinite(h) && h > 0) setInnerHeight(h);
});
observer.observe(node);
innerObserverRef.current = observer;
}, []);
// Desktop = verfuegbare Breite ab CANVAS_MIN_WIDTH; nur dort Leinwand.
const isDesktop = width >= CANVAS_MIN_WIDTH;
const scaled = isDesktop && canvas !== null;
// "Nie scrollen" (Nutzerentscheidung): ist der Inhalt hoeher als die
// Leinwand (schon am Original-PC laenger als der Bildschirm), zaehlt die
// Inhaltshoehe — dann passt auch dort alles hinein, und jeder andere
// Bildschirm zeigt dasselbe Bild. Im Bearbeitungsmodus bleibt die Hoehe vom
// Beginn des Bearbeitens stehen, sonst wuerde das Raster beim Ziehen nach
// unten laufend kleiner und die Kachel liefe dem Mauszeiger davon.
const editHeightRef = useRef<number | null>(null);
if (isEditMode && editHeightRef.current === null) editHeightRef.current = innerHeight;
if (!isEditMode) editHeightRef.current = null;
const contentHeight = isEditMode ? (editHeightRef.current ?? innerHeight) : innerHeight;
const scale = scaled
? computeCanvasScale({ w: width, h: availableHeight }, fitCanvasToContent(canvas, contentHeight))
: 1;
const positionStrategy = useMemo(() => scaledPositionStrategy(scale), [scale]);
// Leinwand erfassen: nur mit eingehaengtem Raster (also mit Kacheln), am
// Desktop und mit brauchbarer Hoehe. Der Store uebernimmt sie einmal und
// ignoriert weitere Aufrufe; danach ist `canvas` gesetzt und dieser
// Effekt tut nichts mehr.
const hasWidgets = widgets.length > 0;
useEffect(() => {
if (!onCaptureCanvas || canvas !== null || !hasWidgets) return;
if (!isDesktop || availableHeight < CANVAS_MIN_HEIGHT) return;
onCaptureCanvas({ w: width, h: availableHeight });
}, [onCaptureCanvas, canvas, hasWidgets, isDesktop, width, availableHeight]);
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS — // quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge // siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
// stabil bleibt. // stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); const effectiveLayouts = useMemo(
() => applyConstraintMinima(layouts, widgets),
[layouts, widgets],
);
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne // Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten // Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
@@ -269,7 +461,18 @@ export function DashboardGrid({
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center"> <div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8"> <div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground"> <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"> <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"
>
<rect x="3" y="3" width="7" height="9" rx="1.5" /> <rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" /> <rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" /> <rect x="14" y="12" width="7" height="9" rx="1.5" />
@@ -296,7 +499,19 @@ export function DashboardGrid({
)} )}
{onOpenCatalog && ( {onOpenCatalog && (
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5"> <button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
{t('addWidget')} {t('addWidget')}
</button> </button>
)} )}
@@ -305,56 +520,72 @@ export function DashboardGrid({
); );
} }
// Gleiche DOM-Struktur in beiden Modi, damit react-grid-layout beim
// Wechsel (etwa direkt nach dem Erfassen, s = 1) nicht neu einhaengt.
// Aeusseres Element: volle Breite (Messung), im skalierten Modus die
// sichtbare Hoehe = unskalierte Rasterhoehe x s und `overflow: hidden` —
// der transform aendert die Layout-Box nicht, ohne beides bliebe toter
// Scrollraum (s < 1) bzw. ragte die 1:1-Breite seitlich hinaus.
const outerStyle: React.CSSProperties | undefined = scaled
? { overflow: 'hidden', ...(innerHeight > 0 ? { height: innerHeight * scale } : {}) }
: undefined;
const innerStyle: React.CSSProperties | undefined = scaled
? { width: canvas.w, transform: `scale(${scale})`, transformOrigin: 'top left' }
: undefined;
return ( return (
<div ref={measureRef}> <div ref={measureRef} style={outerStyle} data-canvas-scale={scaled ? scale : undefined}>
<Responsive <div ref={innerRef} style={innerStyle}>
width={width} <Responsive
breakpoints={BREAKPOINTS} width={scaled ? canvas.w : width}
cols={COLS} positionStrategy={positionStrategy}
layouts={effectiveLayouts as ResponsiveLayouts} breakpoints={BREAKPOINTS}
rowHeight={20} cols={COLS}
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]} layouts={effectiveLayouts as ResponsiveLayouts}
constraints={GRID_CONSTRAINTS} rowHeight={20}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin). constraints={GRID_CONSTRAINTS}
dragConfig={{ // containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
enabled: isEditMode, // dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
handle: WIDGET_DRAG_HANDLE_SELECTOR, dragConfig={{
cancel: WIDGET_DRAG_CANCEL_SELECTOR, enabled: isEditMode,
threshold: 3, handle: WIDGET_DRAG_HANDLE_SELECTOR,
}} cancel: WIDGET_DRAG_CANCEL_SELECTOR,
resizeConfig={{ threshold: 3,
enabled: isEditMode, }}
}} resizeConfig={{
compactor={FREE_PLACEMENT_COMPACTOR} enabled: isEditMode,
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)} }}
> compactor={FREE_PLACEMENT_COMPACTOR}
{widgets.map((widget, index) => { onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType]; >
return ( {widgets.map((widget, index) => {
<div const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
key={widget.id} return (
data-grid={{ <div
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? { key={widget.id}
x: 0, data-grid={{
y: 0, ...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
w: constraints?.defaultW ?? 8, x: 0,
h: constraints?.defaultH ?? 4, y: 0,
}), w: constraints?.defaultW ?? 8,
minW: constraints?.minW ?? 8, h: constraints?.defaultH ?? 4,
minH: constraints?.minH ?? 4, }),
}} minW: constraints?.minW ?? 8,
> minH: constraints?.minH ?? 4,
<WidgetWrapper }}
widget={widget} >
isEditMode={isEditMode} <WidgetWrapper
onRemove={onRemoveWidget} widget={widget}
enterIndex={index} isEditMode={isEditMode}
/> onRemove={onRemoveWidget}
</div> enterIndex={index}
); />
})} </div>
</Responsive> );
})}
</Responsive>
</div>
</div> </div>
); );
} }
@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// quick-260930: Stammt die Anordnung von einem neueren Programmstand, ist der
// Stift gesperrt und bittet ums Neuladen.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
const storeState = vi.hoisted(() => ({ layoutFromNewerVersion: false }));
vi.mock('@/lib/stores/dashboard-store', () => ({
useDashboardStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
import { EditModeToggle } from './edit-mode-toggle';
beforeEach(() => {
storeState.layoutFromNewerVersion = false;
});
describe('EditModeToggle (quick-260930)', () => {
it('normal: Stift ist bedienbar', () => {
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeEnabled();
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('Anordnung eines neueren Programmstands: gesperrt, Hinweis „neu laden“ als Beschreibung und Tooltip', () => {
storeState.layoutFromNewerVersion = true;
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeDisabled();
expect(button).toHaveAttribute('title', 'reloadRequired');
expect(button).toHaveAccessibleDescription('reloadRequired');
fireEvent.click(button);
expect(onToggle).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useId } from 'react';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
interface EditModeToggleProps { interface EditModeToggleProps {
isEditMode: boolean; isEditMode: boolean;
@@ -13,55 +15,72 @@ interface EditModeToggleProps {
* Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der * Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der
* Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus * Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus
* der gelbe Knopf „Fertig“. * der gelbe Knopf „Fertig“.
*
* quick-260930: Stammt die geladene Anordnung von einem neueren
* Programmstand (`layoutFromNewerVersion` im Store), ist der Stift gesperrt
* und nennt als Beschreibung den Grund („Bitte laden Sie die Seite neu“) —
* Speichern wuerde die Anordnung sonst mit der alten Rastermarke
* ueberschreiben.
*/ */
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) { export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const locked = useDashboardStore((state) => state.layoutFromNewerVersion) && !isEditMode;
const hintId = useId();
return ( return (
<button <>
type="button" <button
onClick={onToggle} type="button"
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`} onClick={onToggle}
aria-pressed={isEditMode} disabled={locked}
aria-label={isEditMode ? undefined : t('editShort')} className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'} ${locked ? 'cursor-not-allowed opacity-60' : ''}`}
title={isEditMode ? t('saveChanges') : t('editMode')} aria-pressed={isEditMode}
> aria-label={isEditMode ? undefined : t('editShort')}
{isEditMode ? ( aria-describedby={locked ? hintId : undefined}
// Checkmark icon title={locked ? t('reloadRequired') : isEditMode ? t('saveChanges') : t('editMode')}
<svg >
aria-hidden="true" {isEditMode ? (
xmlns="http://www.w3.org/2000/svg" // Checkmark icon
width="16" <svg
height="16" aria-hidden="true"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="none" width="16"
stroke="currentColor" height="16"
strokeWidth="2" viewBox="0 0 24 24"
strokeLinecap="round" fill="none"
strokeLinejoin="round" stroke="currentColor"
> strokeWidth="2"
<polyline points="20 6 9 17 4 12" /> strokeLinecap="round"
</svg> strokeLinejoin="round"
) : null} >
{isEditMode ? null : ( <polyline points="20 6 9 17 4 12" />
// Pencil icon </svg>
<svg ) : null}
aria-hidden="true" {isEditMode ? null : (
xmlns="http://www.w3.org/2000/svg" // Pencil icon
width="16" <svg
height="16" aria-hidden="true"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="none" width="16"
stroke="currentColor" height="16"
strokeWidth="2" viewBox="0 0 24 24"
strokeLinecap="round" fill="none"
strokeLinejoin="round" stroke="currentColor"
> strokeWidth="2"
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> strokeLinecap="round"
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> strokeLinejoin="round"
</svg> >
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
{isEditMode ? t('done') : null}
</button>
{locked && (
<span id={hintId} className="sr-only">
{t('reloadRequired')}
</span>
)} )}
{isEditMode ? t('done') : null} </>
</button>
); );
} }
@@ -1,13 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import { WIDGET_TYPES } from '@tessera/shared'; import { WIDGET_TYPES } from '@tessera/shared';
import { describe, expect, it, vi } from 'vitest';
import { import {
clampWidthToCols,
GRID_COLS,
registerWidget,
visibleWidgetTypes,
WIDGET_CONSTRAINTS, WIDGET_CONSTRAINTS,
WIDGET_REGISTRY, WIDGET_REGISTRY,
type WidgetDefinition, type WidgetDefinition,
type WidgetProps, type WidgetProps,
type WidgetType, type WidgetType,
registerWidget,
visibleWidgetTypes,
} from './widget-registry'; } from './widget-registry';
/** /**
@@ -38,29 +40,31 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
describe('WIDGET_CONSTRAINTS (DASH-11)', () => { describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('contains entries for all expected widget types', () => { it('contains entries for all expected widget types', () => {
for (const type of ALL_WIDGET_TYPES) { for (const type of ALL_WIDGET_TYPES) {
expect( expect(WIDGET_CONSTRAINTS, `WIDGET_CONSTRAINTS must contain key "${type}"`).toHaveProperty(
WIDGET_CONSTRAINTS, type,
`WIDGET_CONSTRAINTS must contain key "${type}"`, );
).toHaveProperty(type);
} }
}); });
it.each(ALL_WIDGET_TYPES)( it.each(
'WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', ALL_WIDGET_TYPES,
(type) => { )('WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', (type) => {
const constraints = WIDGET_CONSTRAINTS[type]; const constraints = WIDGET_CONSTRAINTS[type];
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number'); expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number'); expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number'); expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number'); expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1); expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1); expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(constraints.minW); expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(constraints.minH); constraints.minW,
}, );
); expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(
constraints.minH,
);
});
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => { it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator'); expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
@@ -71,12 +75,13 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => { it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle // Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche // Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8 // Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW
// (rund 250 px, gemessene kleinste benutzbare Breite; vorher 6 von 24). // (quick-260930: 11 = rund 260 px an der schmalsten lg-Breite; das
// vorige 8 ergab nur rund 186 px; vorher 6 von 24).
expect(WIDGET_CONSTRAINTS).toEqual({ expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 }, clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 }, search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 }, calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 }, note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 }, calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 }, favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
@@ -98,6 +103,38 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
}); });
}); });
describe('Kalender-minW (quick-260930)', () => {
it('ergibt an der schmalsten lg-Breite (1200 px, 48 Spalten, 12 px Abstand) mindestens 250 px', () => {
const margin = 12;
const cols = GRID_COLS.lg;
const colWidth = (1200 - 2 * margin - (cols - 1) * margin) / cols;
const px = (w: number) => w * colWidth + (w - 1) * margin;
const { minW } = WIDGET_CONSTRAINTS.calendar;
expect(px(minW)).toBeGreaterThanOrEqual(250);
// und nicht unnoetig breit: eine Spalte weniger laege unter 250 px
expect(px(minW - 1)).toBeLessThan(250);
});
});
describe('clampWidthToCols (quick-260930)', () => {
it('begrenzt Breiten auf die Spaltenzahl des Breakpoints', () => {
expect(clampWidthToCols(24, 'xxs')).toBe(4);
expect(clampWidthToCols(24, 'xs')).toBe(16);
expect(clampWidthToCols(24, 'lg')).toBe(24);
expect(clampWidthToCols(3, 'xxs')).toBe(3);
expect(clampWidthToCols(99, 'unbekannt')).toBe(99);
});
it('kein Minimum und keine Vorgabe ist nach der Begrenzung groesser als die Spaltenzahl', () => {
for (const bp of Object.keys(GRID_COLS)) {
for (const c of Object.values(WIDGET_CONSTRAINTS)) {
expect(clampWidthToCols(c.minW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
expect(clampWidthToCols(c.defaultW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
}
}
});
});
/** /**
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau * quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die * EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react';
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared'; import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
import type { ComponentType } from 'react';
/** /**
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in * Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
@@ -57,11 +57,17 @@ export const WIDGET_CONSTRAINTS: Record<
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block. // fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter // Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin. // den Monat nur ein Termin.
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar — minW 8 von // quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar. Ziel sind
// 48 Spalten = rund 250 px am lg-Breakpoint (gemessen: Monatsraster, // rund 250 px (Monatsraster, Ueberschrift und Terminliste noch benutzbar;
// Ueberschrift und Terminliste sind dort noch benutzbar; bei rund 185 px // bei rund 185 px wird die Ueberschrift abgeschnitten). quick-260930: das
// wird die Ueberschrift abgeschnitten). defaultW 16 = unveraenderte Breite. // damalige minW 8 ergab am lg-Breakpoint nur rund 186 px — gerechnet an
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 }, // der schmalsten lg-Breite (1200 px Raster, 48 Spalten, 12 px Abstand und
// Rand): Spalte = (1200 - 2*12 - 47*12) / 48 = 12,75 px, Kachel = w*12,75 +
// (w-1)*12. minW 11 = rund 260 px (10 waeren 235 px), auf breiteren
// Bildschirmen entsprechend mehr. defaultW 16 = unveraenderte Breite.
// Bestehende schmalere Kalender hebt dashboard-grid.tsx beim Anzeigen an,
// soweit rechts Platz ist (applyConstraintMinima).
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 }, note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
// Phase 8 new widgets (D-01) // Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat // minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
@@ -98,6 +104,24 @@ export const WIDGET_CONSTRAINTS: Record<
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
}; };
/**
* Spalten je Breakpoint (quick-260929-dmx: 48stel am lg-Breakpoint). Steht
* hier und nicht in dashboard-grid.tsx, weil auch der Store (neue Kacheln)
* die Spaltenzahl braucht und die Registry ohnehin von beiden importiert wird.
*/
export const GRID_COLS: Record<string, number> = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
/**
* Begrenzt eine Breite (minW, defaultW) auf die Spaltenzahl des Breakpoints
* (quick-260930). Sonst waere z. B. die Suche (minW 12, defaultW 24) am
* xxs-Breakpoint mit 4 Spalten breiter als das ganze Raster. Unbekannte
* Breakpoints bleiben unbegrenzt.
*/
export function clampWidthToCols(width: number, breakpoint: string): number {
const cols = GRID_COLS[breakpoint];
return cols === undefined ? width : Math.min(width, cols);
}
/** /**
* Definition for a widget type in the catalog. * Definition for a widget type in the catalog.
*/ */
@@ -37,20 +37,20 @@ vi.mock('@/lib/dashboard-api', () => ({
updateWidgetConfig: vi.fn().mockResolvedValue(undefined), updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
})); }));
import { updateWidgetConfig } from '@/lib/dashboard-api';
import { import {
createFavorite,
deleteFavorite,
FavoriteRequestError, FavoriteRequestError,
fetchFavorites, fetchFavorites,
createFavorite,
updateFavorite,
deleteFavorite,
reorderFavorites,
uploadFavoriteIcon,
removeFavoriteIcon, removeFavoriteIcon,
reorderFavorites,
updateFavorite,
uploadFavoriteIcon,
} from '@/lib/favorites-api'; } from '@/lib/favorites-api';
import { updateWidgetConfig } from '@/lib/dashboard-api';
// Must import AFTER mocks // Must import AFTER mocks
import { FavoritesWidget } from './favorites-widget'; import { FavoritesWidget, LauncherLabel } from './favorites-widget';
// Typed mock helpers // Typed mock helpers
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>; const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
@@ -106,13 +106,7 @@ describe('FavoritesWidget', () => {
}); });
it('calls fetchFavorites with instanceId on mount and renders link titles', async () => { it('calls fetchFavorites with instanceId on mount and renders link titles', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3) // fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
expect(mockFetch).toHaveBeenCalledWith('fav-1'); expect(mockFetch).toHaveBeenCalledWith('fav-1');
@@ -127,13 +121,7 @@ describe('FavoritesWidget', () => {
it('shows empty message when fetchFavorites resolves to empty array', async () => { it('shows empty message when fetchFavorites resolves to empty array', async () => {
mockFetch.mockResolvedValue([]); mockFetch.mockResolvedValue([]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('favorites.empty')).toBeInTheDocument(); expect(screen.getByText('favorites.empty')).toBeInTheDocument();
@@ -143,13 +131,7 @@ describe('FavoritesWidget', () => {
it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => { it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => {
mockFetch.mockResolvedValue([]); mockFetch.mockResolvedValue([]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('favorites.empty')).toBeInTheDocument(); expect(screen.getByText('favorites.empty')).toBeInTheDocument();
@@ -189,13 +171,7 @@ describe('FavoritesWidget', () => {
}); });
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => { it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -239,13 +215,7 @@ describe('FavoritesWidget', () => {
}); });
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => { it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -271,13 +241,7 @@ describe('FavoritesWidget', () => {
}); });
it('view toggle: switching to grid in edit mode renders grid container', async () => { it('view toggle: switching to grid in edit mode renders grid container', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{ viewMode: 'list' }} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{ viewMode: 'list' }}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -299,13 +263,7 @@ describe('FavoritesWidget', () => {
}); });
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => { it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument(); expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -318,24 +276,16 @@ describe('FavoritesWidget', () => {
// quick-260916-iex: optionaler Titel // quick-260916-iex: optionaler Titel
describe('optionaler Titel (quick-260916-iex)', () => { describe('optionaler Titel (quick-260916-iex)', () => {
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => { it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
const r1 = render( const r1 = render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
}); });
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect( expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
r1.unmount(); r1.unmount();
const r2 = render( const r2 = render(
<FavoritesWidget <FavoritesWidget instanceId="fav-1" config={{ title: ' ' }} isEditMode={false} />,
instanceId="fav-1"
config={{ title: ' ' }}
isEditMode={false}
/>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -343,13 +293,7 @@ describe('FavoritesWidget', () => {
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
r2.unmount(); r2.unmount();
render( render(<FavoritesWidget instanceId="fav-1" config={{ title: 42 }} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{ title: 42 }}
isEditMode={false}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
}); });
@@ -358,11 +302,7 @@ describe('FavoritesWidget', () => {
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => { it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
render( render(
<FavoritesWidget <FavoritesWidget instanceId="fav-1" config={{ title: 'Werkzeuge' }} isEditMode={false} />,
instanceId="fav-1"
config={{ title: 'Werkzeuge' }}
isEditMode={false}
/>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -371,15 +311,11 @@ describe('FavoritesWidget', () => {
expect(heading.tagName).toBe('H2'); expect(heading.tagName).toBe('H2');
expect(heading.className).toContain('text-sm'); expect(heading.className).toContain('text-sm');
expect(heading.className).toContain('font-semibold'); expect(heading.className).toContain('font-semibold');
expect( expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
}); });
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => { it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
}); });
@@ -391,25 +327,17 @@ describe('FavoritesWidget', () => {
expect(titleField.className).toContain('widgetNoDrag'); expect(titleField.className).toContain('widgetNoDrag');
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect( expect(screen.getByRole('button', { name: /favorites\.listView/i })).toBeInTheDocument();
screen.getByRole('button', { name: /favorites\.listView/i }), expect(screen.getByRole('button', { name: /favorites\.gridView/i })).toBeInTheDocument();
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /favorites\.gridView/i }),
).toBeInTheDocument();
}); });
it('entprellt das Speichern des Titels um 1500 ms', async () => { it('entprellt das Speichern des Titels um 1500 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try { try {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await act(async () => {}); await act(async () => {});
const titleField = screen.getByPlaceholderText( const titleField = screen.getByPlaceholderText('favorites.titlePlaceholder');
'favorites.titlePlaceholder',
);
for (const value of ['W', 'We', 'Werkzeuge']) { for (const value of ['W', 'We', 'Werkzeuge']) {
await act(async () => { await act(async () => {
@@ -444,9 +372,7 @@ describe('FavoritesWidget', () => {
// Sortierpfeile im Bearbeitungsmodus. // Sortierpfeile im Bearbeitungsmodus.
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => { describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => { it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument(); expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -460,9 +386,7 @@ describe('FavoritesWidget', () => {
}); });
it('Kette Proxy -> direkt -> Buchstabe', async () => { it('Kette Proxy -> direkt -> Buchstabe', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -490,14 +414,88 @@ describe('FavoritesWidget', () => {
expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G'); expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G');
}); });
it('kein Direktbild bei Nicht-http-URL', async () => { it('ausdrueckliche Symbol-Adresse (260929-lh3): Proxy -> Browser laedt iconUrl direkt (no-referrer) -> Origin-Favicon -> Buchstabe', async () => {
mockFetch.mockResolvedValue([ mockFetch.mockResolvedValue([
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 }, {
id: 'fav-id-9',
widgetId: 'fav-1',
title: 'Docuvita',
url: 'https://docuvita.ctl.local/server/services/web/',
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
position: 0,
},
]); ]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
await waitFor(() => {
expect(screen.getByText('Docuvita')).toBeInTheDocument();
});
act(() => {
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-9'));
});
const direct1 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
expect(direct1.src).toBe(
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
); );
expect(direct1.getAttribute('referrerpolicy')).toBe('no-referrer');
act(() => {
fireEvent.error(direct1);
});
const direct2 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
expect(direct2.src).toBe('https://docuvita.ctl.local/favicon.ico');
act(() => {
fireEvent.error(direct2);
});
expect(screen.queryByTestId('icon-direct-fav-id-9')).not.toBeInTheDocument();
expect(screen.getByTestId('letter-fallback-fav-id-9')).toHaveTextContent('D');
});
it('iconUrl mit Nicht-http-Schema wird NIE direkt geladen (javascript:/data:)', async () => {
mockFetch.mockResolvedValue([
{
id: 'fav-id-8',
widgetId: 'fav-1',
title: 'Boese',
url: 'https://boese.example',
iconUrl: 'javascript:alert(1)',
position: 0,
},
]);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Boese')).toBeInTheDocument();
});
act(() => {
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-8'));
});
const direct = screen.getByTestId('icon-direct-fav-id-8') as HTMLImageElement;
expect(direct.src).toBe('https://boese.example/favicon.ico');
});
it('kein Direktbild bei Nicht-http-URL', async () => {
mockFetch.mockResolvedValue([
{
id: 'fav-id-3',
widgetId: 'fav-1',
title: 'Ablage',
url: 'ftp://files.example',
iconUrl: null,
position: 0,
},
]);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Ablage')).toBeInTheDocument(); expect(screen.getByText('Ablage')).toBeInTheDocument();
@@ -514,9 +512,7 @@ describe('FavoritesWidget', () => {
{ ...BASE_FAVORITES[0], position: 1 }, { ...BASE_FAVORITES[0], position: 1 },
]); ]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -549,9 +545,7 @@ describe('FavoritesWidget', () => {
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => { it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
mockReorder.mockRejectedValue(new Error('boom')); mockReorder.mockRejectedValue(new Error('boom'));
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -587,9 +581,7 @@ describe('FavoritesWidget', () => {
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
rerender( rerender(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
}); });
@@ -692,7 +684,7 @@ describe('FavoritesWidget', () => {
}); });
}); });
it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => { it("Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon('fav-id-1', Datei); Formular schliesst", async () => {
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 }); mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
mockUploadIcon.mockResolvedValue({ mockUploadIcon.mockResolvedValue({
...BASE_FAVORITES[0], ...BASE_FAVORITES[0],
@@ -720,7 +712,10 @@ describe('FavoritesWidget', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' })); expect(mockUpdate).toHaveBeenCalledWith(
'fav-id-1',
expect.objectContaining({ title: 'GitHub' }),
);
}); });
await waitFor(() => { await waitFor(() => {
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file); expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
@@ -730,8 +725,8 @@ describe('FavoritesWidget', () => {
}); });
}); });
it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => { it('updateFavorite wirft (z. B. 400 ungueltige Adresse) -> allgemeine Meldung favorites.error im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable')); mockUpdate.mockRejectedValue(new Error('Failed to update favorite'));
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />); render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
@@ -753,7 +748,7 @@ describe('FavoritesWidget', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable'); expect(screen.getByRole('alert')).toHaveTextContent('favorites.error');
}); });
expect(mockUploadIcon).not.toHaveBeenCalled(); expect(mockUploadIcon).not.toHaveBeenCalled();
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
@@ -788,12 +783,16 @@ describe('FavoritesWidget', () => {
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
}); });
it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => { it("Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon('fav-id-1'), danach verschwindet der Knopf, Formular bleibt offen", async () => {
mockFetch.mockResolvedValue([ mockFetch.mockResolvedValue([
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 }, { ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
BASE_FAVORITES[1], BASE_FAVORITES[1],
]); ]);
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 }); mockRemoveIcon.mockResolvedValue({
...BASE_FAVORITES[0],
uploadedIconMime: null,
iconVersion: 2,
});
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />); render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
@@ -812,7 +811,9 @@ describe('FavoritesWidget', () => {
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1'); expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); expect(
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
).not.toBeInTheDocument();
}); });
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
}); });
@@ -826,7 +827,9 @@ describe('FavoritesWidget', () => {
openEditForGitHub(); openEditForGitHub();
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); expect(
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
).not.toBeInTheDocument();
}); });
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => { it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
@@ -866,7 +869,11 @@ describe('FavoritesWidget', () => {
await waitFor(() => { await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith( expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }), expect.objectContaining({
widgetId: 'fav-1',
title: 'New Link',
url: 'https://example.com',
}),
); );
}); });
await waitFor(() => { await waitFor(() => {
@@ -878,3 +885,31 @@ describe('FavoritesWidget', () => {
}); });
}); });
}); });
describe('LauncherLabel — Beschriftung in der Kachelansicht', () => {
afterEach(() => {
vi.restoreAllMocks();
});
function mockWidths(scroll: number, client: number) {
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(scroll);
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(client);
}
it('bleibt in normaler Groesse, wenn der Titel in eine Zeile passt', () => {
mockWidths(40, 60);
render(<LauncherLabel text="Wiki" />);
const label = screen.getByText('Wiki');
expect(label).not.toHaveAttribute('data-small');
expect(label.className).toContain('text-xs');
});
it('wird kleiner und zweizeilig, wenn der Titel nicht in eine Zeile passt', () => {
mockWidths(120, 60);
render(<LauncherLabel text="Reisekostenabrechnung" />);
const label = screen.getByText('Reisekostenabrechnung');
expect(label).toHaveAttribute('data-small', 'true');
expect(label.className).toContain('text-[10px]');
expect(label.className).toContain('line-clamp-2');
});
});
@@ -1,21 +1,21 @@
'use client'; 'use client';
import { WidgetIcon } from './widget-icon';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { type FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { updateWidgetConfig } from '@/lib/dashboard-api'; import { updateWidgetConfig } from '@/lib/dashboard-api';
import { import {
FavoriteRequestError,
createFavorite, createFavorite,
deleteFavorite, deleteFavorite,
type FavoriteLink,
FavoriteRequestError,
fetchFavorites, fetchFavorites,
removeFavoriteIcon, removeFavoriteIcon,
reorderFavorites, reorderFavorites,
updateFavorite, updateFavorite,
uploadFavoriteIcon, uploadFavoriteIcon,
type FavoriteLink,
} from '@/lib/favorites-api'; } from '@/lib/favorites-api';
import type { WidgetProps } from '@/components/dashboard/widget-registry'; import { WidgetIcon } from './widget-icon';
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget // quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
const TITLE_DEBOUNCE_MS = 1500; const TITLE_DEBOUNCE_MS = 1500;
@@ -23,7 +23,7 @@ const TITLE_DEBOUNCE_MS = 1500;
/** /**
* Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs- * Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs-
* schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden * schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden
* Grund (`iconUrlUnreachable`/`iconTooLarge`/`iconInvalidType`/ * Grund (`iconTooLarge`/`iconInvalidType`/
* `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf * `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf
* die bisherige allgemeine Meldung zurueck. * die bisherige allgemeine Meldung zurueck.
*/ */
@@ -51,11 +51,7 @@ function favoriteErrorKey(err: unknown): string {
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch, * - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
* persistiert ueber PUT /favorites/order * persistiert ueber PUT /favorites/order
*/ */
export function FavoritesWidget({ export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps) {
instanceId,
config,
isEditMode,
}: WidgetProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [favorites, setFavorites] = useState<FavoriteLink[]>([]); const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
@@ -68,12 +64,8 @@ export function FavoritesWidget({
); );
// Optionaler Titel (quick-260916-iex) // Optionaler Titel (quick-260916-iex)
const [title, setTitle] = useState<string>( const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '');
typeof config.title === 'string' ? config.title : '', const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
);
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
useEffect(() => { useEffect(() => {
return () => clearTimeout(titleTimerRef.current); return () => clearTimeout(titleTimerRef.current);
@@ -335,174 +327,167 @@ export function FavoritesWidget({
/> />
) : ( ) : (
hasTitle && ( hasTitle && (
<h2 className="truncate text-sm font-semibold text-foreground"> <h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>
{title.trim()}
</h2>
) )
)} )}
</div> </div>
)} )}
<div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}> <div
{/* View mode toggle (edit mode only) */} className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}
{isEditMode && ( >
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten") {/* View mode toggle (edit mode only) */}
// war sachlich falsch fuer einen Umschalter zwischen Listen- und {isEditMode && (
// Kachelansicht — hier greift D-03 im Sinn des Streichens (die // role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
// Beschriftung trug keine echte Bedeutung), eine richtige // war sachlich falsch fuer einen Umschalter zwischen Listen- und
// Beschriftung ist trotzdem besser als gar keine. // Kachelansicht — hier greift D-03 im Sinn des Streichens (die
<div // Beschriftung trug keine echte Bedeutung), eine richtige
className="flex gap-1 widgetNoDrag" // Beschriftung ist trotzdem besser als gar keine.
role="toolbar"
aria-label={t('favorites.viewModeLabel')}
>
<button
type="button"
onClick={() => handleViewMode('list')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'list'
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{t('favorites.listView')}
</button>
<button
type="button"
onClick={() => handleViewMode('grid')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'grid'
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{t('favorites.gridView')}
</button>
</div>
)}
{/* Status messages */}
{loading && (
<p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* Favorites list / grid */}
{!loading && sortedFavorites.length === 0 && (
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
)}
{!loading && sortedFavorites.length > 0 && (
viewMode === 'grid' ? (
<div <div
data-testid="favorites-grid" className="flex gap-1 widgetNoDrag"
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1" role="toolbar"
aria-label={t('favorites.viewModeLabel')}
> >
{sortedFavorites.map((fav, index) => ( <button
<FavoriteTile type="button"
launcher onClick={() => handleViewMode('list')}
key={fav.id} className={`px-2 py-1 text-xs rounded ${
fav={fav} viewMode === 'list'
isEditMode={isEditMode} ? 'bg-foreground text-background'
editingId={editingId} : 'bg-muted text-muted-foreground hover:text-foreground'
editTitle={editTitle} }`}
editUrl={editUrl} >
editIconUrl={editIconUrl} {t('favorites.listView')}
editError={editError} </button>
editBusy={editBusy} <button
getFallbackLetter={getFallbackLetter} type="button"
onStartEdit={startEdit} onClick={() => handleViewMode('grid')}
onCancelEdit={cancelEdit} className={`px-2 py-1 text-xs rounded ${
onSaveEdit={handleSaveEdit} viewMode === 'grid'
onDelete={handleDelete} ? 'bg-foreground text-background'
onEditTitleChange={setEditTitle} : 'bg-muted text-muted-foreground hover:text-foreground'
onEditUrlChange={setEditUrl} }`}
onEditIconUrlChange={setEditIconUrl} >
onEditIconFileChange={setEditIconFile} {t('favorites.gridView')}
onRemoveIcon={handleRemoveIcon} </button>
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div> </div>
) : ( )}
<div
data-testid="favorites-list"
className="flex flex-col gap-0.5"
>
{sortedFavorites.map((fav, index) => (
<FavoriteTile
key={fav.id}
fav={fav}
isEditMode={isEditMode}
editingId={editingId}
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
editError={editError}
editBusy={editBusy}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
onSaveEdit={handleSaveEdit}
onDelete={handleDelete}
onEditTitleChange={setEditTitle}
onEditUrlChange={setEditUrl}
onEditIconUrlChange={setEditIconUrl}
onEditIconFileChange={setEditIconFile}
onRemoveIcon={handleRemoveIcon}
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div>
)
)}
{/* Add form (edit mode only, D-04) */} {/* Status messages */}
{isEditMode && ( {loading && <p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>}
<form {error && <p className="text-xs text-destructive">{error}</p>}
onSubmit={(e) => void handleAdd(e)}
className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag" {/* Favorites list / grid */}
> {!loading && sortedFavorites.length === 0 && (
<input <p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring" )}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)} {!loading &&
placeholder={t('favorites.addTitle')} sortedFavorites.length > 0 &&
/> (viewMode === 'grid' ? (
<input <div
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring" data-testid="favorites-grid"
value={newUrl} className="grid grid-cols-[repeat(auto-fill,minmax(52px,1fr))] gap-1"
onChange={(e) => setNewUrl(e.target.value)} >
placeholder={t('favorites.addUrl')} {sortedFavorites.map((fav, index) => (
/> <FavoriteTile
{/* 260923-lrr: eigenes Symbol beim Hinzufuegen */} launcher
<label className="text-xs text-muted-foreground"> key={fav.id}
{t('favorites.iconUploadLabel')} fav={fav}
isEditMode={isEditMode}
editingId={editingId}
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
editError={editError}
editBusy={editBusy}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
onSaveEdit={handleSaveEdit}
onDelete={handleDelete}
onEditTitleChange={setEditTitle}
onEditUrlChange={setEditUrl}
onEditIconUrlChange={setEditIconUrl}
onEditIconFileChange={setEditIconFile}
onRemoveIcon={handleRemoveIcon}
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div>
) : (
<div data-testid="favorites-list" className="flex flex-col gap-0.5">
{sortedFavorites.map((fav, index) => (
<FavoriteTile
key={fav.id}
fav={fav}
isEditMode={isEditMode}
editingId={editingId}
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
editError={editError}
editBusy={editBusy}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
onSaveEdit={handleSaveEdit}
onDelete={handleDelete}
onEditTitleChange={setEditTitle}
onEditUrlChange={setEditUrl}
onEditIconUrlChange={setEditIconUrl}
onEditIconFileChange={setEditIconFile}
onRemoveIcon={handleRemoveIcon}
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div>
))}
{/* Add form (edit mode only, D-04) */}
{isEditMode && (
<form
onSubmit={(e) => void handleAdd(e)}
className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag"
>
<input <input
type="file" className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
data-testid="favorite-add-icon-upload" value={newTitle}
ref={newIconFileInputRef} onChange={(e) => setNewTitle(e.target.value)}
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg" placeholder={t('favorites.addTitle')}
className="mt-0.5 block w-full text-xs"
onChange={(e) => setNewIconFile(e.target.files?.[0] ?? null)}
/> />
</label> <input
<button className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
type="submit" value={newUrl}
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity" onChange={(e) => setNewUrl(e.target.value)}
> placeholder={t('favorites.addUrl')}
{t('favorites.addButton')} />
</button> {/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
</form> <label className="text-xs text-muted-foreground">
)} {t('favorites.iconUploadLabel')}
<input
type="file"
data-testid="favorite-add-icon-upload"
ref={newIconFileInputRef}
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
className="mt-0.5 block w-full text-xs"
onChange={(e) => setNewIconFile(e.target.files?.[0] ?? null)}
/>
</label>
<button
type="submit"
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('favorites.addButton')}
</button>
</form>
)}
</div> </div>
</div> </div>
); );
@@ -527,13 +512,37 @@ function getDirectFaviconSrc(url: string): string | null {
} }
/** /**
* FavoriteIcon — dreistufiger Symbol-Ersatzweg (260917-jdd): * Direkt-ladbare Adressen fuer Stufe 2 des Ersatzwegs (260929-lh3): erst die
* gespeicherte `iconUrl`, dann das Origin-Favicon der Favoriten-URL; nur
* http:/https: (kein javascript:/data:, T-JDD-04), ohne Doppelte.
*/
function getDirectCandidates(iconUrl: string | null, url: string): string[] {
const candidates: string[] = [];
if (iconUrl) {
try {
const u = new URL(iconUrl);
if (u.protocol === 'http:' || u.protocol === 'https:') candidates.push(u.toString());
} catch {
// ungueltige Adresse: uebersprungen
}
}
const origin = getDirectFaviconSrc(url);
if (origin && !candidates.includes(origin)) candidates.push(origin);
return candidates;
}
/**
* FavoriteIcon — Symbol-Ersatzweg (260917-jdd, erweitert 260929-lh3):
* *
* 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan * 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan
* auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher). * auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher).
* 2. `direct` — Direktbild aus dem Browser des Nutzers * 2. `direct` — Direktbilder aus dem Browser des Nutzers
* (`referrerPolicy="no-referrer"`, Origin nur aus http/https); erreicht * (`referrerPolicy="no-referrer"`, nur http/https), nacheinander:
* interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt. * zuerst die gespeicherte `iconUrl` (ausdrueckliche Symbol-Adresse oder
* erkannter Verweis — Server wie docuvita geben dem Server 404/HTML, dem
* Browser aber das Bild), dann `{origin}/favicon.ico` (entfaellt, wenn
* identisch). Erreicht auch interne Hosts, die der SSRF-Schutz des
* Servers absichtlich ablehnt.
* 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter. * 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter.
* *
* Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach * Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach
@@ -575,10 +584,12 @@ function FavoriteIcon({
const proxySrc = hasServerIcon const proxySrc = hasServerIcon
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}` ? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
: null; : null;
const directSrc = getDirectFaviconSrc(fav.url); // 260929-lh3: Direkt-Kandidaten in Reihenfolge — gespeicherte Adresse zuerst,
const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>( // dann das Origin-Favicon; nur http/https, ohne Doppelte.
proxySrc ? 'proxy' : 'direct', const directSrcs = getDirectCandidates(fav.iconUrl, fav.url);
); // 'proxy' | Index in directSrcs | Ende der Kette (Buchstabe).
const [stage, setStage] = useState<'proxy' | number>(proxySrc ? 'proxy' : 0);
const directSrc = typeof stage === 'number' ? (directSrcs[stage] ?? null) : null;
return ( return (
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}> <div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
@@ -598,10 +609,10 @@ function FavoriteIcon({
height={px} height={px}
loading="lazy" loading="lazy"
className={`relative rounded-sm ${img}`} className={`relative rounded-sm ${img}`}
onError={() => setStage('direct')} onError={() => setStage(0)}
/> />
)} )}
{stage === 'direct' && directSrc && ( {directSrc && (
<img <img
data-testid={`icon-direct-${fav.id}`} data-testid={`icon-direct-${fav.id}`}
src={directSrc} src={directSrc}
@@ -612,7 +623,7 @@ function FavoriteIcon({
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
className={`relative rounded-sm ${img}`} className={`relative rounded-sm ${img}`}
onError={() => setStage('none')} onError={() => setStage((prev) => (typeof prev === 'number' ? prev + 1 : 0))}
/> />
)} )}
</div> </div>
@@ -651,6 +662,60 @@ type FavoriteTileProps = {
launcher?: boolean; launcher?: boolean;
}; };
/**
* Beschriftung einer Favoriten-Kachel in der Kachelansicht. Passt der Titel
* in normaler Groesse (12 px) nicht in eine Zeile, wuerde er an die
* Nachbarkachel stossen bzw. mitten im Wort umbrechen — dann wird die Schrift
* kleiner (10 px) und darf zweizeilig umbrechen (Silbentrennung, danach "…").
* Gemessen wird immer in normaler Groesse und einzeilig, damit das Ergebnis
* nicht von der eigenen Umschaltung abhaengt (keine Rueckkopplung). Der
* ResizeObserver misst neu, wenn die Kachel breiter oder schmaler wird;
* scrollWidth/clientWidth sind Layoutmasse und von der Dashboard-Skalierung
* (transform) unberuehrt.
*/
export function LauncherLabel({ text }: { text: string }) {
const ref = useRef<HTMLSpanElement>(null);
const [small, setSmall] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: neuer Titel = neu messen, der Wert selbst steht im DOM
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const prev = {
whiteSpace: el.style.whiteSpace,
fontSize: el.style.fontSize,
display: el.style.display,
};
el.style.whiteSpace = 'nowrap';
el.style.fontSize = '12px';
el.style.display = 'block';
const overflow = el.scrollWidth > el.clientWidth + 1;
el.style.whiteSpace = prev.whiteSpace;
el.style.fontSize = prev.fontSize;
el.style.display = prev.display;
setSmall(overflow);
};
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [text]);
return (
<span
ref={ref}
data-small={small ? 'true' : undefined}
className={`line-clamp-2 w-full break-words hyphens-auto ${
small ? 'text-[10px] leading-3' : 'text-xs leading-4'
}`}
>
{text}
</span>
);
}
function FavoriteTile({ function FavoriteTile({
fav, fav,
isEditMode, isEditMode,
@@ -702,9 +767,11 @@ function FavoriteTile({
large={launcher} large={launcher}
/> />
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}> {launcher ? (
{fav.title} <LauncherLabel text={fav.title} />
</span> ) : (
<span className="truncate text-sm">{fav.title}</span>
)}
</a> </a>
{/* Action buttons (edit mode only, D-04) */} {/* Action buttons (edit mode only, D-04) */}
@@ -6,10 +6,10 @@ import { createPortal } from 'react-dom';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify'; import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time'; import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
import { import {
createReminder,
type Reminder, type Reminder,
type ReminderEmailStatus, type ReminderEmailStatus,
ReminderRequestError, ReminderRequestError,
createReminder,
updateReminder, updateReminder,
} from '@/lib/reminders-api'; } from '@/lib/reminders-api';
@@ -85,9 +85,15 @@ export function ReminderFormModal({
setError(t('reminder.titleRequired')); setError(t('reminder.titleRequired'));
return; return;
} }
const dueAt = localInputsToIso(date, time); // Beim Bearbeiten nur senden, wenn Datum oder Uhrzeit wirklich geaendert
// wurden: das Formular kennt keine Sekunden, ein unveraendertes Feld wuerde
// die Faelligkeit sonst um bis zu 59 s verschieben — und kurz vor der
// Faelligkeit faelschlich „in der Vergangenheit“ melden. Ist sie inzwischen
// faellig, antwortet der Server ohnehin mit 409 (`alreadyDue`).
const dueChanged = !reminder || date !== initial.date || time !== initial.time;
const dueAt = dueChanged ? localInputsToIso(date, time) : undefined;
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen. // Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) { if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) {
setError(t('reminder.pastError')); setError(t('reminder.pastError'));
return; return;
} }
@@ -98,16 +104,15 @@ export function ReminderFormModal({
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400 // ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen. // ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled; const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
const input = { const common = {
title: trimmedTitle, title: trimmedTitle,
description: description.trim(), description: description.trim(),
dueAt,
...(emailChanged ? { emailEnabled } : {}), ...(emailChanged ? { emailEnabled } : {}),
}; };
if (reminder) { if (reminder) {
await updateReminder(reminder.id, input); await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) });
} else { } else if (dueAt) {
await createReminder(input); await createReminder({ ...common, dueAt });
} }
onSaved(); onSaved();
onClose(); onClose();
@@ -21,11 +21,11 @@ vi.mock('@/lib/reminders-api', async () => {
}); });
import { import {
ReminderRequestError,
createReminder, createReminder,
deleteReminder, deleteReminder,
getReminderEmailStatus, getReminderEmailStatus,
listReminders, listReminders,
ReminderRequestError,
snoozeReminder, snoozeReminder,
updateReminder, updateReminder,
} from '@/lib/reminders-api'; } from '@/lib/reminders-api';
@@ -79,7 +79,9 @@ async function openForm() {
function fillAndSubmit(title: string) { function fillAndSubmit(title: string) {
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } }); fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' } }); fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2099-03-04' },
});
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } }); fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
fireEvent.click(screen.getByText('reminder.save')); fireEvent.click(screen.getByText('reminder.save'));
} }
@@ -146,7 +148,9 @@ describe('ReminderWidget', () => {
render(<ReminderWidget {...props} />); render(<ReminderWidget {...props} />);
await openForm(); await openForm();
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } }); fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' } }); fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2001-01-01' },
});
fireEvent.click(screen.getByText('reminder.save')); fireEvent.click(screen.getByText('reminder.save'));
expect(await screen.findByText('reminder.pastError')).toBeTruthy(); expect(await screen.findByText('reminder.pastError')).toBeTruthy();
expect(mockCreate).not.toHaveBeenCalled(); expect(mockCreate).not.toHaveBeenCalled();
@@ -260,6 +264,38 @@ describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
expect(requestPermission).not.toHaveBeenCalled(); expect(requestPermission).not.toHaveBeenCalled();
}); });
it('Bearbeiten ohne Aenderung von Datum/Uhrzeit sendet kein dueAt (keine Sekunden-Verschiebung)', async () => {
// Faellig in 30 s, mit Sekunden: auf Minuten gekuerzt laege sie in der Vergangenheit.
const due = new Date(Date.now() + 30_000);
due.setSeconds(due.getSeconds(), 500);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'Neu' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(screen.queryByText('reminder.pastError')).toBeNull();
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('dueAt');
expect(mockUpdate.mock.calls[0][1].title).toBe('Neu');
});
it('Bearbeiten mit geaenderter Uhrzeit sendet das neue dueAt', async () => {
const due = new Date(Date.now() + 2 * 3600_000);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
const next = new Date(Date.now() + 26 * 3600_000);
const pad = (n: number) => String(n).padStart(2, '0');
const nextDate = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: nextDate } });
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '09:15' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate.mock.calls[0][1].dueAt).toBe(new Date(`${nextDate}T09:15`).toISOString());
});
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => { it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]); mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
mockSnooze.mockRejectedValue(new ReminderRequestError(409)); mockSnooze.mockRejectedValue(new ReminderRequestError(409));
@@ -313,6 +349,27 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
}); });
}); });
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
afterEach(() => {
vi.useRealTimers();
});
it('faellig gestern 23:50, jetzt 00:10: die Option heisst „Heute“ und verschiebt auf heute 23:50', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(2026, 8, 30, 0, 10));
mockList.mockResolvedValue([
reminder('a', 'Spaet', new Date(2026, 8, 29, 23, 50).toISOString()),
]);
mockSnooze.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByText('reminder.snooze'));
expect(screen.queryByText('reminder.snoozeTomorrow')).toBeNull();
fireEvent.click(await screen.findByText('reminder.snoozeToday'));
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
expect(mockSnooze.mock.calls[0][1]).toBe(new Date(2026, 8, 30, 23, 50).toISOString());
});
});
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => { describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
async function openFormAndWaitForStatus() { async function openFormAndWaitForStatus() {
await openForm(); await openForm();
@@ -383,7 +440,10 @@ describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => { it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true }); mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
mockList.mockResolvedValue([ mockList.mockResolvedValue([
{ ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true }, {
...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()),
emailEnabled: true,
},
]); ]);
mockUpdate.mockResolvedValue({}); mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />); render(<ReminderWidget {...props} />);
@@ -3,15 +3,15 @@
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { browserPermissionState } from '@/lib/reminder-notify'; import { browserPermissionState } from '@/lib/reminder-notify';
import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time'; import { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import { import {
deleteReminder,
getReminderEmailStatus,
listReminders,
REMINDERS_CHANGED_EVENT, REMINDERS_CHANGED_EVENT,
type Reminder, type Reminder,
type ReminderEmailStatus, type ReminderEmailStatus,
ReminderRequestError, ReminderRequestError,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder, snoozeReminder,
} from '@/lib/reminders-api'; } from '@/lib/reminders-api';
import type { WidgetProps } from '../widget-registry'; import type { WidgetProps } from '../widget-registry';
@@ -107,7 +107,9 @@ export function ReminderWidget(_props: WidgetProps) {
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */ /** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
const handleFailure = (err: unknown, conflictKey: string) => { const handleFailure = (err: unknown, conflictKey: string) => {
setNotice( setNotice(
err instanceof ReminderRequestError && err.status === 409 ? t(conflictKey) : t('reminder.saveError'), err instanceof ReminderRequestError && err.status === 409
? t(conflictKey)
: t('reminder.saveError'),
); );
void refetch(); void refetch();
}; };
@@ -134,6 +136,16 @@ export function ReminderWidget(_props: WidgetProps) {
} }
}; };
// „Heute“ oder „Morgen“ aus dem tatsaechlich berechneten Zeitpunkt: kurz nach
// Mitternacht liegt das naechste Vorkommen der Uhrzeit noch heute.
const tomorrowLabel = (r: Reminder) => {
const target = snoozeTarget('tomorrow', new Date(r.dueAt), new Date(now));
const time = timeOnly.format(target);
return isSameLocalDay(target, new Date(now))
? t('reminder.snoozeToday', { time })
: t('reminder.snoozeTomorrow', { time });
};
const remove = async (id: string) => { const remove = async (id: string) => {
setNotice(null); setNotice(null);
setConfirmDeleteId(null); setConfirmDeleteId(null);
@@ -177,7 +189,9 @@ export function ReminderWidget(_props: WidgetProps) {
} }
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p> <p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{r.title}
</p>
{r.emailEnabled && ( {r.emailEnabled && (
<span <span
className="shrink-0 text-muted-foreground" className="shrink-0 text-muted-foreground"
@@ -244,8 +258,7 @@ export function ReminderWidget(_props: WidgetProps) {
> >
{preset === '10m' && t('reminder.snooze10m')} {preset === '10m' && t('reminder.snooze10m')}
{preset === '1h' && t('reminder.snooze1h')} {preset === '1h' && t('reminder.snooze1h')}
{preset === 'tomorrow' && {preset === 'tomorrow' && tomorrowLabel(r)}
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
</button> </button>
))} ))}
</div> </div>
@@ -71,7 +71,9 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()} onRemove={vi.fn()}
/>, />,
); );
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' })); const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true }); expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
// im Bearbeitungsmodus bleibt der Titel sichtbar // im Bearbeitungsmodus bleibt der Titel sichtbar
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title'); expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
@@ -86,7 +88,11 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()} onRemove={vi.fn()}
/>, />,
); );
fireEvent.click(screen.getByRole('button', { name: 'showTitle' })); // quick-260930: gleiche Beschriftung, der Zustand steckt in aria-pressed.
const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(screen.queryByRole('button', { name: 'showTitle' })).toBeNull();
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false }); expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
rerender( rerender(
@@ -48,7 +48,12 @@ interface WidgetWrapperProps {
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx. * .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup. * Renders the actual widget component via WIDGET_REGISTRY lookup.
*/ */
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) { export function WidgetWrapper({
widget,
isEditMode,
onRemove,
enterIndex = 0,
}: WidgetWrapperProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType]; const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component; const WidgetComponent = definition?.component;
@@ -103,8 +108,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
void setWidgetConfig(widget.id, { hideTitle: !titleHidden }); void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
}} }}
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`} className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
aria-label={titleHidden ? t('showTitle') : t('hideTitle')} // quick-260930: feste Beschriftung plus aria-pressed — ein
title={titleHidden ? t('showTitle') : t('hideTitle')} // Umschalter, dessen Name mit dem Zustand wechselt, liest sich
// doppelt („Titel einblenden, gedrueckt“).
aria-label={t('hideTitle')}
title={t('hideTitle')}
aria-pressed={titleHidden} aria-pressed={titleHidden}
data-no-drag="" data-no-drag=""
> >
@@ -167,31 +175,29 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */} aendert die Hoehenkette nicht. */}
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && ( {WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
<div className="widget-head" data-testid="widget-frame-head"> <div className="widget-head" data-testid="widget-frame-head">
<WidgetIcon type={widget.widgetType} /> <WidgetIcon type={widget.widgetType} />
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2> <h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
</div> </div>
)} )}
<div className="@container-size min-h-0 flex-1"> <div className="@container-size min-h-0 flex-1">
{WidgetComponent ? ( {WidgetComponent ? (
<WidgetComponent <WidgetComponent
instanceId={widget.id} instanceId={widget.id}
config={widget.config} config={widget.config}
isEditMode={isEditMode} isEditMode={isEditMode}
/> />
) : ( ) : (
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte /* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
stand hier der rohe Typname, der dem Anwender nichts sagte. */ stand hier der rohe Typname, der dem Anwender nichts sagte. */
<div className="flex h-full items-center justify-center p-3"> <div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">{t('unavailable')}</p>
{t('unavailable')} </div>
</p> )}
</div> </div>
)}
</div>
</div> </div>
</article> </article>
); );
+11 -6
View File
@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { installErrorBuffer } from '@/lib/error-buffer';
import { Header } from '@/components/layout/header'; import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host'; import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
import { ReminderNotifier } from '@/components/reminders/reminder-notifier'; import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
import { installErrorBuffer } from '@/lib/error-buffer';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
export function AppShell({ children }: { children: React.ReactNode }) { export function AppShell({ children }: { children: React.ReactNode }) {
const isCollapsed = useSidebarStore((s) => s.isCollapsed); const isCollapsed = useSidebarStore((s) => s.isCollapsed);
@@ -24,9 +25,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
// Desktop: sidebar pushes content via margin-left // Desktop: sidebar pushes content via margin-left
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css) // Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
const sidebarWidth = isCollapsed const sidebarWidth = isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)';
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)';
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -35,7 +34,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */} {/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */}
<main <main
className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-3" className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-3"
style={mounted ? { '--current-sidebar-width': sidebarWidth } as React.CSSProperties : undefined} style={
mounted ? ({ '--current-sidebar-width': sidebarWidth } as React.CSSProperties) : undefined
}
> >
{children} {children}
</main> </main>
@@ -46,6 +47,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */} Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
<ReminderNotifier /> <ReminderNotifier />
{/* Eigene Module offen halten (30.09.2026): die <iframe>s leben hier im
Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur
einen Platzhalter, ueber den der Behaelter das aktive Modul legt. */}
<CustomModuleFrameHost />
</div> </div>
); );
} }
+28 -27
View File
@@ -1,18 +1,19 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
import { fetchSessionState, logout } from '@/lib/auth-actions';
import { buildNextParam } from '@/lib/safe-next';
import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button'; import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { HEADER_ACTIONS_SLOT_ID, HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot'; import { HEADER_ACTIONS_SLOT_ID, HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import { usePathname } from 'next/navigation'; import { ThemeToggle } from '@/components/theme-toggle';
import { fetchSessionState, logout } from '@/lib/auth-actions';
import { buildNextParam } from '@/lib/safe-next';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store'; import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
export function Header() { export function Header() {
const t = useTranslations(); const t = useTranslations();
@@ -60,13 +61,8 @@ export function Header() {
return; return;
} }
redirectedRef.current = true; redirectedRef.current = true;
const next = buildNextParam( const next = buildNextParam(window.location.pathname, window.location.search);
window.location.pathname, window.location.href = next ? `/login?next=${encodeURIComponent(next)}` : '/login';
window.location.search,
);
window.location.href = next
? `/login?next=${encodeURIComponent(next)}`
: '/login';
} }
// 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles // 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles
// Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen. // Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen.
@@ -76,10 +72,7 @@ export function Header() {
// Close dropdown on outside click // Close dropdown on outside click
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if ( if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false); setDropdownOpen(false);
} }
} }
@@ -91,9 +84,7 @@ export function Header() {
const [avatarError, setAvatarError] = useState(false); const [avatarError, setAvatarError] = useState(false);
const userInitial = user const userInitial = user ? (user.displayName ?? user.username).charAt(0).toUpperCase() : '?';
? (user.displayName ?? user.username).charAt(0).toUpperCase()
: '?';
const roleLabel = user ? tHeader(`role.${user.role}`) : ''; const roleLabel = user ? tHeader(`role.${user.role}`) : '';
@@ -101,11 +92,7 @@ export function Header() {
// der Name der aktuellen Seite bzw. des Moduls. // der Name der aktuellen Seite bzw. des Moduls.
const navModules = useNavStore((s) => s.modules); const navModules = useNavStore((s) => s.modules);
const pageTitle = resolvePageTitle(pathname, navModules); const pageTitle = resolvePageTitle(pathname, navModules);
const pageTitleText = pageTitle const pageTitleText = pageTitle ? ('key' in pageTitle ? t(pageTitle.key) : pageTitle.text) : null;
? 'key' in pageTitle
? t(pageTitle.key)
: pageTitle.text
: null;
return ( return (
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]"> <header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]">
@@ -258,7 +245,18 @@ export function Header() {
: 'text-foreground hover:bg-muted' : 'text-foreground hover:bg-muted'
}`} }`}
> >
<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /> <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg> </svg>
{tHeader('admin.label')} {tHeader('admin.label')}
@@ -273,6 +271,9 @@ export function Header() {
type="button" type="button"
onClick={() => { onClick={() => {
setDropdownOpen(false); setDropdownOpen(false);
// Offen gehaltene eigene Module und ihr Sitzungsspeicher
// gehoeren zur Sitzung — beim Abmelden alles verwerfen.
useCustomModuleCacheStore.getState().clear();
logout(); logout();
}} }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
+54 -10
View File
@@ -8,8 +8,18 @@ vi.mock('next/navigation', () => ({
})); }));
vi.mock('next/link', () => ({ vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( default: ({
<a href={href} className={className}>{children}</a> children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
), ),
})); }));
@@ -100,9 +110,7 @@ const mockActiveModules = [
function stubFetch() { function stubFetch() {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn(() => vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })),
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
),
); );
} }
@@ -316,7 +324,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Mein Wiki')).toBeInTheDocument(); expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
}); });
const groups = Array.from( const groups = Array.from(
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'), screen
.getByRole('group', { name: 'Kategorien' })
.querySelectorAll(':scope > ul > li > button span.truncate'),
).map((n) => n.textContent); ).map((n) => n.textContent);
expect(groups.at(-1)).toBe('custom-modules'); expect(groups.at(-1)).toBe('custom-modules');
expect(groups).toContain('fleet'); expect(groups).toContain('fleet');
@@ -340,12 +350,40 @@ describe('Sidebar', () => {
render(<Sidebar />); render(<Sidebar />);
const hrefs = () => const hrefs = () =>
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href')); Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
a.getAttribute('href'),
);
await waitFor(() => { await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-1'); expect(hrefs()).toContain('/modules/custom/cm-1');
}); });
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1'); expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1')); expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(
hrefs().indexOf('/modules/custom/cm-1'),
);
});
it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => {
mockCustomModules = [
customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }),
customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }),
];
mockSidebarCollapsed(true);
const Sidebar = await importSidebar();
render(<Sidebar />);
const hrefs = () =>
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
a.getAttribute('href'),
);
await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-2');
});
expect(hrefs()).toEqual([
'/modules/Domain-Tools/domaincheck',
'/modules/custom/cm-1',
'/modules/Utilities/converter',
'/modules/custom/cm-2',
]);
}); });
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => { it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
@@ -360,7 +398,10 @@ describe('Sidebar', () => {
.getByText('Domain-Tools') .getByText('Domain-Tools')
.closest('li') .closest('li')
?.querySelectorAll('a span.truncate'); ?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']); expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
'Domaincheck',
'Aaa Eigenes',
]);
}); });
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => { it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
@@ -379,7 +420,10 @@ describe('Sidebar', () => {
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull(); ).toBeNull();
expect( expect(
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), screen
.getByText('Domaincheck')
.closest('a')
?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull(); ).toBeNull();
}); });
+120 -45
View File
@@ -1,20 +1,20 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store';
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting'; import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { ModuleTile } from '@/components/modules/module-tile';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useNavStore } from '@/lib/stores/nav-store';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useCategoryLabel } from '@/lib/use-category-label';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */ /** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() { function SelectionPill() {
@@ -109,13 +109,17 @@ export function Sidebar() {
const linkClass = (href: string) => itemClass(isActive(href)); const linkClass = (href: string) => itemClass(isActive(href));
const isEntryActive = (entry: SidebarEntry) => const isEntryActive = (entry: SidebarEntry) =>
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref)); isActive(entry.href) ||
(entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die // Moduleintraege unter einer Kategorie eine Stufe kleiner als die
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift; // Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
// die 20-px-Modulkachel passt weiter hinein. // die 20-px-Modulkachel passt weiter hinein.
const moduleItemClass = (active: boolean) => const moduleItemClass = (active: boolean) =>
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]'); itemClass(active)
.replace('h-9', 'h-8')
.replace('gap-3', 'gap-2.5')
.replace('text-sm', 'text-[13px]');
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt // Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch // 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
@@ -186,28 +190,29 @@ export function Sidebar() {
}); });
}; };
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“ // Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
// also auch hier immer zuletzt. // die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
const orderedCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
const list = categories.get(entry.category);
if (list) list.push(entry);
else categories.set(entry.category, [entry]);
}
return Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
);
}, [entries]);
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
const collapsedEntries = useMemo( const collapsedEntries = useMemo(
() => () => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
[...entries].sort( [orderedCategories],
(a, b) =>
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
),
[entries],
); );
const filteredCategories = useMemo(() => { const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>(); const ordered = orderedCategories;
for (const entry of entries) {
if (!categories.has(entry.category)) categories.set(entry.category, []);
categories.get(entry.category)!.push(entry);
}
// „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
const ordered = Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
);
if (!searchQuery) return ordered; if (!searchQuery) return ordered;
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
@@ -221,10 +226,22 @@ export function Sidebar() {
} }
} }
return result; return result;
}, [entries, searchQuery, categoryLabel]); }, [orderedCategories, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => ( const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width={isCollapsed ? 22 : 20}
height={isCollapsed ? 22 : 20}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
{children} {children}
</svg> </svg>
); );
@@ -234,7 +251,12 @@ export function Sidebar() {
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}> <nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}> <ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
<li> <li>
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}> <Link
href="/"
className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')}
aria-label={t('dashboard')}
title={isCollapsed ? t('dashboard') : undefined}
>
{isActive('/') && <SelectionPill />} {isActive('/') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -248,7 +270,16 @@ export function Sidebar() {
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}> <Link
href="/marketplace"
className={
isCollapsed
? collapsedItemClass(isActive('/marketplace'))
: linkClass('/marketplace')
}
aria-label={t('marketplace')}
title={isCollapsed ? t('marketplace') : undefined}
>
{isActive('/marketplace') && <SelectionPill />} {isActive('/marketplace') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -268,9 +299,18 @@ export function Sidebar() {
const active = isEntryActive(entry); const active = isEntryActive(entry);
return ( return (
<li key={entry.key}> <li key={entry.key}>
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}> <Link
href={entry.href}
className={collapsedItemClass(active)}
aria-label={entry.name}
title={entry.name}
>
{active && <SelectionPill />} {active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} /> <ModuleTile
slug={entry.tileSlug}
size={24}
tone={active ? 'accent' : 'neutral'}
/>
</Link> </Link>
</li> </li>
); );
@@ -290,9 +330,7 @@ export function Sidebar() {
{t('categories.empty')} {t('categories.empty')}
</div> </div>
) : filteredCategories.length === 0 ? ( ) : filteredCategories.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground"> <div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
{t('noResults')}
</div>
) : ( ) : (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{filteredCategories.map(([category, categoryEntries]) => { {filteredCategories.map(([category, categoryEntries]) => {
@@ -307,7 +345,19 @@ export function Sidebar() {
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
> >
<span className="truncate">{categoryLabel(category)}</span> <span className="truncate">{categoryLabel(category)}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
>
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
</button> </button>
@@ -319,7 +369,11 @@ export function Sidebar() {
<li key={entry.key}> <li key={entry.key}>
<Link href={entry.href} className={moduleItemClass(active)}> <Link href={entry.href} className={moduleItemClass(active)}>
{active && <SelectionPill />} {active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} /> <ModuleTile
slug={entry.tileSlug}
size={20}
tone={active ? 'accent' : 'neutral'}
/>
<span className="truncate">{entry.name}</span> <span className="truncate">{entry.name}</span>
</Link> </Link>
</li> </li>
@@ -353,7 +407,19 @@ export function Sidebar() {
aria-label={isCollapsed ? t('expand') : t('collapse')} aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : undefined} title={isCollapsed ? t('expand') : undefined}
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
>
<rect x="3" y="3" width="18" height="18" rx="2" /> <rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" /> <path d="M9 3v18" />
<path d="m16 15-3-3 3-3" /> <path d="m16 15-3-3 3-3" />
@@ -377,9 +443,7 @@ export function Sidebar() {
<aside <aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block" className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
style={{ style={{
width: isCollapsed width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)',
}} }}
> >
{sidebarContent} {sidebarContent}
@@ -408,7 +472,18 @@ export function Sidebar() {
aria-label={tCommon('close')} aria-label={tCommon('close')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
@@ -0,0 +1,134 @@
'use client';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { checkCustomModuleUrl } from '@/lib/custom-modules-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
interface FrameRect {
top: number;
left: number;
width: number;
height: number;
}
/**
* Behaelter fuer die offen gehaltenen eigenen Module (30.09.2026). Dauerhaft
* im Portal-Rahmen (AppShell) eingehaengt, damit ein <iframe> das Wegnavigieren
* ueberlebt: zurueck auf dem Modul ist die Fremdseite sofort da, mit
* Scrollstand, Anmeldung und Eingaben.
*
* Positionierung — warum Ueberlagern statt Einhaengen in die Seite: Ein
* <iframe>, das im DOM verschoben wird (auch per Portal in einen anderen
* Elternknoten), laedt in allen Browsern neu. Deshalb bleibt jedes <iframe>
* immer im selben Elternknoten hier, und die Modulseite rendert nur einen
* leeren Platzhalter (custom-module-view.tsx). Das aktive <iframe> wird per
* `position: fixed` deckungsgleich ueber den Platzhalter gelegt; gemessen wird
* mit `getBoundingClientRect` bei jeder Groessenaenderung des Platzhalters
* (ResizeObserver — faengt auch das Ein-/Ausklappen der Seitenleiste ab, deren
* Uebergang die Breite des Inhaltsbereichs Bild fuer Bild aendert), bei
* Fenstergroesse, beim Scrollen und am Ende von CSS-Uebergaengen. Eine reine
* CSS-Nachbildung der Rahmenmasse (Kopfzeile, Seitenleiste, Innenabstand)
* waere zerbrechlicher: jede Layoutaenderung dort muesste hier nachgezogen
* werden.
*
* Versteckt wird mit `visibility: hidden` + `pointer-events: none` an der
* zuletzt gemessenen Stelle — NICHT mit `display: none`: dort haette die
* Fremdseite die Groesse 0, manche Seiten rechnen dann ihr Layout neu und
* verlieren den Scrollstand. `visibility: hidden` nimmt das <iframe> zudem aus
* Tab-Reihenfolge und Vorlesebaum.
*
* Ebene: `z-index: 10` — ueber dem Inhaltsbereich (Platzhalter), aber unter
* Seitenleiste (30), Kopfzeile und Dialogen (50) und der Handy-Seitenleiste
* (60), die das Modul also weiterhin ueberdecken.
*
* Sandbox und Attribute wie bisher in der Modulansicht bzw. im XFrame-Widget
* (`XFRAME_SANDBOX`, `allow=""`, kein Referrer); ein <iframe> entsteht nur bei
* einer https-Adresse ohne Zugangsdaten (T-9WC-03).
*/
export function CustomModuleFrameHost() {
const modules = useCustomModuleCacheStore((s) => s.modules);
const openIds = useCustomModuleCacheStore((s) => s.openIds);
const active = useCustomModuleCacheStore((s) => s.active);
const clear = useCustomModuleCacheStore((s) => s.clear);
const [rect, setRect] = useState<FrameRect | null>(null);
// Benutzerwechsel ohne Neuladen der Seite: nichts vom vorigen Benutzer
// weiterzeigen. (Das Abmelden selbst leert den Speicher in der Kopfzeile.)
const userId = useAuthStore((s) => s.user?.id ?? null);
const lastUserId = useRef<string | null>(null);
useEffect(() => {
if (userId && lastUserId.current && lastUserId.current !== userId) clear();
if (userId) lastUserId.current = userId;
}, [userId, clear]);
const slot = active?.slot ?? null;
useLayoutEffect(() => {
if (!slot) return;
let frame = 0;
const measure = () => {
frame = 0;
const r = slot.getBoundingClientRect();
setRect((prev) =>
prev &&
prev.top === r.top &&
prev.left === r.left &&
prev.width === r.width &&
prev.height === r.height
? prev
: { top: r.top, left: r.left, width: r.width, height: r.height },
);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
measure();
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null;
observer?.observe(slot);
window.addEventListener('resize', schedule);
window.addEventListener('scroll', schedule, true);
document.addEventListener('transitionend', schedule, true);
return () => {
if (frame) cancelAnimationFrame(frame);
observer?.disconnect();
window.removeEventListener('resize', schedule);
window.removeEventListener('scroll', schedule, true);
document.removeEventListener('transitionend', schedule, true);
};
}, [slot]);
// Immer derselbe Elternknoten, auch ohne offene Module — sonst haengte React
// die <iframe>s beim Wechsel zwischen leer und nicht leer neu ein.
return (
<div data-testid="custom-module-frame-host">
{openIds.map((id) => {
const mod = modules[id];
if (!mod || checkCustomModuleUrl(mod.url) !== 'ok') return null;
const visible = active?.id === id && rect !== null;
return (
<iframe
key={id}
src={mod.url}
title={mod.name}
sandbox={XFRAME_SANDBOX}
allow=""
referrerPolicy="no-referrer"
className="fixed z-10 rounded-lg border-0 bg-background"
style={{
top: rect?.top ?? 0,
left: rect?.left ?? 0,
width: rect?.width ?? 0,
height: rect?.height ?? 0,
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
}}
aria-hidden={visible ? undefined : true}
data-custom-module-id={id}
data-testid="custom-module-frame"
/>
);
})}
</div>
);
}
@@ -1,12 +1,14 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { act, 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';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => { useTranslations: () => (key: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
openInNewTab: 'In neuem Tab öffnen', openInNewTab: 'In neuem Tab öffnen',
notFound: 'Dieses Modul gibt es nicht mehr.', notFound: 'Dieses Modul gibt es nicht mehr.',
loadError: 'Dieses Modul konnte nicht geladen werden.',
invalidUrl: 'Ungültige Adresse', invalidUrl: 'Ungültige Adresse',
loading: 'Wird geladen', loading: 'Wird geladen',
}; };
@@ -22,12 +24,33 @@ vi.mock('@/lib/custom-modules-api', async () => {
return { ...actual, getCustomModule: mockGetCustomModule }; return { ...actual, getCustomModule: mockGetCustomModule };
}); });
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
import { CustomModuleFrameHost } from './custom-module-frame-host';
import { CustomModuleView } from './custom-module-view'; import { CustomModuleView } from './custom-module-view';
function mod(url: string) { /**
* Der <iframe> lebt seit dem Offenhalten (30.09.2026) im Behaelter des
* Portal-Rahmens; die Modulseite zeigt nur den Platzhalter. Die Tests rendern
* deshalb beide nebeneinander wie in AppShell. `route` bildet den
* Seitenwechsel nach: nur die Modulseite wechselt, der Behaelter bleibt.
*/
function Portal({ route }: { route: string | null }) {
return (
<>
<main>{route ? <CustomModuleView id={route} /> : <p>Dashboard</p>}</main>
<CustomModuleFrameHost />
</>
);
}
function renderModule(id: string) {
return render(<Portal route={id} />);
}
function mod(url: string, id = 'abc', name = 'Wiki') {
return { return {
id: 'abc', id,
name: 'Wiki', name,
url, url,
category: 'infrastructure', category: 'infrastructure',
createdAt: '2026-09-29T00:00:00.000Z', createdAt: '2026-09-29T00:00:00.000Z',
@@ -37,6 +60,7 @@ function mod(url: string) {
beforeEach(() => { beforeEach(() => {
mockGetCustomModule.mockReset(); mockGetCustomModule.mockReset();
useCustomModuleCacheStore.getState().clear();
}); });
afterEach(() => { afterEach(() => {
@@ -46,7 +70,7 @@ afterEach(() => {
describe('CustomModuleView', () => { describe('CustomModuleView', () => {
it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => { it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki')); mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />); renderModule('abc');
const frame = await screen.findByTitle('Wiki'); const frame = await screen.findByTitle('Wiki');
expect(frame.tagName).toBe('IFRAME'); expect(frame.tagName).toBe('IFRAME');
@@ -60,7 +84,7 @@ describe('CustomModuleView', () => {
it('zeigt immer den Link „In neuem Tab öffnen“, aber keine Name- oder Hinweiszeile', async () => { it('zeigt immer den Link „In neuem Tab öffnen“, aber keine Name- oder Hinweiszeile', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki')); mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />); renderModule('abc');
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' }); const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
expect(link).toHaveAttribute('href', 'https://example.com/wiki'); expect(link).toHaveAttribute('href', 'https://example.com/wiki');
@@ -75,7 +99,7 @@ describe('CustomModuleView', () => {
document.body.appendChild(slot); document.body.appendChild(slot);
try { try {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki')); mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />); renderModule('abc');
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' }); const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
expect(slot.contains(link)).toBe(true); expect(slot.contains(link)).toBe(true);
} finally { } finally {
@@ -90,7 +114,7 @@ describe('CustomModuleView', () => {
'https://user:pw@example.com', 'https://user:pw@example.com',
])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => { ])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => {
mockGetCustomModule.mockResolvedValue(mod(url)); mockGetCustomModule.mockResolvedValue(mod(url));
const { container } = render(<CustomModuleView id="abc" />); const { container } = renderModule('abc');
await screen.findByText('Ungültige Adresse'); await screen.findByText('Ungültige Adresse');
expect(container.querySelector('iframe')).toBeNull(); expect(container.querySelector('iframe')).toBeNull();
@@ -99,11 +123,122 @@ describe('CustomModuleView', () => {
it('zeigt bei 404 den Nicht-gefunden-Text', async () => { it('zeigt bei 404 den Nicht-gefunden-Text', async () => {
mockGetCustomModule.mockResolvedValue(null); mockGetCustomModule.mockResolvedValue(null);
const { container } = render(<CustomModuleView id="weg" />); const { container } = renderModule('weg');
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument(); expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
}); });
expect(container.querySelector('iframe')).toBeNull(); expect(container.querySelector('iframe')).toBeNull();
}); });
it.each([
['500', new CustomModuleRequestError(500, 'Internal server error')],
['403', new CustomModuleRequestError(403, 'Forbidden')],
['Netzwerkfehler', new TypeError('Failed to fetch')],
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
mockGetCustomModule.mockRejectedValue(err);
const { container } = renderModule('abc');
expect(await screen.findByRole('alert')).toHaveTextContent(
'Dieses Modul konnte nicht geladen werden.',
);
expect(screen.queryByText('Dieses Modul gibt es nicht mehr.')).toBeNull();
expect(container.querySelector('iframe')).toBeNull();
});
});
describe('Eigene Module offen halten', () => {
it('haelt den Rahmen beim Weg- und Zuruecknavigieren als dasselbe Element im DOM', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
const { rerender } = render(<Portal route="abc" />);
const frame = await screen.findByTitle('Wiki');
expect(frame).toHaveStyle({ visibility: 'visible' });
rerender(<Portal route={null} />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
const hidden = document.querySelector('iframe');
expect(hidden).toBe(frame);
expect(frame.isConnected).toBe(true);
expect(frame).toHaveStyle({ visibility: 'hidden', pointerEvents: 'none' });
rerender(<Portal route="abc" />);
await waitFor(() => expect(frame).toHaveStyle({ visibility: 'visible' }));
expect(document.querySelector('iframe')).toBe(frame);
});
it('zeigt ein schon geladenes Modul sofort und laedt im Hintergrund nach', async () => {
useCustomModuleCacheStore.getState().remember(mod('https://example.com/alt'));
let resolve: (value: ReturnType<typeof mod>) => void = () => {};
mockGetCustomModule.mockReturnValue(
new Promise((r) => {
resolve = r;
}),
);
renderModule('abc');
// Ohne auf die API zu warten: kein Ladehinweis, Rahmen schon da.
expect(screen.queryByText('Wird geladen')).toBeNull();
const frame = screen.getByTitle('Wiki');
expect(frame).toHaveAttribute('src', 'https://example.com/alt');
expect(mockGetCustomModule).toHaveBeenCalledWith('abc');
// Nachgeladene neue Adresse landet im selben Rahmen.
await act(async () => {
resolve(mod('https://example.com/neu'));
});
expect(screen.getByTitle('Wiki')).toBe(frame);
expect(frame).toHaveAttribute('src', 'https://example.com/neu');
});
it('bleibt bei gespeichertem Stand sichtbar, wenn nur das Nachladen scheitert', async () => {
useCustomModuleCacheStore.getState().remember(mod('https://example.com/wiki'));
mockGetCustomModule.mockRejectedValue(new TypeError('Failed to fetch'));
renderModule('abc');
await waitFor(() => expect(mockGetCustomModule).toHaveBeenCalled());
expect(screen.queryByRole('alert')).toBeNull();
expect(screen.getByTitle('Wiki').tagName).toBe('IFRAME');
});
it('verwirft bei 404 Speicherstand und offenen Rahmen und zeigt „nicht gefunden“', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
const { rerender } = render(<Portal route="abc" />);
await screen.findByTitle('Wiki');
rerender(<Portal route={null} />);
mockGetCustomModule.mockResolvedValue(null);
rerender(<Portal route="abc" />);
expect(await screen.findByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
expect(document.querySelector('iframe')).toBeNull();
const state = useCustomModuleCacheStore.getState();
expect(state.modules.abc).toBeUndefined();
expect(state.openIds).toEqual([]);
});
it('haelt hoechstens fuenf Module offen und verwirft das am laengsten unbenutzte', async () => {
mockGetCustomModule.mockImplementation(async (id: string) =>
mod(`https://example.com/${id}`, id, `Modul ${id}`),
);
const { rerender } = render(<Portal route="m1" />);
await screen.findByTitle('Modul m1');
for (const id of ['m2', 'm3', 'm4', 'm5', 'm6']) {
rerender(<Portal route={id} />);
await screen.findByTitle(`Modul ${id}`);
}
expect(document.querySelectorAll('iframe')).toHaveLength(5);
expect(screen.queryByTitle('Modul m1')).toBeNull();
expect(screen.getByTitle('Modul m2')).toBeInTheDocument();
});
it('verwirft beim Leeren des Speichers (Abmelden) alle Rahmen', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
const { rerender } = render(<Portal route="abc" />);
await screen.findByTitle('Wiki');
rerender(<Portal route={null} />);
act(() => useCustomModuleCacheStore.getState().clear());
expect(document.querySelector('iframe')).toBeNull();
});
}); });
@@ -1,13 +1,13 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot'; import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; import { checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api'; import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule }; type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready';
/** /**
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen * Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
@@ -19,40 +19,85 @@ type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'read
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne * Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit * Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03). * `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
* zeigen „konnte nicht geladen werden“ — der Eintrag existiert vielleicht noch.
*
* Offen halten (30.09.2026): Der <iframe> selbst lebt NICHT mehr hier, sondern
* im Behaelter `CustomModuleFrameHost` im Portal-Rahmen; diese Seite rendert
* nur den Platzhalter, ueber den der Behaelter ihn legt (Begruendung dort).
* Name und Adresse kommen aus dem Sitzungsspeicher
* (`custom-module-cache-store.ts`): ein schon einmal geladenes Modul erscheint
* sofort, `getCustomModule` laeuft trotzdem im Hintergrund. Eine geaenderte
* Adresse landet so im Speicher und laedt den <iframe> um; ein 404 verwirft den
* Eintrag samt <iframe> und zeigt „nicht gefunden“. Scheitert NUR das
* Nachladen (403, 500, Netz) bei vorhandenem Speicherstand, bleibt das Modul
* sichtbar — ohne Speicherstand gilt wie bisher „konnte nicht geladen werden“.
*/ */
export function CustomModuleView({ id }: { id: string }) { export function CustomModuleView({ id }: { id: string }) {
const t = useTranslations('customModules'); const t = useTranslations('customModules');
const [state, setState] = useState<LoadState>({ status: 'loading' }); const mod = useCustomModuleCacheStore((s) => s.modules[id]);
const remember = useCustomModuleCacheStore((s) => s.remember);
const forget = useCustomModuleCacheStore((s) => s.forget);
const activate = useCustomModuleCacheStore((s) => s.activate);
const deactivate = useCustomModuleCacheStore((s) => s.deactivate);
const [status, setStatus] = useState<LoadStatus>(() =>
useCustomModuleCacheStore.getState().modules[id] ? 'ready' : 'loading',
);
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null); const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
const slotRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID)); setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
}, []); }, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setState({ status: 'loading' }); const hasCached = () => Boolean(useCustomModuleCacheStore.getState().modules[id]);
setStatus(hasCached() ? 'ready' : 'loading');
getCustomModule(id) getCustomModule(id)
.then((mod) => { .then((loaded) => {
if (cancelled) return; if (cancelled) return;
setState(mod ? { status: 'ready', mod } : { status: 'missing' }); if (loaded) {
remember(loaded);
setStatus('ready');
} else {
forget(id);
setStatus('missing');
}
}) })
.catch(() => { .catch(() => {
if (!cancelled) setState({ status: 'missing' }); if (!cancelled) setStatus(hasCached() ? 'ready' : 'failed');
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [id]); }, [id, remember, forget]);
if (state.status === 'loading') { const urlOk = mod ? checkCustomModuleUrl(mod.url) === 'ok' : false;
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>; const showFrame = status === 'ready' && urlOk;
}
if (state.status === 'missing') { // Platzhalter beim Behaelter anmelden; beim Verlassen wird der <iframe> nur
// versteckt. Wechselt die Adresse auf ungueltig, verschwindet der
// Platzhalter und damit auch die Anmeldung (`showFrame`).
useEffect(() => {
const slot = slotRef.current;
if (!showFrame || !slot) return;
activate(id, slot);
return () => deactivate(slot);
}, [showFrame, id, activate, deactivate]);
if (status === 'missing') {
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>; return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
} }
if (status === 'failed') {
const { mod } = state; return (
const urlOk = checkCustomModuleUrl(mod.url) === 'ok'; <div role="alert" className="p-4 text-sm text-destructive">
{t('loadError')}
</div>
);
}
if (status === 'loading' || !mod) {
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
}
const openLink = urlOk ? ( const openLink = urlOk ? (
<a <a
@@ -85,14 +130,10 @@ export function CustomModuleView({ id }: { id: string }) {
<div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2"> <div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2">
{openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)} {openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)}
{urlOk ? ( {urlOk ? (
<iframe <div
src={mod.url} ref={slotRef}
title={mod.name} className="w-full flex-1 rounded-lg bg-background"
sandbox={XFRAME_SANDBOX} data-testid="custom-module-slot"
allow=""
referrerPolicy="no-referrer"
className="w-full flex-1 rounded-lg border-0 bg-background"
data-testid="custom-module-frame"
/> />
) : ( ) : (
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground"> <div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
@@ -17,12 +17,13 @@ vi.mock('@/lib/reminders-api', async () => {
}); });
vi.mock('@/lib/reminder-notify', async () => { vi.mock('@/lib/reminder-notify', async () => {
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify'); const actual =
await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
return { ...actual, showReminderNotification: vi.fn(async () => undefined) }; return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
}); });
import { showReminderNotification } from '@/lib/reminder-notify'; import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify';
import { ReminderRequestError, listReminders } from '@/lib/reminders-api'; import { listReminders, ReminderRequestError } from '@/lib/reminders-api';
import { ReminderNotifier } from './reminder-notifier'; import { ReminderNotifier } from './reminder-notifier';
const mockList = listReminders as ReturnType<typeof vi.fn>; const mockList = listReminders as ReturnType<typeof vi.fn>;
@@ -53,6 +54,7 @@ beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(NOW); vi.setSystemTime(NOW);
window.localStorage.clear(); window.localStorage.clear();
resetNotifiedMemoryForTests();
mockList.mockReset(); mockList.mockReset();
mockShow.mockClear(); mockShow.mockClear();
}); });
@@ -77,7 +79,9 @@ describe('ReminderNotifier', () => {
}); });
it('meldet nichts, solange nichts faellig ist', async () => { it('meldet nichts, solange nichts faellig ist', async () => {
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]); mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() }),
]);
render(<ReminderNotifier />); render(<ReminderNotifier />);
await advance(30_000); await advance(30_000);
expect(mockShow).not.toHaveBeenCalled(); expect(mockShow).not.toHaveBeenCalled();
@@ -85,7 +89,10 @@ describe('ReminderNotifier', () => {
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => { it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
mockList.mockResolvedValue([ mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }), reminder({
dueAt: new Date(NOW.getTime() - 1000).toISOString(),
description: 'x'.repeat(300),
}),
]); ]);
render(<ReminderNotifier />); render(<ReminderNotifier />);
await advance(0); await advance(0);
+13 -15
View File
@@ -14,9 +14,9 @@ import {
* Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld * Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld
* `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst -> * `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst ->
* `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie * `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie
* `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit * `createFavorite`/`updateFavorite` (jeder Fehler bleibt eine generische
* `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische * `Error`; die 422-Abrufprobe ist seit 260929-lh3 entfallen). Muster
* `Error` wie bisher). Muster `dashboard-images-api.test.ts`. * `dashboard-images-api.test.ts`.
*/ */
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() })); const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
@@ -112,9 +112,9 @@ describe('favorites-api (quick-260923-lrr)', () => {
expect(init.credentials).toBe('include'); expect(init.credentials).toBe('include');
}); });
describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => { describe('createFavorite / updateFavorite — Fehler bleiben generisch (260929-lh3)', () => {
it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => { it('createFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 422 })); mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
const err = await createFavorite({ const err = await createFavorite({
widgetId: 'w1', widgetId: 'w1',
@@ -123,8 +123,8 @@ describe('favorites-api (quick-260923-lrr)', () => {
iconUrl: 'https://x.invalid/logo.png', iconUrl: 'https://x.invalid/logo.png',
}).catch((e) => e); }).catch((e) => e);
expect(err).toBeInstanceOf(FavoriteRequestError); expect(err).toBeInstanceOf(Error);
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable'); expect(err).not.toBeInstanceOf(FavoriteRequestError);
}); });
it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => { it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
@@ -138,15 +138,13 @@ describe('favorites-api (quick-260923-lrr)', () => {
expect(err).not.toBeInstanceOf(FavoriteRequestError); expect(err).not.toBeInstanceOf(FavoriteRequestError);
}); });
it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => { it('updateFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 422 })); mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch( const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch((e) => e);
(e) => e,
);
expect(err).toBeInstanceOf(FavoriteRequestError); expect(err).toBeInstanceOf(Error);
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable'); expect(err).not.toBeInstanceOf(FavoriteRequestError);
}); });
it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => { it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
+7 -11
View File
@@ -7,10 +7,9 @@
* 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung * 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
* erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion` * erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion`
* (optional, weil Testdaten und aeltere Antworten sie nicht tragen). * (optional, weil Testdaten und aeltere Antworten sie nicht tragen).
* `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API * `FavoriteRequestError` traegt den Grund einer abgewiesenen Symbol-Datei
* fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit * (Widget bildet ihn auf eine sprachrichtige Meldung ab). 260929-lh3: die
* das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen * 422-Abrufprobe fuer Logo-Adressen ist entfallen. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
* generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
* Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts). * Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts).
*/ */
@@ -34,7 +33,6 @@ export interface FavoriteLink {
/** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */ /** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */
export type FavoriteErrorReason = export type FavoriteErrorReason =
| 'iconUrlUnreachable'
| 'iconTooLarge' | 'iconTooLarge'
| 'iconInvalidType' | 'iconInvalidType'
| 'iconUploadFailed'; | 'iconUploadFailed';
@@ -72,8 +70,9 @@ export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]>
/** /**
* Create a new favorite link. * Create a new favorite link.
* Server-side icon discovery runs automatically if iconUrl is not provided. * Server-side icon discovery runs automatically if iconUrl is not provided.
* 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden * 260929-lh3: eine ausdrueckliche `iconUrl` wird auch gespeichert, wenn der
* laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`. * Server sie nicht abrufen kann (die Kachel laedt sie dann im Browser); die
* frueheren 422-Antworten gibt es nicht mehr.
*/ */
export async function createFavorite(payload: { export async function createFavorite(payload: {
widgetId: string; widgetId: string;
@@ -87,7 +86,6 @@ export async function createFavorite(payload: {
credentials: 'include', credentials: 'include',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
if (!res.ok) throw new Error('Failed to create favorite'); if (!res.ok) throw new Error('Failed to create favorite');
return res.json(); return res.json();
@@ -96,8 +94,7 @@ export async function createFavorite(payload: {
/** /**
* Update an existing favorite link. * Update an existing favorite link.
* Pass iconUrl: null to clear a stored icon. * Pass iconUrl: null to clear a stored icon.
* 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 -> * 260929-lh3: wie `createFavorite` — keine 422-Abweisung wegen Abrufbarkeit mehr.
* `FavoriteRequestError('iconUrlUnreachable')`.
*/ */
export async function updateFavorite( export async function updateFavorite(
id: string, id: string,
@@ -109,7 +106,6 @@ export async function updateFavorite(
credentials: 'include', credentials: 'include',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
if (!res.ok) throw new Error('Failed to update favorite'); if (!res.ok) throw new Error('Failed to update favorite');
return res.json(); return res.json();
+135 -9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
GRID_CANVAS_KEY,
GRID_SCALE_FACTOR, GRID_SCALE_FACTOR,
GRID_VERSION, GRID_VERSION,
GRID_VERSION_KEY, GRID_VERSION_KEY,
@@ -36,7 +37,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const { layouts, migrated } = migrateGridLayouts(altLayouts()); const { layouts, migrated } = migrateGridLayouts(altLayouts());
expect(layouts.lg[0]).toEqual({ expect(layouts.lg[0]).toEqual({
i: 'a', x: 4, y: 4, w: 8, h: 6, minW: 8, minH: 4, moved: false, static: false, i: 'a',
x: 4,
y: 4,
w: 8,
h: 6,
minW: 8,
minH: 4,
moved: false,
static: false,
}); });
expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 }); expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 }); expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 });
@@ -53,7 +62,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 }); const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 });
expect(layouts.lg[0]).toEqual({ expect(layouts.lg[0]).toEqual({
i: 'a', x: 2, y: 2, w: 4, h: 3, minW: 4, minH: 2, moved: false, static: false, i: 'a',
x: 2,
y: 2,
w: 4,
h: 3,
minW: 4,
minH: 2,
moved: false,
static: false,
}); });
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 }); expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 }); expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 });
@@ -131,6 +148,7 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const future = migrateGridLayouts({ ...alt, __gridVersion: 4 }); const future = migrateGridLayouts({ ...alt, __gridVersion: 4 });
expect(future.layouts).toEqual(alt); expect(future.layouts).toEqual(alt);
expect(future.migrated).toBe(false); expect(future.migrated).toBe(false);
expect(future.newer).toBe(true);
const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' }); const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' });
expect(stringMarker.migrated).toBe(true); expect(stringMarker.migrated).toBe(true);
@@ -138,24 +156,132 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY); expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY);
const odd = migrateGridLayouts({ const odd = migrateGridLayouts({
lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }], lg: [
{
i: 'c',
x: 'kaputt',
y: 1,
w: 2,
h: 2,
resizeHandles: ['se'],
moved: true,
static: false,
},
],
}); });
expect(odd.layouts.lg[0]).toEqual({ expect(odd.layouts.lg[0]).toEqual({
i: 'c', x: 'kaputt', y: 2, w: 8, h: 4, resizeHandles: ['se'], moved: true, static: false, i: 'c',
x: 'kaputt',
y: 2,
w: 8,
h: 4,
resizeHandles: ['se'],
moved: true,
static: false,
}); });
expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false); expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false);
expect(odd.migrated).toBe(true); expect(odd.migrated).toBe(true);
}); });
it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => { it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => {
const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] }); const r = migrateGridLayouts({
lg: 'kaputt',
md: null,
sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }],
});
expect(Object.keys(r.layouts)).toEqual(['sm']); expect(Object.keys(r.layouts)).toEqual(['sm']);
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 }); expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
expect(r.migrated).toBe(true); expect(r.migrated).toBe(true);
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false }); expect(migrateGridLayouts(null)).toEqual({
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false }); layouts: {},
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false }); migrated: false,
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false }); newer: false,
canvas: null,
});
expect(migrateGridLayouts(undefined)).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
expect(migrateGridLayouts(42)).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
expect(migrateGridLayouts([1, 2])).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
});
it('Test 8 (quick-260930): nur eine HOEHERE Marke meldet newer — v1/v2/v3 nicht', () => {
const alt = altLayouts();
expect(migrateGridLayouts(alt).newer).toBe(false);
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: 2 }).newer).toBe(false);
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION }).newer).toBe(false);
const future = migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION + 1 });
expect(future.newer).toBe(true);
// Unveraendert uebernommen, nichts skaliert.
expect(future.layouts).toEqual(alt);
});
});
describe('grid-layout-migration: Leinwand `__canvas` (Dashboard wie ein Bild mitskalieren)', () => {
const v3 = () => ({ lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], [GRID_VERSION_KEY]: GRID_VERSION });
it('liest eine gueltige Leinwand und laesst sie aus `layouts` heraus', () => {
const r = migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 1600, h: 820 } });
expect(r.canvas).toEqual({ w: 1600, h: 820 });
expect(Object.keys(r.layouts)).toEqual(['lg']);
expect(r.migrated).toBe(false);
});
it('fehlende oder ungueltige Werte -> null', () => {
const bad: unknown[] = [
undefined,
null,
'x',
[1600, 800],
{ w: 1600 },
{ w: '1600', h: 800 },
{ w: Number.NaN, h: 800 },
{ w: Number.POSITIVE_INFINITY, h: 800 },
{ w: 767, h: 800 },
{ w: 1600, h: 199 },
];
for (const value of bad) {
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: value }).canvas).toBeNull();
}
// Grenzwerte sind gueltig.
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 768, h: 200 } }).canvas).toEqual({
w: 768,
h: 200,
});
});
it('withGridVersion schreibt die Leinwand nur, wenn eine da ist', () => {
const { layouts } = migrateGridLayouts(v3());
expect(withGridVersion(layouts)).not.toHaveProperty(GRID_CANVAS_KEY);
expect(withGridVersion(layouts, null)).not.toHaveProperty(GRID_CANVAS_KEY);
expect(withGridVersion(layouts, { w: 1920, h: 950 })).toEqual({
...layouts,
[GRID_VERSION_KEY]: GRID_VERSION,
[GRID_CANVAS_KEY]: { w: 1920, h: 950 },
});
});
it('Idempotenz mit Leinwand: Laden -> Speichern -> Laden liefert dasselbe, nichts skaliert', () => {
const once = migrateGridLayouts({ ...altLayouts(), [GRID_CANVAS_KEY]: { w: 1400, h: 700 } });
expect(once.migrated).toBe(true);
expect(once.canvas).toEqual({ w: 1400, h: 700 });
const twice = migrateGridLayouts(withGridVersion(once.layouts, once.canvas));
expect(twice.migrated).toBe(false);
expect(twice.layouts).toEqual(once.layouts);
expect(twice.canvas).toEqual(once.canvas);
}); });
}); });
+65 -7
View File
@@ -34,6 +34,26 @@
* Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))` * Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))`
* liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`. * liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`.
* *
* Neuere Marke (quick-260930): Traegt das JSON eine HOEHERE Marke als
* `GRID_VERSION`, hat es ein neuerer Programmstand geschrieben (etwa ein
* frisch geladener Tab neben einem alten). Umgerechnet wird dann nichts, aber
* `newer: true` gemeldet — der Store darf diese Anordnung NIE speichern: er
* wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde
* sie beim naechsten Laden ein zweites Mal skalieren.
*
* Leinwand (Bezugsgroesse, "wie ein Bild mitskalieren"): Zusaetzlich kann das
* JSON den Schluessel `__canvas: { w, h }` tragen — die verfuegbare Flaeche
* (Pixel), auf der die Anordnung eingerichtet wurde. Das Raster zeichnet
* sich auf JEDEM Desktop-Bildschirm mit genau dieser Breite und skaliert das
* Ergebnis per `transform: scale` in die dortige Flaeche (siehe
* dashboard-grid.tsx). Wie der Marker lebt `__canvas` nur im JSON, nicht in
* `layouts`: `migrateGridLayouts` liefert ihn getrennt als `canvas`,
* `withGridVersion(layouts, canvas)` schreibt ihn zurueck — JEDER
* Speicherpfad muss ihn mitgeben, sonst geht er verloren. Kein neuer
* `GRID_VERSION`: aeltere Programmstaende ueberspringen den Schluessel, weil
* sein Wert kein Array ist (`!Array.isArray(value)` unten) — und schreiben
* ihn beim Speichern nicht zurueck; dann wird er einfach neu erfasst.
*
* Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API * Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem * das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne * GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
@@ -43,6 +63,17 @@
export const GRID_VERSION = 3; export const GRID_VERSION = 3;
export const GRID_VERSION_KEY = '__gridVersion'; export const GRID_VERSION_KEY = '__gridVersion';
export const GRID_SCALE_FACTOR = 2; export const GRID_SCALE_FACTOR = 2;
export const GRID_CANVAS_KEY = '__canvas';
/** Unter dieser Breite gilt die Handy-/Schmalansicht, dort wird nicht skaliert. */
export const CANVAS_MIN_WIDTH = 768;
/** Kleinere Hoehen sind Messfehler (Fenster kurz zusammengeklappt o. ae.). */
export const CANVAS_MIN_HEIGHT = 200;
/** Bezugsflaeche in Pixeln, auf der die Anordnung eingerichtet wurde. */
export interface GridCanvas {
w: number;
h: number;
}
export interface GridLayoutItem { export interface GridLayoutItem {
i: string; i: string;
@@ -67,19 +98,40 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
/**
* Prueft eine Leinwand: endliche Zahlen, w >= CANVAS_MIN_WIDTH,
* h >= CANVAS_MIN_HEIGHT. Alles andere -> null (wird dann neu erfasst).
*/
export function parseGridCanvas(value: unknown): GridCanvas | null {
if (!isPlainObject(value)) return null;
const { w, h } = value;
if (typeof w !== 'number' || typeof h !== 'number') return null;
if (!Number.isFinite(w) || !Number.isFinite(h)) return null;
if (w < CANVAS_MIN_WIDTH || h < CANVAS_MIN_HEIGHT) return null;
return { w, h };
}
/** /**
* Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen * Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob * Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
* etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern). * etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern).
*/ */
export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrated: boolean } { export function migrateGridLayouts(raw: unknown): {
layouts: GridLayouts;
migrated: boolean;
/** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */
newer: boolean;
/** Gueltige Leinwand aus `__canvas`, sonst null. */
canvas: GridCanvas | null;
} {
if (!isPlainObject(raw)) { if (!isPlainObject(raw)) {
return { layouts: {}, migrated: false }; return { layouts: {}, migrated: false, newer: false, canvas: null };
} }
const markerValue = raw[GRID_VERSION_KEY]; const markerValue = raw[GRID_VERSION_KEY];
// Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt. // Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt.
const version = typeof markerValue === 'number' ? markerValue : 1; const version = typeof markerValue === 'number' ? markerValue : 1;
const newer = version > GRID_VERSION;
const steps = MIGRATION_STEPS.filter((step) => version <= step.from); const steps = MIGRATION_STEPS.filter((step) => version <= step.from);
const needsScaling = steps.length > 0; const needsScaling = steps.length > 0;
@@ -87,7 +139,7 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
let migrated = false; let migrated = false;
for (const key of Object.keys(raw)) { for (const key of Object.keys(raw)) {
if (key === GRID_VERSION_KEY) continue; if (key === GRID_VERSION_KEY || key === GRID_CANVAS_KEY) continue;
const value = raw[key]; const value = raw[key];
if (!Array.isArray(value)) continue; if (!Array.isArray(value)) continue;
@@ -108,12 +160,18 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
}); });
} }
return { layouts, migrated }; return { layouts, migrated, newer, canvas: parseGridCanvas(raw[GRID_CANVAS_KEY]) };
} }
/** /**
* Haengt den Marker fuer das Speichern an, ohne die Eingabe zu veraendern. * Haengt den Marker (und, falls vorhanden, die Leinwand) fuer das Speichern
* an, ohne die Eingabe zu veraendern.
*/ */
export function withGridVersion(layouts: GridLayouts): Record<string, unknown> { export function withGridVersion(
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION }; layouts: GridLayouts,
canvas?: GridCanvas | null,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
if (canvas) result[GRID_CANVAS_KEY] = { w: canvas.w, h: canvas.h };
return result;
} }
+27 -1
View File
@@ -5,6 +5,7 @@ import {
isTauriWebview, isTauriWebview,
remindersToNotify, remindersToNotify,
requestBrowserPermissionOnce, requestBrowserPermissionOnce,
resetNotifiedMemoryForTests,
showReminderNotification, showReminderNotification,
withNotifyLock, withNotifyLock,
} from './reminder-notify'; } from './reminder-notify';
@@ -34,6 +35,7 @@ function installNotification(permission: NotificationPermission) {
beforeEach(() => { beforeEach(() => {
window.localStorage.clear(); window.localStorage.clear();
resetNotifiedMemoryForTests();
}); });
afterEach(() => { afterEach(() => {
@@ -65,6 +67,28 @@ describe('claimNotification', () => {
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}'); const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
expect(Object.keys(stored)).toEqual(['neu']); expect(Object.keys(stored)).toEqual(['neu']);
}); });
it('ohne nutzbaren localStorage (wirft beim Lesen und Schreiben) meldet es trotzdem nur einmal', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('SecurityError');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 10_000)).toBe(false);
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 3600_000)).toBe(false);
// ein neuer Schluessel (verschoben) wird weiterhin gemeldet
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
});
it('ohne window.localStorage (Zugriff wirft) meldet es trotzdem nur einmal', () => {
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
throw new Error('SecurityError');
});
expect(claimNotification('k', NOW)).toBe(true);
expect(claimNotification('k', NOW + 10_000)).toBe(false);
});
}); });
describe('remindersToNotify', () => { describe('remindersToNotify', () => {
@@ -93,7 +117,9 @@ describe('showReminderNotification', () => {
const invoke = installTauri(); const invoke = installTauri();
invoke.mockRejectedValue(new Error('nope')); invoke.mockRejectedValue(new Error('nope'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined(); await expect(
showReminderNotification({ title: 'T', body: 'B', tag: 'x' }),
).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledTimes(1);
}); });
+23 -9
View File
@@ -30,12 +30,7 @@ export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */ /** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export type BrowserPermissionState = export type BrowserPermissionState = 'desktop' | 'granted' | 'default' | 'denied' | 'unsupported';
| 'desktop'
| 'granted'
| 'default'
| 'denied'
| 'unsupported';
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>; type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
@@ -135,6 +130,19 @@ function readNotified(): Record<string, number> {
} }
} }
/**
* Merkliste im Arbeitsspeicher dieses Tabs. Sie greift IMMER, auch wenn der
* localStorage gesperrt, voll oder im privaten Modus nicht nutzbar ist — sonst
* meldete der Melder dieselbe faellige Erinnerung 24 Stunden lang bei jeder
* Pruefung erneut. Der localStorage bleibt fuer die Abstimmung zwischen Tabs.
*/
const notifiedInMemory = new Map<string, number>();
/** Nur fuer Tests: leert die Merkliste im Arbeitsspeicher. */
export function resetNotifiedMemoryForTests(): void {
notifiedInMemory.clear();
}
/** /**
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so * Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere * meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
@@ -143,16 +151,22 @@ function readNotified(): Record<string, number> {
* aelter als 7 Tage werden dabei entfernt. * aelter als 7 Tage werden dabei entfernt.
*/ */
export function claimNotification(key: string, nowMs: number): boolean { export function claimNotification(key: string, nowMs: number): boolean {
for (const [k, at] of notifiedInMemory) {
if (nowMs - at > NOTIFIED_TTL_MS) notifiedInMemory.delete(k);
}
const record = readNotified(); const record = readNotified();
for (const [k, at] of Object.entries(record)) { for (const [k, at] of Object.entries(record)) {
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k]; if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
} }
const first = !(key in record); const first = !(key in record) && !notifiedInMemory.has(key);
if (first) record[key] = nowMs; if (first) {
record[key] = nowMs;
notifiedInMemory.set(key, nowMs);
}
try { try {
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record)); window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
} catch { } catch {
/* Speicher voll oder gesperrt: dann eben ohne Merkliste */ /* Speicher voll oder gesperrt: dann greift nur die Merkliste im Arbeitsspeicher */
} }
return first; return first;
} }
+25 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
defaultNewReminderInputs, defaultNewReminderInputs,
isoToLocalInputs, isoToLocalInputs,
isSameLocalDay,
localInputsToIso, localInputsToIso,
snoozeTarget, snoozeTarget,
} from './reminder-time'; } from './reminder-time';
@@ -50,7 +51,9 @@ describe('snoozeTarget (E-05)', () => {
}); });
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => { it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(local(2026, 9, 30, 14, 0).getTime()); expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(
local(2026, 9, 30, 14, 0).getTime(),
);
}); });
it('morgen ueber das Monatsende', () => { it('morgen ueber das Monatsende', () => {
@@ -67,3 +70,24 @@ describe('snoozeTarget (E-05)', () => {
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime()); expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
}); });
}); });
describe('snoozeTarget kurz nach Mitternacht / isSameLocalDay', () => {
it('faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50 (heute, nicht morgen)', () => {
const n = local(2026, 9, 30, 0, 10);
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 23, 50), n);
expect(target.getTime()).toBe(local(2026, 9, 30, 23, 50).getTime());
expect(isSameLocalDay(target, n)).toBe(true);
});
it('der Normalfall liegt am naechsten Tag', () => {
const n = local(2026, 9, 29, 14, 5);
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 14, 0), n);
expect(isSameLocalDay(target, n)).toBe(false);
});
it('isSameLocalDay vergleicht den Kalendertag in Ortszeit', () => {
expect(isSameLocalDay(local(2026, 9, 30, 0, 0), local(2026, 9, 30, 23, 59))).toBe(true);
expect(isSameLocalDay(local(2026, 9, 30, 23, 59), local(2026, 10, 1, 0, 0))).toBe(false);
expect(isSameLocalDay(local(2026, 9, 30), local(2025, 9, 30))).toBe(false);
});
});
+13 -1
View File
@@ -45,7 +45,10 @@ export type SnoozePreset = '10m' | '1h' | 'tomorrow';
* Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit- * Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit-
* sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00, * sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00,
* verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig, * verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig,
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. * landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. Das kann auch
* HEUTE sein (faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50) —
* die Beschriftung leitet „heute“/„morgen“ deshalb aus dem Ergebnis ab
* (`isSameLocalDay`), nicht aus dem Namen der Option.
*/ */
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date { export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000); if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
@@ -56,3 +59,12 @@ export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Dat
} while (d.getTime() <= now.getTime()); } while (d.getTime() <= now.getTime());
return d; return d;
} }
/** Liegen beide Zeitpunkte am selben Kalendertag (Ortszeit)? */
export function isSameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
CUSTOM_MODULE_KEEP_ALIVE_MAX,
touchLru,
useCustomModuleCacheStore,
} from './custom-module-cache-store';
describe('touchLru', () => {
it('setzt ein neues Modul nach vorn ohne zu verwerfen', () => {
expect(touchLru(['a', 'b'], 'c', 5)).toEqual({ recent: ['c', 'a', 'b'], evicted: [] });
});
it('holt ein vorhandenes Modul nach vorn, ohne es zu verdoppeln', () => {
expect(touchLru(['a', 'b', 'c'], 'c', 5)).toEqual({ recent: ['c', 'a', 'b'], evicted: [] });
});
it('verwirft ueber der Grenze das am laengsten unbenutzte', () => {
expect(touchLru(['e', 'd', 'c', 'b', 'a'], 'f', 5)).toEqual({
recent: ['f', 'e', 'd', 'c', 'b'],
evicted: ['a'],
});
});
it('haelt das gerade benutzte Modul auch bei unsinniger Grenze', () => {
expect(touchLru(['a'], 'b', 0)).toEqual({ recent: ['b'], evicted: ['a'] });
});
});
describe('useCustomModuleCacheStore', () => {
const slot = () => document.createElement('div');
const remember = (id: string) =>
useCustomModuleCacheStore.getState().remember({ id, name: id, url: `https://x.test/${id}` });
beforeEach(() => {
useCustomModuleCacheStore.getState().clear();
});
it('haelt die Einhaengereihenfolge stabil und verwirft nach LRU', () => {
const ids = Array.from({ length: CUSTOM_MODULE_KEEP_ALIVE_MAX + 1 }, (_, i) => `m${i}`);
for (const id of ids) remember(id);
const { activate } = useCustomModuleCacheStore.getState();
for (const id of ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX)) activate(id, slot());
// m0 erneut benutzen -> m1 ist jetzt am laengsten unbenutzt.
activate('m0', slot());
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['m0', 'm1', 'm2', 'm3', 'm4']);
activate('m5', slot());
const state = useCustomModuleCacheStore.getState();
expect(state.openIds).toEqual(['m0', 'm2', 'm3', 'm4', 'm5']);
expect(state.recent[0]).toBe('m5');
// Metadaten bleiben — nur der offene Rahmen wird verworfen.
expect(state.modules.m1).toBeDefined();
});
it('aktiviert kein Modul ohne Metadaten', () => {
useCustomModuleCacheStore.getState().activate('unbekannt', slot());
expect(useCustomModuleCacheStore.getState().openIds).toEqual([]);
expect(useCustomModuleCacheStore.getState().active).toBeNull();
});
it('deaktiviert nur den eigenen Platzhalter', () => {
remember('a');
const first = slot();
const { activate, deactivate } = useCustomModuleCacheStore.getState();
activate('a', first);
deactivate(slot());
expect(useCustomModuleCacheStore.getState().active?.slot).toBe(first);
deactivate(first);
expect(useCustomModuleCacheStore.getState().active).toBeNull();
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['a']);
});
it('vergisst ein Modul samt offenem Rahmen', () => {
remember('a');
useCustomModuleCacheStore.getState().activate('a', slot());
useCustomModuleCacheStore.getState().forget('a');
const state = useCustomModuleCacheStore.getState();
expect(state.modules).toEqual({});
expect(state.openIds).toEqual([]);
expect(state.recent).toEqual([]);
expect(state.active).toBeNull();
});
});
@@ -0,0 +1,102 @@
import { create } from 'zustand';
import type { CustomModule } from '@/lib/custom-modules-api';
/**
* Eigene Module offen halten (30.09.2026, Nutzerwunsch „der
* Aufruf dauert jedes Mal lange“). Zwei Dinge liegen hier im Speicher der
* Sitzung (bewusst OHNE persist — nach dem Neuladen der Seite ist alles leer):
*
* 1. `modules`: Name und Adresse je bereits geladenem Modul. Ein erneuter
* Aufruf zeigt sofort diesen Stand und laedt im Hintergrund nach
* (custom-module-view.tsx), statt erst auf `getCustomModule` zu warten.
* 2. `openIds`/`recent`: welche Module ihren <iframe> behalten
* (custom-module-frame-host.tsx). `openIds` ist die Einhaengereihenfolge
* und bleibt STABIL — neue Module kommen hinten dazu, verworfene fallen
* heraus. Wuerde die Liste bei jedem Aufruf umsortiert, verschoebe React
* die <iframe>-Knoten im DOM, und ein verschobenes <iframe> laedt neu.
* Die Nutzungsreihenfolge fuer die Begrenzung steht deshalb getrennt in
* `recent` (zuletzt benutzt vorn).
*/
/**
* Hoechstens so viele eigene Module halten ihren <iframe> offen. Jede offene
* Fremdseite belegt weiter Speicher und laeuft im Hintergrund mit (Skripte,
* Abfragen); fuenf deckt den ueblichen Wechsel zwischen wenigen Werkzeugen ab,
* ohne den Browser bei vielen Eintraegen in der Seitenleiste zu belasten.
*/
export const CUSTOM_MODULE_KEEP_ALIVE_MAX = 5;
export type CachedCustomModule = Pick<CustomModule, 'id' | 'name' | 'url'>;
export interface LruResult {
/** Neue Nutzungsreihenfolge, zuletzt benutzt vorn. */
recent: string[];
/** Ids, die dabei ueber die Grenze gefallen sind (aelteste zuerst verworfen). */
evicted: string[];
}
/** Reine LRU-Regel: `id` nach vorn, alles jenseits von `max` wird verworfen. */
export function touchLru(recent: readonly string[], id: string, max: number): LruResult {
const next = [id, ...recent.filter((other) => other !== id)];
const limit = Math.max(1, max);
return { recent: next.slice(0, limit), evicted: next.slice(limit) };
}
export interface ActiveCustomModuleSlot {
id: string;
/** Platzhalter der Modulseite, ueber den der Behaelter den <iframe> legt. */
slot: HTMLElement;
}
interface CustomModuleCacheState {
modules: Record<string, CachedCustomModule>;
openIds: string[];
recent: string[];
active: ActiveCustomModuleSlot | null;
/** Metadaten merken oder aktualisieren (Name/Adresse nach dem Laden oder Speichern). */
remember: (mod: CachedCustomModule) => void;
/** Modul vergessen (404 oder geloescht): Metadaten UND offener <iframe> weg. */
forget: (id: string) => void;
/** Die Modulseite zeigt `id` im Platzhalter `slot`: offen halten und nach vorn. */
activate: (id: string, slot: HTMLElement) => void;
/** Die Modulseite verschwindet: <iframe> nur verstecken, nicht verwerfen. */
deactivate: (slot: HTMLElement) => void;
/** Abmelden/Benutzerwechsel: alles verwerfen. */
clear: () => void;
}
export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set) => ({
modules: {},
openIds: [],
recent: [],
active: null,
remember: (mod) =>
set((s) => {
const prev = s.modules[mod.id];
if (prev && prev.name === mod.name && prev.url === mod.url) return s;
return { modules: { ...s.modules, [mod.id]: { id: mod.id, name: mod.name, url: mod.url } } };
}),
forget: (id) =>
set((s) => {
const { [id]: _removed, ...modules } = s.modules;
return {
modules,
openIds: s.openIds.filter((other) => other !== id),
recent: s.recent.filter((other) => other !== id),
active: s.active?.id === id ? null : s.active,
};
}),
activate: (id, slot) =>
set((s) => {
if (!s.modules[id]) return s;
const { recent, evicted } = touchLru(s.recent, id, CUSTOM_MODULE_KEEP_ALIVE_MAX);
const kept = s.openIds.filter((other) => !evicted.includes(other));
return {
recent,
openIds: kept.includes(id) ? kept : [...kept, id],
active: { id, slot },
};
}),
deactivate: (slot) => set((s) => (s.active?.slot === slot ? { active: null } : s)),
clear: () => set({ modules: {}, openIds: [], recent: [], active: null }),
}));
+246 -8
View File
@@ -53,6 +53,8 @@ beforeEach(() => {
isDirty: false, isDirty: false,
isLoading: false, isLoading: false,
error: null, error: null,
layoutFromNewerVersion: false,
canvas: null,
}); });
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]); vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
@@ -68,7 +70,11 @@ afterEach(() => {
describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => { describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => {
it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => { it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
}); });
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
@@ -88,7 +94,12 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => { it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3, lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
__gridVersion: 3,
}); });
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
@@ -129,7 +140,11 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
it('Test 5: scheitert das Sofort-Speichern, bleibt der Zustand umgerechnet, kein Fehlerzustand, console.error einmal', async () => { it('Test 5: scheitert das Sofort-Speichern, bleibt der Zustand umgerechnet, kein Fehlerzustand, console.error einmal', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
}); });
vi.mocked(api.saveLayout).mockRejectedValue(new Error('PUT failed')); vi.mocked(api.saveLayout).mockRejectedValue(new Error('PUT failed'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -197,7 +212,9 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => { it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] }); useDashboardStore
.getState()
.updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] });
vi.mocked(api.saveLayout).mockClear(); vi.mocked(api.saveLayout).mockClear();
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.fetchWidgets).mockResolvedValue([]); vi.mocked(api.fetchWidgets).mockResolvedValue([]);
@@ -213,9 +230,16 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3, lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
__gridVersion: 3,
}); });
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); vi.mocked(api.fetchWidgets).mockResolvedValue([
{ id: 'w-on-dash-2', widgetType: 'clock', config: {} },
]);
await useDashboardStore.getState().selectDashboard('dash-2'); await useDashboardStore.getState().selectDashboard('dash-2');
@@ -253,7 +277,11 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a
it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => { it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => {
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
vi.mocked(api.renameDashboardTab).mockResolvedValue({ id: 'dash-1', name: 'Finanzen', position: 0 }); vi.mocked(api.renameDashboardTab).mockResolvedValue({
id: 'dash-1',
name: 'Finanzen',
position: 0,
});
await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen'); await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen');
@@ -265,7 +293,9 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined); vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined);
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); vi.mocked(api.fetchWidgets).mockResolvedValue([
{ id: 'w-on-dash-2', widgetType: 'clock', config: {} },
]);
await useDashboardStore.getState().deleteDashboard('dash-1'); await useDashboardStore.getState().deleteDashboard('dash-1');
@@ -320,3 +350,211 @@ describe('dashboard-store — Reiter per Ziehen umsortieren (quick-260923-ad9, T
expect(errorSpy).toHaveBeenCalledTimes(1); expect(errorSpy).toHaveBeenCalledTimes(1);
}); });
}); });
describe('dashboard-store — Anordnung eines neueren Programmstands (quick-260930)', () => {
const FUTURE = {
lg: [{ i: 'a', x: 2, y: 0, w: 8, h: 4 }],
md: [],
sm: [],
xs: [],
xxs: [],
__gridVersion: 99,
};
it('Test 18: hoehere Marke -> Merker gesetzt, nichts skaliert, nie gespeichert, Bearbeiten gesperrt', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...FUTURE });
await useDashboardStore.getState().loadDashboard();
const state = useDashboardStore.getState();
expect(state.layoutFromNewerVersion).toBe(true);
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 2, y: 0, w: 8, h: 4 });
expect(api.saveLayout).not.toHaveBeenCalled();
// Bearbeiten bleibt aus.
useDashboardStore.getState().setEditMode(true);
expect(useDashboardStore.getState().isEditMode).toBe(false);
// Auch ein ausdrueckliches Speichern (etwa nach einer Aenderung) schreibt nichts.
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'a', x: 0, y: 0, w: 4, h: 4 }] });
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).not.toHaveBeenCalled();
});
it('Test 19: Wechsel von einem Reiter mit neuerer Marke speichert ihn nicht; ein normaler Reiter hebt den Merker wieder auf', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
vi.mocked(api.fetchLayout).mockResolvedValueOnce({ ...FUTURE });
await useDashboardStore.getState().loadDashboard();
useDashboardStore.setState({ isDirty: true });
vi.mocked(api.fetchLayout).mockResolvedValueOnce({ ...EMPTY, __gridVersion: 3 });
await useDashboardStore.getState().selectDashboard('dash-2');
expect(api.saveLayout).not.toHaveBeenCalled();
expect(useDashboardStore.getState().layoutFromNewerVersion).toBe(false);
useDashboardStore.getState().setEditMode(true);
expect(useDashboardStore.getState().isEditMode).toBe(true);
});
it('Test 20: normale Anordnung -> Merker aus, Speichern wie gewohnt', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY, __gridVersion: 3 });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().layoutFromNewerVersion).toBe(false);
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).toHaveBeenCalledTimes(1);
});
});
describe('dashboard-store — Breiten je Breakpoint (quick-260930)', () => {
it('Test 21: neue Kachel ist in keinem Breakpoint breiter als dessen Spaltenzahl', async () => {
await useDashboardStore.getState().loadDashboard();
useDashboardStore.setState({ layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] } });
vi.mocked(api.addWidget).mockResolvedValue({ id: 's1', widgetType: 'search', config: {} });
await useDashboardStore.getState().addWidget('search');
const { layouts } = useDashboardStore.getState();
expect(layouts.lg[0].w).toBe(24);
expect(layouts.xs[0].w).toBe(16);
expect(layouts.xxs[0].w).toBe(4);
});
});
describe('dashboard-store — optimistische Kachel-Konfiguration (quick-260930)', () => {
it('Test 22: scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
useDashboardStore.setState({ widgets: [{ id: 'w1', widgetType: 'clock', config: {} }] });
let rejectFirst: (e: Error) => void = () => {};
vi.mocked(api.updateWidgetConfig)
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockResolvedValueOnce(undefined as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const first = useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: true });
await useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: false });
rejectFirst(new Error('PATCH failed'));
await first;
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it('Test 23: scheitert der einzige Klick, kommt der vorige Wert zurueck', async () => {
useDashboardStore.setState({
widgets: [{ id: 'w1', widgetType: 'clock', config: { hideTitle: false } }],
});
vi.mocked(api.updateWidgetConfig).mockRejectedValue(new Error('PATCH failed'));
vi.spyOn(console, 'error').mockImplementation(() => {});
await useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: true });
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
});
});
describe('dashboard-store — Leinwand (Dashboard wie ein Bild mitskalieren)', () => {
const V3 = { lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], __gridVersion: 3 };
it('Test 24: Reiter ohne Leinwand -> captureCanvas setzt sie und speichert SOFORT mit __canvas und Marker', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
await useDashboardStore.getState().captureCanvas({ w: 1600.4, h: 820.2 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
lg: V3.lg,
__gridVersion: 3,
__canvas: { w: 1600, h: 820 },
});
// Danach aendert sie sich nicht mehr automatisch.
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
expect(api.saveLayout).toHaveBeenCalledTimes(1);
});
it('Test 25: geladene Leinwand steht im Zustand, wird nicht neu erfasst, und saveLayout schreibt sie mit', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __canvas: { w: 1920, h: 950 } });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(api.saveLayout).not.toHaveBeenCalled();
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
lg: V3.lg,
__gridVersion: 3,
__canvas: { w: 1920, h: 950 },
});
});
it('Test 26: Sofort-Speichern nach der Umrechnung behaelt die Leinwand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
__canvas: { w: 1400, h: 700 },
});
await useDashboardStore.getState().loadDashboard();
expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1',
expect.objectContaining({ __gridVersion: 3, __canvas: { w: 1400, h: 700 } }),
);
});
it('Test 27: Reiterwechsel setzt die Leinwand des neuen Reiters (auch null)', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
vi.mocked(api.fetchLayout).mockImplementation(async (id: string) =>
id === 'dash-1' ? { ...V3, __canvas: { w: 1920, h: 950 } } : { ...V3 },
);
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
await useDashboardStore.getState().selectDashboard('dash-2');
expect(useDashboardStore.getState().canvas).toBeNull();
// Erfassen gilt nun fuer dash-2.
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(api.saveLayout).toHaveBeenLastCalledWith(
'dash-2',
expect.objectContaining({ __canvas: { w: 1280, h: 700 } }),
);
await useDashboardStore.getState().selectDashboard('dash-1');
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
});
it('Test 28: nicht erfassen bei Anordnung eines neueren Programmstands oder bei zu kleiner Flaeche', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __gridVersion: 99 });
await useDashboardStore.getState().loadDashboard();
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
useDashboardStore.setState({ activeDashboardId: null });
await useDashboardStore.getState().loadDashboard();
await useDashboardStore.getState().captureCanvas({ w: 767, h: 800 });
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 150 });
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
});
it('Test 29: scheitert das Speichern beim Erfassen, bleibt die Leinwand im Zustand, console.error einmal', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.saveLayout).mockRejectedValueOnce(new Error('offline'));
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 800 });
expect(useDashboardStore.getState().error).toBeNull();
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});
+102 -17
View File
@@ -1,9 +1,16 @@
import { create } from 'zustand'; import { create } from 'zustand';
import * as api from '@/lib/dashboard-api';
import type { DashboardTab } from '@/lib/dashboard-api';
import type { WidgetType } from '@/components/dashboard/widget-registry'; import type { WidgetType } from '@/components/dashboard/widget-registry';
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry'; import { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration'; import type { DashboardTab } from '@/lib/dashboard-api';
import * as api from '@/lib/dashboard-api';
import {
CANVAS_MIN_HEIGHT,
CANVAS_MIN_WIDTH,
type GridCanvas,
migrateGridLayouts,
parseGridCanvas,
withGridVersion,
} from '@/lib/grid-layout-migration';
export interface WidgetInstance { export interface WidgetInstance {
id: string; id: string;
@@ -21,6 +28,19 @@ interface DashboardState {
isDirty: boolean; isDirty: boolean;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
/**
* Die geladene Anordnung traegt eine hoehere Rastermarke, als dieser
* Programmstand kennt (quick-260930) — ein neuerer Stand hat sie
* geschrieben. Solange das gilt, wird nie gespeichert und der
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
*/
layoutFromNewerVersion: boolean;
/**
* Leinwand des aktiven Reiters (Bezugsflaeche in Pixeln), siehe
* `captureCanvas`. Gehoert wie `layouts` zum aktiven Reiter; null =
* noch nicht erfasst.
*/
canvas: GridCanvas | null;
setEditMode: (mode: boolean) => void; setEditMode: (mode: boolean) => void;
updateLayouts: (layouts: Record<string, unknown>) => void; updateLayouts: (layouts: Record<string, unknown>) => void;
@@ -31,6 +51,8 @@ interface DashboardState {
loadDashboard: () => Promise<void>; loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>; saveLayout: () => Promise<void>;
selectDashboard: (id: string) => Promise<void>; selectDashboard: (id: string) => Promise<void>;
/** Erfasst einmalig die Leinwand des aktiven Reiters und speichert sie sofort. */
captureCanvas: (area: GridCanvas) => Promise<void>;
createDashboard: () => Promise<void>; createDashboard: () => Promise<void>;
renameDashboard: (id: string, name: string) => Promise<void>; renameDashboard: (id: string, name: string) => Promise<void>;
deleteDashboard: (id: string) => Promise<void>; deleteDashboard: (id: string) => Promise<void>;
@@ -79,6 +101,20 @@ function loadDashboardsOnce(): Promise<DashboardTab[]> {
* Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard` * Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard`
* durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie * durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie
* `loadDashboard`. * `loadDashboard`.
*
* quick-260930: Traegt die geladene Anordnung eine HOEHERE Marke als
* `GRID_VERSION` (ein neuerer Programmstand hat sie geschrieben, dieser Tab
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
* Neuladen. Der Merker gilt je geladenem Reiter.
*
* Leinwand (Dashboard wie ein Bild mitskalieren): `canvas` ist die Flaeche,
* auf der der Reiter eingerichtet wurde. Sie lebt im gespeicherten JSON als
* `__canvas` und wird — wie der Marker — an JEDER Speicherstelle mit
* `withGridVersion(layouts, canvas)` zurueckgeschrieben (Sofort-Speichern nach
* der Umrechnung, `saveLayout`, `captureCanvas`); fehlt sie dort, ist sie
* verloren und wird beim naechsten Oeffnen am dann benutzten Bildschirm neu
* erfasst.
*/ */
export const useDashboardStore = create<DashboardState>()((set, get) => ({ export const useDashboardStore = create<DashboardState>()((set, get) => ({
dashboards: [], dashboards: [],
@@ -90,8 +126,12 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
isDirty: false, isDirty: false,
isLoading: false, isLoading: false,
error: null, error: null,
layoutFromNewerVersion: false,
canvas: null,
setEditMode: (mode: boolean) => { setEditMode: (mode: boolean) => {
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
if (mode && get().layoutFromNewerVersion) return;
const prev = get().isEditMode; const prev = get().isEditMode;
set({ isEditMode: mode }); set({ isEditMode: mode });
// On exit edit mode, auto-save if dirty (D-01) // On exit edit mode, auto-save if dirty (D-01)
@@ -125,7 +165,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
i: newWidget.id, i: newWidget.id,
x: 0, x: 0,
y: maxY, y: maxY,
w: constraints.defaultW, // quick-260930: nie breiter als das Raster dieses Breakpoints.
w: clampWidthToCols(constraints.defaultW, bp),
h: constraints.defaultH, h: constraints.defaultH,
}); });
newLayouts[bp] = arr; newLayouts[bp] = arr;
@@ -168,12 +209,18 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
set((state) => ({ set((state) => ({
widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)), widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)),
})); }));
patch({ ...before, ...partial }); const optimistic = { ...before, ...partial };
patch(optimistic);
try { try {
await api.updateWidgetConfig(id, partial); await api.updateWidgetConfig(id, partial);
} catch (err) { } catch (err) {
console.error('Failed to update widget config:', err); console.error('Failed to update widget config:', err);
patch(before); // quick-260930: nur zuruecksetzen, wenn noch GENAU dieser optimistische
// Stand angezeigt wird. Hat inzwischen ein zweiter Klick etwas anderes
// gewaehlt, wuerde der Fehler des ersten sonst die zweite Wahl
// ueberschreiben.
const current = get().widgets.find((w) => w.id === id)?.config;
if (current === optimistic) patch(before);
} }
}, },
@@ -191,20 +238,23 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
api.fetchLayout(first.id), api.fetchLayout(first.id),
api.fetchWidgets(first.id), api.fetchWidgets(first.id),
]); ]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts); const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
set({ set({
dashboards, dashboards,
activeDashboardId: first.id, activeDashboardId: first.id,
layouts: migratedLayouts, layouts: migratedLayouts,
canvas,
widgets, widgets,
isLoading: false, isLoading: false,
layoutFromNewerVersion: newer,
...(newer ? { isEditMode: false, isDirty: false } : {}),
}); });
// Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set, // Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set,
// damit die Oberflaeche unabhaengig vom Speichern rendert; eigener // damit die Oberflaeche unabhaengig vom Speichern rendert; eigener
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint. // try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
if (migrated) { if (migrated) {
try { try {
await api.saveLayout(first.id, withGridVersion(migratedLayouts)); await api.saveLayout(first.id, withGridVersion(migratedLayouts, canvas));
} catch (err) { } catch (err) {
console.error('Failed to persist migrated layout:', err); console.error('Failed to persist migrated layout:', err);
} }
@@ -220,8 +270,11 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
saveLayout: async () => { saveLayout: async () => {
const dashboardId = get().activeDashboardId; const dashboardId = get().activeDashboardId;
if (!dashboardId) return; if (!dashboardId) return;
// quick-260930: NIE eine Anordnung eines neueren Programmstands speichern —
// sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert.
if (get().layoutFromNewerVersion) return;
try { try {
await api.saveLayout(dashboardId, withGridVersion(get().layouts)); await api.saveLayout(dashboardId, withGridVersion(get().layouts, get().canvas));
set({ isDirty: false }); set({ isDirty: false });
} catch (err) { } catch (err) {
console.error('Failed to save layout:', err); console.error('Failed to save layout:', err);
@@ -246,21 +299,21 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
set({ isSwitchingDashboard: true, error: null }); set({ isSwitchingDashboard: true, error: null });
try { try {
const [rawLayouts, widgets] = await Promise.all([ const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]);
api.fetchLayout(id), const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
api.fetchWidgets(id),
]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
set({ set({
activeDashboardId: id, activeDashboardId: id,
layouts: migratedLayouts, layouts: migratedLayouts,
canvas,
widgets, widgets,
isDirty: false, isDirty: false,
isSwitchingDashboard: false, isSwitchingDashboard: false,
layoutFromNewerVersion: newer,
...(newer ? { isEditMode: false } : {}),
}); });
if (migrated) { if (migrated) {
try { try {
await api.saveLayout(id, withGridVersion(migratedLayouts)); await api.saveLayout(id, withGridVersion(migratedLayouts, canvas));
} catch (err) { } catch (err) {
console.error('Failed to persist migrated layout:', err); console.error('Failed to persist migrated layout:', err);
} }
@@ -271,6 +324,36 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
} }
}, },
/**
* Leinwand erfassen (Dashboard wie ein Bild mitskalieren). Das Raster ruft
* das auf, solange der aktive Reiter keine Leinwand hat und die verfuegbare
* Flaeche Desktop-Breite hat. Die Flaeche wird EINMAL uebernommen und
* sofort gespeichert; danach aendert sie sich nicht mehr automatisch.
* Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus — auf jedem
* anderen Bildschirm wird es gleichmaessig (auch die Schrift) auf dessen
* Flaeche verkleinert oder vergroessert, ohne Scrollen.
*
* Nichts geschieht bei vorhandener Leinwand, ungueltiger Flaeche (Handy,
* Messfehler) oder einer Anordnung eines neueren Programmstands (die wird
* nie gespeichert). Eigener try/catch wie beim Sofort-Speichern nach der
* Umrechnung: ein Speicherfehler laesst die Leinwand im Zustand stehen, ein
* spaeteres `saveLayout` schreibt sie dann mit.
*/
captureCanvas: async (area: GridCanvas) => {
const state = get();
const dashboardId = state.activeDashboardId;
if (!dashboardId || state.canvas || state.layoutFromNewerVersion) return;
if (area.w < CANVAS_MIN_WIDTH || area.h < CANVAS_MIN_HEIGHT) return;
const canvas = parseGridCanvas({ w: Math.round(area.w), h: Math.round(area.h) });
if (!canvas) return;
set({ canvas });
try {
await api.saveLayout(dashboardId, withGridVersion(state.layouts, canvas));
} catch (err) {
console.error('Failed to persist dashboard canvas:', err);
}
},
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */ /** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
createDashboard: async () => { createDashboard: async () => {
try { try {
@@ -332,7 +415,9 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
reorderDashboards: async (ids: string[]) => { reorderDashboards: async (ids: string[]) => {
const previous = get().dashboards; const previous = get().dashboards;
const byId = new Map(previous.map((d) => [d.id, d])); const byId = new Map(previous.map((d) => [d.id, d]));
const optimistic = ids.map((id) => byId.get(id)).filter((d): d is DashboardTab => d !== undefined); const optimistic = ids
.map((id) => byId.get(id))
.filter((d): d is DashboardTab => d !== undefined);
set({ dashboards: optimistic }); set({ dashboards: optimistic });
try { try {
+6 -3
View File
@@ -246,7 +246,6 @@
"catalogClose": "Dialog schließen", "catalogClose": "Dialog schließen",
"deleteTooltip": "Widget entfernen", "deleteTooltip": "Widget entfernen",
"hideTitle": "Titel ausblenden", "hideTitle": "Titel ausblenden",
"showTitle": "Titel einblenden",
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben", "dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
"editMode": "Dashboard bearbeiten", "editMode": "Dashboard bearbeiten",
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
@@ -380,10 +379,9 @@
"viewModeLabel": "Ansicht wechseln", "viewModeLabel": "Ansicht wechseln",
"iconUrlPlaceholder": "Logo-Adresse (optional)", "iconUrlPlaceholder": "Logo-Adresse (optional)",
"iconUploadLabel": "Eigenes Symbol hochladen", "iconUploadLabel": "Eigenes Symbol hochladen",
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.", "iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; eine neu eingetragene Logo-Adresse ersetzt es aber.",
"iconUploadedHint": "Für diesen Favoriten ist ein eigenes Symbol hochgeladen.", "iconUploadedHint": "Für diesen Favoriten ist ein eigenes Symbol hochgeladen.",
"iconRemoveButton": "Hochgeladenes Symbol entfernen", "iconRemoveButton": "Hochgeladenes Symbol entfernen",
"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.",
"iconTooLarge": "Die Datei ist zu groß – erlaubt sind höchstens 512 KB.", "iconTooLarge": "Die Datei ist zu groß – erlaubt sind höchstens 512 KB.",
"iconInvalidType": "Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.", "iconInvalidType": "Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.",
"iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden." "iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden."
@@ -499,6 +497,7 @@
"snooze10m": "In 10 Minuten", "snooze10m": "In 10 Minuten",
"snooze1h": "In 1 Stunde", "snooze1h": "In 1 Stunde",
"snoozeTomorrow": "Morgen um {time}", "snoozeTomorrow": "Morgen um {time}",
"snoozeToday": "Heute um {time}",
"edit": "Erinnerung bearbeiten", "edit": "Erinnerung bearbeiten",
"editTitle": "Erinnerung bearbeiten", "editTitle": "Erinnerung bearbeiten",
"delete": "Erinnerung löschen", "delete": "Erinnerung löschen",
@@ -516,6 +515,7 @@
}, },
"editShort": "Bearbeiten", "editShort": "Bearbeiten",
"done": "Fertig", "done": "Fertig",
"reloadRequired": "Das Dashboard wurde von einer neueren Version von Tessera gespeichert. Bitte laden Sie die Seite neu, um es zu bearbeiten.",
"toolbarLabel": "Dashboard-Aktionen", "toolbarLabel": "Dashboard-Aktionen",
"emptyState": { "emptyState": {
"title": "Ihr Dashboard ist noch leer", "title": "Ihr Dashboard ist noch leer",
@@ -1403,6 +1403,7 @@
"customModules": { "customModules": {
"openInNewTab": "In neuem Tab öffnen", "openInNewTab": "In neuem Tab öffnen",
"notFound": "Dieses Modul gibt es nicht mehr.", "notFound": "Dieses Modul gibt es nicht mehr.",
"loadError": "Dieses Modul konnte nicht geladen werden. Bitte versuchen Sie es später erneut.",
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.", "invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
"loading": "Wird geladen …", "loading": "Wird geladen …",
"form": { "form": {
@@ -1417,6 +1418,8 @@
"urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.", "urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.",
"saveError": "Das Modul konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.", "saveError": "Das Modul konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.", "deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"forbidden": "Sie haben keine Berechtigung für diese Aktion.",
"invalidInput": "Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.",
"deleteConfirm": { "deleteConfirm": {
"title": "Eigenes Modul löschen", "title": "Eigenes Modul löschen",
"bodyShared": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.", "bodyShared": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.",
+6 -3
View File
@@ -246,7 +246,6 @@
"catalogClose": "Close dialog", "catalogClose": "Close dialog",
"deleteTooltip": "Remove widget", "deleteTooltip": "Remove widget",
"hideTitle": "Hide title", "hideTitle": "Hide title",
"showTitle": "Show title",
"dragHint": "Drag the tile to move it", "dragHint": "Drag the tile to move it",
"editMode": "Edit dashboard", "editMode": "Edit dashboard",
"saveChanges": "Save changes", "saveChanges": "Save changes",
@@ -380,10 +379,9 @@
"viewModeLabel": "Switch view", "viewModeLabel": "Switch view",
"iconUrlPlaceholder": "Logo URL (optional)", "iconUrlPlaceholder": "Logo URL (optional)",
"iconUploadLabel": "Upload custom icon", "iconUploadLabel": "Upload custom icon",
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.", "iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL; a newly entered logo URL replaces it, though.",
"iconUploadedHint": "A custom icon has been uploaded for this favorite.", "iconUploadedHint": "A custom icon has been uploaded for this favorite.",
"iconRemoveButton": "Remove uploaded icon", "iconRemoveButton": "Remove uploaded icon",
"iconUrlUnreachable": "The image at this address could not be loaded. The site likely blocks automated requests (for example via a Cloudflare check) or is unreachable. Please upload the icon instead.",
"iconTooLarge": "The file is too large – at most 512 KB is allowed.", "iconTooLarge": "The file is too large – at most 512 KB is allowed.",
"iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.", "iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.",
"iconUploadFailed": "The icon could not be uploaded." "iconUploadFailed": "The icon could not be uploaded."
@@ -499,6 +497,7 @@
"snooze10m": "In 10 minutes", "snooze10m": "In 10 minutes",
"snooze1h": "In 1 hour", "snooze1h": "In 1 hour",
"snoozeTomorrow": "Tomorrow at {time}", "snoozeTomorrow": "Tomorrow at {time}",
"snoozeToday": "Today at {time}",
"edit": "Edit reminder", "edit": "Edit reminder",
"editTitle": "Edit reminder", "editTitle": "Edit reminder",
"delete": "Delete reminder", "delete": "Delete reminder",
@@ -516,6 +515,7 @@
}, },
"editShort": "Edit", "editShort": "Edit",
"done": "Done", "done": "Done",
"reloadRequired": "This dashboard was saved by a newer version of Tessera. Please reload the page to edit it.",
"toolbarLabel": "Dashboard actions", "toolbarLabel": "Dashboard actions",
"emptyState": { "emptyState": {
"title": "Your dashboard is still empty", "title": "Your dashboard is still empty",
@@ -1403,6 +1403,7 @@
"customModules": { "customModules": {
"openInNewTab": "Open in new tab", "openInNewTab": "Open in new tab",
"notFound": "This module no longer exists.", "notFound": "This module no longer exists.",
"loadError": "This module could not be loaded. Please try again later.",
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.", "invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
"loading": "Loading …", "loading": "Loading …",
"form": { "form": {
@@ -1417,6 +1418,8 @@
"urlCredentials": "The address must not contain a user name or a password.", "urlCredentials": "The address must not contain a user name or a password.",
"saveError": "The module could not be saved. Please try again.", "saveError": "The module could not be saved. Please try again.",
"deleteError": "The module could not be deleted. Please try again.", "deleteError": "The module could not be deleted. Please try again.",
"forbidden": "You do not have permission for this action.",
"invalidInput": "The details were not accepted. Please check name, address and category.",
"deleteConfirm": { "deleteConfirm": {
"title": "Delete custom module", "title": "Delete custom module",
"bodyShared": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users.", "bodyShared": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users.",
@@ -116,6 +116,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Energiequellen', 'Energiequellen',
'neue', 'neue',
'neuen', 'neuen',
'neueren',
'neuere', 'neuere',
'Neue', 'Neue',
'Neues', 'Neues',
+69
View File
@@ -205,6 +205,75 @@ Unter „Verwaltung → Eigene Module“ legen Sie **gemeinsame** Seitenleisten-
Daneben kann **jeder Benutzer** – auch Sie selbst – unter „Einstellungen → Eigene Module“ persönliche Einträge anlegen. Diese sieht ausschließlich der Besitzer; Administratoren sehen und ändern sie nicht. Wird ein Benutzer gelöscht, verschwinden seine persönlichen Einträge mit. Die Verwaltungsseite zeigt deshalb nur die gemeinsamen Einträge. Daneben kann **jeder Benutzer** – auch Sie selbst – unter „Einstellungen → Eigene Module“ persönliche Einträge anlegen. Diese sieht ausschließlich der Besitzer; Administratoren sehen und ändern sie nicht. Wird ein Benutzer gelöscht, verschwinden seine persönlichen Einträge mit. Die Verwaltungsseite zeigt deshalb nur die gemeinsamen Einträge.
### Proxmox-Server anbinden: Zugang nur zum Lesen einrichten
Tessera schaut bei Proxmox nur zu und ändert dort nichts. Legen Sie deshalb auf jedem Proxmox-Server einen **eigenen Zugang nur für Tessera** an, der ausschließlich lesen darf – nie Ihr persönliches Konto und nie `root`. Die Server tragen Sie danach im Proxmox-Modul unter „Zu den Einstellungen“ ein.
| Produkt | Zugangsart in Tessera | Rolle am Proxmox-Server |
|---|---|---|
| PVE (Virtualisierung) | API-Token (empfohlen) | PVEAuditor |
| PBS (Backup) | API-Token (empfohlen) | Audit |
| PMG (Mail-Gateway) | Benutzer/Passwort | Auditor |
**Warum bei PMG kein Token?** Das Mail-Gateway kennt keine API-Token – Proxmox hat sie dort bisher nicht eingebaut (Stand September 2026, offene Funktionsanfrage bei Proxmox). Tessera bietet bei PMG deshalb nur Benutzername und Passwort an. Mit einem eigenen Benutzer, der nur die Rolle „Auditor“ hat, ist das genauso sicher: dieses Konto kann nichts verändern.
Ein Token hat bei PVE und PBS zwei Vorteile gegenüber einem Passwort: Er lässt sich einzeln widerrufen, ohne das Konto anzufassen, und er kann sich nicht an der Proxmox-Oberfläche anmelden. Wichtig bei beiden: Ein Token darf nie mehr als sein Benutzer. Die Leserolle muss deshalb **dem Benutzer und dem Token** zugewiesen werden, sonst meldet Tessera beim Verbindungstest „keine Berechtigung“.
#### PVE: Benutzer und API-Token anlegen
In der Proxmox-VE-Oberfläche (Ansicht „Rechenzentrum“ / „Datacenter“):
1. **Berechtigungen → Benutzer → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox VE authentication server“ (`pve`), ein langes, zufälliges Passwort (es wird nirgends gebraucht), Kommentar z. B. „Tessera, nur lesen“.
2. **Berechtigungen → API-Token → Hinzufügen:** Benutzer `tessera@pve`, Token-ID `tessera`, Haken bei „Privilegien-Trennung“ gesetzt lassen. Nach dem Speichern zeigt Proxmox die **Token-ID** (`tessera@pve!tessera`) und das **Geheimnis** – das Geheimnis erscheint **nur dieses eine Mal**, kopieren Sie es sofort.
3. **Berechtigungen → Hinzufügen → Benutzer-Berechtigung:** Pfad `/`, Benutzer `tessera@pve`, Rolle `PVEAuditor`, „Weitergeben“ angehakt.
4. **Berechtigungen → Hinzufügen → API-Token-Berechtigung:** Pfad `/`, API-Token `tessera@pve!tessera`, Rolle `PVEAuditor`, „Weitergeben“ angehakt.
Dasselbe auf der Kommandozeile eines PVE-Knotens (als root):
```
pveum user add tessera@pve --comment "Tessera, nur lesen"
pveum user token add tessera@pve tessera --privsep 1
pveum acl modify / --users tessera@pve --roles PVEAuditor
pveum acl modify / --tokens 'tessera@pve!tessera' --roles PVEAuditor
```
Der zweite Befehl gibt das Geheimnis (`value`) einmalig aus. In einem Cluster genügt das auf einem Knoten – Benutzer und Rechte gelten für den ganzen Cluster.
In Tessera: Typ **PVE**, Adresse z. B. `https://pve01.intern:8006`, Zugangsart **API-Token**, Token-Kennung `tessera@pve!tessera`, Token-Geheimnis = das kopierte Geheimnis.
#### PBS: Benutzer und API-Token anlegen
In der Proxmox-Backup-Server-Oberfläche:
1. **Konfiguration → Zugriffssteuerung → Benutzerverwaltung → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox Backup authentication server“ (`pbs`), ein langes, zufälliges Passwort.
2. **Reiter „API-Token“ → Hinzufügen:** Benutzer `tessera@pbs`, Token-Name `tessera`. Proxmox zeigt danach **Token-ID** (`tessera@pbs!tessera`) und **Geheimnis** – das Geheimnis nur dieses eine Mal, sofort kopieren.
3. **Reiter „Berechtigungen“ → Hinzufügen → Benutzer-Berechtigung:** Pfad `/`, Benutzer `tessera@pbs`, Rolle `Audit`, „Weitergeben“ angehakt.
4. **Reiter „Berechtigungen“ → Hinzufügen → API-Token-Berechtigung:** Pfad `/`, API-Token `tessera@pbs!tessera`, Rolle `Audit`, „Weitergeben“ angehakt.
Kommandozeile des Backup-Servers (als root):
```
proxmox-backup-manager user create tessera@pbs --comment "Tessera, nur lesen"
proxmox-backup-manager user generate-token tessera@pbs tessera
proxmox-backup-manager acl update / Audit --auth-id tessera@pbs
proxmox-backup-manager acl update / Audit --auth-id 'tessera@pbs!tessera'
```
In Tessera: Typ **PBS**, Adresse z. B. `https://pbs01.intern:8007`, Zugangsart **API-Token**, Token-Kennung `tessera@pbs!tessera`, Token-Geheimnis = das kopierte Geheimnis.
#### PMG: Benutzer mit Leserolle anlegen
In der Proxmox-Mail-Gateway-Oberfläche:
1. **Konfiguration → Benutzerverwaltung → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox Mail Gateway authentication server“ (`pmg`), Rolle **Auditor**, ein langes, zufälliges Passwort, Haken bei „Aktiviert“.
2. Das Passwort notieren – Tessera braucht es.
In Tessera: Typ **PMG**, Adresse z. B. `https://pmg01.intern:8006`, Zugangsart **Benutzer/Passwort** (bei PMG die einzige Auswahl), Benutzername `tessera@pmg`, Passwort = das vergebene Passwort.
#### Prüfen und später widerrufen
Nach dem Speichern prüft **„Verbindung testen“** den Zugang sofort. „Keine Berechtigung“ heißt fast immer: Die Rolle fehlt am Benutzer oder am Token, oder „Weitergeben“ ist nicht angehakt. Soll Tessera einen Server nicht mehr sehen, löschen Sie bei PVE/PBS einfach den Token, bei PMG den Benutzer `tessera` – andere Konten sind davon nicht betroffen.
## 6. SMTP ## 6. SMTP
Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde. Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde.
+3 -1
View File
@@ -60,6 +60,8 @@ Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Name
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln. Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
**Gleiches Bild auf jedem Bildschirm:** Tessera merkt sich je Dashboard die Größe der Fläche auf dem Bildschirm, an dem Sie es zum ersten Mal geöffnet haben. Auf jedem anderen Bildschirm – etwa am Laptop statt am großen Monitor – zeigt es dasselbe Dashboard maßstäblich verkleinert oder vergrößert, Kacheln und Schrift gleichermaßen, sodass es vollständig hineinpasst. Hat der Bildschirm eine andere Form, bleibt rechts oder unten etwas Platz frei. Bearbeiten können Sie das Dashboard auf jedem Bildschirm. Auf dem Handy (schmaler als 768 Pixel) ordnet Tessera die Kacheln weiterhin untereinander an.
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem: **Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter. - Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung). - Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).
@@ -160,7 +162,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
**Einen Server anlegen** (nur für Administratoren, über den Link „Zu den Einstellungen"): Name, Typ (PVE/PBS/PMG), Adresse (z. B. `https://pve.intern:8006`) und Zugang. Beim Zugang wählen Sie zwischen einem API-Token oder Benutzername/Passwort — bei PMG bietet Tessera von vornherein nur Benutzername/Passwort an, weil dieses Proxmox-Produkt keine API-Token kennt. Ein einmal gespeichertes Geheimnis (Token oder Passwort) wird nie wieder im Klartext angezeigt; lassen Sie das Feld beim Bearbeiten leer, um es unverändert zu lassen, oder tragen Sie ein neues ein, um es zu ersetzen. **Einen Server anlegen** (nur für Administratoren, über den Link „Zu den Einstellungen"): Name, Typ (PVE/PBS/PMG), Adresse (z. B. `https://pve.intern:8006`) und Zugang. Beim Zugang wählen Sie zwischen einem API-Token oder Benutzername/Passwort — bei PMG bietet Tessera von vornherein nur Benutzername/Passwort an, weil dieses Proxmox-Produkt keine API-Token kennt. Ein einmal gespeichertes Geheimnis (Token oder Passwort) wird nie wieder im Klartext angezeigt; lassen Sie das Feld beim Bearbeiten leer, um es unverändert zu lassen, oder tragen Sie ein neues ein, um es zu ersetzen.
**Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**. **Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**. Wie Sie diesen Zugang Schritt für Schritt anlegen (bei PVE und PBS als API-Token, bei PMG als eigener Benutzer), steht im Administrationshandbuch unter [Proxmox-Server anbinden](anleitung-administration.md#proxmox-server-anbinden-zugang-nur-zum-lesen-einrichten).
**Zertifikat prüfen:** Dieser Schalter steht standardmäßig auf „prüfen" (an). Nutzen Sie für einen Server ein selbstsigniertes oder sonst nicht vertrauenswürdiges Zertifikat, können Sie die Prüfung für **genau diesen einen Server** ausschalten — die Ausnahme gilt nie für einen anderen Server. **Zertifikat prüfen:** Dieser Schalter steht standardmäßig auf „prüfen" (an). Nutzen Sie für einen Server ein selbstsigniertes oder sonst nicht vertrauenswürdiges Zertifikat, können Sie die Prüfung für **genau diesen einen Server** ausschalten — die Ausnahme gilt nie für einen anderen Server.
@@ -730,6 +730,7 @@ werden.
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. | | apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | **quick-260924-m4n:** Stand zurück von `system-gebunden` auf `gebunden`. Stufe 2 der Umstellung (Migration 20260924120000_dashboard_image_drop_data) löscht die Spalte `data` und macht `storagePath` zur Pflicht; der Bootstrap-Umzug hatte auf allen Servern gearbeitet und ist samt seinem einzigen `forSystem()`-Aufruf entfernt (Eintrag aus `FORSYSTEM_ALLOWED_CALL_SITES` gestrichen). Dieselbe Migration entfernt die `system_read_policy` auf "DashboardImage" — auf der Tabelle bleibt allein `tenant_isolation_policy` (Mandant UND Benutzer). Alle vier Anfragewege laufen wie bisher ausschließlich über `forTenant(this.prisma, tenantId, userId)`; der Upload vergibt die UUID jetzt selbst und legt die Zeile gleich mit Pfad an. Vorher: **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). | | apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | **quick-260924-m4n:** Stand zurück von `system-gebunden` auf `gebunden`. Stufe 2 der Umstellung (Migration 20260924120000_dashboard_image_drop_data) löscht die Spalte `data` und macht `storagePath` zur Pflicht; der Bootstrap-Umzug hatte auf allen Servern gearbeitet und ist samt seinem einzigen `forSystem()`-Aufruf entfernt (Eintrag aus `FORSYSTEM_ALLOWED_CALL_SITES` gestrichen). Dieselbe Migration entfernt die `system_read_policy` auf "DashboardImage" — auf der Tabelle bleibt allein `tenant_isolation_policy` (Mandant UND Benutzer). Alle vier Anfragewege laufen wie bisher ausschließlich über `forTenant(this.prisma, tenantId, userId)`; der Upload vergibt die UUID jetzt selbst und legt die Zeile gleich mit Pfad an. Vorher: **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
| apps/api/src/dashboard/dashboard-images.service.ts | user | muss-mandantengebunden | gebunden | **quick-260930:** `remove` setzt im selben Vorgang die Hintergrund-Wahl des Benutzers (`User.dashboardBackground`) auf `{ kind: 'none' }` zurück, wenn sie auf das gelöschte Bild zeigte — ein bedingtes `updateMany` mit `where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } }` über denselben gebundenen Klienten `forTenant(this.prisma, tenantId, userId)` wie der Rest der Methode. Nur die eigene Zeile, kein Kennungsparameter von außen (die Bild-Kennung ist zu diesem Zeitpunkt schon als eigenes Bild geprüft). |
| apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. | | apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. | | apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. |
| apps/api/src/dashboard/dashboard.service.ts | favoriteLink | muss-mandantengebunden | gebunden | quick-260923-lrr — nur LESEND, zum Aufraeumen hochgeladener Favoriten-Symbole: `removeWidget` und `deleteDashboard` lesen VOR dem Loeschen die Favoriten mit hochgeladenem Symbol (`findMany`, Bedingung traegt `userId` UND `widgetId`) ueber DENSELBEN, bereits gebundenen Klienten `tenantPrisma` der Methode (kein zweiter `forTenant()`-Aufruf). Die Zeilen selbst verschwinden ueber den Fremdschluessel-Kaskadenweg; danach werden die Dateien best effort entfernt, ein Dateifehler bricht das Loeschen nie ab. | | apps/api/src/dashboard/dashboard.service.ts | favoriteLink | muss-mandantengebunden | gebunden | quick-260923-lrr — nur LESEND, zum Aufraeumen hochgeladener Favoriten-Symbole: `removeWidget` und `deleteDashboard` lesen VOR dem Loeschen die Favoriten mit hochgeladenem Symbol (`findMany`, Bedingung traegt `userId` UND `widgetId`) ueber DENSELBEN, bereits gebundenen Klienten `tenantPrisma` der Methode (kein zweiter `forTenant()`-Aufruf). Die Zeilen selbst verschwinden ueber den Fremdschluessel-Kaskadenweg; danach werden die Dateien best effort entfernt, ein Dateifehler bricht das Loeschen nie ab. |