Compare commits
24 Commits
ae36a22a51
...
v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ad004b286d | |||
| 39b1f74b56 | |||
| cf67c8a389 | |||
| d9f2af32d6 | |||
| 3e8c0f4ef5 | |||
| 6c5946ca6e | |||
| 1315f370a9 | |||
| 8be0725577 | |||
| 56c07c3581 | |||
| ee2b0256b5 | |||
| 82472ee665 | |||
| 8cbfb8b69d | |||
| 9039cea686 | |||
| 441854af72 | |||
| d146234bba | |||
| a8a39a4842 | |||
| 80a0d23ccf | |||
| cf70a197cd | |||
| 30fdd99724 | |||
| 445b1d3100 | |||
| 5aa577a7fa | |||
| 747a4d432b | |||
| 2c01f9d783 | |||
| d73aad1ef1 |
@@ -0,0 +1,154 @@
|
||||
---
|
||||
context: default
|
||||
phase: null
|
||||
task: null
|
||||
total_tasks: null
|
||||
status: paused
|
||||
last_updated: 2026-09-22T14:15:00.000Z
|
||||
---
|
||||
|
||||
<current_state>
|
||||
Kein laufender Meilenstein. Alle 18 Phasen sind abgeschlossen; seit der Freigabe
|
||||
1.2.0 laeuft die Arbeit als Quick-Tasks. **Version 1.3.0 wurde am 22.09.2026
|
||||
freigegeben** (Tag `v1.3.0` auf `d146234`, Abbilder `live` und `v1.3.0`,
|
||||
Gitea-Release mit `Tessera-Setup-1.3.0.exe` und `Tessera-1.3.0.AppImage`).
|
||||
|
||||
`main == origin/main` auf `1315f37`, Arbeitsbaum sauber, CI gruen. Der lokale
|
||||
Docker-Stack laeuft mit genau diesem Stand (web, api, db).
|
||||
|
||||
Unterbrochen wurde NICHT mitten in einer Aufgabe — alle acht Auftraege dieser
|
||||
Sitzung sind fertig, nachgewiesen und gepusht. Der naechste Auftrag (Proxmox)
|
||||
ist inhaltlich geklaert, wartet aber auf Zugangsdaten des Nutzers.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
|
||||
Diese Sitzung (21.09. abends bis 22.09. nachmittags):
|
||||
|
||||
- quick-260921-pi9 — Dashboard-Widget „Bilderrahmen" (Upload oder https-Adresse, Diashow, Grossansicht)
|
||||
- quick-260921-qd3 — Dashboard-Widget „XFrame" (Webseite als Rahmen, Sandbox ohne Top-Navigation)
|
||||
- quick-260922-frg — Tray-Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h
|
||||
- fast `747a4d4` — Download-Knoepfe im Desktop-Client oeffnen den System-Browser (waren ohne Funktion)
|
||||
- quick-260922-ge2 — XFrame: Ausschnitt waehlen und einpassen, Zoom, „Nur anzeigen"
|
||||
- **Freigabe 1.3.0** — CHANGELOG abgeschlossen, `live` vorgezogen, Tag gepusht, drei CI-Laeufe gruen
|
||||
- quick-260922-hk4 — Bilderrahmen-Bilder in den Dateibereich `user-files` statt in die Datenbank, automatischer Umzug beim Start, Selbstheilung aus der alten Spalte
|
||||
- quick-260922-m1h — Widget-Typen an EINER Stelle, Katalog aus der Registry + Modulfilter, Kachel kennt ihr Modul
|
||||
|
||||
Jeder Punkt wurde im Browser (Playwright-MCP) gegen den lokalen Stack geprueft;
|
||||
die Pruefprotokolle stehen in den jeweiligen SUMMARY.md unter `.planning/quick/`.
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
|
||||
1. **Proxmox-Modul** (PVE, PBS, PMG) — **nur beobachten**, keine Eingriffe.
|
||||
Seite: Server anbinden, VMs/Container mit CPU, Arbeitsspeicher, Plattenplatz
|
||||
und Erreichbarkeit; bei PBS Sicherungslaeufe und Pruefstatus, bei PMG die
|
||||
Mail-Zahlen (zugestellt, gefiltert, blockiert, Quarantaene).
|
||||
2. **Proxmox-Kachel** — kompakte Fassung ueber den neuen Weg (drei Stellen,
|
||||
siehe unten). Vorschlag fuer den Inhalt steht in der Sitzung: Ampel je Server
|
||||
+ drei Balken; PBS: Alter der letzten Sicherung, Pruefergebnis, freier Platz;
|
||||
PMG: Tageszahlen in einer Zeile. Idee fuer spaeter: eine Sammelkachel
|
||||
„Alles in Ordnung?" mit einer Zeile je Server.
|
||||
</remaining_work>
|
||||
|
||||
<decisions_made>
|
||||
|
||||
- **Bilder auf die Festplatte, nicht in die Datenbank** — Grund ist die Sicherung
|
||||
(`pg_dump` von Hand; 30 Bilder à 5 MiB je Benutzer waeren 150 MB pro Benutzer
|
||||
im Abzug), nicht die Geschwindigkeit, und die Einheitlichkeit mit Avataren
|
||||
(`user-files/avatars`) und DKV-Exporten.
|
||||
- **Zweistufige Umstellung:** Spalte `data` bleibt vorerst stehen; `getBytes`
|
||||
stellt eine fehlende Datei daraus wieder her. DROP erst, wenn alpha UND live
|
||||
einmal mit dieser Version gelaufen sind — Todo liegt unter
|
||||
`.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md`.
|
||||
- **Proxmox: nur beobachten**, Zugriff ueber die normale Modulfreigabe
|
||||
(Nutzeransage 22.09.).
|
||||
- **Kacheln gesperrter Module erscheinen gar nicht erst im Katalog**
|
||||
(Nutzeransage 22.09.) — so umgesetzt in m1h.
|
||||
- **Basic-Auth am Proxy vor alpha bleibt** (Nutzerentscheidung). Aus dem
|
||||
Firmennetz greift eine Ausnahme; von aussen 401, und der Client sagt das seit
|
||||
frg selbst. **Nicht erneut vorschlagen, das Thema ist entschieden.**
|
||||
</decisions_made>
|
||||
|
||||
<blockers>
|
||||
- Proxmox braucht Zugangsdaten und Serveradressen des Nutzers (API-Token, nur
|
||||
lesend, z. B. Rolle `PVEAuditor`). Der Nutzer legt sie morgen an. Planung und
|
||||
Modulskelett koennen vorher entstehen, die Anbindung nicht getestet werden.
|
||||
</blockers>
|
||||
|
||||
## Required Reading (in order)
|
||||
|
||||
1. `.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md` — Abschnitt „So fuegt man kuenftig eine Modul-Kachel hinzu": drei Stellen statt sieben.
|
||||
2. `docs/anleitung-entwicklung.md` — Modul-Walkthrough (Backend + Frontend) und der neue Abschnitt „Eine Kachel zum Modul".
|
||||
3. `.planning/quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/260922-hk4-SUMMARY.md` — Dateiablage, wenn das Proxmox-Modul jemals Dateien speichert.
|
||||
4. `.planning/STATE.md` — Abschnitte „Current Position" und die letzten Zeilen der Quick-Tabelle.
|
||||
|
||||
## Critical Anti-Patterns (do NOT repeat these)
|
||||
|
||||
- [ANTI-PATTERN]: Planannahmen ueber den Bestand ungeprueft uebernehmen → m1h: der Plan
|
||||
behauptete, `apps/web` importiere `@tessera/shared` bereits; es gab KEINE
|
||||
Abhaengigkeit, und zwei Kommentare hielten das als Absicht fest. Der Executor
|
||||
hat vor der ersten Zeile Code nachgemessen (Bau, Produktions-Abbild, natives
|
||||
Type-Stripping unter `node:24-alpine`) statt der Annahme zu folgen.
|
||||
**Mitigation:** jede Plan-Behauptung ueber vorhandene Abhaengigkeiten oder
|
||||
Muster vor dem Umsetzen einmal am Code pruefen.
|
||||
- [ANTI-PATTERN]: Vorschau und Darstellung mit unterschiedlichen Layoutmassen →
|
||||
ge2: die Kachel nutzte eine andere Rahmenhoehe als die Vorschau, wodurch Seiten
|
||||
mit fensterhoehen-abhaengigem Layout (`vh`) an anderer Stelle lagen als
|
||||
ausgewaehlt. **Mitigation:** Auswahl und Darstellung immer gegen dieselben
|
||||
Masse rechnen.
|
||||
- [ANTI-PATTERN]: `position: fixed` in einer Dashboard-Kachel → pi9: die
|
||||
Grossansicht blieb auf die Kachelflaeche beschraenkt, weil `react-grid-item`
|
||||
eine CSS-`transform` traegt und damit zum Bezugsrahmen wird.
|
||||
**Mitigation:** Overlays aus einer Kachel per `createPortal` in `document.body`
|
||||
rendern (Muster: Kalender-Tooltip, jetzt auch `picture-frame-lightbox.tsx`).
|
||||
- [ANTI-PATTERN]: Playwright klickt in einem per `transform` skalierten iframe
|
||||
nicht → ge2. **Mitigation:** Klickpunkt umrechnen und per
|
||||
`elementFromPoint` + `mouse.click` pruefen; ist eine Werkzeuggrenze, kein Produktfehler.
|
||||
|
||||
## Infrastructure State
|
||||
|
||||
- **Lokaler Stack:** `docker compose` mit web, api, db laeuft auf dem Stand
|
||||
`1315f37` (`up -d --build` am 22.09. nachmittags). `up` allein baut NICHT neu.
|
||||
DB ohne Host-Port — Prisma vom Host ueber die Container-IP
|
||||
(`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`,
|
||||
`tessera:tessera_dev`).
|
||||
- **Testdaten lokal:** im Admin-Dashboard stehen ein Bilderrahmen (zwei Bilder)
|
||||
und ein XFrame auf `example.com` mit Ausschnitt. Harmlos, darf bleiben.
|
||||
- **alpha** (`alpha.tessera.ctl.de`) wurde am 22.09. auf `80a0d23` gezogen; die
|
||||
spaeteren Commits (hk4, m1h) sind dort noch nicht drauf. Basic-Auth am Proxy
|
||||
bleibt — von aussen 401, deshalb Messungen gegen alpha nur am Proxy vorbei
|
||||
(auf dem Testserver `docker compose exec api` gegen `localhost:3001` oder
|
||||
Host-Port 3000).
|
||||
- **Live** (`tessera.ctl.de`) laeuft noch auf 1.2.0; der Pull auf 1.3.0 steht beim
|
||||
Nutzer aus.
|
||||
- **Desktop-Client:** neuester CI-Stempel `5aa577a` (Lauf 405). Der Nutzer muss
|
||||
ihn einmal per Browser installieren, danach laeuft das Update ueber das Tray.
|
||||
|
||||
<context>
|
||||
Die Sitzung war eine lange Kette kleiner, vollstaendig abgeschlossener Auftraege.
|
||||
Der rote Faden am Ende: Der Nutzer will als naechstes ein Proxmox-Modul, das
|
||||
zusaetzlich als Kachel auf dem Dashboard erscheint — und kuenftig sollen weitere
|
||||
Module dasselbe tun. Deshalb wurde zuerst das Fundament geraeumt (m1h), damit
|
||||
eine Modul-Kachel drei Handgriffe kostet statt sieben und Kacheln gesperrter
|
||||
Module automatisch verschwinden. Das Geruest dafuer (`WIDGET_MODULE_SLUGS`,
|
||||
serverseitiger Filter in `dashboard.service.ts`) ist vorhanden und noch leer;
|
||||
Proxmox waere der erste Eintrag.
|
||||
|
||||
Fuer Proxmox selbst ist vorgemerkt: Serveradressen traegt nur ein Administrator
|
||||
ein (damit ist die Adresse eine bewusste Freigabe statt beliebiger Eingabe), fuer
|
||||
genau diese Adressen werden Zertifikatsfehler toleriert (Muster:
|
||||
`favorites/icon-discovery.service.ts`, undici-Dispatcher — Nodes globales `fetch`
|
||||
ignoriert ihn), Zugangsdaten verschluesselt per `CryptoService` (Muster
|
||||
`ldap-config.service.ts`), Abfrage im Hintergrund je Mandant nach dem Muster
|
||||
`dkv-scheduler.service.ts` (`onApplicationBootstrap`, nicht `onModuleInit`).
|
||||
</context>
|
||||
|
||||
<next_action>
|
||||
Start with: Proxmox-Modul planen (`/gsd-quick` mit eigenem Plan wie bei ge2/hk4) —
|
||||
Datenmodell fuer Serverzugaenge (Adresse, Typ `pve|pbs|pmg`, verschluesselter
|
||||
Token), Abfrage im Hintergrund je Mandant, Modulskelett nach
|
||||
`docs/anleitung-entwicklung.md`, Seite mit Serverliste und Auslastung. Die
|
||||
Kachel kommt danach als eigener kleiner Auftrag ueber den neuen Weg. Vorher beim
|
||||
Nutzer abholen: Serveradressen und die lesenden API-Token.
|
||||
</next_action>
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"timestamp": "2026-09-22T14:15:00.000Z",
|
||||
"phase": null,
|
||||
"phase_name": "kein laufender Meilenstein — Quick-Tasks nach der Freigabe 1.3.0",
|
||||
"phase_dir": null,
|
||||
"plan": null,
|
||||
"task": null,
|
||||
"total_tasks": null,
|
||||
"status": "paused",
|
||||
"completed_tasks": [
|
||||
{"id": 1, "name": "quick-260921-pi9 Bilderrahmen-Widget", "status": "done", "commit": "737974b,c080580,c3b4597,8bf3601"},
|
||||
{"id": 2, "name": "quick-260921-qd3 XFrame-Widget", "status": "done", "commit": "d63d9f5,20a9eb2"},
|
||||
{"id": 3, "name": "quick-260922-frg Tray-Update nennt den Grund, prueft alle 4 h", "status": "done", "commit": "d73aad1"},
|
||||
{"id": 4, "name": "fast Download-Knoepfe im Desktop-Client", "status": "done", "commit": "747a4d4"},
|
||||
{"id": 5, "name": "quick-260922-ge2 XFrame-Ausschnitt, Zoom, Nur anzeigen", "status": "done", "commit": "445b1d3,30fdd99,cf70a19"},
|
||||
{"id": 6, "name": "Freigabe 1.3.0", "status": "done", "commit": "d146234 (Tag v1.3.0)"},
|
||||
{"id": 7, "name": "quick-260922-hk4 Bilder in den Dateibereich statt in die Datenbank", "status": "done", "commit": "9039cea,8cbfb8b,82472ee"},
|
||||
{"id": 8, "name": "quick-260922-m1h Modul bringt seine Kachel selbst mit", "status": "done", "commit": "56c07c3,8be0725"}
|
||||
],
|
||||
"remaining_tasks": [
|
||||
{"id": 9, "name": "Proxmox-Modul (PVE/PBS/PMG), nur beobachten — Plan schreiben und bauen", "status": "not_started"},
|
||||
{"id": 10, "name": "Proxmox-Kachel (kompakte Version) ueber den neuen Weg", "status": "not_started"}
|
||||
],
|
||||
"blockers": [
|
||||
{"description": "Proxmox braucht Zugangsdaten des Nutzers (API-Token, nur lesend) und die Server-Adressen", "type": "human_action", "workaround": "Nutzer legt sie morgen an; Planung und Geruest koennen vorher entstehen"}
|
||||
],
|
||||
"async_jobs": [],
|
||||
"human_actions_pending": [
|
||||
{"action": "Live-Server auf 1.3.0 ziehen (docker compose -f docker-compose.prod.yml pull && up -d --force-recreate api web)", "context": "Version 1.3.0 ist freigegeben, Abbilder live/v1.3.0 liegen bereit", "blocking": false},
|
||||
{"action": "Neuen Desktop-Client einmal per Browser installieren", "context": "Danach laeuft das Update ueber das Tray-Symbol; Live hatte den Updater bis 1.2.0 nicht", "blocking": false},
|
||||
{"action": "Proxmox-API-Token (nur lesend, z. B. Rolle PVEAuditor) fuer PVE, PBS und PMG anlegen und die Server-Adressen bereithalten", "context": "Voraussetzung fuer das naechste Modul; der Nutzer macht das morgen", "blocking": true}
|
||||
],
|
||||
"decisions": [
|
||||
{"decision": "Bilderrahmen-Bilder liegen im Dateibereich user-files, nicht als bytea in der Datenbank", "rationale": "Sicherung laeuft per pg_dump von Hand; 30 Bilder a 5 MiB je Benutzer waeren 150 MB pro Benutzer im Abzug. Geschwindigkeit war NICHT das Argument. Dazu Einheitlichkeit mit Avataren und DKV-Exporten.", "phase": null},
|
||||
{"decision": "Spalte data bleibt vorerst stehen, DROP erst nach nachgewiesenem Lauf auf alpha und live", "rationale": "Zweistufige Umstellung; getBytes stellt eine fehlende Datei aus data wieder her (alter pg_dump + leeres Volume).", "phase": null},
|
||||
{"decision": "Widget-Typen an EINER Stelle (packages/shared), Katalog filtert nach Modulzugriff", "rationale": "Vorarbeit fuer Proxmox und weitere Modul-Kacheln; vorher sieben Hartkodierungen je Kachel.", "phase": null},
|
||||
{"decision": "Proxmox zunaechst NUR beobachten, keine Eingriffe; Zugriff ueber die normale Modulfreigabe", "rationale": "Ausdrueckliche Ansage des Nutzers am 22.09.", "phase": null},
|
||||
{"decision": "Kacheln gesperrter Module erscheinen gar nicht erst im Katalog", "rationale": "Ausdrueckliche Ansage des Nutzers am 22.09.", "phase": null},
|
||||
{"decision": "Basic-Auth am Proxy vor alpha bleibt", "rationale": "Entscheidung des Nutzers; aus dem Firmennetz greift eine Ausnahme. Nicht mehr ansprechen.", "phase": null}
|
||||
],
|
||||
"uncommitted_files": [],
|
||||
"next_action": "Proxmox-Modul planen: Prisma-Modelle fuer Serverzugaenge (Adresse, Typ pve|pbs|pmg, Token verschluesselt per CryptoService), Poller je Mandant nach dem Muster dkv-scheduler (onApplicationBootstrap), Modul-Skelett nach docs/anleitung-entwicklung.md, danach die Kachel ueber den neuen Weg (WIDGET_TYPES + WIDGET_MODULE_SLUGS + registerWidget).",
|
||||
"context_notes": "Sitzung lief ueber 1,5 Tage: zwei neue Widgets, drei Desktop-Korrekturen, Freigabe 1.3.0, Speicherumstellung, Widget-Aufraeumen. Alles gepusht, main == origin/main auf 1315f37, Arbeitsbaum sauber, CI gruen. Der lokale Docker-Stack laeuft mit dem aktuellen Stand; Testfavoriten/Test-Widgets im Admin-Dashboard sind lokal stehen geblieben (harmlos). Naechster Schritt ist inhaltlich klar, haengt aber an den Zugangsdaten des Nutzers."
|
||||
}
|
||||
+16
-10
@@ -1,13 +1,13 @@
|
||||
---
|
||||
gsd_state_version: "1.0"
|
||||
milestone: v1.2
|
||||
milestone: v1.3
|
||||
current_phase: 18
|
||||
current_phase_name: desktop-client-fertigstellen
|
||||
status: verified
|
||||
stopped_at: "Zwei Dashboard-Widgets am 2026-09-21 abends gebaut, im Browser nachgewiesen und gepusht: Bilderrahmen (quick-260921-pi9, 4 Commits inkl. Portal-Korrektur der Grossansicht) und XFrame (quick-260921-qd3, 2 Commits). NICHTS OFFEN aus diesen Auftraegen. Offen beim Nutzer: alpha ziehen (beide Widgets kommen mit dem naechsten Beta-Pull), Windows-Client pruefen, Freigabe 1.3.0 auf Zuruf. Kein weiterer Auftrag benannt."
|
||||
last_updated: "2026-09-21T17:40:00.000Z"
|
||||
last_activity: 2026-09-21
|
||||
last_activity_desc: Quick 260921-pi9 (Bilderrahmen) und 260921-qd3 (XFrame) — zwei neue Dashboard-Widgets, beide im Browser nachgewiesen, drei Rundgang-Befunde beim Bilderrahmen behoben (Grossansicht per Portal, ICU-Plural, Kachel 8x12); api 1175, web 603
|
||||
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-22T21:10:00.000Z"
|
||||
last_activity: 2026-09-22
|
||||
last_activity_desc: Quick 260922-vdk — Dashboard-Raster misst seine Breite auch aus dem Leerzustand; im echten Linux-Client gegengemessen (469 -> 389 px, Ziehen erreicht den rechten Rand)
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
total_phases: 18
|
||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||
Plan: 6 of 6
|
||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||
Last activity: 2026-09-22 - Kosmetik am Bilderrahmen (fast, 8b45a28): „1 Stunde“ statt „60 Minuten“, Bildanzahl in der Einstellungs-Kopfzeile; am 21.09. davor Quick 260921-pi9 und 260921-qd3: die zwei bestellten Dashboard-Widgets „Bilderrahmen“ und „XFrame“ gebaut, im Browser nachgewiesen, gepusht
|
||||
Last activity: 2026-09-22 - Quick 260922-ge2: XFrame-Ausschnitt waehlen und einpassen, Zoom, Nur anzeigen (Browser-Befund Rahmenhoehe behoben); davor Desktop: Download-Knoepfe in der App oeffnen jetzt den System-Browser (fast, 747a4d4); davor Quick 260922-frg: Tray-Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung (HTTP 401 durch Passwortschutz am Proxy vor alpha), Klick prueft erneut, Pruefung alle 4 h; davor Kosmetik am Bilderrahmen (fast, 8b45a28): „1 Stunde“ statt „60 Minuten“, Bildanzahl in der Einstellungs-Kopfzeile; am 21.09. davor Quick 260921-pi9 und 260921-qd3: die zwei bestellten Dashboard-Widgets „Bilderrahmen“ und „XFrame“ gebaut, im Browser nachgewiesen, gepusht
|
||||
|
||||
Progress: [██████████] 99%
|
||||
|
||||
@@ -458,6 +458,12 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260921-pi9 | **Dashboard-Widget „Bilderrahmen“: eigene Bilder oder https-Adressen als Diashow.** Erstes der zwei vom Nutzer bestellten Widgets. **API:** neues Prisma-Modell `DashboardImage` (Bytes in der Datenbank — kein neues Docker-Volume, Sicherung ueber den DB-Dump), handgeschriebene Migration `20260921120000_dashboard_image` mit RLS-Regel inklusive Benutzerdimension; Routen `GET/POST /dashboard/images`, `GET/DELETE /dashboard/images/:id`; Bildtyp ausschliesslich ueber Magic Bytes (PNG/JPEG/GIF/WebP), nicht ueber den behaupteten MIME-Typ; 5 MiB je Datei (multer-Grenze, 413), 30 Bilder je Benutzer; fremde Kennung → 404, nie 403; Binaerantwort mit `Cache-Control: private`, `nosniff`, `Content-Disposition: inline` ohne Dateinamen, CSP `default-src 'none'; sandbox`. **Web:** Widget `picture-frame` mit einer geordneten Liste `images` aus Eintraegen mit `kind`-Unterscheider (`upload` oder `url`), Bildausschnitt contain/cover, Intervall 0/5…3600 s, Reihenfolge oder Zufall (nie dasselbe zweimal), Unterschrift-Streifen, Grossansicht per Klick (nicht im Bearbeitungsmodus), kaputte Bilder fallen aus dem Umlauf; Bildverwaltung im `WidgetSettingsPanel` (Upload, https-Adresse, Unterschrift, Pfeile, Entfernen loescht den Upload auch serverseitig). Fremdbilder laedt AUSSCHLIESSLICH der Browser (`<img referrerPolicy="no-referrer">`) — die API ruft nie eine Adresse ab, keine SSRF-Flaeche; https-Pflicht web-seitig zweifach (Formular + Render-Resolver), weil die API Widget-Configs nicht inhaltlich prueft. **Browser-Rundgang (Orchestrator, zehn Punkte) fand drei Dinge, behoben in 8bf3601:** die Grossansicht war auf die Kachelflaeche beschraenkt (ein `react-grid-item` mit CSS-`transform` wird fuer `position: fixed` zum Bezugsrahmen → `createPortal` in `document.body` wie der Kalender-Tooltip), „1 Minuten“ → ICU-Plural, Standardkachel 8x8 zu flach → 8x12. curl-Rundgang gegen die lebende API belegt 201/400/413/404/401 und fremder Benutzer → 404. **Zahlen:** api 1148 → 1175, web 531 → 569, type-check 4/4, lint 5/5, RLS-Waechter 78/78, Zaehler unveraendert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` 13). Anwenderhandbuch und CHANGELOG ergaenzt. | 2026-09-21 | 737974b,c080580,c3b4597,8bf3601 | [260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc](./quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/) |
|
||||
| 260921-qd3 | **Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel.** Zweites der zwei vom Nutzer bestellten Widgets, vom Nutzer so benannt. Config `url` (https-Pflicht ueber dieselbe `isHttpsUrl`-Regel wie der Bilderrahmen, web-seitig doppelt: Formular + Render-Resolver), `title` (max. 100 Zeichen, Kopfleiste), `reloadSeconds` (0/60/300/600/1800/3600; Timer haengt den Rahmen per `key` neu ein, nicht im Bearbeitungsmodus). `<iframe sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox">` — bewusst OHNE `allow-top-navigation*` (die eingebettete Seite kann den Tessera-Tab nicht umleiten) und OHNE `allow-modals`; `allow=""` (keine Kamera/Mikro/Standort-Delegation), `referrerPolicy="no-referrer"`, `loading="lazy"`. Die API ruft die Adresse nie ab (nur `'xframe'` im `@IsIn` des DTO; kein CSP/Frame-Header in apps/web noetig, per grep belegt). Im Bearbeitungsmodus liegt eine unsichtbare Flaeche ueber dem Rahmen, sonst schluckt der iframe die Zeigerereignisse und die Kachel liesse sich nicht ziehen. Ob eine Seite das Einbetten verweigert, entscheidet die fremde Seite (`X-Frame-Options`/`frame-ancestors`, cross-origin nicht erkennbar) — deshalb dauerhafter Hinweis im Formular und immer ein Link „In neuem Tab öffnen“ (`rel="noopener noreferrer"`, in der Kopfleiste oder als Ecksymbol). Formular als eigenes Modul `xframe-config-form.tsx` wie beim Bilderrahmen; Kachel-Vorgabe 12x12. Browser-Rundgang (Orchestrator, neun Punkte + Tests) ohne Befund: example.com im Rahmen, google.com verweigert mit `X-Frame-Options: sameorigin` und der Link fuehrt trotzdem hin, Neuladen nach 60 s mit genau einem zweiten Dokumentabruf, Ziehen und Groesse aendern ueber dem Rahmen, API-Log ohne Fremdabruf. **Zahlen:** web 569 → 603, api 1175 unveraendert, type-check 4/4, lint 5/5, Zaehler unveraendert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` 13). Biome `useAnchorContent` wertet `aria-label` nicht als Linkinhalt → `sr-only`-Text statt `biome-ignore`. | 2026-09-21 | d63d9f5,20a9eb2 | [260921-qd3-dashboard-widget-xframe-eine-webseite-pe](./quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/) |
|
||||
| fast-260922 | **Kosmetik nach dem Browser-Rundgang (fast, ohne Akte).** Bilderrahmen: das laengste Wechselintervall (3600 s) hiess „60 Minuten“, beim XFrame dieselbe Stufe „Jede Stunde“ → neuer Schluessel `pictureFrame.intervalHours` (ICU-Plural, de + en); Kopfzeile „Bilderrahmen #N“ unter Einstellungen → Dashboard nennt jetzt „— 1 Bild“ / „— N Bilder“ (zwei Schluessel statt ICU, weil die Panel-Tests eine einfache Uebersetzungs-Attrappe nutzen). Web-Tests 603 → 604. Hinweis fuer spaeter: `gsd-tools quick-tasks-append` scheitert an dieser Tabelle, weil aeltere Zeilen (260918-gza, 260921-iwr, 260921-m34) unmaskierte `\|` im Text tragen — Zeilen daher von Hand anfuegen. | 2026-09-22 | 8b45a28 | — |
|
||||
| 260922-frg | **Desktop-Client: Update-Eintrag im Tray nie mehr stumm ausgegraut.** Befund des Nutzers: „Update installieren“ bleibt grau, obwohl alpha `1.2.0-beta.gc001a08` anbietet und der Client auf `a6d1a64` steht — auch nach App-Neustart. Nachgemessen: Tessera-seitig antwortet `/desktop/update` auf dem alpha-Server selbst (am Proxy vorbei) mit 200 und gueltigem Manifest; DAVOR antwortet der Nginx Proxy Manager auf jede Anfrage an alpha mit `401 Basic` (vom Dev-Host und vom Testserver ueber 217.7.63.32 gemessen). Die Webansicht der App merkt sich das Proxy-Passwort, der Updater (`tauri-plugin-updater`, eigener reqwest) nicht. **Produktfehler:** das Plugin verschluckt Nicht-2xx-Status (`updater.rs` 529-559: `last_error` bleibt leer → `Err(ReleaseNotFound)`), unser `Err(_) => {}` machte daraus stumm denselben grauen Eintrag wie „kein Update“; geprueft wurde nur beim Start. **Fix (d73aad1, nur lib.rs + CHANGELOG):** drei Endzustaende, alle anklickbar — „Auf Beta-Stand … aktualisieren“ (installiert), „Kein Update verfügbar – erneut prüfen“, „Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen“ (Statuscode per eigener Diagnose-Anfrage nachgeliefert, nur Status gelesen); Benachrichtigung mit Erklaerung (Passwortschutz/Zugriffsliste am Proxy), entprellt ueber `LastCheckNotice`; Wiederhol-Thread alle 4 h (`std::thread`, ueberspringt bei abgelegtem Update); http-Server weiterhin „Update nur über https möglich“. Proxy-Zugangsdaten NICHT in den Client (T-FRG-03). `cargo fmt/clippy/test/build` gruen, 37 → 44 Tests, Rot-Nachweis 9x E0425. **Behebung beim Nutzer:** Passwortschutz vor alpha im Proxy Manager entfernen oder `/api-proxy/desktop/*` durchlassen; neuen Client einmal ueber den Browser installieren. | 2026-09-22 | d73aad1 | [260922-frg-desktop-client-update-eintrag-im-tray-ni](./quick/260922-frg-desktop-client-update-eintrag-im-tray-ni/) |
|
||||
| fast-260922-b | **Desktop-App: Download-Knoepfe in der App ohne Funktion (fast, 747a4d4).** Befund des Nutzers: „Herunterladen“ unter Einstellungen → Desktop-App tut in der App nichts (Windows und Linux). Ursache: die Webansicht hatte keinen Download-Handler — webkit2gtk verwirft Downloads dann still, WebView2 zeigte ebenfalls nichts. Fix: Hauptfenster entsteht im Code (`app.windows` in tauri.conf.json leer), weil nur `WebviewWindowBuilder` `on_download` annimmt; der Handler bricht den Download in der App ab und oeffnet die Adresse per Opener im System-Browser (Fortschritt, Speicherort, Passwortfenster fuer den Proxy). Capability `main` unveraendert. cargo fmt/clippy/test gruen. Nicht am laufenden Client geprueft (kein Display auf dem Dev-Host) — CI baut, Nachweis beim Nutzer oder auf der Windows-VM. | 2026-09-22 | 747a4d4 | — |
|
||||
| 260922-ge2 | **XFrame: Ausschnitt der Seite waehlen und einpassen, Zoom, „Nur anzeigen“.** Wunsch des Nutzers: nur einen bestimmten Ausschnitt der eingebetteten Seite zeigen, und die Groesse soll skalieren. Config: `crop {x,y,w,h}` in Seitenpixeln bei fester Layoutbreite 1280 (`XFRAME_PAGE_WIDTH`, keine UI), Klemmung ueber EINE Funktion `clampXframeCrop` (x+w ≤ 1280 verschiebt x; w ≥ 100, h ≥ 60, y+h ≤ 4000); `zoom` (50…150 %, nur Ganzseiten-Modus); `readOnly` (transparente Flaeche ueber dem Rahmen im Ansichtsmodus). Kachel: `computeCropLayout` (contain + Zentrierung, Massstab darf > 1 sein), der `<iframe>` wird selbst verschoben und skaliert (cross-origin — die Seite laesst sich von aussen nicht scrollen), Kachelmass per ResizeObserver. Einstellungen: Vorschau der Seite bei 1280 px (Stage 3000 Seitenpixel hoch, eigener Bildlauf), Rahmen als `<fieldset>` (Biome `useSemanticElements`) mit vier Eckgriffen, Ziehen per Pointer-Events mit lokalem Entwurf und genau einem PATCH beim Loslassen, Zahlenfelder als Tastaturweg; Zoom-Auswahl nur ohne Ausschnitt; Aktivieren setzt `readOnly` mit. **Befund im Browser-Rundgang, behoben (cf70a19):** Kachel und Vorschau hatten verschiedene Rahmenhoehen (max(y+h,720) vs. 3000) — bei vh-relativen Seiten (example.com `margin: 15vh`) lag derselbe Inhalt an verschiedenen Stellen, der gewaehlte Ausschnitt haette in der Kachel daneben gelegen; jetzt dieselbe Layouthoehe. Neun Pruefpunkte bestanden (Verschieben, Ecken mit fester Gegenecke und Mindestbreite, Klemmung der Zahlenfelder, Einpassen und Mitskalieren bei Kachelgroesse, Nur-anzeigen, Zoom 60 %, verweigernde Seite). Playwright kann in einem per `transform` skalierten iframe nicht selbst klicken — per `elementFromPoint` + `mouse.click` umgangen, ist eine Werkzeuggrenze. Test-Helfer `src/test/fake-resize-observer.ts`. **Zahlen:** web 604 → 640, api 1175, type-check 4/4, lint 5/5 (web 53 Warnungen unveraendert), `as unknown as` 27/6, Umlaut-Allowlist + „Ausschnitt“. | 2026-09-22 | 445b1d3,30fdd99,cf70a19 | [260922-ge2-xframe-widget-ausschnitt-der-eingebettet](./quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/) |
|
||||
| 260922-hk4 | **Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Datenbank.** Frage des Nutzers nach der Freigabe 1.3.0, ob `bytea` auf Dauer sinnvoll ist. Befund: Geschwindigkeit ist NICHT das Argument (ein Bild wird je Browser einmal taeglich geladen), die SICHERUNG ist es — gesichert wird von Hand per `pg_dump`, und 30 Bilder à 5 MiB je Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug (alpha-DB heute 18 MB). Dazu Einheitlichkeit: Profilbilder (`user-files/avatars`, `User.avatarPath`) und DKV-Exporte liegen laengst im Volume. Umsetzung: Spalte `storagePath`, Ablage `user-files/dashboard-images/<userId>/<uuid>.<ext>` — Dateiname IMMER vom Server (UUID + Endung aus dem erkannten Mime-Typ), `originalName` nie im Pfad; ein eigener Ordner je Benutzer ist ausdruecklich KEIN Schutz, es entscheidet weiterhin die Besitzpruefung im Dienst. Umzug laeuft automatisch beim Start (`onApplicationBootstrap` ueber `forSystem()`), idempotent; die Spalte `data` bleibt bewusst vorerst stehen (Todo fuer den DROP, erst wenn alpha und live einmal gelaufen sind). **Befund im Rundgang, eigener Commit:** eine Zeile zeigte auf eine fehlende Datei (lokal Host vs. Container-Volume; im Betrieb: alter `pg_dump` + leeres Volume) — `getBytes` stellt die Datei jetzt aus der noch vorhandenen Spalte `data` wieder her, statt 404 zu melden. **Zahlen:** api 1175 → 1188, web 640, type-check 4/4, lint 5/5, RLS-Waechter 78/78. | 2026-09-22 | 9039cea,8cbfb8b,82472ee | [260922-hk4-bilderrahmen-bilder-auf-die-festplatte](./quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/) |
|
||||
| 260922-m1h | **Ein Modul bringt seine Dashboard-Kachel jetzt selbst mit (Vorarbeit fuer Proxmox).** Bestandsaufnahme (lesend) hatte ergeben: ein neuer Widget-Typ war an SIEBEN Stellen hartkodiert (Union-Typ, Constraints, Registry, eigene `wireXWidget()` je Typ, Aufruf in page.tsx, zweite Liste im Katalogfenster, `@IsIn` im API-DTO); die Verbindung Kachel↔Modul existierte als `WIDGET_MODULE_MAP` in `dashboard.service.ts` (filtert fail-closed), war aber nie befuellt; der Katalog zeigte jedem alle Kacheln, auch die gesperrter Module. Umbau: `WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` in `packages/shared` als EINE Quelle (API validiert per `@IsIn` gegen genau sie), ein generisches `registerWidget()` statt neun Funktionen, Katalog leitet seine Liste aus der Registry ab und filtert ueber `/modules/active` (fail-closed bei Fehler, reine Funktion `visibleWidgetTypes`), nicht verfuegbare Kachel zeigt `widgets.unavailable` statt leer zu bleiben. Deckungsgleichheits-Test faengt kuenftig jede vergessene Stelle. **Befund des Executors, geprueft statt vermutet:** `apps/web` hatte KEINE Abhaengigkeit auf `@tessera/shared` (frueher bewusst) — vor der Umsetzung nachgemessen, dass Bau und Produktions-Abbild das tragen (node:24-alpine strippt die Typen nativ); Folgeregel „nur loeschbare Syntax in shared“ steht als Warnung in der Datei. Verhalten der neun Kacheln unveraendert, im Browser bestaetigt (Reihenfolge, Anlegen, Entfernen, keine rohen Schluessel). Bewusst offen: der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` und die Live-Aktualisierung des Katalogs. **Zahlen:** api 1188 → 1202, web 640 → 659, type-check 4/4, lint 5/5 (74/53 wie Basis). | 2026-09-22 | 56c07c3,8be0725 | [260922-m1h-dashboard-widgets-ein-modul-bringt-seine](./quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/) |
|
||||
| 260922-vdk | **Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus.** Meldung des Nutzers aus dem **Linux-Client**: rechts neben dem Kalender freie Flaeche, in die sich keine Kachel schieben laesst — „als ob es keinen Anker gibt“. Aus dem Bildschirmfoto zurueckgerechnet (Spaltenbreite 51,5 px, Platzhalter auf Spalte 13 = letzte moegliche, Rasterende bei x=1459 bei ~1660 px Inhaltsbreite): das Raster rechnete mit **1200 px** statt mit der echten Breite, rechts blieben ~460 px totes Feld. Ursache: die Breitenmessung hing in `useEffect(..., [])` mit `if (!containerRef.current) return` — haengt `DashboardGrid` mit NULL Kacheln ein, rendert der fruehe Ruecksprung in den Leerzustand den gemessenen `<div>` gar nicht, der Effekt bricht ab und laeuft nie wieder, auch nicht wenn spaeter die erste Kachel entsteht. `width` blieb die ganze Sitzung auf dem Startwert 1200; react-grid-layout vergleicht strikt (`width > breakpoint`), 1200 ist damit `md` (20 Spalten, 51,6 px) statt `lg`. Fix: Ref-Rueckruf `measureRef` statt Einmal-Effekt — folgt dem Knoten ueber den Wechsel Leerzustand ↔ gefuellt, misst synchron in der Commit-Phase, haengt den ResizeObserver dort an; Fenster-Horcher als zusaetzliches Netz; `applyWidth` verwirft 0 und nicht endliche Werte. **Verhalten sonst unveraendert** — belegte Plaetze bleiben gesperrt, nichts weicht aus (Ansage des Nutzers). **Geprueft im echten Client**, nicht im Browser: `Tessera-1.3.0.AppImage` auf `DISPLAY=:10` ueber den WebKit-Remote-Inspektor gesteuert. Gleicher Fehlerfall vorher/nachher: Kachel 469 px → **389 px** bei 1000 px Bereich, Ziehen endet jetzt bei 603 px = `1000 − 8 − 389`, exakt der rechte Rand. **Messfalle notiert:** im Client gegen `style.width`/`style.transform` messen, nie gegen `getBoundingClientRect()` — bei Fenster im Hintergrund friert WebKitGTK die Animationsuhr ein und der `width`-Uebergang bleibt auf dem alten Wert stehen. **Zahlen:** web 659 → 661 Tests, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-22 | d9f2af3,cf67c8a | [260922-vdk-dashboard-raster-misst-seine-breite-nich](./quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -499,8 +505,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-09-21T17:40:00Z
|
||||
Resumed: 2026-09-21 (abends) — Sitzung ueber /gsd-resume-work fortgesetzt; danach die zwei bestellten Widgets gebaut.
|
||||
Stopped at: Beide Widgets fertig, nachgewiesen, gepusht (main == origin/main). Naechster Auftrag nicht benannt. Offen beim Nutzer: alpha ziehen, Windows-Client pruefen, Freigabe 1.3.0 auf Zuruf.
|
||||
Last session: 2026-09-22T13:40:00Z
|
||||
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; seitdem Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, Freigabe 1.3.0, Bilder in den Dateibereich.
|
||||
Stopped at: hk4 fertig und nachgewiesen. Dem Nutzer vorgelegt: erst das Aufraeumen (Modul bringt seine Kachel selbst mit), dann Proxmox-Modul + Kachel — Antwort steht aus.
|
||||
Resume file: None
|
||||
Last activity: 2026-09-22 - Kosmetik am Bilderrahmen (fast, 8b45a28): „1 Stunde“ statt „60 Minuten“, Bildanzahl in der Einstellungs-Kopfzeile; davor am 21.09. die zwei bestellten Widgets „Bilderrahmen“ und „XFrame“
|
||||
Last activity: 2026-09-22 - Quick 260922-hk4: Bilderrahmen-Bilder im Dateibereich, Selbstheilung aus der alten Spalte
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
---
|
||||
phase: quick-260922-frg
|
||||
plan: 01
|
||||
type: tdd
|
||||
autonomous: true
|
||||
subsystem: apps/desktop/src-tauri
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-frg: Update-Eintrag im Tray nie mehr stumm ausgegraut
|
||||
|
||||
## Befund (Orchestrator, 22.09.2026)
|
||||
|
||||
Der Nutzer sieht im Tray-Menü nur den grauen Eintrag „Update installieren“,
|
||||
obwohl alpha das Paket `1.2.0-beta.gc001a08` anbietet und der Client auf
|
||||
Stand `a6d1a64` steht (Neustart der App ändert nichts). Nachgemessen:
|
||||
|
||||
- Die Update-Anfrage genau in der Form des Clients
|
||||
(`/api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https://alpha.tessera.ctl.de`)
|
||||
liefert **auf dem alpha-Server selbst** (Port 3000 am Proxy vorbei) `200` mit
|
||||
gültigem Manifest. Tessera-seitig ist alles in Ordnung.
|
||||
- **Vor** Tessera steht der Nginx Proxy Manager und antwortet auf jede Anfrage
|
||||
an `alpha.tessera.ctl.de` mit `401 Authorization Required`
|
||||
(`WWW-Authenticate: Basic`), gemessen vom Dev-Host (192.168.13.11) und vom
|
||||
Testserver selbst (192.168.200.240 über die öffentliche Adresse 217.7.63.32).
|
||||
Die Webansicht der App kann so ein Passwortfenster beantworten und sich das
|
||||
merken; der Updater (`tauri-plugin-updater`, eigener `reqwest`-Client) nicht.
|
||||
- Im Plugin führt ein Nicht-2xx-Status NICHT zu einem Fehler mit Statuscode:
|
||||
`updater.rs` Z. 529-559 loggt nur „did not respond with a successful status
|
||||
code“, lässt `last_error` leer und endet in `Err(Error::ReleaseNotFound)`.
|
||||
Unser `spawn_version_check` fängt das mit `Err(_) => {}` — **stumm**. Der
|
||||
Eintrag bleibt für immer „Update installieren“ (gesperrt), die App prüft
|
||||
außerdem nur beim Start (und beim Serverwechsel).
|
||||
|
||||
Das ist der eigentliche Produktfehler dieser Aufgabe: ein fehlgeschlagener
|
||||
Update-Check ist vom Zustand „kein Update“ nicht unterscheidbar, und es gibt
|
||||
keinen Weg, die Prüfung ohne Neustart zu wiederholen. Den Passwortschutz am
|
||||
Proxy selbst kann der Client nicht lösen (und soll es nicht: Zugangsdaten
|
||||
gehören nicht in ausgelieferte Clients) — er wird dem Nutzer als Grund
|
||||
angezeigt.
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator)
|
||||
|
||||
1. **Der Update-Eintrag hat drei Endzustände, alle anklickbar außer während
|
||||
Prüfung/Installation und bei http-Server:**
|
||||
- Update gefunden → `Auf Beta-Stand <sha7> aktualisieren` / `Auf Version X.Y.Z aktualisieren` (wie bisher), Klick installiert.
|
||||
- Kein Update → `Kein Update verfügbar – erneut prüfen`, Klick startet die Prüfung erneut.
|
||||
- Prüfung fehlgeschlagen → `Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen` bzw. ohne Status `Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen`, Klick startet die Prüfung erneut.
|
||||
- Während der Prüfung: `Suche nach Updates…` (gesperrt). Während Download/Installation wie bisher (gesperrt, Fortschritt im Text).
|
||||
- http-Server: `Update nur über https möglich` (gesperrt, unverändert).
|
||||
- Beim Bau des Menüs steht `Suche nach Updates…` (gesperrt), weil `setup` die Prüfung sofort startet; ohne gespeicherte Server-Adresse `Kein Update verfügbar – erneut prüfen` (Klick ohne Adresse: nichts tun).
|
||||
2. **Statuscode nachliefern.** Bei `Err(ReleaseNotFound)` (= Server hat geantwortet, aber nicht 2xx/204) stellt der Client dieselbe Anfrage einmal mit seinem eigenen `reqwest`-Client (Timeout 8 s, Muster `check_server`) an die konkret gebaute Adresse (Platzhalter ersetzt: `target` = `std::env::consts::OS`, `arch` = `std::env::consts::ARCH`, `current` = `CARGO_PKG_VERSION`, `base` wie bisher) und liest NUR den Statuscode. Rumpf wird nicht ausgewertet. Bei `Err(Reqwest(..))`/`Err(Network(..))`/`Err(Io(..))` des Plugins (keine Verbindung, TLS, Timeout) keine zweite Anfrage: Status `None`.
|
||||
3. **Benachrichtigung mit Erklärung**, einmal je unterschiedlichem Fehlertext (Mutex<String> mit dem zuletzt gemeldeten Text; gleicher Text wird bei der periodischen Prüfung nicht erneut gemeldet). Texte über eine reine Funktion `check_failure_labels(status: Option<u16>) -> (String, String)`:
|
||||
- `Some(401)` / `Some(403)`: Menü `Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen`; Body `Der Server hat die Update-Anfrage mit HTTP 401 abgewiesen. Meist steht ein Passwortschutz oder eine Zugriffsliste am vorgeschalteten Proxy davor, die die App für Updates nicht durchlaufen kann. Anmeldung und Arbeiten in der App sind davon nicht betroffen.`
|
||||
- `Some(n)` sonst: Menü `Update-Prüfung fehlgeschlagen (HTTP n) – erneut prüfen`; Body `Der Server hat auf die Update-Anfrage mit HTTP n geantwortet statt mit Paketdaten.`
|
||||
- `None`: Menü `Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen`; Body `Der Server war für die Update-Prüfung nicht erreichbar. Die App prüft in vier Stunden erneut – oder über den Menüeintrag.`
|
||||
4. **Periodische Prüfung alle 4 Stunden** (`std::thread::spawn` mit `std::thread::sleep(Duration::from_secs(4 * 3600))` in Schleife; kein neues Crate). Je Durchlauf: gespeicherte Adresse frisch über `stored_server_url(app)` lesen (Serverwechsel berücksichtigt); wenn `PendingUpdate` bereits ein Update hält → überspringen (keine wiederholte Benachrichtigung „Neuer Beta-Stand“); sonst `spawn_version_check`. Der Thread wird in `setup` einmal gestartet.
|
||||
5. **Klick auf „update“:** `PendingUpdate.take()` → vorhanden: `spawn_update_install` (wie bisher); sonst: wenn eine Server-Adresse gespeichert ist → `spawn_version_check` (manuelles „erneut prüfen“); ohne Adresse → nichts. Der bisherige Browser-Rückfall (`open_download_page`) bleibt NUR im Fehlerpfad der Installation.
|
||||
6. Ein Mutex-Zustand für die Benachrichtigungs-Entprellung als eigener `app.manage`-Typ (`LastCheckNotice(Mutex<String>)`), damit `spawn_version_check` keine Signatur-Änderung nach außen braucht.
|
||||
7. Alle Menütexte Deutsch (wie bisher, „Sie“ in Benachrichtigungen), Kommentare Deutsch im Stil der Datei, Verweise auf `updater.rs`-Zeilen wie im Bestand.
|
||||
|
||||
## Aufgabe (eine Datei Code, plus Changelog)
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 1: lib.rs — Zustände des Update-Eintrags, Statuscode-Diagnose, Entprellung, 4-Stunden-Prüfung, Klick = erneut prüfen; Tests; Changelog</name>
|
||||
<files>apps/desktop/src-tauri/src/lib.rs, CHANGELOG.md</files>
|
||||
<behavior>
|
||||
- `check_failure_labels(Some(401))` → Menü enthält `HTTP 401` und endet auf `– erneut prüfen`; Body enthält `Passwortschutz` und `Zugriffsliste`. `Some(403)` gleiche Body-Erklärung mit `HTTP 403`. `Some(502)` → Menü `HTTP 502`, Body ohne `Passwortschutz`, enthält `statt mit Paketdaten`. `None` → Menü enthält `keine Verbindung`, Body enthält `vier Stunden`.
|
||||
- `diagnostic_update_url("https://alpha.example", "windows", "x86_64", "1.2.0")` → `https://alpha.example/api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example` (Schlussstrich getrimmt; `base` kodiert wie in `update_endpoint`).
|
||||
- Konstanten: `UPDATE_ITEM_CHECKING == "Suche nach Updates…"`, `UPDATE_ITEM_NONE == "Kein Update verfügbar – erneut prüfen"`, `UPDATE_ITEM_INSECURE` unverändert.
|
||||
- `is_update_newer`, `update_labels`, `release_labels`, `update_endpoint` unverändert (bestehende Tests bleiben grün).
|
||||
</behavior>
|
||||
<action>
|
||||
1. Konstanten: `UPDATE_ITEM_DEFAULT` entfernen (durch `UPDATE_ITEM_CHECKING` und `UPDATE_ITEM_NONE` ersetzt), Kommentare anpassen. Neue Konstante `UPDATE_CHECK_INTERVAL: Duration = 4 h` mit Begründung (Tray-App läuft tagelang; nur Start-Prüfung → Update nie gesehen, Befund 22.09.2026).
|
||||
2. Reine Funktionen `check_failure_labels(status: Option<u16>) -> (String, String)` und `diagnostic_update_url(server: &str, target: &str, arch: &str, current: &str) -> String` (nutzt `api_url` + `url::Url::query_pairs_mut` wie `update_endpoint`, damit die Kodierung identisch ist). Tests zuerst (rot: Funktionen fehlen), mindestens 6 `#[test]` gemäß `<behavior>`.
|
||||
3. `async fn probe_update_status(url: String) -> Option<u16>`: `reqwest::Client::builder().timeout(8 s)`, `GET`, `Some(resp.status().as_u16())`, bei Fehler `None`. Keine Auswertung des Rumpfs, kein Folgen von Redirects nötig (Standard).
|
||||
4. `spawn_version_check`: zu Beginn `UPDATE_ITEM_CHECKING` + gesperrt (statt DEFAULT). Ergebnisse:
|
||||
- `Ok(Some(update))` wie bisher.
|
||||
- `Ok(None)` → Text `UPDATE_ITEM_NONE`, `set_enabled(true)`.
|
||||
- `Err(InsecureTransportProtocol)` → wie bisher (gesperrt).
|
||||
- `Err(ReleaseNotFound)` → `probe_update_status(diagnostic_update_url(&server_url, std::env::consts::OS, std::env::consts::ARCH, env!("CARGO_PKG_VERSION"))).await` → `check_failure_labels(status)` → Menütext setzen, `set_enabled(true)`, Benachrichtigung nur, wenn der Body vom zuletzt gemeldeten (`LastCheckNotice`) abweicht; danach dort ablegen.
|
||||
- `Err(_)` sonst → `check_failure_labels(None)`, gleiche Behandlung.
|
||||
Bei `Ok(Some)` und `Ok(None)` `LastCheckNotice` leeren, damit ein späterer Fehler wieder gemeldet wird.
|
||||
5. `setup`: Menüeintrag mit `UPDATE_ITEM_CHECKING` bauen, wenn eine Adresse gespeichert ist, sonst `UPDATE_ITEM_NONE`; `.enabled(server_url.is_none())` entsprechend. `app.manage(LastCheckNotice(Mutex::new(String::new())))`. Nach dem Start der Erstprüfung den Wiederhol-Thread starten: `let handle = app.handle().clone(); std::thread::spawn(move || loop { std::thread::sleep(UPDATE_CHECK_INTERVAL); let pending = handle.state::<PendingUpdate>().0.lock().map(|g| g.is_some()).unwrap_or(false); if pending { continue; } if let Some(url) = stored_server_url(&handle) { spawn_version_check(handle.clone(), url); } })`. Kommentar: warum kein `tokio::time` (kein neues Feature/Crate) und warum `PendingUpdate` den Durchlauf überspringt.
|
||||
6. Klick „update“: `take()` wie bisher; `None` → `if let Some(url) = stored_server_url(app) { spawn_version_check(app.clone(), url) }`. Kommentar aktualisieren (der Browser-Weg ist nicht mehr der Rückfall des Klicks).
|
||||
7. `cargo fmt`, `cargo clippy` (0 Warnungen, wie CI), `cargo test` in `apps/desktop/src-tauri` — alle bestehenden Tests plus die neuen grün. `cargo build` (Debug reicht lokal; Release/Windows baut die CI).
|
||||
8. `CHANGELOG.md` unter „Unveröffentlicht → Behoben“ als erster Stichpunkt: „Desktop-App: der Update-Eintrag im Menü des Infobereich-Symbols bleibt nicht mehr stumm ausgegraut – schlägt die Update-Prüfung fehl, steht der Grund im Eintrag (z. B. „HTTP 401“, wenn ein Passwortschutz am Proxy die Anfrage abweist) und ein Klick prüft erneut; die App prüft außerdem alle vier Stunden, nicht mehr nur beim Start“ (kein Fließtext, Tonlage der Nachbarzeilen).
|
||||
Commit: `fix(desktop): Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h` (Wortlaut frei, Stil `git log --oneline -15`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo fmt --check && cargo clippy 2>&1 | tail -3 && cargo test 2>&1 | tail -5 && grep -q 'HTTP 401' /home/vicolab/projects/tessera-ctl/CHANGELOG.md</automated>
|
||||
</verify>
|
||||
<done>Tests in `lib.rs` ≥ 6 neue, alle grün, die Label-/URL-Tests nachweislich zuerst rot (Kompilierfehler „cannot find function“ zählt als rot — im SUMMARY nennen). `cargo clippy` ohne Warnung, `cargo fmt --check` sauber, `cargo build` erfolgreich. Menüzustände wie in Entscheidung 1; Klick ohne abgelegtes Update startet die Prüfung; Wiederhol-Thread alle 4 h; Benachrichtigung entprellt. CHANGELOG-Zeile steht. Genau ein Commit mit Scope `desktop`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Hinweise für den Executor
|
||||
|
||||
- Nur `apps/desktop/src-tauri/src/lib.rs` und `CHANGELOG.md` anfassen. Keine neuen Crates, keine Cargo.toml-Änderung (reqwest ist da, `url` kommt über `tauri::Url`).
|
||||
- `MenuItem::set_text`/`set_enabled` liefern `Result`, wie im Bestand mit `let _ =` ignorieren.
|
||||
- `stored_server_url(app)` existiert (siehe `open_download_page`). `AppHandle` ist Clone + Send; `std::thread::spawn` mit dem Klon ist zulässig (die Setup-Funktion nutzt bereits `app.handle().clone()` für den Async-Task).
|
||||
- Die Prüfung im CI: `cargo check` + `cargo clippy`; lokal zusätzlich `cargo test`. Der Cross-Bau für Windows läuft in der CI (Stempel ändert sich, weil `apps/desktop` berührt wird).
|
||||
- Nicht anfassen: `is_update_newer`, `update_labels`, `release_labels`, `update_endpoint`, `spawn_update_install`.
|
||||
|
||||
## Verifikation durch den Orchestrator (nach CI)
|
||||
|
||||
- CI-Lauf grün, Job `desktop` hat neu gebaut (kein Cache-Treffer), Manifest im API-Abbild trägt den neuen Stempel.
|
||||
- Optional auf der Windows-Test-VM gegen alpha: neuer Client zeigt `Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen` und eine Benachrichtigung mit der Proxy-Erklärung.
|
||||
|
||||
<threat_model>
|
||||
ASVS 1, block on high.
|
||||
|
||||
| ID | Bedrohung | Schwere | Disposition |
|
||||
|---|---|---|---|
|
||||
| T-FRG-01 | Diagnose-Anfrage folgt Redirects zu fremden Hosts | low | Nur Statuscode wird gelesen, kein Rumpf; Ziel ist die vom Nutzer gespeicherte Server-Adresse; kein Geheimnis in der Anfrage. Akzeptiert. |
|
||||
| T-FRG-02 | Benachrichtigungs-Spam durch periodische Prüfung gegen kaputten Proxy | low | Entprellung über `LastCheckNotice` (gleicher Text wird nicht erneut gemeldet). Mitigiert. |
|
||||
| T-FRG-03 | Proxy-Zugangsdaten in den Client einbauen, um 401 zu umgehen | high | Ausdrücklich NICHT umgesetzt (Entscheidung Befund); der Grund wird angezeigt, die Behebung liegt am Proxy. Mitigiert durch Nicht-Bau. |
|
||||
| T-FRG-04 | Wiederhol-Thread startet Prüfung während Installation | low | Während der Installation ist `PendingUpdate` durch `take()` leer, ein Durchlauf würde nur eine Prüfung anstoßen; `spawn_version_check` setzt den Menütext — Restrisiko: Fortschrittstext wird überschrieben, wenn genau im Download-Fenster die 4-h-Marke fällt. Akzeptiert (Download dauert Minuten, Intervall Stunden). |
|
||||
</threat_model>
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
---
|
||||
phase: quick-260922-frg
|
||||
plan: 01
|
||||
subsystem: apps/desktop/src-tauri
|
||||
tags: [desktop, tauri, updater, tray, proxy, 401, tdd]
|
||||
status: complete
|
||||
requires:
|
||||
- "quick-260917-kgc (In-App-Updater, PendingUpdate, spawn_version_check)"
|
||||
provides:
|
||||
- "Update-Eintrag im Tray mit drei sichtbaren Endzustaenden, nie mehr stumm gesperrt"
|
||||
- "Statuscode-Diagnose nach Err(ReleaseNotFound) ueber eigene Anfrage"
|
||||
- "Wiederholte Update-Pruefung alle vier Stunden"
|
||||
- "Klick auf den Eintrag ohne abgelegtes Update prueft erneut"
|
||||
affects:
|
||||
- "apps/desktop/src-tauri/src/lib.rs"
|
||||
- "CHANGELOG.md"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fehlerzustand eines Hintergrund-Checks im Menuetext ausschreiben statt Eintrag stumm sperren"
|
||||
- "Zweite Diagnose-Anfrage nur fuer den Statuscode, wenn eine Bibliothek ihn verschluckt"
|
||||
- "Benachrichtigung ueber Mutex<String> mit dem zuletzt gemeldeten Text entprellen"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/desktop/src-tauri/src/lib.rs
|
||||
- CHANGELOG.md
|
||||
decisions:
|
||||
- "Proxy-Zugangsdaten werden NICHT in den Client eingebaut (T-FRG-03); der Grund wird angezeigt, die Behebung liegt am Proxy"
|
||||
- "Wiederhol-Thread als std::thread mit sleep statt tokio::time, damit kein neues Feature/Crate noetig ist"
|
||||
- "diagnostic_update_url liefert String statt Option: bei unparsbarer Adresse faellt sie auf api_url zurueck, gespeicherte Adressen sind ohnehin immer parsebar"
|
||||
- "report_check_failure und clear_check_notice als eigene Helfer, damit die drei Fehlerzweige in spawn_version_check kurz bleiben"
|
||||
metrics:
|
||||
duration: "ca. 20 min (11:05 bis 11:26 Uhr, 22.09.2026)"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 5600
|
||||
tasks: 1
|
||||
commits: 1
|
||||
plan_head_before: ae36a22
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-frg: Update-Eintrag im Tray nie mehr stumm ausgegraut Summary
|
||||
|
||||
Der Update-Eintrag im Menue des Infobereich-Symbols zeigt jetzt in jedem Fall,
|
||||
was die Pruefung ergeben hat: ein Update, kein Update, oder den Grund des
|
||||
Fehlschlags (z. B. „HTTP 401“, wenn der Passwortschutz am Proxy die Anfrage
|
||||
abweist). Ein Klick auf den Eintrag prueft erneut, und die App prueft von
|
||||
selbst alle vier Stunden statt nur beim Start. Nur der http-Fall bleibt
|
||||
weiterhin dauerhaft gesperrt.
|
||||
|
||||
## Was sich fuer den Betrieb aendert
|
||||
|
||||
Bisher konnte der Nutzer nicht unterscheiden, ob es kein Update gibt oder ob
|
||||
die Pruefung gescheitert ist — in beiden Faellen stand da grau „Update
|
||||
installieren“, und ohne Neustart der App wurde nie wieder geprueft. Genau das
|
||||
war auf dem Client gegen alpha passiert: der Nginx Proxy Manager antwortet auf
|
||||
die Update-Anfrage mit 401, das Updater-Plugin macht daraus stumm
|
||||
`ReleaseNotFound`, und der Eintrag blieb grau, obwohl alpha das Paket
|
||||
`1.2.0-beta.gc001a08` bereithielt.
|
||||
|
||||
Jetzt steht in diesem Fall „Update-Prüfung fehlgeschlagen (HTTP 401) – erneut
|
||||
prüfen“ im Menue, und einmalig erscheint eine Benachrichtigung, die den
|
||||
Passwortschutz am vorgeschalteten Proxy als wahrscheinlichen Grund nennt und
|
||||
klarstellt, dass Anmeldung und Arbeiten in der App nicht betroffen sind. Den
|
||||
Passwortschutz selbst kann und soll der Client nicht umgehen; die Behebung
|
||||
liegt am Proxy (Ausnahme fuer `/api-proxy/desktop/*` oder Aufhebung des
|
||||
Schutzes fuer alpha).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- **Konstanten:** `UPDATE_ITEM_DEFAULT` („Update installieren“) ist weg,
|
||||
ersetzt durch `UPDATE_ITEM_CHECKING` („Suche nach Updates…“, gesperrt
|
||||
waehrend der Pruefung) und `UPDATE_ITEM_NONE` („Kein Update verfügbar –
|
||||
erneut prüfen“, anklickbar). Neu `UPDATE_CHECK_INTERVAL = 4 h`.
|
||||
- **Reine Funktionen:** `check_failure_labels(Option<u16>)` liefert Menue- und
|
||||
Benachrichtigungstext (401/403 mit Proxy-Erklaerung, andere Codes neutral,
|
||||
`None` = keine Verbindung). `diagnostic_update_url` baut die Update-Adresse
|
||||
mit ersetzten Platzhaltern in genau der Kodierung von `update_endpoint`.
|
||||
- **Statuscode-Diagnose:** Bei `Err(ReleaseNotFound)` stellt
|
||||
`probe_update_status` dieselbe Anfrage einmal mit eigenem `reqwest`-Client
|
||||
(8 s Timeout, Muster `check_server`) und liest nur den Statuscode. Bei
|
||||
Verbindungsfehlern des Plugins (`Err(_)` sonst) keine zweite Anfrage.
|
||||
- **Entprellung:** `LastCheckNotice(Mutex<String>)` als eigener
|
||||
`app.manage`-Typ; `report_check_failure` meldet nur einen abweichenden Text,
|
||||
`clear_check_notice` leert ihn nach `Ok(Some)`/`Ok(None)`.
|
||||
- **Wiederhol-Thread:** in `setup` einmal gestartet, `std::thread::spawn` mit
|
||||
`sleep(UPDATE_CHECK_INTERVAL)` in Schleife, liest die Adresse je Durchlauf
|
||||
frisch und ueberspringt, wenn `PendingUpdate` bereits ein Update haelt.
|
||||
- **Klick „update“:** `take()` wie bisher; ohne abgelegtes Update startet
|
||||
`spawn_version_check` mit der gespeicherten Adresse; ohne Adresse nichts.
|
||||
`open_download_page` bleibt nur im Fehlerpfad der Installation.
|
||||
- **Menuebau:** „Suche nach Updates…“ (gesperrt) mit gespeicherter Adresse,
|
||||
sonst „Kein Update verfügbar – erneut prüfen“ (aktiv).
|
||||
- **Changelog:** neuer erster Stichpunkt unter „Unveröffentlicht → Behoben“.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
**RED (nachgewiesen):** Die sieben neuen Tests wurden zuerst eingefuegt.
|
||||
`cargo test` brach mit neun Fehlern `E0425` ab — `cannot find function
|
||||
check_failure_labels`, `cannot find function diagnostic_update_url`,
|
||||
`cannot find value UPDATE_ITEM_CHECKING / UPDATE_ITEM_NONE /
|
||||
UPDATE_CHECK_INTERVAL`. Der Kompilierfehler zaehlt laut Plan als rot.
|
||||
|
||||
**GREEN:** Nach der Umsetzung `cargo test`: 44 bestanden, 0 fehlgeschlagen
|
||||
(37 Bestandstests plus 7 neue). `is_update_newer`, `update_labels`,
|
||||
`release_labels`, `update_endpoint`, `spawn_update_install` unveraendert.
|
||||
|
||||
**REFACTOR:** Zwei Helfer (`report_check_failure`, `clear_check_notice`)
|
||||
herausgezogen, damit die Fehlerzweige in `spawn_version_check` lesbar bleiben;
|
||||
der Kommentar bei `with_desktop_marker` nennt nicht mehr den alten Text
|
||||
„Update installieren“.
|
||||
|
||||
## Cargo-Ergebnisse (apps/desktop/src-tauri, lokal)
|
||||
|
||||
| Schritt | Ergebnis |
|
||||
|---|---|
|
||||
| `cargo fmt --check` | sauber |
|
||||
| `cargo clippy` | 0 Warnungen, 0 Fehler |
|
||||
| `cargo test` | 44 passed, 0 failed |
|
||||
| `cargo build` (Debug) | erfolgreich, Systembibliotheken vorhanden |
|
||||
| `grep 'HTTP 401' CHANGELOG.md` | Treffer |
|
||||
|
||||
## Commit
|
||||
|
||||
- `d73aad1` fix(desktop): Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h — `apps/desktop/src-tauri/src/lib.rs`, `CHANGELOG.md`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Einzige Ergaenzung ausserhalb der
|
||||
Aufzaehlung: der Doc-Kommentar bei `with_desktop_marker` verwies noch auf den
|
||||
entfernten Menuetext „Update installieren“ und wurde mitgezogen (reine
|
||||
Kommentar-Korrektur, kein Verhalten).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Offen (fuer den Orchestrator, nach CI)
|
||||
|
||||
- CI-Lauf: Job `desktop` muss neu bauen (apps/desktop beruehrt), Manifest im
|
||||
API-Abbild traegt den neuen Stempel.
|
||||
- Optional auf der Windows-Test-VM gegen alpha: neuer Client zeigt
|
||||
„Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen“ plus die
|
||||
Benachrichtigung mit der Proxy-Erklaerung. Damit der Client danach das
|
||||
Update tatsaechlich bekommt, muss der Proxy die Update-Anfrage durchlassen.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/desktop/src-tauri/src/lib.rs` vorhanden und geaendert
|
||||
- `CHANGELOG.md` enthaelt die neue Zeile
|
||||
- Commit `d73aad1` in `git log` vorhanden
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
---
|
||||
phase: quick-260922-ge2
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260922-GE2]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-crop.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-crop.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.test.tsx
|
||||
- apps/web/src/test/fake-resize-observer.ts
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/messages/umlaut-dictionary.ts
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-anwender.md
|
||||
|
||||
estimate:
|
||||
tokens: 130000
|
||||
raw_tokens: 130000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Unter Einstellungen → Dashboard → XFrame schaltet „Nur einen Ausschnitt der Seite anzeigen“ eine Vorschau der Seite bei 1280 Pixel Breite frei (sichtbare Höhe 420 px, eigener Bildlauf); darüber liegt ein Rahmen, den der Benutzer mit der Maus verschiebt und an den vier Ecken in der Größe zieht; beim Loslassen wird der Ausschnitt genau einmal gespeichert; Links/Oben/Breite/Höhe stehen zusätzlich als Zahlenfelder (Übernahme bei Blur/Enter) mit demselben Klemmen."
|
||||
- "Die Kachel zeigt bei gesetztem Ausschnitt nur diesen Bereich der Seite, eingepasst (contain) und zentriert; ändert sich die Kachelgröße, skaliert der Ausschnitt mit und bleibt immer ganz sichtbar (gemessen per ResizeObserver am Kachelkörper)."
|
||||
- "Ohne Ausschnitt gibt es eine Vergrößerung (50/60/75/90/100/125/150 %) für die ganze Seite; 100 % rendert exakt wie heute (keine Transformation); die Auswahl ist nur ohne Ausschnitt sichtbar."
|
||||
- "„Nur anzeigen“ legt im Ansichtsmodus eine transparente Fläche über den Rahmen (Klicken und Scrollen in der Seite sind gesperrt); beim Einschalten des Ausschnitts wird es automatisch mit eingeschaltet; „In neuem Tab öffnen“ bleibt klickbar; im Bearbeitungsmodus liegt weiterhin nur die Bearbeitungsfläche (nie beide)."
|
||||
- "Unsinnige Werte kommen nie ins CSS: Ausschnitt-Zahlen werden auf x ≥ 0, y ≥ 0, w ∈ [100, 1280], h ∈ [60, 4000], x + w ≤ 1280 (x wird verschoben, nicht abgewiesen) geklemmt, ungültige Formen fallen auf null; Zoom auf die nächstniedrigere erlaubte Stufe (mindestens 50); readOnly nur bei echtem true."
|
||||
- "Das Formular sagt dauerhaft, dass der Ausschnitt eine Position auf der Seite ist und verrutschen kann, wenn die Seite ihren Aufbau ändert; Seiten, die das Einbetten verweigern, bleiben in Vorschau und Kachel leer (Hinweis von qd3 bleibt)."
|
||||
- "Sandbox, referrerPolicy, allow, loading, Neuladen-Nonce/Key, Kopfleiste, Leerzustand und Bearbeitungsfläche sind unverändert; die Vorschau nutzt dieselbe Sandbox und pointer-events: none; der Server ruft die Adresse weiterhin nie ab; alle Tore grün (type-check 4/4, lint 5/5 mit web weiterhin 53 Warnungen, Web-Tests ≥ 634 bei heute 604, API-Tests 1175, `as unknown as` web 6, kein `any`, kein `!`)."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/widgets/xframe-config.ts — `XframeCrop`, `XframeConfig` um `crop | null`, `zoom`, `readOnly` erweitert; `XFRAME_PAGE_WIDTH`, `XFRAME_CROP_MIN_W/MIN_H/MAX_H`, `XFRAME_CROP_DEFAULT`, `XFRAME_ZOOM_OPTIONS`, `XFRAME_ZOOM_DEFAULT`, `clampXframeCrop`; Resolver klemmt"
|
||||
- "apps/web/src/components/dashboard/widgets/xframe-crop.ts — reine Geometrie ohne React: `computeCropLayout(crop, tileW, tileH)`, `applyCropDrag(mode, start, dxPage, dyPage)`, `XFRAME_PREVIEW_PAGE_HEIGHT`"
|
||||
- "apps/web/src/components/dashboard/widgets/xframe-widget.tsx — Ausschnitt-Modus (Clip + verschobener, skalierter <iframe>), Zoom-Modus, readOnly-Fläche, ResizeObserver am Kachelkörper, `data-tile-size`"
|
||||
- "apps/web/src/components/settings/xframe-config-form.tsx — Checkbox Ausschnitt, Vorschau mit Rahmen und vier Griffen (Pointer-Events), Zahlenfelder, Zoom-Auswahl (nur ohne Ausschnitt), Checkbox „Nur anzeigen“, dauerhafter Hinweis"
|
||||
- "apps/web/src/test/fake-resize-observer.ts — `stubResizeObserver({ width, height })` für Widget- und Formular-Test (ein einziger Cast `as ResizeObserverEntry`)"
|
||||
- "apps/web/src/messages/de.json + en.json — 13 neue Schlüssel unter `widgets.xframe`, identischer Satz; umlaut-dictionary.ts: `Ausschnitt` gelistet"
|
||||
- "CHANGELOG.md — Stichpunkt direkt nach dem XFrame-Stichpunkt; docs/anleitung-anwender.md — je ein Satz in Tabellenzeile und Absatz „Dashboard > Widgets“"
|
||||
key_links:
|
||||
- "Checkbox `xframe-crop-enable` -> `onChange({ crop: XFRAME_CROP_DEFAULT, readOnly: true })` (EIN Aufruf) -> `PATCH /dashboard/widgets/:id/config` (flache Zusammenführung, bestehend) -> Kachel `resolveXframeConfig` -> `crop !== null` -> ResizeObserver misst den Körper -> `computeCropLayout` -> Clip-`div` + <iframe style={left/top/transform}>"
|
||||
- "Rahmen-`onPointerDown` (Capture, jsdom: ohne) -> `onPointerMove` -> `applyCropDrag(mode, startCrop, dx/p, dy/p)` -> Entwurf im lokalen State (Rahmen folgt der Maus) -> `onPointerUp` -> `clampXframeCrop` -> genau ein `onChange({ crop })` nur bei Änderung"
|
||||
- "`zoom !== 100 && crop === null` -> <iframe absolute style={width/height %, transform scale}>; `zoom === 100` -> heutige Klassen `h-full w-full`, kein style"
|
||||
- "`readOnly && !isEditMode` -> `xframe-readonly-overlay` NACH dem Rahmen/Clip im DOM; `isEditMode` -> nur `xframe-edit-overlay`; Ecksymbol-Link `z-10` bleibt über beiden"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-ge2: XFrame-Widget — Ausschnitt, Zoom, „Nur anzeigen“
|
||||
|
||||
<objective>
|
||||
Das XFrame-Widget (quick-260921-qd3, HEAD 5aa577a) bekommt drei Erweiterungen: (1) einen **Ausschnitt** der eingebetteten Seite — in den Einstellungen wählt der Benutzer ihn in einer Vorschau der Seite bei fester Seitenbreite 1280 px, indem er ein Rechteck verschiebt und an den Ecken zieht (alternativ vier Zahlenfelder); die Kachel zeigt nur diesen Ausschnitt, eingepasst und zentriert, und skaliert ihn mit der Kachelgröße; (2) eine **Vergrößerung** (50–150 %) für die Ganzseiten-Ansicht ohne Ausschnitt; (3) **„Nur anzeigen“** — eine Fläche über dem Rahmen sperrt Klicks und Bildlauf in der Seite (beim Einschalten des Ausschnitts automatisch an). Ehrliche Grenzen stehen im Formular: der Ausschnitt ist eine Pixelposition auf der Seite, keine Inhaltserkennung; verweigernde Seiten bleiben leer.
|
||||
|
||||
Purpose: Produktwunsch des Nutzers (abgestimmt); die technischen Entscheidungen hat der Orchestrator getroffen (Kasten unten) — dieser Plan verfeinert sie, ohne sie neu zu öffnen.
|
||||
Output: erweiterter Resolver + neues reines Geometrie-Modul (zuerst rot), erweitertes Widget und Formular mit Tests, Test-Helfer für den ResizeObserver, Übersetzungen, Changelog- und Handbuch-Sätze; alle Tore grün.
|
||||
</objective>
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator, verfeinert — nicht neu verhandeln)
|
||||
|
||||
1. **Konfiguration** (alle Felder optional, Resolver klemmt; Speicherung im bestehenden Config-JSON per flacher Zusammenführung der API). `crop: { x, y, w, h } | null` in Seitenpixeln bei virtueller Seitenbreite `XFRAME_PAGE_WIDTH = 1280` (Konstante, keine UI). Klemmen in `clampXframeCrop` (exportiert, EINE Funktion für Resolver, Zahlenfelder und Ziehen): alle vier Werte auf ganze Zahlen runden; `w` auf [`XFRAME_CROP_MIN_W` 100, 1280], `h` auf [`XFRAME_CROP_MIN_H` 60, `XFRAME_CROP_MAX_H` 4000], `x = max(0, x)`, `y = max(0, y)`, danach `x + w > 1280 → x = 1280 − w` (x verschieben, nie abweisen). Resolver: `crop` nur, wenn ein Nicht-null-Objekt mit vier endlichen Zahlen `x, y, w, h` vorliegt → geklemmt; alles andere → `null`. `zoom: number` in Prozent, `XFRAME_ZOOM_OPTIONS = [50, 60, 75, 90, 100, 125, 150]`, `XFRAME_ZOOM_DEFAULT = 100`; nicht endliche Zahl/fehlt → 100; sonst die größte Stufe ≤ n, unter 50 → 50. `readOnly: boolean`, nur `=== true` → true. `XFRAME_CROP_DEFAULT = { x: 0, y: 0, w: 1280, h: 720 }`.
|
||||
2. **Reine Geometrie** in NEUEM Modul `xframe-crop.ts` (ohne React, eigener Test zuerst rot): `computeCropLayout(crop, tileW, tileH) → { scale, left, top, frameHeight }` mit `scale = min(tileW / crop.w, tileH / crop.h)` (contain, darf > 1 sein), `left = (tileW − crop.w·scale) / 2`, `top = (tileH − crop.h·scale) / 2`, `frameHeight = max(crop.y + crop.h, 720)`; `tileW ≤ 0 || tileH ≤ 0 → { scale: 0, left: 0, top: 0, frameHeight }` (nichts rendern, bis gemessen). `applyCropDrag(mode, start, dxPage, dyPage) → XframeCrop` mit `mode ∈ 'move' | 'nw' | 'ne' | 'sw' | 'se'`: Kanten `left/top/right/bottom` aus `start` bilden; `move` verschiebt alle vier um `(dx, dy)` und klemmt die Position bei fester Größe auf `left ∈ [0, 1280 − w]`, `top ∈ [0, max(0, XFRAME_PREVIEW_PAGE_HEIGHT − h)]`; die Eckmodi bewegen nur die beiden Kanten der Ecke (`nw`: left+dx, top+dy; `ne`: right+dx, top+dy; `sw`: left+dx, bottom+dy; `se`: right+dx, bottom+dy), klemmen `left ≥ 0`, `top ≥ 0`, `right ≤ 1280`, `bottom ≤ XFRAME_PREVIEW_PAGE_HEIGHT` und halten die Mindestgröße an der bewegten Kante (`left = min(left, right − 100)`, `right = max(right, left + 100)`, `top = min(top, bottom − 60)`, `bottom = max(bottom, top + 60)`) — die gegenüberliegende Ecke bleibt stehen; Ergebnis durch `clampXframeCrop`. `XFRAME_PREVIEW_PAGE_HEIGHT = 3000` lebt hier (Vorschau UND Ziehmathematik brauchen es; 4000 bleibt die Speichergrenze für getippte Werte — ein getippter Wert jenseits der Vorschau ist gültig, aber in der Vorschau nicht sichtbar; im Hinweis nicht erwähnen, Randfall).
|
||||
3. **Rendering (Widget).** Der Kachelkörper (`<div className="relative min-h-0 flex-1 overflow-hidden">`, bereits vorhanden) bekommt `ref={bodyRef}`; ein `useEffect` mit Abhängigkeit `hasCrop = crop !== null` hängt NUR bei Ausschnitt einen `ResizeObserver` an (Muster dashboard-grid.tsx Zeile 134: `entries[0].contentRect`), hält `{ w, h }` im State, Aufräumfunktion `disconnect()`. Körper trägt im Ausschnitt-Modus `data-tile-size={`${Math.round(w)}x${Math.round(h)}`}`. **Ausschnitt-Modus** (`crop !== null`, nur wenn `layout.scale > 0`): Clip `<div data-testid="xframe-crop-clip" style={{ position: 'absolute', left, top, width: crop.w·scale, height: crop.h·scale, overflow: 'hidden' }}>` mit dem `<iframe>` darin: `style={{ position: 'absolute', left: −crop.x·scale, top: −crop.y·scale, width: XFRAME_PAGE_WIDTH, height: frameHeight, transform: `scale(${scale})`, transformOrigin: '0 0' }}`, `className="border-0 bg-background"` (KEIN `h-full w-full`), alle heutigen Attribute (`key`, `src`, `title`, `sandbox`, `allow=""`, `referrerPolicy`, `loading`, `data-testid`, `data-reload-nonce`) unverändert. Dateikopf erklärt: cross-origin lässt sich die Seite nicht von außen scrollen — deshalb wird der Rahmen SELBST verschoben und skaliert und ein Clip schneidet ihn; die feste Layoutbreite 1280 hält den Seitenaufbau über alle Kachelgrößen stabil, sonst würde die Seite bei jeder Kachelgröße anders umbrechen und der Ausschnitt verrutschen. **Zoom-Modus** (`crop === null`, `z = zoom / 100 ≠ 1`): `className="absolute left-0 top-0 border-0 bg-background"`, `style={{ width: pct, height: pct, transform: `scale(${z})`, transformOrigin: '0 0' }}` mit `pct = `${Math.round(10000 / z) / 100}%`` (60 % → `166.67%`, 150 % → `66.67%`; absolut positioniert, damit die Prozente sicher am Körper hängen). `z === 1` → exakt heute: `className="h-full w-full border-0 bg-background"`, kein `style`. **readOnly**: `!isEditMode && readOnly` → `<div data-testid="xframe-readonly-overlay" aria-hidden="true" className="absolute inset-0" />` NACH dem Rahmen bzw. Clip im DOM; `isEditMode` → nur die bestehende `xframe-edit-overlay` (nie beide). Das Ecksymbol (`z-10`) und die Kopfleiste liegen über beiden Flächen — unverändert.
|
||||
4. **Formular** (`xframe-config-form.tsx`, unter den bestehenden Feldern, in dieser Reihenfolge): (a) Checkbox `xframe-crop-enable` (Klassen wie `show-date-toggle` im Panel: `h-4 w-4 rounded border-border text-primary`, Label `text-sm text-foreground`) — Einschalten → `onChange({ crop: XFRAME_CROP_DEFAULT, readOnly: true })` (EIN Aufruf), Ausschalten → `onChange({ crop: null })` (readOnly unberührt); (b) bei `crop !== null` der Vorschaublock; (c) Zahlenfelder; (d) Zoom-Auswahl `<select id="xframe-zoom">` NUR bei `crop === null` (Optionen aus `XFRAME_ZOOM_OPTIONS`, Text `t('xframe.zoomOption', { n })`, `value={String(zoom)}`, `onChange({ zoom: Number(value) })`); (e) Checkbox `xframe-readonly`; (f) darunter dauerhaft `<p id="xframe-crop-hint" className="text-xs text-muted-foreground">{t('xframe.cropHint')}</p>`. **Vorschau**: äußerer `<div ref={previewRef} data-testid="xframe-crop-preview" className="relative overflow-y-auto rounded border border-border bg-background" style={{ height: 420 }}>`; Breite per `ResizeObserver` auf `previewRef` (gleiches Muster wie im Widget), `p = width > 0 ? width / 1280 : 0.5`; darin die Bühne `<div data-testid="xframe-crop-stage" className="relative" style={{ width: 1280·p, height: XFRAME_PREVIEW_PAGE_HEIGHT·p }}>` mit (i) `<iframe data-testid="xframe-crop-preview-frame" src={url} title={t('xframe.cropRectangle')} sandbox={XFRAME_SANDBOX} allow="" referrerPolicy="no-referrer" loading="lazy" className="border-0" style={{ position: 'absolute', left: 0, top: 0, width: 1280, height: XFRAME_PREVIEW_PAGE_HEIGHT, transform: `scale(${p})`, transformOrigin: '0 0', pointerEvents: 'none' }}>`, (ii) dem Rahmen `<div role="group" aria-label={t('xframe.cropRectangle')} data-testid="xframe-crop-rect" className="absolute cursor-move touch-none border-2 border-primary" style={{ left: c.x·p, top: c.y·p, width: c.w·p, height: c.h·p }}>` (`c = draft ?? crop`) mit vier Griffen `<div aria-hidden="true" data-testid="xframe-crop-handle-{nw|ne|sw|se}" className="absolute h-3 w-3 touch-none bg-primary …">` an den Ecken (`-left-1.5 -top-1.5 cursor-nwse-resize`, `-right-1.5 -top-1.5 cursor-nesw-resize`, `-left-1.5 -bottom-1.5 cursor-nesw-resize`, `-right-1.5 -bottom-1.5 cursor-nwse-resize`). Keine Maskierung außerhalb des Rahmens (Rand genügt). Ohne `url`: statt Bühne `<p data-testid="xframe-crop-preview-empty" className="p-3 text-sm text-muted-foreground">{t('xframe.cropPreviewEmpty')}</p>`, kein `<iframe>`. Über der Vorschau ein Hinweis `t('xframe.cropPreviewHint')` (Bedienung + „1280 Pixel Breite“). **Pointer-Logik** (eine Fabrik `dragProps(mode)` liefert `onPointerDown/onPointerMove/onPointerUp/onPointerCancel` für Rahmen und Griffe; Griffe rufen in `onPointerDown` `e.stopPropagation()`, sonst startet der Rahmen zusätzlich ein Verschieben): `onPointerDown` — nur Haupttaste (`e.button === 0`), `dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: crop }`, Capture per Helfer `capturePointer(e.currentTarget, e.pointerId)`; `onPointerMove` — bei aktivem Zug `setDraft(applyCropDrag(mode, start, (e.clientX − startX) / p, (e.clientY − startY) / p))`; `onPointerUp` — Capture lösen, `dragRef.current = null`, Ergebnis `next = draft ?? start`, `setDraft(null)`, `onChange({ crop: next })` NUR wenn sich eines der vier Felder gegenüber `crop` geändert hat (ein bloßer Klick erzeugt keinen PATCH); `onPointerCancel` — verwerfen ohne `onChange`. Deltas statt `getBoundingClientRect` — Bildlauf im Vorschaubehälter stört so nicht. **Capture-Wächter (Befund, jsdom 29.1.1):** jsdom kennt `PointerEvent` (clientX/pointerId funktionieren), aber NICHT `Element.setPointerCapture`/`releasePointerCapture` — Helfer `capturePointer(el: HTMLElement, id: number)` / `releasePointer(el, id)` rufen nur, wenn `typeof el.setPointerCapture === 'function'` (kompiliert, Biome still — geprüft); im Browser wird gefangen, in jsdom nicht (die Tests feuern Move/Up auf demselben Element). **Zahlenfelder** als eigene kleine Funktion `CropNumberFields({ crop, onCommit })` mit `key={`${crop.x}-${crop.y}-${crop.w}-${crop.h}`}` (State-Reset per Key, damit die Felder nach Ziehen/Speichern die neuen Werte zeigen): vier String-Entwürfe, `<input id="xframe-crop-{x|y|w|h}" type="number" inputMode="numeric" min={0} aria-describedby="xframe-crop-unit-hint">` in einem `grid grid-cols-4 gap-2`, Labels `t('xframe.cropX')` … (`mb-1 block text-sm text-foreground`), Felder `h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground`; Übernahme bei Blur/Enter: `Number(draft)` endlich → `next = clampXframeCrop({ ...crop, [k]: n })`, `onCommit(next)` nur bei Änderung; nicht endlich → nichts. Darunter `<p id="xframe-crop-unit-hint" className="text-xs text-muted-foreground">{t('xframe.cropUnitHint')}</p>`.
|
||||
5. **Kachelgrößen-Testhaken — Entscheidung: KEIN `measuredSize`-Prop.** Befund: `apps/web/src/test/setup.ts` polyfillt `ResizeObserver` bereits global für jsdom (feuert in `observe()` sofort mit `contentRect` 1200×800). Der Produktionscode bleibt frei von Test-Oberfläche; Widget- und Formular-Test überschreiben den Observer dateiweise mit expliziter Größe über den neuen Helfer `apps/web/src/test/fake-resize-observer.ts`: `export function stubResizeObserver(size: { width: number; height: number }): void` ruft `vi.stubGlobal('ResizeObserver', class { constructor(cb: ResizeObserverCallback) {…} observe() { this.cb([{ contentRect: new DOMRectReadOnly(0, 0, size.width, size.height) } as ResizeObserverEntry], this); } unobserve() {} disconnect() {} })` — EIN Cast `as ResizeObserverEntry` (geprüft: kompiliert unter strict/dom, kein `as unknown as`; jsdom hat `DOMRectReadOnly`); Tests rufen `vi.unstubAllGlobals()` in `afterEach`. Widget-Test misst 640×360, Formular-Test 640 breit (`p = 0.5`: 50 Bildschirm-px = 100 Seiten-px, glatte Zahlen). Die Mathematik selbst wird in `xframe-crop.test.ts` direkt geprüft.
|
||||
6. **Übersetzungen** `widgets.xframe.*`, 13 neue Schlüssel in de UND en (gleicher Satz, Deutsch mit „Sie“): `cropEnable` („Nur einen Ausschnitt der Seite anzeigen“ / „Show only a section of the page“), `cropHint` („Der Ausschnitt ist eine Position auf der Seite. Ändert die Seite ihren Aufbau, kann der Ausschnitt verrutschen und muss neu gesetzt werden.“ / „The section is a position on the page. If the page changes its layout, the section may shift and has to be set again.“), `cropPreviewHint` („Vorschau der Seite bei 1280 Pixel Breite. Ziehen Sie den Rahmen an die gewünschte Stelle; an den Ecken ändern Sie seine Größe. Nach unten scrollen zeigt mehr von der Seite.“ / „Preview of the page at 1280 pixels wide. Drag the frame to the desired spot; use the corners to change its size. Scroll down to see more of the page.“), `cropPreviewEmpty` („Die Vorschau erscheint, sobald eine Adresse eingetragen ist.“ / „The preview appears once an address is set.“), `cropRectangle` („Ausschnitt – ziehen zum Verschieben, Ecken zum Ändern der Größe“ / „Section – drag to move, corners to resize“), `cropX` („Links“/„Left“), `cropY` („Oben“/„Top“), `cropW` („Breite“/„Width“), `cropH` („Höhe“/„Height“), `cropUnitHint` („Werte in Pixeln der Seite bei 1280 Pixel Breite“ / „Values in page pixels at 1280 pixels wide“), `zoomLabel` („Vergrößerung der ganzen Seite“ / „Zoom of the full page“), `zoomOption` („{n} %“ beide), `readOnly` („Nur anzeigen – Klicks und Scrollen im Rahmen sperren“ / „View only – block clicks and scrolling inside the frame“). **Umlaut-Wächter** (Tokenizer `/[A-Za-zÄÖÜäöüß]+/g`, ganze Wörter): einziger neuer Verdachts-Token ist `Ausschnitt` (korrektes Deutsch; `Bildausschnitt` ist ein anderer Token) → in `UMLAUT_ALLOWLIST` nach `neuem` mit Kommentar `// XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“`; `muss`, `Adresse` sind gelistet. Danach `pnpm --filter @tessera/web exec vitest run src/messages` — meldet der Wächter weitere Tokens, ebenfalls listen (nur korrekte deutsche Wörter).
|
||||
7. **Tore unverändert**: kein `any`, `as unknown as` web bleibt 6, kein `!`, keine HTML-Einfügung, `pnpm type-check` 4/4, `pnpm lint` 5/5, Biome-Warnungen web bleiben **53** (Ausgang gemessen) — Pointer-Handler stehen nicht auf der a11y-Handlerliste von Biome (`onClick/onMouseDown/onKey*`); sollte dennoch eine a11y-Warnung auf Rahmen oder Griffen erscheinen, ohne `biome-ignore` beheben und im SUMMARY vermerken. Keine neuen Pakete. Keine Änderung an `widget-settings-panel.tsx` (der Zweig reicht `config`/`onChange` durch), keiner an Registry, Katalog, API — die API führt die neuen Felder flach zusammen, `crop: null` wird als JSON-null gespeichert.
|
||||
|
||||
## Ausgangsmessung (22.09.2026, HEAD 5aa577a, Baum sauber)
|
||||
|
||||
| Größe | Wert |
|
||||
|---|---:|
|
||||
| Web-Tests | 604 |
|
||||
| API-Tests | 1175 |
|
||||
| `as unknown as` in apps/web/src | 6 (davon 1 in test/setup.ts) |
|
||||
| `as unknown as` in apps/api/src | 27 |
|
||||
| Biome-Warnungen web (`pnpm --filter @tessera/web lint`) | 53 |
|
||||
| `noNonNullAssertion` / `noExplicitAny` / `biome-ignore` in apps/api/src | 56 / 13 / 1 |
|
||||
| jsdom | 29.1.1 — `PointerEvent` ja, `setPointerCapture` nein, `DOMRectReadOnly` ja |
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-config.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-config.test.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/xframe-config-form.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/xframe-config-form.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/test/setup.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/messages/umlaut-dictionary.ts
|
||||
@/home/vicolab/projects/tessera-ctl/.planning/quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/260921-qd3-SUMMARY.md
|
||||
</context>
|
||||
|
||||
## Hinweise für den Executor
|
||||
|
||||
- **Ausgangspunkt HEAD 5aa577a**, Baum sauber. Nur die in `files_modified` genannten Dateien anfassen; **nie** `.planning/**` (Akte/STATE macht der Orchestrator).
|
||||
- **Tore vor jedem Commit:** `pnpm type-check`, `pnpm lint`, die betroffenen Vitest-Dateien; am Ende (Aufgabe 2) `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollständig.
|
||||
- **Rot-Nachweis:** `xframe-config.test.ts` (neue Fälle), `xframe-crop.test.ts` und die neuen Widget-Fälle werden VOR der Umsetzung geschrieben und einmal rot gefahren (Ausgabe kurz im SUMMARY festhalten).
|
||||
- **Bestehende Erwartungen anpassen:** `xframe-config.test.ts` Test 1 und Test 8 prüfen mit `toEqual` das ganze Objekt — um `crop: null, zoom: 100, readOnly: false` erweitern, sonst rot aus dem falschen Grund.
|
||||
- **Kein `any`**, keine neue `as unknown as`, kein `!`. `style`-Werte sind Zahlen (React hängt `px` an) oder Template-Strings aus eigenen Zahlen; `transform`/`transformOrigin`/`pointerEvents` sind reguläre CSS-Props.
|
||||
- **jsdom lädt keine Unterressourcen** — auch der Vorschau-`<iframe>` erzeugt keinen Netzabruf. Inline-Styles per `element.style.left` usw. prüfen (`'20px'`, `'-160px'`, `'scale(0.8)'`, `'166.67%'`).
|
||||
- **Pointer-Tests:** `fireEvent.pointerDown(el, { clientX, clientY, pointerId: 1, button: 0 })`, dann `pointerMove` und `pointerUp` auf DEMSELBEN Element (jsdom fängt nicht); Griffe stoppen die Propagation, sonst zählt der Rahmen mit.
|
||||
- **Commits:** je Aufgabe genau ein Commit, Stil `git log --oneline -15`, Scope `quick-260922-ge2`, deutsche Betreffzeile, Abschlusszeile `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
|
||||
- **Nie** auf den Testserver deployen; Browser-Rundgang macht der Orchestrator lokal (Prüfliste im SUMMARY).
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Resolver + Geometrie (rot → grün), Widget-Ausschnitt/Zoom/readOnly, Formular mit Vorschau und Ziehen, Übersetzungen — Ende-zu-Ende „Ausschnitt einschalten → Rahmen ziehen → Kachel zeigt den Ausschnitt eingepasst“</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/xframe-config.ts, apps/web/src/components/dashboard/widgets/xframe-config.test.ts, apps/web/src/components/dashboard/widgets/xframe-crop.ts, apps/web/src/components/dashboard/widgets/xframe-crop.test.ts, apps/web/src/components/dashboard/widgets/xframe-widget.tsx, apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx, apps/web/src/components/settings/xframe-config-form.tsx, apps/web/src/components/settings/xframe-config-form.test.tsx, apps/web/src/test/fake-resize-observer.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
|
||||
<reversibility rating="reversible">Alle neuen Config-Felder sind optional und werden geklemmt; ohne sie rendert das Widget exakt wie heute.</reversibility>
|
||||
<behavior>
|
||||
- Resolver (≥ 8 neue `it` in `xframe-config.test.ts`): `crop` fehlt / `null` / `'x'` / `{ x: 1 }` / `{ x: 'a', y: 0, w: 500, h: 300 }` → `null`; `{ x: 0, y: 0, w: 1280, h: 720 }` → unverändert; `{ x: 500, y: 0, w: 1000, h: 300 }` → `x: 280` (x verschoben, w bleibt); `w: 50 → 100`, `w: 5000 → 1280` (und x → 0), `h: 10 → 60`, `h: 9999 → 4000`; `x: -5 → 0`, `y: -1 → 0`; `x: 10.6 → 11` (gerundet); `zoom` fehlt/`'abc'`/`NaN` → 100, `70 → 60`, `200 → 150`, `0 → 50`, `10 → 50`, `125 → 125`; `readOnly: true → true`, `'true'`/`1`/fehlt → `false`; `XFRAME_ZOOM_OPTIONS` gleich `[50, 60, 75, 90, 100, 125, 150]`, `XFRAME_PAGE_WIDTH` 1280, `XFRAME_CROP_DEFAULT` gleich `{ x: 0, y: 0, w: 1280, h: 720 }`; alle sechs Felder zusammen (`url`, `title`, `reloadSeconds`, `crop`, `zoom`, `readOnly`) → `toEqual` mit Erwartung; `clampXframeCrop` ist exportiert und liefert für `{ x: 1200, y: 0, w: 200, h: 100 }` → `{ x: 1080, y: 0, w: 200, h: 100 }`.
|
||||
- Geometrie (≥ 9 `it` in `xframe-crop.test.ts`): `computeCropLayout({0,0,1280,720}, 640, 720)` → `scale 0.5, left 0, top 180` (breitenbegrenzt); `(…, 1280, 180)` → `scale 0.25, left 480, top 0` (höhenbegrenzt); `({0,0,200,100}, 800, 400)` → `scale 4, left 0, top 0` (Vergrößerung erlaubt); `frameHeight`: `{0,0,1280,100}` → 720, `{0,3000,1280,400}` → 3400; `(crop, 0, 0)` und `(crop, 640, 0)` → `scale 0` (Rest 0); `applyCropDrag('move', {100,100,400,300}, 100, 50)` → `{200,150,400,300}`; `move` um `+2000, 0` → `x: 880` (1280 − 400), `move` um `−500, −500` → `{0,0,400,300}`; `'se'` um `+100, +100` → `{100,100,500,400}`; `'nw'` um `+350, +10` → `{400,110,100,290}` (Mindestbreite an der bewegten Kante, rechte Kante 500 bleibt); `'ne'` um `+2000, 0` → `w: 1180` (rechte Kante 1280); `'sw'` um `0, +5000` → `h: 2900` (untere Kante 3000).
|
||||
- Widget (≥ 6 neue `it`, `stubResizeObserver({ width: 640, height: 360 })` in `beforeEach`, `vi.unstubAllGlobals()` in `afterEach`): (1) `{ url: U, crop: {0,0,1280,720} }` → Körper `data-tile-size="640x360"`; Clip `style.left '0px'`, `top '0px'`, `width '640px'`, `height '360px'`, `overflow 'hidden'`; `<iframe>` im Clip mit `style.left '0px'`, `top '0px'`, `width '1280px'`, `height '720px'`, `transform 'scale(0.5)'`, `transformOrigin '0 0'`, Klasse OHNE `h-full`, und weiterhin `sandbox === XFRAME_SANDBOX`, `referrerpolicy 'no-referrer'`, `allow ''`, `loading 'lazy'`, `data-reload-nonce '0'`; (2) `{ url: U, crop: {200,100,800,400} }` → Clip `left '0px'`, `top '20px'`, `width '640px'`, `height '320px'`; `<iframe>` `left '-160px'`, `top '-80px'`, `height '720px'`, `transform 'scale(0.8)'`; (3) `{ url: U, zoom: 60 }` → kein Clip, `<iframe>` `style.width '166.67%'`, `height '166.67%'`, `transform 'scale(0.6)'`, Klasse enthält `absolute`; (4) `{ url: U, zoom: 100 }` und `{ url: U }` → `style.transform ''`, Klasse enthält `h-full` und `w-full`, kein Clip; `{ url: U, crop: {…}, zoom: 60 }` → Ausschnitt-Modus, Zoom ignoriert (kein `scale(0.6)`); (5) `{ url: U, readOnly: true }` Ansichtsmodus → `xframe-readonly-overlay` mit `aria-hidden="true"`, Klassen `absolute inset-0`, im DOM NACH dem `<iframe>`; kein `xframe-edit-overlay`; Link `xframe.openInNewTab` vorhanden mit Klasse `z-10`; (6) `{ url: U, readOnly: true }` Bearbeitungsmodus → `xframe-edit-overlay` vorhanden, `xframe-readonly-overlay` NICHT; `{ url: U }` Ansichtsmodus → kein readonly-Overlay; (7) `{ url: U, crop: {…} }` Bearbeitungsmodus → Clip vorhanden UND `xframe-edit-overlay` vorhanden; `{ url: U, crop: {…}, title: 'Board' }` → `<h2>` Board bleibt. Bestehende Tests 1–12 bleiben grün.
|
||||
- Formular (≥ 7 neue `it`, `stubResizeObserver({ width: 640, height: 420 })`, Übersetzer wie bisher aus der echten de.json): (1) `{ url: U }` → Checkbox `texts.cropEnable` nicht angehakt, keine Vorschau, `<select>` `texts.zoomLabel` vorhanden mit Optionen `['50','60','75','90','100','125','150']` und Texten `'50 %' … '150 %'`, Wert `'100'`; Klick auf die Checkbox → `onChange` genau einmal mit `{ crop: { x: 0, y: 0, w: 1280, h: 720 }, readOnly: true }`; (2) `{ url: U, crop: {100,100,400,300} }` → Checkbox angehakt, `xframe-crop-preview` vorhanden, Vorschau-`<iframe>` mit `src U`, `sandbox === XFRAME_SANDBOX`, `referrerpolicy 'no-referrer'`, `allow ''`, `style.pointerEvents 'none'`, `width '1280px'`, `height '3000px'`, `transform 'scale(0.5)'`; Rahmen `xframe-crop-rect` (`role="group"`, Name `texts.cropRectangle`) mit `style.left '50px'`, `top '50px'`, `width '200px'`, `height '150px'`; vier Griffe vorhanden; KEIN `<select>` `texts.zoomLabel`; Klick auf die Checkbox → `onChange({ crop: null })` genau einmal; (3) Zahlenfelder: `xframe-crop-w` auf `'2000'` + Blur → `onChange({ crop: { x: 0, y: 100, w: 1280, h: 300 } })`; `xframe-crop-h` auf `'10'` + Enter → `onChange({ crop: { x: 100, y: 100, w: 400, h: 60 } })`; `xframe-crop-x` auf `'abc'` + Blur → kein weiterer Aufruf; `xframe-crop-x` auf `'100'` (unverändert) + Blur → kein Aufruf; (4) Ziehen am Rahmen: `pointerDown(rect, { clientX: 100, clientY: 100, pointerId: 1, button: 0 })`, `pointerMove(rect, { clientX: 150, clientY: 125, pointerId: 1 })` → `onChange` noch NICHT gerufen, Rahmen `style.left '100px'`, `top '75px'` (Entwurf folgt); `pointerUp(rect, { clientX: 150, clientY: 125, pointerId: 1 })` → `onChange` genau einmal mit `{ crop: { x: 200, y: 150, w: 400, h: 300 } }`; (5) Ecke `xframe-crop-handle-se`: Down/Move `+50/+50`/Up → genau ein Aufruf `{ crop: { x: 100, y: 100, w: 500, h: 400 } }` (der Rahmen hat NICHT zusätzlich verschoben); `xframe-crop-handle-nw` um `−50/−50` → `{ crop: { x: 0, y: 0, w: 500, h: 400 } }`; (6) Down + Up ohne Bewegung → kein Aufruf; `pointerCancel` nach Move → kein Aufruf und Rahmen wieder bei `50px`; (7) `{ url: U, readOnly: true }` → Checkbox `texts.readOnly` angehakt, Klick → `onChange({ readOnly: false })`; `{ url: U }` → nicht angehakt, Klick → `{ readOnly: true }`; Hinweis `texts.cropHint` immer sichtbar; (8) `{ crop: {…} }` ohne `url` → `xframe-crop-preview-empty` mit `texts.cropPreviewEmpty`, kein Vorschau-`<iframe>`; (9) Zoom-Auswahl `'60'` → `onChange({ zoom: 60 })` sofort. Bestehende Tests 1–8 bleiben grün.
|
||||
</behavior>
|
||||
<action>
|
||||
**Reihenfolge: Resolver + Geometrie rot → grün, dann Widget (Tests zuerst), dann Formular (Tests zuerst), zuletzt Übersetzungen + Allowlist.**
|
||||
|
||||
1. **`xframe-config.ts`** (Entscheidung 1): Dateikopf um einen Absatz ergänzen — warum Ausschnitt-Zahlen geklemmt und nie abgewiesen werden (T-GE2-05: kein negativer, kein riesiger, kein NaN-Wert erreicht das CSS; ein manipulierter Config-Wert ergibt höchstens einen anderen Ausschnitt) und dass `zoom` nur ohne Ausschnitt wirkt. Exporte: `XFRAME_PAGE_WIDTH`, `XFRAME_CROP_MIN_W`, `XFRAME_CROP_MIN_H`, `XFRAME_CROP_MAX_H`, `XFRAME_CROP_DEFAULT`, `XFRAME_ZOOM_OPTIONS: number[]`, `XFRAME_ZOOM_DEFAULT`, `interface XframeCrop { x: number; y: number; w: number; h: number }`, `clampXframeCrop(raw: XframeCrop): XframeCrop`, `XframeConfig` um `crop: XframeCrop | null; zoom: number; readOnly: boolean` erweitert; private `resolveCrop(raw: unknown)` (Objekt-Prüfung ohne Cast: `typeof raw === 'object' && raw !== null`, dann die vier Felder über `Record<string, unknown>`-Zugriff mit `typeof === 'number' && Number.isFinite`), `resolveZoom(raw: unknown)` nach dem Muster `resolveReload` (größte Stufe ≤ n, unter 50 → 50, nicht endlich → 100), `resolveReadOnly(raw: unknown)` (`raw === true`). **`xframe-config.test.ts`**: neuer `describe('resolveXframeConfig — Ausschnitt, Zoom, readOnly (quick-260922-ge2)')` mit allen Fällen aus `<behavior>`; Tests 1 und 8 um die drei neuen Felder ergänzen. Vor der Umsetzung rot.
|
||||
|
||||
2. **`xframe-crop.ts`** NEU (Entscheidung 2, ohne React-Import). Dateikopf: Zweck (Geometrie für Kachel und Vorschau), warum contain + Zentrierung (der Ausschnitt muss immer GANZ sichtbar sein, Kachelseitenverhältnis ≠ Ausschnittverhältnis), warum `frameHeight` mindestens 720 (die Seite braucht Layouthöhe, sonst rendern viele Seiten ihren Inhalt nicht), warum die Ziehmathematik die gegenüberliegende Ecke festhält. Exporte: `XFRAME_PREVIEW_PAGE_HEIGHT = 3000`, `type XframeDragMode = 'move' | 'nw' | 'ne' | 'sw' | 'se'`, `interface XframeCropLayout { scale: number; left: number; top: number; frameHeight: number }`, `computeCropLayout(crop: XframeCrop, tileW: number, tileH: number): XframeCropLayout`, `applyCropDrag(mode: XframeDragMode, start: XframeCrop, dxPage: number, dyPage: number): XframeCrop` — beide exakt wie in Entscheidung 2 beschrieben, Ergebnis von `applyCropDrag` durch `clampXframeCrop`. **`xframe-crop.test.ts`** mit den Fällen aus `<behavior>` (≥ 9 `it`), vor der Umsetzung rot (Import schlägt fehl).
|
||||
|
||||
3. **`xframe-widget.tsx`** (Entscheidung 3). Dateikopf um zwei Absätze ergänzen: (a) Ausschnitt — cross-origin kann Tessera die Seite nicht von außen scrollen, deshalb wird der `<iframe>` selbst mit `left/top` verschoben und mit `transform: scale` skaliert, ein Clip-`div` schneidet ihn; die feste Layoutbreite `XFRAME_PAGE_WIDTH` hält den Seitenaufbau über alle Kachelgrößen stabil (sonst bräche die Seite bei jeder Kachelgröße anders um und der Ausschnitt verrutschte); der ResizeObserver am Körper liefert die Kachelgröße; (b) readOnly — eine transparente Fläche über dem Rahmen sperrt Zeiger-Eingaben (Bedienkomfort, KEINE Sicherheitsmaßnahme: die Seite läuft weiter, T-GE2-02); nie zusammen mit der Bearbeitungsfläche. Imports: `useRef` dazu, `computeCropLayout` aus `./xframe-crop`, `XFRAME_PAGE_WIDTH` aus `./xframe-config`. State `const [tile, setTile] = useState({ w: 0, h: 0 })`, `const bodyRef = useRef<HTMLDivElement>(null)`, `const hasCrop = crop !== null`; `useEffect(() => { const el = bodyRef.current; if (!el || !hasCrop) return; const observer = new ResizeObserver((entries) => { const rect = entries[0].contentRect; setTile({ w: rect.width, h: rect.height }); }); observer.observe(el); return () => observer.disconnect(); }, [hasCrop])`. **Hook-Reihenfolge:** alle Hooks VOR dem Leerzustand-`return` (wie heute `useEffect` für den Timer). Ein kleines `frameAttrs`-Objekt (`src`, `title`, `sandbox`, `allow`, `referrerPolicy`, `loading`, `data-testid`, `data-reload-nonce`) und der `key` werden in allen drei Zweigen identisch gesetzt (Zweige: Ausschnitt / Zoom ≠ 100 / heute). Körper: `ref={bodyRef}`, `data-tile-size` nur bei `hasCrop`; Reihenfolge der Kinder: Rahmen bzw. Clip → `isEditMode && xframe-edit-overlay` → `!isEditMode && readOnly && xframe-readonly-overlay` → Ecksymbol-Link (unverändert `z-10`). **`xframe-widget.test.tsx`**: Import `stubResizeObserver` aus `@/test/fake-resize-observer`; `beforeEach` zusätzlich `stubResizeObserver({ width: 640, height: 360 })`, `afterEach` zusätzlich `vi.unstubAllGlobals()`; neuer `describe('XframeWidget — Ausschnitt, Zoom, readOnly (quick-260922-ge2)')` mit den Fällen aus `<behavior>` (≥ 6 `it`; Inline-Styles über `(el as HTMLElement).style.left` usw. — `getByTestId` liefert `HTMLElement`, kein Cast nötig; der `<iframe>` über `frame()` wie bisher). Vor der Umsetzung rot.
|
||||
|
||||
4. **`apps/web/src/test/fake-resize-observer.ts`** NEU (Entscheidung 5): Kopfkommentar (Englisch wie setup.ts): warum ein dateiweiser Stub statt des globalen Polyfills (explizite Größe, deterministische Geometrie), dass der eine Cast `as ResizeObserverEntry` genügt (die Klasse ist strukturell ein `ResizeObserver`), Aufräumen per `vi.unstubAllGlobals()`. Export `stubResizeObserver(size: { width: number; height: number }): void`. Import von `vi` aus `vitest`. Kein `as unknown as`.
|
||||
|
||||
5. **`xframe-config-form.tsx`** (Entscheidung 4). Dateikopf um einen Absatz ergänzen: Vorschau = derselbe `<iframe>` mit derselben Sandbox (T-GE2-01), `pointer-events: none` (T-GE2-03: die Vorschau ist nur zum Sehen — Zeigerereignisse gehen an Rahmen und Griffe, nie an die Fremdseite), Ziehen über Pointer-Events mit Capture (Wächter für jsdom), Entwurf lokal, ein `onChange` beim Loslassen; Ausschnitt = Position, kein Inhalt (Hinweis dauerhaft). Imports: `useEffect, useRef` dazu; `XFRAME_CROP_DEFAULT, XFRAME_PAGE_WIDTH, XFRAME_SANDBOX, XFRAME_ZOOM_OPTIONS, clampXframeCrop` und `type XframeCrop` aus `xframe-config`; `XFRAME_PREVIEW_PAGE_HEIGHT, applyCropDrag` und `type XframeDragMode` aus `@/components/dashboard/widgets/xframe-crop`. Destrukturierung um `crop, zoom, readOnly` erweitern. Konstante `PREVIEW_HEIGHT_PX = 420`. Komponenten in derselben Datei: `CropPreview({ url, crop, onCommit })` (Vorschau + Rahmen + Griffe + Pointer-Logik + Breitenmessung) und `CropNumberFields({ crop, onCommit })` (mit `key` vom Aufrufer); Helfer `capturePointer`/`releasePointer` (typeof-Wächter) als Modulfunktionen; `sameCrop(a, b)` für den Änderungsvergleich. Reihenfolge im JSX nach dem Intervall-Feld: Checkbox Ausschnitt → (bei crop) Hinweis `cropPreviewHint`, `CropPreview`, `CropNumberFields key=…` → (ohne crop) Zoom-Auswahl → Checkbox readOnly → `cropHint`. Klassen der Checkboxen wie `show-date-toggle` im Panel (`flex items-center gap-3`, Input `h-4 w-4 rounded border-border text-primary`). **`xframe-config-form.test.tsx`**: Import `stubResizeObserver`; `beforeEach` `stubResizeObserver({ width: 640, height: 420 })`, `afterEach` `cleanup()` + `vi.unstubAllGlobals()`; `renderForm` liefert zusätzlich `cropEnable`, `readOnly` (per `getByLabelText`) und Helfer `rect()`, `handle(corner)`, `numberField(k)`; neuer `describe('XframeConfigForm — Ausschnitt, Zoom, readOnly (quick-260922-ge2)')` mit den Fällen aus `<behavior>` (≥ 7 `it`). Vor der Umsetzung rot.
|
||||
|
||||
6. **Übersetzungen + Allowlist** (Entscheidung 6): 13 Schlüssel in `de.json` und `en.json` unter `widgets.xframe` nach `embedHint`; `Ausschnitt` in `UMLAUT_ALLOWLIST`; `pnpm --filter @tessera/web exec vitest run src/messages` grün.
|
||||
|
||||
Commit: `feat(quick-260922-ge2): XFrame-Widget - Ausschnitt der Seite waehlen und einpassen, Zoom fuer die ganze Seite, Nur anzeigen` (Wortlaut frei, Stil beachten, Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/xframe-config.test.ts src/components/dashboard/widgets/xframe-crop.test.ts src/components/dashboard/widgets/xframe-widget.test.tsx src/components/settings/xframe-config-form.test.tsx src/components/settings/widget-settings-panel.test.tsx src/messages && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/web lint && pnpm --filter @tessera/web lint 2>&1 | grep -q 'Found 53 warnings' && test "$(grep -rn 'as unknown as' apps/web/src --include=*.ts --include=*.tsx | wc -l)" -eq 6 && node -e "const d=require('./apps/web/src/messages/de.json').widgets.xframe,e=require('./apps/web/src/messages/en.json').widgets.xframe;for(const k of ['cropEnable','cropHint','cropPreviewHint','cropPreviewEmpty','cropRectangle','cropX','cropY','cropW','cropH','cropUnitHint','zoomLabel','zoomOption','readOnly']){if(!(k in d)||!(k in e)){console.error('fehlt:',k);process.exit(1)}}const m=Object.keys(d).filter(k=>!(k in e)).concat(Object.keys(e).filter(k=>!(k in d)));if(m.length){console.error('Schluessel ungleich:',m);process.exit(1)}"</automated>
|
||||
</verify>
|
||||
<done>`xframe-config.test.ts` ≥ 20 Fälle (12 alte + ≥ 8 neue), `xframe-crop.test.ts` ≥ 9, `xframe-widget.test.tsx` ≥ 18 (12 + ≥ 6), `xframe-config-form.test.tsx` ≥ 15 (8 + ≥ 7) — alle grün, die neuen Resolver-, Geometrie- und Widget-Fälle nachweislich zuerst rot (Rot-Lauf im SUMMARY). Umlaut-Wächter grün (`Ausschnitt` gelistet), beide Sprachdateien tragen dieselben 13 neuen Schlüssel. Kette nachgewiesen (Tests): Checkbox → EIN `onChange` mit Ausschnitt + readOnly; Ziehen → Entwurf folgt, EIN `onChange` beim Loslassen mit geklemmtem Ausschnitt; Ecke → Größe, Gegenecke bleibt; Zahlenfelder klemmen; Kachel → Clip und verschobener, skalierter Rahmen für gemessene 640×360; Zoom 60 → Prozentmaße + `scale(0.6)`, Zoom 100 → wie heute; readOnly-Fläche nur im Ansichtsmodus, Link darüber; Sandbox/no-referrer/allow/lazy in Kachel UND Vorschau. `tsc --noEmit` ohne Befund, Biome web weiterhin 53 Warnungen, `as unknown as` web 6, keine `any`, kein `!`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog, Anwenderhandbuch, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-anwender.md</files>
|
||||
<action>
|
||||
1. **`CHANGELOG.md`** unter „Unveröffentlicht → Neu“ als Stichpunkt DIREKT NACH dem bestehenden XFrame-Stichpunkt (kein Fließtext, Tonlage der Nachbarzeilen): „Dashboard-Widget „XFrame“: nur einen Ausschnitt der Webseite zeigen – den Rahmen in einer Vorschau verschieben und an den Ecken ziehen oder Links/Oben/Breite/Höhe eintippen; die Kachel zeigt genau diesen Ausschnitt und passt ihn an ihre Größe an; Vergrößerung der ganzen Seite (50 bis 150 %); „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen – der Ausschnitt ist eine Position auf der Seite und kann verrutschen, wenn die Seite ihren Aufbau ändert“.
|
||||
2. **`docs/anleitung-anwender.md`**: (a) in der Zeile „| XFrame | …“ der Widget-Tabelle (Zeile 83) vor dem schließenden „ |“ genau einen Satz anfügen: „Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen.“; (b) im Absatz „**Dashboard > Widgets:**“ (Zeile 155) nach dem letzten Satz („… dass manche Webseiten das Einbetten verweigern.“) genau einen Satz anfügen: „Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert.“ Siezen, Schreibweise der Nachbarzeilen (Anführungszeichen „…“).
|
||||
3. **Volle Tore**: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`; Zähler messen wie in der qd3-SUMMARY (Tabelle „Messungen“: `as unknown as` api/web per grep, `noNonNullAssertion`/`noExplicitAny` per `biome lint` in apps/api/src, `biome-ignore` per grep, Biome-Warnungen web) und ins SUMMARY schreiben.
|
||||
4. **Prüfliste** im SUMMARY für den Orchestrator (Browser, Playwright-MCP, lokal — NICHT Testserver), Punkt für Punkt abhakbar: (a) Einstellungen → Dashboard → XFrame mit `https://example.com`: „Nur einen Ausschnitt der Seite anzeigen“ anhaken → genau ein PATCH mit `crop` UND `readOnly: true`; Vorschau (420 px hoch, scrollbar) zeigt die Seite bei 1280 px Breite mit blauem Rahmen über der ganzen Breite (0/0/1280/720), Zahlenfelder zeigen 0/0/1280/720, Zoom-Auswahl ist verschwunden; (b) Rahmen mit der Maus verschieben → folgt flüssig, beim Loslassen genau ein PATCH, Zahlenfelder springen auf die neuen Werte; (c) Ecke unten rechts ziehen → Größe ändert sich, obere linke Ecke bleibt, ein PATCH; Ecke oben links ziehen bis unter die Mindestbreite → Rahmen bleibt 100 px breit, rechte Kante steht; (d) Zahlenfeld Breite auf 2000 → Feld zeigt 1280, Links springt auf 0; Höhe 10 → 60; (e) Dashboard: die Kachel zeigt genau den Ausschnitt, eingepasst und zentriert (DOM: `xframe-crop-clip` mit `data-tile-size` am Körper, `<iframe>` mit `transform: scale(…)`), Sandbox/no-referrer/allow unverändert; (f) Kachel im Bearbeitungsmodus vergrößern/verkleinern → nach dem Speichern skaliert der Ausschnitt mit und bleibt ganz sichtbar; Ziehen über dem Rahmen funktioniert weiterhin (`xframe-edit-overlay`); (g) „Nur anzeigen“ an: Klick und Mausrad im Rahmen bewirken nichts (DOM: `xframe-readonly-overlay`), „In neuem Tab öffnen“ klickt weiterhin; aus: Seite bedienbar; (h) Ausschnitt abhaken → PATCH `crop: null`, Zoom-Auswahl erscheint; Zoom 60 % → Kachel zeigt die Seite verkleinert (DOM: `width: 166.67%`, `transform: scale(0.6)`), 100 % → kein `style`; (i) verweigernde Seite (`https://www.google.com`) → Vorschau und Kachel bleiben leer, Formular-Hinweise stehen; API-Log ohne Abruf der Fremdadresse.
|
||||
Commit: `docs(quick-260922-ge2): Changelog und Anwenderhandbuch - XFrame-Ausschnitt, Zoom, Nur anzeigen` (nur CHANGELOG.md + docs/anleitung-anwender.md; Akte/STATE macht der Orchestrator; Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -A1 'Dashboard-Widget „XFrame“: eine Webseite per https-Adresse' CHANGELOG.md | grep -q 'nur einen Ausschnitt der Webseite zeigen' && grep -q '| XFrame | .*Wahlweise zeigen Sie nur einen Ausschnitt der Seite' docs/anleitung-anwender.md && grep -q 'Nur einen Ausschnitt der Seite anzeigen' docs/anleitung-anwender.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
|
||||
</verify>
|
||||
<done>Changelog-Stichpunkt steht direkt nach dem XFrame-Stichpunkt; Handbuch trägt den Tabellensatz und den Absatzsatz; `pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Befund der Stufe `error`, Biome web 53 Warnungen; API 1175 Tests, Web ≥ 634 Tests, alle grün; Zähler unverändert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1); die neunpunktige Prüfliste steht im SUMMARY; genau zwei Commits mit Scope `quick-260922-ge2` (`git log --oneline -2`).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<assumption_delta_decision>
|
||||
API-Coverage-Detektor (`api-coverage.cjs --json` über die Aufgabenbeschreibung, real ausgeführt): `{"detected":false,"signals":[]}` — kein externer Dienst, keine SDK-Integration; alles bleibt im Browser (Rahmen, Transformationen, Pointer-Events), die API sieht nur drei weitere Schlüssel im undurchsichtigen Config-JSON. Keine COVERAGE.md nötig. **Feuert nicht.**
|
||||
|
||||
Assumption-Delta-Detektor: Quick-Aufgabe ohne ROADMAP-Abschnitt → `phase_unresolved` (übersprungen, kein Verdikt). Gedanklich ausgeführt: kein Pflichtfeld wird optional (die drei Felder sind neu und optional), keine zweite Variante einer Identität; `zoom` macht aus dem festen 100 % einen gewählten Wert — eine Anzeigeeinstellung je Kachel, kein Identitätsmodell. Entscheidung: `no-change`. **Feuert nicht.**
|
||||
|
||||
Schema-Tor: kein Prisma-, Migrations- oder Schema-Pfad im Umfang (Config-JSON bleibt `Json`). **Feuert nicht.**
|
||||
</assumption_delta_decision>
|
||||
|
||||
<threat_model>
|
||||
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
|
||||
|
||||
## Vertrauensgrenzen
|
||||
|
||||
| Grenze | Beschreibung |
|
||||
|---|---|
|
||||
| Config-JSON → Browser | `crop`/`zoom`/`readOnly` stammen aus dem vom Benutzer selbst beschreibbaren Widget-Config (API prüft nicht inhaltlich) und landen in Inline-Styles (`left/top/width/height/transform`) |
|
||||
| Tessera-Seite ↔ eingebettete Fremdseite (Kachel UND Vorschau) | Zwei `<iframe>` derselben Adresse; die Fremdseite läuft in ihrem Origin, sieht keine Tessera-Eingaben, kann das oberste Fenster nicht navigieren |
|
||||
| Zeiger → Rahmen/Griffe (Vorschau) | Pointer-Capture bindet Bewegungen an Tesseras eigene Elemente; die Vorschau-Seite ist für Zeiger unerreichbar |
|
||||
| Browser → Fremdhost | Nur der Browser des Benutzers ruft die Adresse ab (jetzt bis zu zweimal: Kachel, Vorschau); der Server nie |
|
||||
|
||||
## STRIDE-Register
|
||||
|
||||
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|
||||
|---|---|---|---|---|---|
|
||||
| T-GE2-01 | Spoofing / Elevation (Navigation des obersten Fensters, Modaldialoge) | `<iframe sandbox>` in Kachel (alle drei Render-Zweige) UND Vorschau | high | mitigate | Beide Rahmen tragen exakt `XFRAME_SANDBOX` (ohne `top-navigation`, ohne `modals`), `allow=""`, `referrerPolicy="no-referrer"`; Clip, Verschiebung und `transform: scale` ändern nichts an der Sandbox — getestet am gerenderten Attribut im Ausschnitt-Modus (Widget-Test 1) und an der Vorschau (Formular-Test 2). |
|
||||
| T-GE2-02 | Information Disclosure / Tampering (Fehldeutung: readOnly als Schutz) | `xframe-readonly-overlay` | low | accept | Die Fläche ist Bedienkomfort, keine Sicherheitsmaßnahme: die Fremdseite lädt und läuft weiter (Skripte, Cookies, Neuladen), nur Zeigerereignisse erreichen sie nicht; Tastaturfokus in den Rahmen ist weiterhin möglich. Dateikopf des Widgets sagt das ausdrücklich; die Sicherheitsgrenze bleibt die Sandbox (T-GE2-01). |
|
||||
| T-GE2-03 | Tampering (Vorschau-Seite fängt Zeigerereignisse / Clickjacking in der Vorschau) | Vorschau-`<iframe>` mit `pointer-events: none`, Rahmen und Griffe darüber | medium | mitigate | `style.pointerEvents 'none'` (getestet), Rahmen/Griffe absolut über dem Rahmen, Capture auf Tesseras eigenen Elementen; die Fremdseite kann keine Ziehbewegung abfangen und keinen Klick unter dem Rahmen entgegennehmen. |
|
||||
| T-GE2-04 | Server-Side Request Forgery | API | high | mitigate | Unverändert: die API ruft NIE die Adresse ab, kein Proxy, kein Fetch; die Vorschau lädt die Seite ein zweites Mal im Browser des Benutzers, nie am Server; keine API-Änderung in dieser Aufgabe. Nachweis im Rundgang (i). |
|
||||
| T-GE2-05 | Denial of Service (riesige/negative/NaN-Maße im CSS: Browser friert bei 10⁹-px-Rahmen ein, negative Breiten, `transform: scale(NaN)`) | `crop`/`zoom` → Inline-Styles | medium | mitigate | `clampXframeCrop` (Rundung, w ∈ [100, 1280], h ∈ [60, 4000], x/y ≥ 0, x + w ≤ 1280) im Resolver, in den Zahlenfeldern und beim Ziehen; `zoom` nur aus der erlaubten Stufenliste (mindestens 50); `computeCropLayout` liefert bei ungemessener Kachel `scale 0` (nichts rendern statt Division durch 0); Vorschau-Rahmen fest 1280×3000. Getestet: Resolver-Fälle (5000 → 1280, 9999 → 4000, −5 → 0, NaN → null/100), Geometrie (Nullkachel). |
|
||||
| T-GE2-06 | Tampering (XSS über Konfigurationswerte) | Inline-Styles, `aria-label`, Hinweistexte | low | mitigate | Keine HTML-Einfügung; alle Style-Werte sind Zahlen oder Template-Strings aus eigenen geklemmten Zahlen (React setzt sie als CSS-Eigenschaften, nicht als Markup); Texte nur aus den Sprachdateien. Sichtbarer Nachweis im Rundgang (e), (h). |
|
||||
| T-GE2-07 | Elevation of Privilege (Pointer-Capture) | `capturePointer` auf Rahmen/Griffen | low | accept | Capture bindet nur die Ereignisse eines Zeigers an ein Tessera-eigenes Element bis zum Loslassen; `onPointerCancel` verwirft; keine Rechte, kein Zugriff auf die Fremdseite; in jsdom fehlt Capture, im Browser wird sie ordnungsgemäß gelöst. |
|
||||
| T-GE2-08 | Denial of Service (Vorschau lädt die Fremdseite zusätzlich) | Vorschau-`<iframe>` | low | accept | Nur während die Einstellungen offen sind und nur bei gesetztem Ausschnitt; kein Neuladen-Timer in der Vorschau; höchstens ein zweiter Dokumentabruf — vertretbar. |
|
||||
| T-GE2-09 | Repudiation | Änderungen an Ausschnitt/Zoom/readOnly | low | accept | Kein Audit-Log — persönliche Kachel ohne Fremdwirkung; ASVS 1 genügt. |
|
||||
| T-GE2-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — Pointer-Events, `ResizeObserver`, `transform` sind Browser-APIs. Sollte der Executor dennoch ein Paket installieren wollen: Stopp, Rückfrage an den Orchestrator. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Automatisch (Executor, je Aufgabe im `<verify>`): Web-Tests der neuen und angefassten Dateien inklusive `src/messages` (Umlaut-Wächter + de/en-Schlüsselgleichheit mit den 13 neuen Schlüsseln), `tsc --noEmit`, Biome mit exakt 53 Warnungen, Zähler `as unknown as` web 6.
|
||||
|
||||
Am Ende (Aufgabe 2): `pnpm type-check` 4/4, `pnpm lint` 5/5, volle Testläufe beider Apps (API 1175, Web ≥ 634), Disziplin-Zähler wie in der Ausgangsmessung, Changelog-Stichpunkt und Handbuch-Sätze genau einmal.
|
||||
|
||||
Manuell (Orchestrator, Prüfliste aus Aufgabe 2 Punkt 4, lokal im Browser): Ausschnitt einschalten → Vorschau mit Rahmen; Ziehen verschiebt; Ecke ändert die Größe; Zahlenfelder klemmen; Kachel zeigt den Ausschnitt eingepasst und zentriert; Kachelgröße ändern → Ausschnitt skaliert mit; „Nur anzeigen“ sperrt Klicks, Link bleibt klickbar; Zoom 60 % in der Ganzseiten-Ansicht; verweigernde Seite bleibt leer; kein Server-Abruf.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Alle sieben `must_haves.truths` erfüllt und je mit Test oder Rundgangspunkt belegt
|
||||
- [ ] Resolver klemmt Ausschnitt (x-Verschiebung statt Abweisung), Zoom-Stufen, readOnly — per Test
|
||||
- [ ] `computeCropLayout`/`applyCropDrag` rein, ohne React, mit den Rand- und Mindestgrößenfällen getestet
|
||||
- [ ] Kachel: Clip + verschobener, skalierter Rahmen für gemessene Größe; Zoom-Zweig; 100 % wie heute; readOnly-Fläche nur im Ansichtsmodus; Sandbox unverändert
|
||||
- [ ] Formular: Checkbox → EIN Aufruf mit Ausschnitt + readOnly; Vorschau mit `pointer-events: none` und derselben Sandbox; Ziehen/Ecken → EIN Aufruf beim Loslassen; Zahlenfelder; Zoom nur ohne Ausschnitt; dauerhafter Hinweis
|
||||
- [ ] Kein `measuredSize`-Prop; Kachelgröße im Test über `stubResizeObserver` (ein Cast `as ResizeObserverEntry`); Pointer-Capture mit typeof-Wächter
|
||||
- [ ] Beide Sprachdateien vollständig (13 neue Schlüssel), Texte siezen, Umlaut-Wächter grün (`Ausschnitt` gelistet)
|
||||
- [ ] Changelog-Stichpunkt und Handbuch-Sätze vorhanden
|
||||
- [ ] Tore grün, Biome web 53 Warnungen, Zähler unverändert, keine neue `any`, zwei Commits mit Scope `quick-260922-ge2`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Nach Abschluss `.planning/quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/260922-ge2-SUMMARY.md` anlegen (Muster `260921-qd3-SUMMARY.md`): Rot-Nachweis der Resolver-/Geometrie-/Widget-Tests, Zahlen der Endmessung, die neunpunktige Browser-Prüfliste für den Orchestrator, offene Punkte.
|
||||
</output>
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
---
|
||||
phase: quick-260922-ge2
|
||||
plan: 01
|
||||
subsystem: apps/web/src/components/dashboard/widgets, apps/web/src/components/settings, apps/web/src/test
|
||||
tags: [dashboard, widget, xframe, iframe, crop, zoom, pointer-events, resize-observer, tdd, i18n]
|
||||
status: complete
|
||||
requires:
|
||||
- "quick-260921-qd3 (XFrame-Widget): Resolver, Widget, Formular, Sandbox, Neuladen"
|
||||
provides:
|
||||
- "XFrame-Ausschnitt: crop {x,y,w,h} in Seitenpixeln bei fester Layoutbreite 1280, in der Kachel eingepasst (contain) und zentriert, skaliert mit der Kachelgroesse"
|
||||
- "Vorschau der Seite in den Einstellungen mit verschieb- und ziehbarem Rahmen (vier Ecken) plus Zahlenfelder Links/Oben/Breite/Hoehe"
|
||||
- "Vergroesserung 50..150 % fuer die Ganzseiten-Ansicht ohne Ausschnitt"
|
||||
- "„Nur anzeigen“: transparente Flaeche ueber dem Rahmen im Ansichtsmodus"
|
||||
- "Test-Helfer stubResizeObserver({ width, height }) fuer deterministische Geometrie in jsdom"
|
||||
affects:
|
||||
- "apps/web/src/messages/de.json + en.json (13 neue Schluessel widgets.xframe.*)"
|
||||
- "apps/web/src/messages/umlaut-dictionary.ts (Allowlist: Ausschnitt)"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Cross-origin laesst sich die Seite nicht von aussen scrollen: der <iframe> selbst wird mit left/top verschoben, mit transform: scale skaliert und von einem Clip-div geschnitten; feste Layoutbreite 1280 haelt den Seitenaufbau stabil"
|
||||
- "EINE Klemmregel clampXframeCrop fuer Resolver, Zahlenfelder und Ziehen — Werte werden verschoben/gekappt, nie abgewiesen (T-GE2-05)"
|
||||
- "Reine Geometrie (computeCropLayout, applyCropDrag) ohne React in eigenem Modul, zuerst rot getestet"
|
||||
- "Pointer-Events mit Capture-Waechter (typeof setPointerCapture) — Browser faengt, jsdom nicht; Deltas statt getBoundingClientRect"
|
||||
- "Kein Test-Prop im Produktionscode: Kachelgroesse im Test ueber dateiweisen ResizeObserver-Stub (ein Cast as ResizeObserverEntry)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/components/dashboard/widgets/xframe-crop.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-crop.test.ts
|
||||
- apps/web/src/test/fake-resize-observer.ts
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/messages/umlaut-dictionary.ts
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-anwender.md
|
||||
decisions:
|
||||
- "Rahmen der Vorschau als <fieldset aria-label> statt <div role=\"group\">: Biome useSemanticElements meldete role=group (web 53 -> 54), biome-ignore ist verboten; das <fieldset> traegt die Gruppen-Rolle implizit, getByRole('group', { name }) findet es, Preflight nimmt Rand/Innenabstand (m-0 p-0 min-w-0 zusaetzlich)"
|
||||
- "Objekt-Pruefung im Resolver ueber Typwaechter isRecord(raw): raw is Record<string, unknown> statt Zuweisung — `const r: Record<string, unknown> = raw` kompiliert unter strict nicht (object ohne Index-Signatur), ein Cast waere gegen die Regel"
|
||||
- "Kopfkommentar des Test-Helfers ohne die woertliche Phrase des Zaehlers — der grep zaehlt Kommentare mit"
|
||||
metrics:
|
||||
duration: "ca. 10 min (10:03 bis 10:14 Uhr UTC, 22.09.2026)"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 17700
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 5aa577a
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-ge2: XFrame-Widget — Ausschnitt, Zoom, „Nur anzeigen“ Summary
|
||||
|
||||
Das XFrame-Widget zeigt auf Wunsch nur einen Ausschnitt der eingebetteten
|
||||
Seite: in den Einstellungen erscheint eine Vorschau der Seite bei fester
|
||||
Breite 1280 px, darueber ein Rahmen, den der Benutzer verschiebt und an
|
||||
den vier Ecken zieht (oder Links/Oben/Breite/Hoehe eintippt); die Kachel
|
||||
zeigt genau diesen Ausschnitt, eingepasst und zentriert, und skaliert ihn
|
||||
mit der Kachelgroesse. Ohne Ausschnitt gibt es eine Vergroesserung
|
||||
50–150 % fuer die ganze Seite; „Nur anzeigen“ sperrt Klicken und Scrollen
|
||||
im Rahmen und wird beim Einschalten des Ausschnitts automatisch mit
|
||||
gesetzt. Sandbox, no-referrer, `allow=""`, Neuladen, Kopfleiste und
|
||||
Bearbeitungsflaeche sind unveraendert; der Server ruft die Adresse
|
||||
weiterhin nie ab. Alle Tore gruen.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Resolver (`xframe-config.ts`, Commit 445b1d3).** Neue Exporte
|
||||
`XFRAME_PAGE_WIDTH` 1280, `XFRAME_CROP_MIN_W` 100, `XFRAME_CROP_MIN_H` 60,
|
||||
`XFRAME_CROP_MAX_H` 4000, `XFRAME_CROP_DEFAULT` {0,0,1280,720},
|
||||
`XFRAME_ZOOM_OPTIONS` [50,60,75,90,100,125,150], `XFRAME_ZOOM_DEFAULT` 100,
|
||||
`interface XframeCrop`, `clampXframeCrop` (runden; w auf [100,1280], h auf
|
||||
[60,4000], x/y >= 0, dann `x + w > 1280 -> x = 1280 - w` — verschieben, nie
|
||||
abweisen). `XframeConfig` um `crop: XframeCrop | null`, `zoom`, `readOnly`
|
||||
erweitert; `resolveCrop` nimmt nur ein Nicht-null-Objekt mit vier endlichen
|
||||
Zahlen (Typwaechter `isRecord`, kein Cast), `resolveZoom` liefert die
|
||||
groesste Stufe <= n (unter 50 -> 50, nicht endlich -> 100), `resolveReadOnly`
|
||||
nur bei `=== true`. Der Dateikopf erklaert, warum geklemmt und nie
|
||||
abgewiesen wird (T-GE2-05) und dass `zoom` nur ohne Ausschnitt wirkt.
|
||||
|
||||
**Geometrie (`xframe-crop.ts`, NEU, ohne React).** `XFRAME_PREVIEW_PAGE_HEIGHT`
|
||||
3000; `computeCropLayout(crop, tileW, tileH)` -> `{ scale, left, top,
|
||||
frameHeight }` mit `scale = min(tileW/w, tileH/h)` (contain, darf > 1 sein),
|
||||
Zentrierung im Rest, `frameHeight = max(y + h, 720)`; Nullkachel ->
|
||||
`scale 0` (nichts rendern, keine Division durch 0). `applyCropDrag(mode,
|
||||
start, dxPage, dyPage)`: `move` verschiebt bei fester Groesse und klemmt an
|
||||
den Seitenraendern; `nw/ne/sw/se` bewegen nur die zwei Kanten ihrer Ecke,
|
||||
die Gegenecke bleibt, Mindestgroesse an der bewegten Kante; Ergebnis durch
|
||||
`clampXframeCrop`.
|
||||
|
||||
**Widget (`xframe-widget.tsx`).** Kachelkoerper mit `ref`, `overflow-hidden`
|
||||
und — nur bei Ausschnitt — `data-tile-size="WxH"`; ein `useEffect` haengt
|
||||
nur bei `crop !== null` einen `ResizeObserver` an (Muster dashboard-grid),
|
||||
Aufraeumfunktion `disconnect()`. Drei Render-Zweige mit identischen
|
||||
`frameAttrs` (src, title, sandbox, allow, referrerPolicy, loading,
|
||||
data-testid, data-reload-nonce) und `key`: (1) Ausschnitt — Clip-`div`
|
||||
(`xframe-crop-clip`, absolut, `overflow: hidden`, Groesse `w·scale ×
|
||||
h·scale`, Position `left/top` aus dem Layout) mit dem `<iframe>` darin
|
||||
(`left = -x·scale`, `top = -y·scale`, `width 1280`, `height frameHeight`,
|
||||
`transform: scale(s)`, `transformOrigin 0 0`, Klasse ohne `h-full w-full`),
|
||||
gerendert erst ab `scale > 0`; (2) Zoom ≠ 100 ohne Ausschnitt — absolut
|
||||
positionierter Rahmen mit `width/height = 10000/z %` (60 % -> 166.67 %) und
|
||||
`transform: scale(z)`; (3) 100 % — exakt wie bisher, kein `style`. Nach dem
|
||||
Rahmen: `isEditMode` -> `xframe-edit-overlay`; `!isEditMode && readOnly` ->
|
||||
`xframe-readonly-overlay` (`absolute inset-0`, `aria-hidden`); nie beide.
|
||||
Ecksymbol-Link mit `z-10` bleibt ueber beiden. Dateikopf: warum der Rahmen
|
||||
selbst verschoben und skaliert wird (cross-origin kein Scrollen von
|
||||
aussen), warum die Layoutbreite fest 1280 ist, und dass readOnly
|
||||
Bedienkomfort und keine Sicherheitsmassnahme ist (T-GE2-02).
|
||||
|
||||
**Formular (`xframe-config-form.tsx`).** Unter dem Intervall-Feld in dieser
|
||||
Reihenfolge: Checkbox `xframe-crop-enable` (Einschalten -> EIN `onChange({
|
||||
crop: XFRAME_CROP_DEFAULT, readOnly: true })`, Ausschalten -> `{ crop: null
|
||||
}`); bei Ausschnitt Hinweis `cropPreviewHint`, `CropPreview` und
|
||||
`CropNumberFields` (mit `key` aus den vier Werten -> State-Reset nach
|
||||
Ziehen/Speichern); ohne Ausschnitt die Zoom-Auswahl `xframe-zoom`; Checkbox
|
||||
`xframe-readonly`; dauerhaft `cropHint`. `CropPreview`: aeusserer Behaelter
|
||||
420 px hoch mit eigenem Bildlauf, Breite per `ResizeObserver`, `p = width /
|
||||
1280` (vor der Messung 0.5); Buehne `1280·p × 3000·p` mit dem Vorschau-
|
||||
`<iframe>` (dieselbe `XFRAME_SANDBOX`, `allow=""`, `no-referrer`, `lazy`,
|
||||
`width 1280`, `height 3000`, `transform: scale(p)`, `pointer-events: none`,
|
||||
T-GE2-01/-03) und dem Rahmen (`<fieldset aria-label>`, `xframe-crop-rect`,
|
||||
`cursor-move touch-none border-2 border-primary`) mit vier Griffen
|
||||
(`xframe-crop-handle-{nw,ne,sw,se}`, `h-3 w-3 bg-primary`, Ecken-Cursor).
|
||||
Pointer-Logik als Fabrik `dragProps(mode)`: `onPointerDown` nur Haupttaste,
|
||||
Griffe `stopPropagation`, Zugzustand in `dragRef`, Capture per
|
||||
`capturePointer` (typeof-Waechter); `onPointerMove` -> Entwurf aus
|
||||
`applyCropDrag` mit Deltas `/ p`; `onPointerUp` -> Capture loesen, `onCommit`
|
||||
nur bei Aenderung gegenueber `crop` (`sameCrop`); `onPointerCancel` ->
|
||||
verwerfen ohne Aufruf; alle drei pruefen `drag.mode === mode`, damit das
|
||||
Bubbling vom Griff zum Rahmen (jsdom ohne Capture) nichts doppelt
|
||||
ausloest. Ohne Adresse statt der Buehne `xframe-crop-preview-empty`.
|
||||
`CropNumberFields`: vier `type="number"`-Felder `xframe-crop-{x,y,w,h}` im
|
||||
4er-Raster, Uebernahme bei Blur/Enter, `Number(draft)` endlich ->
|
||||
`clampXframeCrop({ ...crop, [k]: n })`, leer/`abc`/unveraendert -> nichts;
|
||||
darunter `cropUnitHint`.
|
||||
|
||||
**Test-Helfer (`apps/web/src/test/fake-resize-observer.ts`, NEU).**
|
||||
`stubResizeObserver({ width, height })` stubbt `ResizeObserver` dateiweise
|
||||
per `vi.stubGlobal` mit `DOMRectReadOnly`-`contentRect` — ein einziger Cast
|
||||
`as ResizeObserverEntry`; Aufraeumen per `vi.unstubAllGlobals()`. Kein
|
||||
`measuredSize`-Prop im Produktionscode.
|
||||
|
||||
**Uebersetzungen.** 13 neue Schluessel unter `widgets.xframe` in de UND en
|
||||
(28 = 28 Schluessel gesamt, Gleichheit per Skript geprueft), Deutsch mit
|
||||
„Sie“; `Ausschnitt` auf der Umlaut-Allowlist nach `neuem`.
|
||||
|
||||
**Doku (Commit 30fdd99).** Changelog-Stichpunkt direkt nach dem
|
||||
XFrame-Stichpunkt unter „Unveroeffentlicht -> Neu“; Anwenderhandbuch: ein
|
||||
Satz in der Tabellenzeile „XFrame“ und ein Satz am Ende des Absatzes
|
||||
„Dashboard > Widgets“.
|
||||
|
||||
## Die Tests, und der Beleg dass sie rot waren
|
||||
|
||||
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|
||||
|---|---:|---|
|
||||
| `xframe-crop.test.ts` | 11 (neu) | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/xframe-config.test.ts src/components/dashboard/widgets/xframe-crop.test.ts src/components/dashboard/widgets/xframe-widget.test.tsx` -> `Error: Failed to resolve import "./xframe-crop" from "src/components/dashboard/widgets/xframe-crop.test.ts". Does the file exist?` (0 test) |
|
||||
| `xframe-config.test.ts` | 21 (12 + 9) | derselbe Lauf -> `21 tests \| 11 failed` (Tests 1 und 8 mit erweitertem `toEqual`, Tests 13–21 neu; Fehlerstellen Zeilen 26/78/97/106/111/118/128/139/146/161/172) |
|
||||
| `xframe-widget.test.tsx` | 19 (12 + 7) | derselbe Lauf -> `19 tests \| 6 failed` (Tests 13, 14, 15, 16, 17, 19: `Unable to find an element by: [data-testid="xframe-crop-clip"]` bzw. `xframe-readonly-overlay`; Test 18 war schon gruen, weil er nur Abwesenheit prueft) — gesamt `Test Files 3 failed (3), Tests 17 failed \| 23 passed (40)` |
|
||||
| `xframe-config-form.test.tsx` | 17 (8 + 9) | nach den Uebersetzungs-Schluesseln, vor dem Formular geschrieben: `pnpm --filter @tessera/web exec vitest run src/components/settings/xframe-config-form.test.tsx` -> `Test Files 1 failed (1), Tests 17 failed (17)` (der erweiterte `renderForm`-Helfer sucht die Labels `cropEnable`/`readOnly`, die das alte Formular nicht rendert — dadurch auch Tests 1–8 rot); erster Lauf nach der Umsetzung 17/17 |
|
||||
|
||||
Nach der Umsetzung: Resolver 21/21, Geometrie 11/11, Widget 19/19, Formular
|
||||
17/17, `src/messages` 6/6, `widget-settings-panel.test.tsx` unveraendert
|
||||
gruen (7 Dateien, 86 Faelle im `<verify>`-Lauf). Zusammen 36 neue Faelle;
|
||||
Web-Tests 604 -> **640** (Plan: >= 634).
|
||||
|
||||
## Messungen (Endstand, HEAD 30fdd99)
|
||||
|
||||
| Groesse | Ausgang (5aa577a) | Jetzt |
|
||||
|---|---:|---:|
|
||||
| `pnpm type-check` | 4/4 | 4/4 |
|
||||
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web **53**, keine Stufe `error`) |
|
||||
| API-Tests | 1175 | **1175** (75 Dateien) |
|
||||
| Web-Tests | 604 | **640** (81 Dateien) |
|
||||
| `as unknown as` in apps/api/src | 27 | 27 |
|
||||
| `as unknown as` in apps/web/src | 6 | 6 (davon 1 in test/setup.ts) |
|
||||
| `noNonNullAssertion` in apps/api/src (biome) | 56 | 56 |
|
||||
| `noExplicitAny` in apps/api/src (biome) | 13 | 13 |
|
||||
| `biome-ignore` in apps/api/src | 1 | 1 |
|
||||
| `ts-expect-error` | 0 | 0 |
|
||||
| `dangerouslySetInnerHTML` / `!` / `any` in den angefassten Web-Dateien | – | 0 / 0 / 0 |
|
||||
| de/en-Schluesselgleichheit `widgets.xframe` | 15 = 15 | 28 = 28 |
|
||||
|
||||
Keine neue `any`, kein `!`, kein neues Paket (T-GE2-SC nicht ausgeloest),
|
||||
kein Prisma-/Schema-Pfad, keine Aenderung an Panel, Registry, Katalog oder
|
||||
API.
|
||||
|
||||
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP — NICHT Testserver)
|
||||
|
||||
- [x] (a) Einstellungen -> Dashboard -> XFrame mit `https://example.com`: „Nur einen Ausschnitt der Seite anzeigen“ anhaken -> genau EIN PATCH mit `crop: {x:0,y:0,w:1280,h:720}` UND `readOnly: true`; Vorschau (420 px hoch, scrollbar) zeigt die Seite bei 1280 px Breite mit blauem Rahmen ueber der ganzen Breite; Zahlenfelder zeigen 0/0/1280/720; die Zoom-Auswahl ist verschwunden
|
||||
- [x] (b) Rahmen mit der Maus verschieben -> folgt fluessig, beim Loslassen genau EIN PATCH, Zahlenfelder springen auf die neuen Werte
|
||||
- [x] (c) Ecke unten rechts ziehen -> Groesse aendert sich, obere linke Ecke bleibt, ein PATCH; Ecke oben links bis unter die Mindestbreite ziehen -> Rahmen bleibt 100 px breit, rechte Kante steht
|
||||
- [x] (d) Zahlenfeld Breite auf 2000 + Feld verlassen -> Feld zeigt 1280, Links springt auf 0; Hoehe 10 -> 60
|
||||
- [x] (e) Dashboard: die Kachel zeigt genau den Ausschnitt, eingepasst und zentriert (DOM: `xframe-crop-clip`, `data-tile-size` am Koerper, `<iframe>` mit `transform: scale(…)`); `sandbox`, `referrerpolicy=no-referrer`, `allow=""` unveraendert
|
||||
- [x] (f) Kachel im Bearbeitungsmodus vergroessern/verkleinern -> nach dem Speichern skaliert der Ausschnitt mit und bleibt ganz sichtbar; Ziehen ueber dem Rahmen funktioniert weiterhin (`xframe-edit-overlay`)
|
||||
- [x] (g) „Nur anzeigen“ an: Klick und Mausrad im Rahmen bewirken nichts (DOM: `xframe-readonly-overlay`), „In neuem Tab öffnen“ klickt weiterhin; aus: Seite bedienbar
|
||||
- [x] (h) Ausschnitt abhaken -> PATCH `crop: null`, Zoom-Auswahl erscheint; Zoom 60 % -> Kachel zeigt die Seite verkleinert (DOM: `width: 166.67%`, `transform: scale(0.6)`), 100 % -> kein `style`
|
||||
- [x] (i) verweigernde Seite (`https://www.google.com`) -> Vorschau und Kachel bleiben leer, Formular-Hinweise stehen; API-Log ohne Abruf der Fremdadresse
|
||||
|
||||
**Rundgang durch den Orchestrator am 22.09.2026 (lokaler Stack, Abbilder aus cf70a19, Playwright-MCP):** alle neun Punkte bestanden. Belege: (a) Anhaken → genau ein PATCH `{crop:{0,0,1280,720}, readOnly:true}`, Vorschau 403 px hoch mit Bildlaufleiste, Rahmen ueber die ganze Breite (Akzentfarbe gelb, nicht blau — die Kachelfarbe des Nutzers), Felder 0/0/1280/720, Zoom-Auswahl weg; (b) Verschieben folgt der Maus (200 px waehrend des Ziehens), beim Loslassen ein PATCH `{x:194,y:97,…}` (= 200/1,03), Felder springen mit; (c) Ecke unten rechts: obere linke Ecke bleibt (714/413), Groesse 620x310 → 739x389; Ecke oben links weit nach rechts: Breite bleibt 100 px (103 px Bildschirm), rechte Kante steht; (d) Breite 2000 → 1280 und Links → 0; Hoehe 10 → 60; (e) Kachel 933x601 zeigt genau den Ausschnitt 256/430/768/200 (`xframe-crop-clip` 933x243 bei top 179, `<iframe>` `left -311px; top -522px; scale(1.21)`), Sandbox/`no-referrer`/`allow=""` unveraendert; (f) Kachel auf 663x321 verkleinert → Ausschnitt skaliert auf 0,86 und bleibt ganz sichtbar, `xframe-edit-overlay` beim Ziehen da; (g) „Nur anzeigen“: Klick auf „Learn more“ im Rahmen bewirkt nichts (Frame bleibt example.com), `xframe-readonly-overlay` vorhanden, Link „In neuem Tab öffnen“ da; aus → Klick an der auf die Skalierung umgerechneten Stelle navigiert den Rahmen (Playwright selbst kann in einem per `transform` skalierten iframe nicht klicken — Werkzeuggrenze, per `elementFromPoint` + `mouse.click` umgangen); (h) Abhaken → PATCH `{crop:null}`, Zoom-Auswahl erscheint, 60 % → `width: 166.67%; height: 166.67%; transform: scale(0.6)`; (i) google.com → Vorschau-Rahmen und Kachel leer, beide Hinweise stehen, API-Log ohne Treffer.
|
||||
|
||||
**Ein Befund aus dem Rundgang, behoben in cf70a19:** die Kachel nutzte als Layouthoehe des Rahmens `max(crop.y + crop.h, 720)`, die Vorschau 3000 px. example.com setzt `margin: 15vh` — die Ueberschrift lag in der Vorschau bei y 450, in der Kachel mit 720 px Rahmen bei y 108; der in der Vorschau gewaehlte Ausschnitt haette in der Kachel etwas anderes gezeigt. Jetzt nutzt die Kachel dieselbe Layouthoehe wie die Vorschau (`XFRAME_PREVIEW_PAGE_HEIGHT`), damit vh-relative Seiten identisch umbrechen (Tests 4/13 angepasst). Zweitens ein 4-px-Querbalken in der Vorschau durch die Eckgriffe am rechten Rand → `overflow-x-hidden`. Web-Tests 640 unveraendert gruen.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 3 - Blocking] Biome `useSemanticElements`** meldete den Rahmen
|
||||
`<div role="group">` als neue Warnung (web 53 -> 54); `biome-ignore` ist
|
||||
verboten. Der Rahmen ist jetzt ein `<fieldset aria-label=…>` (implizite
|
||||
Gruppen-Rolle; `getByRole('group', { name })` findet ihn; Klassen
|
||||
zusaetzlich `m-0 p-0 min-w-0` gegen Browser-Vorgaben). Formular-Test 10
|
||||
prueft `tagName === 'FIELDSET'` statt `getAttribute('role')`. Die
|
||||
Pointer-Handler nehmen `PointerEvent<HTMLElement>`. Web-Lint wieder 53.
|
||||
Aufgabe 1, Commit 445b1d3.
|
||||
2. **[Rule 3 - Blocking] `resolveCrop`:** die im Plan skizzierte Zuweisung
|
||||
`const record: Record<string, unknown> = raw` kompiliert unter `strict`
|
||||
nicht (`object` ohne Index-Signatur, TS2322). Statt eines Casts ein
|
||||
Typwaechter `isRecord(raw): raw is Record<string, unknown>`. Aufgabe 1,
|
||||
Commit 445b1d3.
|
||||
3. **Zaehler-Falle:** der Kopfkommentar des Test-Helfers enthielt die
|
||||
Phrase „No `as unknown as`“ und liess den grep-Zaehler auf 7 springen;
|
||||
umformuliert („no double cast through `unknown`“). Kein Code betroffen.
|
||||
4. **`onPointerCancel` prueft wie Move/Up den Zugmodus** (`drag.mode ===
|
||||
mode`), damit ein Abbruch am Griff nicht zusaetzlich den Rahmen-Handler
|
||||
durchlaeuft (Bubbling ohne Capture in jsdom). Verhalten im Test 14
|
||||
unveraendert.
|
||||
5. **Commit-Nachricht von Aufgabe 1 einmal per `--amend` korrigiert**
|
||||
(vor jedem weiteren Commit, nichts baute darauf): die Testzahl stand
|
||||
zunaechst als 638, gemessen sind 640. Der endgueltige Hash ist 445b1d3.
|
||||
6. **Zwei Widget-Faelle mehr** als die geforderten sechs (Test 18 und 19
|
||||
getrennt), ein Resolver-Fall mehr (Test 21 `clampXframeCrop`).
|
||||
|
||||
Nicht geaendert: `STATE.md`, `ROADMAP.md`, `.planning/**` (ausser dieser
|
||||
Akte), keine neue Abhaengigkeit, kein Deploy, kein Zugriff auf den
|
||||
Testserver. Ein lokaler Docker-Stack wurde nicht angefasst.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine. Kette verdrahtet: Checkbox -> `onChange({ crop, readOnly })` ->
|
||||
`PATCH /dashboard/widgets/:id/config` (flache Zusammenfuehrung, bestehend)
|
||||
-> `resolveXframeConfig` -> `crop !== null` -> `ResizeObserver` ->
|
||||
`computeCropLayout` -> Clip + `<iframe style>`; Rahmen `pointerdown/move/up`
|
||||
-> `applyCropDrag` -> Entwurf -> `clampXframeCrop` -> EIN `onChange`;
|
||||
Zahlenfelder -> `clampXframeCrop` -> `onChange`; `zoom` -> Prozentmasse +
|
||||
`scale`; `readOnly` -> Flaeche nur im Ansichtsmodus.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Flaeche ausserhalb des `<threat_model>` des Plans: T-GE2-01
|
||||
(beide Rahmen mit exakt `XFRAME_SANDBOX`, `allow=""`, no-referrer —
|
||||
Widget-Test 13/15, Formular-Test 10), T-GE2-03 (`pointer-events: none` der
|
||||
Vorschau — Formular-Test 10), T-GE2-04 (keine API-Aenderung — Rundgang i),
|
||||
T-GE2-05 (Klemmung — Resolver-Tests 13–17, Geometrie-Test 5), T-GE2-06
|
||||
(nur Zahlen/Template-Strings in Styles, Texte aus Sprachdateien), T-GE2-02
|
||||
und -07 im Dateikopf des Widgets bzw. Formulars ausdruecklich als
|
||||
Bedienkomfort/Capture ohne Rechte benannt.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle 3 neu angelegten Dateien liegen auf der Platte, die zwei Commits
|
||||
445b1d3 und 30fdd99 sind in `git log` auffindbar
|
||||
(`git rev-list --count 5aa577a..HEAD` = 2). Die Zahlen der Tabelle stammen
|
||||
aus tatsaechlich gelaufenen Befehlen.
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
phase: quick-260922-hk4
|
||||
plan: 01
|
||||
type: tdd
|
||||
autonomous: true
|
||||
subsystem: apps/api/src/dashboard
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-hk4: Bilderrahmen-Bilder auf die Festplatte statt in die Datenbank
|
||||
|
||||
## Warum (Entscheidung des Nutzers, 22.09.2026)
|
||||
|
||||
Der Bilderrahmen legte die Bilddaten als `bytea` in der Datenbank ab
|
||||
(quick-260921-pi9). Der Nutzer hat nach der Freigabe 1.3.0 gefragt, ob das auf
|
||||
Dauer sinnvoll ist. Befund und Entscheidung:
|
||||
|
||||
- **Geschwindigkeit ist NICHT das Argument.** Ein Bild wird je Browser einmal
|
||||
taeglich geladen (`Cache-Control: private, max-age=86400`); ein paar hundert
|
||||
Kilobyte aus Postgres kosten nichts gegen die uebrige Last.
|
||||
- **Die Sicherung ist das Argument.** Gesichert wird von Hand per `pg_dump`
|
||||
(docs/anleitung-betrieb.md Kap. 6). Jedes Bild waechst in diesen Abzug hinein:
|
||||
30 Bilder à 5 MiB je Benutzer sind im Extremfall 150 MB **pro Benutzer**. Die
|
||||
alpha-Datenbank ist heute 18 MB gross (gemessen 22.09.), da faellt das sofort auf.
|
||||
- **Einheitlichkeit.** Tessera speichert Dateien laengst im Volume `user-files`:
|
||||
Profilbilder unter `user-files/avatars/<userId>.<ext>` mit `User.avatarPath` in
|
||||
der Datenbank (`user.controller.ts`), DKV-Exporte daneben mit ausschliesslich
|
||||
servergenerierten Dateinamen (`dkv-export.service.ts`). Der Bilderrahmen war der
|
||||
Ausreisser.
|
||||
- **Ein eigener Ordner je Benutzer ist KEIN Schutz.** Wer welches Bild sehen darf,
|
||||
entscheidet weiterhin der Server (Besitzpruefung + RLS-Regel). Getrennte Ordner
|
||||
bringen zusaetzlich die Gefahr von Dateinamen, die aus dem Ordner herausfuehren —
|
||||
dagegen hilft nur, was DKV schon macht: der Server vergibt den Dateinamen, nie
|
||||
der Client.
|
||||
|
||||
Bestand: alpha 3 Bilder / 1,8 MB, Live 0 (noch nicht gezogen), lokal 1–2. Der
|
||||
Umzug ist jetzt praktisch kostenlos.
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator)
|
||||
|
||||
1. **Ablage:** `user-files/dashboard-images/<userId>/<imageId>.<ext>` — ein Ordner
|
||||
je Benutzer, Dateiname ist die UUID der Datenbankzeile plus Endung aus dem
|
||||
ERKANNTEN Mime-Typ (`png|jpg|gif|webp`). Kein Byte aus der Anfrage geht in den
|
||||
Pfad. Verzeichnis-Aufloesung nach dem Muster `resolveAvatarsDir()`
|
||||
(`path.resolve(__dirname, '..', '..', '..', '..', 'user-files', ...)`), als
|
||||
eigene Funktion `resolveDashboardImagesDir()` im Dienst.
|
||||
2. **Datenbank:** Spalte `data Bytes` entfaellt, neu `storagePath String` (relativ
|
||||
zur Monorepo-Wurzel, wie `User.avatarPath`: `user-files/dashboard-images/...`).
|
||||
Rest der Zeile unveraendert (id, userId, tenantId, originalName, mimeType, size,
|
||||
createdAt), RLS-Regel und Indizes bleiben.
|
||||
3. **Migration `20260922120000_dashboard_image_to_disk`** in zwei Schritten, weil
|
||||
die vorhandenen Bytes nicht verloren gehen duerfen:
|
||||
- SQL-Migration: `ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;`
|
||||
(erst NULLbar), **nicht** sofort `DROP COLUMN "data"`.
|
||||
- Einmal-Skript `apps/api/scripts/migrate-dashboard-images-to-disk.ts`
|
||||
(ausfuehrbar per `pnpm --filter @tessera/api exec tsx scripts/...`, tsx ist
|
||||
vorhanden — sonst `ts-node`/kompiliertes JS; pruefen): liest alle Zeilen mit
|
||||
`data IS NOT NULL`, schreibt die Datei, setzt `storagePath`, laesst `data`
|
||||
stehen. Idempotent (vorhandene Datei + gesetzter `storagePath` = ueberspringen).
|
||||
- Zweite SQL-Migration `20260922120100_dashboard_image_drop_data`:
|
||||
`ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;` und
|
||||
`ALTER TABLE "DashboardImage" DROP COLUMN "data";`.
|
||||
**Reihenfolge fuer den Betrieb dokumentieren:** beide Migrationen laufen beim
|
||||
Start automatisch (`migrate deploy`), das Umzugs-Skript liegt DAZWISCHEN. Damit
|
||||
das ohne Handarbeit klappt, macht der Dienst den Umzug selbst: siehe Punkt 4.
|
||||
4. **Automatischer Umzug beim Start statt Handarbeit** (der Nutzer soll nichts
|
||||
ausfuehren muessen): `DashboardImagesService` bekommt `onApplicationBootstrap()`,
|
||||
das alle Zeilen ohne `storagePath` einsammelt, die Bytes per rohem SQL liest
|
||||
(`$queryRaw` auf `data`, weil die Spalte dann nicht mehr im Prisma-Modell steht —
|
||||
deshalb liegt der DROP in einer SPAETEREN Migration, die erst in der naechsten
|
||||
Freigabe scharf geschaltet wird), die Datei schreibt und `storagePath` setzt.
|
||||
**Konsequenz fuer diese Aufgabe: die DROP-Migration wird NICHT mitgeliefert.**
|
||||
Sie bekommt einen Platzhalter-Eintrag in `.planning/todos/pending/` und kommt,
|
||||
wenn alle Server einmal mit dieser Version gelaufen sind. Begruendung im
|
||||
Migrations-Kommentar festhalten (Muster: zweistufige Umstellung).
|
||||
Der Bootstrap laeuft ueber den Systemkontext (`forSystem()`, Muster
|
||||
`dkv`-Scheduler), nicht ueber einen Mandantenklienten, und protokolliert
|
||||
„N Bilder auf die Festplatte umgezogen" bzw. schweigt bei 0.
|
||||
5. **Dienst:** `upload` schreibt die Datei (`fs.promises.mkdir(..., {recursive:true})`
|
||||
+ `writeFile`) NACH dem erfolgreichen `create` (Reihenfolge: Zeile zuerst, damit
|
||||
die UUID feststeht; schlaegt das Schreiben fehl, Zeile wieder loeschen und
|
||||
`InternalServerErrorException`). `getBytes` liest die Datei und liefert
|
||||
`{ mimeType, data }` wie bisher; fehlt die Datei, `NotFoundException` (Kachel
|
||||
zeigt dann „Bild nicht verfügbar", schon gebaut). `remove` loescht Zeile und
|
||||
Datei (Datei-Fehler werden geschluckt und protokolliert — eine Dateileiche ist
|
||||
harmloser als eine haengende Loeschung). `list` unveraendert.
|
||||
Der Controller bleibt unveraendert (gleiche Routen, gleiche fuenf Header).
|
||||
6. **Betriebsanleitung:** in Kapitel 6 den Satz zu `user-files` um die
|
||||
Bilderrahmen-Bilder ergaenzen (dort steht schon, wie das Volume gesichert wird);
|
||||
im Anwenderhandbuch nichts aendern (fuer Anwender aendert sich nichts).
|
||||
CHANGELOG unter „Unveröffentlicht → Geändert": „Bilderrahmen: hochgeladene
|
||||
Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die
|
||||
Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten
|
||||
Start automatisch um" (kein Fliesstext).
|
||||
7. **Tests:** Dienst-Tests mit `memfs` ODER einem temporaeren Verzeichnis
|
||||
(`fs.mkdtempSync(os.tmpdir())`) — pruefen, was im Repo schon genutzt wird
|
||||
(`user.controller.spec.ts` fuer Avatare ansehen und demselben Muster folgen).
|
||||
Mindestens: Upload legt Datei unter `<dir>/<userId>/<id>.png` an und speichert
|
||||
`storagePath`; Upload mit fehlschlagendem Schreiben loescht die Zeile wieder;
|
||||
`getBytes` liefert den Dateiinhalt; fehlende Datei → 404; fremder Benutzer → 404
|
||||
(unveraendert); `remove` loescht Zeile und Datei; Dateiname enthaelt NIE
|
||||
`originalName`; Bootstrap-Umzug schreibt Datei und setzt `storagePath`,
|
||||
ueberspringt bereits umgezogene Zeilen.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Schema, Migration, Dienst auf Dateiablage umstellen, Bootstrap-Umzug, Tests</name>
|
||||
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql, apps/api/src/dashboard/dashboard-images.service.ts, apps/api/src/dashboard/dashboard-images.service.spec.ts, apps/api/src/dashboard/dashboard-images.controller.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<action>
|
||||
Entscheidungen 1–5 umsetzen. Reihenfolge: Schema + Migration, `prisma migrate deploy` + `generate` gegen die lokale Container-DB ([BLOCKING], Befehle in den Executor-Hinweisen), dann Tests rot, dann Dienst.
|
||||
Das Klassifikationsdokument braucht keine neue Zeile (Modell unveraendert gebunden), aber die Begruendungsspalte erwaehnt jetzt, dass die Bytes auf der Platte liegen und die Zeile den Pfad haelt — Zahlen nachmessen wie dort beschrieben.
|
||||
Commit: `refactor(quick-260922-hk4): Bilderrahmen-Bilder in user-files statt in der Datenbank`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/dashboard src/prisma && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint</automated>
|
||||
</verify>
|
||||
<done>Migration angewendet, `storagePath` gefuellt fuer die vorhandenen lokalen Zeilen (Bootstrap nachgewiesen), Dateien liegen unter `user-files/dashboard-images/<userId>/`. Spalte `data` bleibt vorerst bestehen (zweistufig, siehe Plan). API-Tests ≥ 8 neue Faelle, RLS-Waechter unveraendert gruen. Keine `any`, Zaehler unveraendert.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog, Betriebsanleitung, Todo fuer die DROP-Migration, Voll-Tore</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-betrieb.md, .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md</files>
|
||||
<action>
|
||||
Entscheidung 6 umsetzen. Das Todo nennt: DROP der Spalte `data` erst, wenn alpha UND live einmal mit einer Version ≥ dieser gelaufen sind (Bootstrap-Umzug erledigt), Migrationsname `20260922120100_dashboard_image_drop_data`, plus `ALTER COLUMN "storagePath" SET NOT NULL`.
|
||||
Volle Tore: `pnpm type-check`, `pnpm lint`, `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`.
|
||||
Commit: `docs(quick-260922-hk4): Changelog, Betriebsanleitung und Todo zur data-Spalte`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'Dateibereich' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test</automated>
|
||||
</verify>
|
||||
<done>Changelog-Zeile steht unter „Unveröffentlicht → Geändert"; Betriebsanleitung Kap. 6 nennt die Bilderrahmen-Bilder beim `user-files`-Volume; Todo angelegt; alle Tore gruen; genau zwei Commits mit Scope `quick-260922-hk4`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Hinweise fuer den Executor
|
||||
|
||||
- Lokale Migration: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`, dann
|
||||
`DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` und `prisma generate`.
|
||||
- Testserver NICHT anfassen.
|
||||
- Commits: Conventional Commits, Scope `quick-260922-hk4`, deutscher Betreff im Stil von `git log --oneline -15`, jede Commit-Nachricht endet mit
|
||||
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||
- `.planning/**` NICHT committen ausser der Todo-Datei in Aufgabe 2.
|
||||
- Qualitaetsregeln wie bisher: keine neue `any`, `as unknown as` api bleibt 27, keine `!`, kein `biome-ignore`.
|
||||
|
||||
<threat_model>
|
||||
ASVS 1, block on high.
|
||||
|
||||
| ID | Bedrohung | Schwere | Disposition |
|
||||
|---|---|---|---|
|
||||
| T-HK4-01 | Pfad-Ausbruch ueber `originalName` oder Kennung aus der Anfrage | high | Dateiname = UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ; `originalName` geht nie in den Pfad (Muster DKV T-07-09). Mitigiert. |
|
||||
| T-HK4-02 | Fremdzugriff auf Bilder ueber geratene Pfade | high | Die Datei wird nie direkt ausgeliefert; nur ueber `GET /dashboard/images/:id` mit Besitzpruefung (Mandant + Benutzer) und 404 fuer Fremde. Das Volume ist nicht im Webserver eingehaengt. Mitigiert. |
|
||||
| T-HK4-03 | Datenverlust beim Umzug | high | Zweistufig: `data` bleibt vorerst stehen, Umzug ist idempotent, DROP erst nach nachgewiesenem Lauf auf beiden Servern (Todo). Mitigiert. |
|
||||
| T-HK4-04 | Halbe Zustaende (Zeile ohne Datei / Datei ohne Zeile) | medium | Upload: Zeile zuerst, bei Schreibfehler Zeile loeschen; Loeschen: Zeile zuerst, Dateifehler wird protokolliert (Dateileiche statt haengender Loeschung); fehlende Datei = 404, die Kachel zeigt „Bild nicht verfügbar". Akzeptiert und benannt. |
|
||||
| T-HK4-05 | Volume geht verloren, Datenbank ueberlebt | low | Bewusst akzeptiert (Entscheidung des Nutzers); Betriebsanleitung nennt die Sicherung des Volumes. |
|
||||
</threat_model>
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
---
|
||||
phase: quick-260922-hk4
|
||||
plan: 01
|
||||
subsystem: apps/api/src/dashboard
|
||||
tags: [bilderrahmen, dashboard, user-files, prisma-migration, rls, tdd]
|
||||
status: complete
|
||||
requires: [quick-260921-pi9]
|
||||
provides:
|
||||
- "DashboardImage.storagePath — Bilder im Dateibereich statt als bytea"
|
||||
- "DashboardImagesService.onApplicationBootstrap() — automatischer Umzug beim Start"
|
||||
- "Migration 20260922120000_dashboard_image_to_disk (Stufe 1 von 2)"
|
||||
affects:
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-betrieb.md
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Servergenerierter Dateiname (UUID + Endung aus dem erkannten Mime-Typ), Muster dkv-export.service.ts (T-07-09)"
|
||||
- "Relativer Pfad in der Zeile, Muster User.avatarPath (user.controller.ts)"
|
||||
- "Einmal systemgebunden lesen, je Zeile mandantengebunden schreiben (Muster DkvService.loadActiveConfigsForScheduler)"
|
||||
- "Zweistufige Spaltenablösung: ADD + NULLbar jetzt, DROP nach nachgewiesenem Lauf"
|
||||
- "Dateitests gegen ein echtes Temp-Verzeichnis statt fs-Mock (Muster desktop.service.spec.ts)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql
|
||||
- .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md
|
||||
modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-betrieb.md
|
||||
- CHANGELOG.md
|
||||
decisions:
|
||||
- "data Bytes? bleibt im Prisma-Modell (optional) statt $queryRaw — der Bootstrap-Umzug bleibt dadurch typisiert und ohne rohes SQL; Spalte und Feld fallen gemeinsam in Stufe 2"
|
||||
- "Systemkontext (forSystem) nur im Startpfad; die vier Anfragewege bleiben ausnahmslos mandantengebunden, auch das Schreiben des Umzugs"
|
||||
- "Neue Regel system_read_policy auf DashboardImage, damit der Umzug nach dem Scharfschalten der Datenbankrolle nicht stumm nichts findet"
|
||||
- "Testschalter DASHBOARD_IMAGES_DIR (Muster DESKTOP_DIST_DIR) statt fs-Mock — die Tests schreiben und lesen wirklich"
|
||||
metrics:
|
||||
duration: "~35 min"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 21000
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 441854a
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-hk4: Bilderrahmen-Bilder auf die Festplatte — Summary
|
||||
|
||||
Die Bilder des Bilderrahmen-Widgets liegen jetzt unter
|
||||
`user-files/dashboard-images/<userId>/<id>.<ext>`; die Datenbankzeile hält nur
|
||||
noch den relativen Pfad, und vorhandene Bilder ziehen beim ersten Start
|
||||
automatisch um — nachgewiesen gegen die lokale Datenbank.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Aufgabe 1 — Schema, Migration, Dienst, Bootstrap-Umzug, Tests** (`9039cea`)
|
||||
|
||||
- `schema.prisma`: `data Bytes` → `data Bytes?`, neu `storagePath String?`.
|
||||
- Migration `20260922120000_dashboard_image_to_disk`: `ADD COLUMN "storagePath"`,
|
||||
`ALTER COLUMN "data" DROP NOT NULL`, dazu `system_read_policy … FOR SELECT`
|
||||
auf `"DashboardImage"`. **Kein DROP** — die Begründung steht im
|
||||
Migrationskopf (Stufe 1 von 2, T-HK4-03).
|
||||
- `DashboardImagesService`:
|
||||
- `upload` legt die Zeile an (erst danach steht die UUID fest), schreibt die
|
||||
Datei, trägt `storagePath` nach; scheitert das Schreiben, wird die Zeile
|
||||
zurückgenommen und 500 geworfen.
|
||||
- `getBytes` liest die Datei; fehlender Pfad oder fehlende Datei → 404.
|
||||
- `remove` löscht Zeile und Datei (Dateifehler wird protokolliert, nicht
|
||||
geworfen).
|
||||
- `onApplicationBootstrap()` zieht Altbestand um: **einmal systemgebunden
|
||||
lesen** (`forSystem`, Zeilen ohne `storagePath` über alle Mandanten),
|
||||
**je Zeile mandantengebunden schreiben** (`forTenant(prisma, row.tenantId,
|
||||
row.userId)`), Log „N Bilderrahmen-Bilder auf die Festplatte umgezogen",
|
||||
still bei 0, wiederholbar.
|
||||
- Dateiname IMMER servergeneriert; `absoluteImagePath()` weist jeden Pfad
|
||||
zurück, der nicht im Bilderverzeichnis liegt (T-HK4-01).
|
||||
- Tests: 23 Fälle (11 neu), echtes Temp-Verzeichnis statt `fs`-Mock.
|
||||
- RLS-Wächter und Klassifikationsdokument nachgezogen (siehe Abweichungen).
|
||||
|
||||
**Aufgabe 2 — Changelog, Betriebsanleitung, Todo** (`8cbfb8b`)
|
||||
|
||||
- CHANGELOG „Unveröffentlicht → Geändert" mit der Nutzerzeile.
|
||||
- `docs/anleitung-betrieb.md` Kap. 6: `user-files` nennt die
|
||||
Bilderrahmen-Bilder und hält fest, dass `pg_dump` sie nicht mehr enthält.
|
||||
- Todo `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md`
|
||||
mit Vorbedingung (`storagePath IS NULL` = 0 auf alpha UND live),
|
||||
Migrationsname `20260922120100_dashboard_image_drop_data` und allen
|
||||
Nacharbeiten an Spec und Klassifikation.
|
||||
|
||||
## TDD-Nachweis (RED → GREEN)
|
||||
|
||||
- **RED** (vor der Umsetzung, `vitest run src/dashboard/dashboard-images.service.spec.ts`):
|
||||
`Tests 12 failed | 11 passed (23)`, u. a.
|
||||
`TypeError: makeService(...).onApplicationBootstrap is not a function`
|
||||
und Erwartungen an `storagePath`, die noch niemand setzte. Die 11 grünen
|
||||
Fälle sind die unveränderten Besitz-/Magic-Byte-Prüfungen aus pi9.
|
||||
- **GREEN** nach dem Dienst: `Tests 23 passed (23)`.
|
||||
- Ein RED war ein Testfehler, kein Dienstfehler: Test 17 („Datei fehlt")
|
||||
nutzte die Kennung `img-1`, für die Test 8/10 im geteilten Temp-Verzeichnis
|
||||
schon eine Datei angelegt hatten — Kennung auf `datei-fehlt` geändert.
|
||||
|
||||
## Nachweis am laufenden System (lokal, kein Testserver)
|
||||
|
||||
- `prisma migrate deploy` gegen die lokale Container-Datenbank: Migration
|
||||
`20260922120000_dashboard_image_to_disk` angewendet, danach `prisma generate`.
|
||||
- `\d "DashboardImage"`: `data` ist jetzt NULLbar, `storagePath text`,
|
||||
Policies `tenant_isolation_policy` + `system_read_policy (FOR SELECT)`.
|
||||
- Bootstrap-Umzug gegen die echte Datenbank ausgeführt (Wegwerf-Spec, danach
|
||||
gelöscht):
|
||||
- vorher: 1 Zeile, `storagePath = null`, 502 Byte in `data`
|
||||
- Log: `1 Bilderrahmen-Bilder auf die Festplatte umgezogen`
|
||||
- nachher: `storagePath = user-files/dashboard-images/1166431d-…/f43be914-….png`
|
||||
- Datei auf der Platte: 502 Byte, `PNG image data, 320 x 200` (`file`)
|
||||
- zweiter Lauf: keine Zeile mehr offen, Datei unverändert (wiederholbar)
|
||||
|
||||
## Tore
|
||||
|
||||
| Tor | Ergebnis |
|
||||
|---|---|
|
||||
| `vitest run src/dashboard src/prisma` | 147 Tests, alle grün |
|
||||
| `pnpm type-check` (4 Pakete) | grün |
|
||||
| `pnpm lint` (5 Pakete) | grün (74 API-/53 Web-Warnungen, alle vorbestehend, keine in den geänderten Dateien) |
|
||||
| `pnpm --filter @tessera/api test` | 75 Dateien, 1186 Tests grün |
|
||||
| `pnpm --filter @tessera/web test` | 81 Dateien, 640 Tests grün |
|
||||
| `as unknown as` in apps/api | 27 (unverändert) |
|
||||
| neue `any` / `!` / `biome-ignore` | keine |
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
### [Regel 3 — blockierend] Das Klassifikationsdokument brauchte doch eine Änderung
|
||||
|
||||
Der Plan sagte, das Dokument brauche keine neue Zeile. Richtig — eine neue
|
||||
ZEILE nicht, aber der `forSystem()`-Aufruf im Startpfad ändert den gemessenen
|
||||
**Stand** des Paars `dashboard-images.service.ts`/`dashboardImage` von
|
||||
`gebunden` auf `system-gebunden`, und `rls-access-inventory.spec.ts` prüft
|
||||
genau diesen Wert. Zwei Tests waren rot, bis nachgezogen war:
|
||||
|
||||
- `FORSYSTEM_ALLOWED_CALL_SITES` (die Liste ist ein „genau", kein
|
||||
„mindestens") um `apps/api/src/dashboard/dashboard-images.service.ts` = 1
|
||||
erweitert, mit Begründung im Kopfkommentar: Startpfad, kein Anfrageweg;
|
||||
geschrieben wird auch dort mandantengebunden. Präzedenz:
|
||||
`ldap-config.service.ts`, dessen Nachverschlüsselung in
|
||||
`onApplicationBootstrap()` genauso gebaut ist.
|
||||
- Klassifikationsdokument: Stand `system-gebunden` mit Begründung, Zahlen der
|
||||
Bereichszeile `dashboard` mit derselben Gate-Schleife nachgemessen
|
||||
(1/18/0 → 1/21/1; +3 gebunden = Nachtragen von `storagePath`, Rücknahme bei
|
||||
Schreibfehler, Nachtragen im Umzug), Summe 187/5 → 190/6.
|
||||
|
||||
Beides ist im Todo für Stufe 2 als Rückbau vermerkt.
|
||||
|
||||
### [Regel 2 — fehlende kritische Funktionalität] `ALTER COLUMN "data" DROP NOT NULL`
|
||||
|
||||
Der Plan nannte nur `ADD COLUMN "storagePath"`. Ohne das Lockern der
|
||||
NOT-NULL-Bedingung wäre jeder neue Upload an der Datenbank gescheitert, weil
|
||||
er keine Bytes mehr in die Zeile schreibt.
|
||||
|
||||
### [Regel 2 — fehlende kritische Funktionalität] `system_read_policy` auf `"DashboardImage"`
|
||||
|
||||
Nicht im Plan. Ohne diese Regel sähe der systemgebundene Umzug nach dem
|
||||
Scharfschalten der Datenbankrolle NULL Zeilen und stellte die Arbeit stumm
|
||||
ein — genau die Falle, die Migration 20260914120000 für die fünf
|
||||
Hintergrunddienst-Tabellen geschlossen hat. Permissiv, nur `FOR SELECT`;
|
||||
Schreiben bleibt allein der Mandantenregel unterstellt.
|
||||
|
||||
### [Entscheidung] Testschalter `DASHBOARD_IMAGES_DIR`
|
||||
|
||||
Der Plan ließ die Wahl zwischen `memfs` und einem Temp-Verzeichnis. Gewählt:
|
||||
Temp-Verzeichnis (keine neue Abhängigkeit), erreichbar über die
|
||||
Umgebungsvariable `DASHBOARD_IMAGES_DIR` — dasselbe Muster, das
|
||||
`desktop.service.ts` mit `DESKTOP_DIST_DIR` schon nutzt. Im Betrieb nie
|
||||
gesetzt; ohne sie gilt der Pfad unter der Monorepo-Wurzel.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Angriffsfläche über den `<threat_model>` des Plans hinaus. Der
|
||||
einzige neue Dateipfad-Umgang ist vollständig servergeneriert und zusätzlich
|
||||
containment-geprüft (`absoluteImagePath`).
|
||||
|
||||
## Von Hand zu prüfen (nach dem nächsten `--build`-Deploy)
|
||||
|
||||
1. Bild im Bilderrahmen-Widget hochladen → erscheint in der Kachel und in der
|
||||
Verwaltung unter Einstellungen → Dashboard.
|
||||
2. Auf dem Server nachsehen:
|
||||
`docker compose exec api ls -R /app/user-files/dashboard-images` — je
|
||||
Benutzer ein Ordner, Dateiname eine UUID mit `.png`/`.jpg`/`.gif`/`.webp`,
|
||||
nie der Originalname.
|
||||
3. Bild löschen → verschwindet aus der Kachel UND die Datei ist weg
|
||||
(`ls` wie oben).
|
||||
4. Nach dem ersten Start mit dieser Version:
|
||||
`docker compose logs api | grep umgezogen` — die Zeile „N
|
||||
Bilderrahmen-Bilder auf die Festplatte umgezogen" steht genau einmal; ein
|
||||
zweiter Neustart schweigt.
|
||||
5. `docker compose exec db psql -U tessera -d tessera -c 'SELECT count(*) FROM "DashboardImage" WHERE "storagePath" IS NULL;'`
|
||||
→ muss `0` sein (Vorbedingung für Stufe 2, siehe Todo).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql` — vorhanden
|
||||
- `apps/api/src/dashboard/dashboard-images.service.ts` — vorhanden
|
||||
- `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md` — vorhanden
|
||||
- Commit `9039cea` — vorhanden
|
||||
- Commit `8cbfb8b` — vorhanden
|
||||
|
||||
## Rundgang durch den Orchestrator (22.09.2026, lokaler Stack, Abbilder aus dem Commit danach)
|
||||
|
||||
Bestanden, und dabei EIN Befund gefunden und behoben (eigener Commit):
|
||||
|
||||
- Hochladen ueber die Oberflaeche legt die Datei unter `user-files/dashboard-images/<userId>/<uuid>.png` im Container-Volume an; die Liste zeigt sie, die Kachel rendert sie.
|
||||
- Loeschen entfernt Zeile UND Datei (3 Dateien/3 Zeilen → 2/2, gemessen im Container und in der Datenbank).
|
||||
- **Befund:** eine Zeile zeigte auf eine Datei, die es im Container nicht gibt — der Bootstrap-Umzug war beim Bauen auf dem HOST gelaufen (Repo-Verzeichnis), der Container hat aber das Volume `user-files`. Lokal ein Artefakt, im Betrieb aber real: wer einen `pg_dump` von VOR dem Umzug zurueckspielt, waehrend das getrennt gesicherte Volume leer ist, haette Zeilen ohne Datei, obwohl die Bytes im Abzug noch stecken.
|
||||
- **Behoben:** `getBytes` schreibt die Datei in diesem Fall aus der noch vorhandenen Spalte `data` neu und liefert sie aus (Protokoll „… aus der Datenbank wiederhergestellt"); fehlt beides, bleibt es bei 404. Nachgewiesen: Abruf lieferte 200/`image/png`/502 Byte, danach lag die Datei im Container. Zwei Tests (10b, 10c), api 1186 → 1188.
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
---
|
||||
phase: quick-260922-m1h
|
||||
plan: 01
|
||||
type: refactor
|
||||
autonomous: true
|
||||
subsystem: apps/web/src/components/dashboard
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-m1h: Ein Modul bringt seine Dashboard-Kachel selbst mit
|
||||
|
||||
## Warum (Auftrag des Nutzers, 22.09.2026)
|
||||
|
||||
Als Naechstes kommt ein Proxmox-Modul (PVE/PBS/PMG), das zusaetzlich als
|
||||
kompakte Kachel auf dem Dashboard erscheinen soll — und kuenftig sollen weitere
|
||||
Module dasselbe tun (PBS: Sicherungsstatus, PMG: Mail-Zahlen). Eine
|
||||
Bestandsaufnahme (lesend, 22.09.) hat ergeben:
|
||||
|
||||
- **Ein neuer Widget-Typ ist heute an SIEBEN Stellen hartkodiert**: `WidgetType`
|
||||
(Union), `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY`, eine eigene `wireXWidget()`
|
||||
je Typ, der Aufruf in `(portal)/page.tsx`, die ZWEITE Liste `WIDGET_TYPES` in
|
||||
`widget-catalog-modal.tsx` und die `@IsIn`-Whitelist in
|
||||
`apps/api/src/dashboard/dto/create-widget.dto.ts`. Vergisst man eine, fehlt die
|
||||
Kachel im Katalog oder die API lehnt sie mit 400 ab.
|
||||
- **Die Verbindung Kachel↔Modul existiert schon, ist aber leer:**
|
||||
`apps/api/src/dashboard/widget-module-map.ts` (`WIDGET_MODULE_MAP = {}`),
|
||||
gelesen von `dashboard.service.ts` — `getWidgets()` filtert Kacheln aus, deren
|
||||
Modul der Benutzer nicht hat (fail-closed, Zeile ~164-205). Das funktioniert,
|
||||
wurde nur nie benutzt.
|
||||
- **Zwei Luecken:** (a) der Katalog („Widget hinzufuegen") zeigt JEDEM alle
|
||||
Kacheln, auch die gesperrter Module — anlegen geht, danach verschwindet die
|
||||
Kachel kommentarlos; (b) eine Kachel mit unbekanntem Typ rendert leer, ohne
|
||||
Erklaerung.
|
||||
|
||||
Diese Aufgabe raeumt das auf, BEVOR Proxmox kommt. Kein neues Modul, keine neue
|
||||
Kachel — reiner Umbau mit unveraendertem Verhalten fuer die neun vorhandenen
|
||||
Kacheln.
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator)
|
||||
|
||||
1. **Eine Quelle fuer die Typliste, geteilt zwischen Web und API.** In
|
||||
`packages/shared/src/index.ts` (wird von beiden Apps bereits importiert, z. B.
|
||||
`desktop.service.ts`, `apps/web/src/lib/app-version.ts`) kommt:
|
||||
```ts
|
||||
export const WIDGET_TYPES = ['clock','search','calendar','note','calculator','favorites','stopwatch','picture-frame','xframe'] as const;
|
||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||
/** Kachel → Modul-Slug; eine Kachel ohne Eintrag ist immer sichtbar. */
|
||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
|
||||
```
|
||||
`create-widget.dto.ts` validiert mit `@IsIn([...WIDGET_TYPES])`, das Frontend
|
||||
leitet `WidgetType` von dort ab. `widget-module-map.ts` behaelt seine
|
||||
oeffentliche Funktion `getModuleSlugForWidgetType()`, liest aber
|
||||
`WIDGET_MODULE_SLUGS` aus `@tessera/shared` statt einer eigenen Kopie
|
||||
(Kommentar: eine Tabelle fuer beide Seiten, damit Katalogfilter und
|
||||
Server-Filter nicht auseinanderlaufen).
|
||||
2. **Eine Anmeldestelle je Kachel.** Statt neun `wireXWidget()`-Funktionen mit je
|
||||
eigenem Bool-Flag ein generisches `registerWidget(type, component)` in
|
||||
`widget-registry.tsx`; `(portal)/page.tsx` ruft es je Kachel einmal auf (die
|
||||
Datei bleibt die Stelle, an der die Komponenten importiert werden — der
|
||||
Zirkelimport-Grund aus dem Bestandskommentar gilt weiter, also NICHT die
|
||||
Komponenten direkt in der Registry importieren). Mehrfachanmeldung desselben
|
||||
Typs ist ein No-Op (wie die bisherigen Flags); Anmeldung eines unbekannten
|
||||
Typs wirft in der Entwicklung und wird in der Produktion ignoriert.
|
||||
3. **`WIDGET_REGISTRY` bekommt `moduleSlug?: string`** je Eintrag, befuellt aus
|
||||
`WIDGET_MODULE_SLUGS`. Heute bleibt es fuer alle neun Kacheln leer.
|
||||
4. **Der Katalog leitet seine Liste aus der Registry ab** (`Object.keys` in der
|
||||
Reihenfolge der Registry-Definition, die heutige Reihenfolge bleibt erhalten —
|
||||
Test darauf) und **filtert nach Modulzugriff**: `widget-catalog-modal.tsx`
|
||||
bekommt eine Liste der zugaenglichen Modul-Slugs als Prop von der Seite, die
|
||||
sie ueber den vorhandenen Weg `/modules/active` holt (Muster
|
||||
`apps/web/src/components/layout/sidebar.tsx` — dort wird genau dieser Endpunkt
|
||||
schon gefetcht; dieselbe Hilfsfunktion nutzen, nicht neu bauen). Eine Kachel
|
||||
ohne `moduleSlug` ist immer sichtbar; eine mit `moduleSlug` nur, wenn der Slug
|
||||
in der Liste steht. Schlaegt der Abruf fehl, werden Kacheln MIT `moduleSlug`
|
||||
ausgeblendet (fail-closed, wie serverseitig).
|
||||
5. **Gesperrte/unbekannte Kachel erklaert sich.** `widget-wrapper.tsx` rendert
|
||||
heute nichts, wenn `definition?.component` fehlt. Neu: ein zentrierter grauer
|
||||
Hinweistext `widgets.unavailable` („Diese Kachel steht nicht zur Verfügung —
|
||||
das zugehörige Modul ist nicht freigegeben.") in de und en. Der Fall tritt
|
||||
erst mit Proxmox real auf, ist aber ab jetzt abgedeckt.
|
||||
6. **Verhalten der neun vorhandenen Kacheln aendert sich NICHT.** Gleiche Namen,
|
||||
gleiche Reihenfolge im Katalog, gleiche Groessenvorgaben, gleiche Einstellungen.
|
||||
Der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` bleibt wie er ist —
|
||||
den generisch zu machen waere ein eigener Umbau und gehoert NICHT in diese
|
||||
Aufgabe (im SUMMARY als bewusst offen gelassen nennen).
|
||||
7. Keine neuen Abhaengigkeiten. Keine Aenderung an der Datenbank.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 1: Typliste nach @tessera/shared, generische Anmeldung, Katalog aus der Registry</name>
|
||||
<files>packages/shared/src/index.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx</files>
|
||||
<action>
|
||||
Entscheidungen 1-4 umsetzen. Reihenfolge: shared zuerst (beide Apps bauen dagegen), dann API-DTO und `widget-module-map.ts`, dann Registry + `registerWidget`, dann `page.tsx`, zuletzt der Katalog.
|
||||
Tests zuerst anpassen/ergaenzen, wo sie die alten Namen festhalten (`widget-registry.test.tsx` prueft heute die Typliste und die Constraints-Tabelle; `widget-catalog-modal.test.tsx` die Eintraege). Neu mindestens: Katalogreihenfolge entspricht der Registry-Reihenfolge; eine Kachel mit `moduleSlug` fehlt im Katalog, wenn der Slug nicht in den zugaenglichen Modulen steht, und erscheint, wenn doch; fehlgeschlagener Modulabruf blendet Kacheln mit `moduleSlug` aus; `registerWidget` ist idempotent; `WIDGET_TYPES` aus shared und die Registry-Schluessel sind deckungsgleich (ein Test, der kuenftig jede vergessene Stelle faengt).
|
||||
Fuer den Katalog-Test eine Kachel mit `moduleSlug` brauchen, ohne eine echte zu erfinden: die Registry im Test per Hilfsfunktion um einen Testeintrag erweitern ODER den Filter als reine Funktion `visibleWidgetTypes(registry, accessibleSlugs | null)` auslagern und diese direkt testen — die reine Funktion ist vorzuziehen (Muster `picture-frame-config.ts`).
|
||||
Commit: `refactor(quick-260922-m1h): Widget-Typen an einer Stelle, Katalog aus der Registry, Kachel kennt ihr Modul`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/api exec vitest run src/dashboard && pnpm type-check && pnpm lint</automated>
|
||||
</verify>
|
||||
<done>`WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` stehen in `packages/shared`; API-DTO und Web leiten davon ab; genau EINE `registerWidget`-Funktion (kein `wireXWidget` mehr); Katalogliste kommt aus der Registry (keine zweite Liste); Deckungsgleichheits-Test vorhanden und gruen. Alle bestehenden Tests gruen, Reihenfolge und Namen der neun Kacheln unveraendert.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Gesperrte Kachel erklaert sich, Uebersetzungen, Changelog, Entwicklerdoku</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-entwicklung.md</files>
|
||||
<action>
|
||||
Entscheidung 5 umsetzen (Hinweistext statt leerer Kachel, Test dafuer), Schluessel `widgets.unavailable` in beiden Sprachdateien.
|
||||
`docs/anleitung-entwicklung.md`: den vorhandenen Modul-Walkthrough (Abschnitt um Zeile 372-400) um einen kurzen Abschnitt „Eine Kachel zum Modul" ergaenzen — welche drei Stellen es NACH diesem Umbau noch sind (Komponente schreiben, `registerWidget` in `page.tsx`, Eintrag in `WIDGET_TYPES` + optional `WIDGET_MODULE_SLUGS` in `packages/shared`, plus Uebersetzungen und Groessenvorgaben) und dass eine Kachel mit `moduleSlug` automatisch aus Katalog und Dashboard verschwindet, wenn das Modul fehlt.
|
||||
CHANGELOG unter „Unveröffentlicht → Geändert": „Dashboard: Kacheln, die zu einem Modul gehören, erscheinen nur noch für Benutzer, die dieses Modul nutzen dürfen; eine nicht mehr freigegebene Kachel erklärt das jetzt, statt leer zu bleiben" (Stichpunkt, kein Fliesstext).
|
||||
Volle Tore am Ende.
|
||||
Commit: `docs(quick-260922-m1h): Hinweis bei gesperrter Kachel, Changelog und Entwicklerdoku`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/messages && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
|
||||
</verify>
|
||||
<done>Unbekannter/gesperrter Typ zeigt den Hinweistext (Test); beide Sprachdateien tragen den Schluessel; Changelog-Zeile steht; Entwicklerdoku nennt die verbliebenen Schritte; alle Tore gruen; genau zwei Commits mit Scope `quick-260922-m1h`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Hinweise fuer den Executor
|
||||
|
||||
- HEAD ist `ee2b025`, Arbeitsbaum sauber, Zweig `main`. Version 1.3.0 wurde heute freigegeben; dieser Umbau geht in die naechste Freigabe. Zweig `live` und Tags NICHT anfassen.
|
||||
- `packages/shared` wird von beiden Apps importiert (`@tessera/shared`); pruefen, ob ein Build-Schritt noetig ist (`pnpm --filter @tessera/shared build`?) — turbo erledigt das ueblicherweise, im Zweifel `pnpm build` fuer shared vor dem Typecheck.
|
||||
- Qualitaetsregeln: keine neue `any`, `as unknown as` api 27 / web 6 unveraendert, keine `!`, kein `biome-ignore`, web-Warnungen bleiben 53, api 74.
|
||||
- Commits: Conventional Commits, Scope `quick-260922-m1h`, deutscher Betreff im Stil von `git log --oneline -15`, Commit-Body endet mit
|
||||
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||
- `.planning/**` NICHT committen.
|
||||
- Testserver nicht anfassen. Lokaler Docker-Stack laeuft, nicht noetig fuer diese Aufgabe.
|
||||
- SUMMARY nach `/home/vicolab/projects/tessera-ctl/.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md` (`status: complete`), mit: was jetzt noch zu tun ist, um eine Modul-Kachel hinzuzufuegen (die kurze Liste), Abweichungen, Zahlen, und einer kurzen Browser-Pruefliste fuer mich.
|
||||
|
||||
<threat_model>
|
||||
ASVS 1, block on high.
|
||||
|
||||
| ID | Bedrohung | Schwere | Disposition |
|
||||
|---|---|---|---|
|
||||
| T-M1H-01 | Katalogfilter clientseitig = Umgehung moeglich (Kachel per API trotzdem anlegen) | medium | Der Katalogfilter ist Komfort, die Durchsetzung bleibt serverseitig in `dashboard.service.ts` (`getWidgets()` filtert fail-closed) und im Modul-Guard der jeweiligen Daten-Endpunkte. Im Code so kommentieren. Akzeptiert. |
|
||||
| T-M1H-02 | Kachel eines gesperrten Moduls zeigt weiter Daten | high | Daten holt jede Kachel ueber ihre eigenen Modul-Endpunkte, die `@UseModule(slug)` tragen muessen — fuer Proxmox in der naechsten Aufgabe verbindlich. Diese Aufgabe aendert daran nichts und schwaecht nichts ab. |
|
||||
| T-M1H-03 | Typliste in `packages/shared` als neue Vertrauensgrenze | low | Reine Konstantenliste, keine Laufzeitdaten; die API validiert weiterhin mit `@IsIn` gegen genau diese Liste. Mitigiert. |
|
||||
| T-M1H-04 | Fehlender Modulabruf oeffnet den Katalog | medium | Fail-closed: bei Fehler werden Kacheln MIT `moduleSlug` ausgeblendet (Entscheidung 4), Test dafuer. Mitigiert. |
|
||||
</threat_model>
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
---
|
||||
phase: quick-260922-m1h
|
||||
plan: 01
|
||||
subsystem: apps/web/src/components/dashboard
|
||||
tags: [refactor, dashboard, widgets, module-access]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "WIDGET_TYPES/WidgetType/WIDGET_MODULE_SLUGS als geteilte Quelle in packages/shared"
|
||||
- "registerWidget() als einzige Anmeldestelle je Kachel"
|
||||
- "visibleWidgetTypes() — Katalogfilter nach Modulzugriff, fail-closed"
|
||||
affects:
|
||||
- apps/api/src/dashboard
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
tech-stack:
|
||||
added:
|
||||
- "apps/web haengt jetzt auf @tessera/shared (workspace:*)"
|
||||
patterns:
|
||||
- "erster Laufzeit-Import aus @tessera/shared (bisher nur import type)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/dashboard/widget-module-map.spec.ts
|
||||
modified:
|
||||
- packages/shared/src/index.ts
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- apps/api/src/dashboard/widget-module-map.ts
|
||||
decisions:
|
||||
- "Typliste als Laufzeit-Konstante in packages/shared statt gespiegelter Kopien — traegt, weil Node 24 rohes TypeScript per Type-Stripping laedt"
|
||||
- "apps/web bekommt die Abhaengigkeit auf @tessera/shared; die frueher dokumentierte Gegenbegruendung war ueberholt"
|
||||
- "Katalogfilter als reine Funktion visibleWidgetTypes(registry, slugs|null) statt Logik im Dialog"
|
||||
metrics:
|
||||
duration: "~70 min"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 21000
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: ee2b025
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-m1h: Ein Modul bringt seine Dashboard-Kachel selbst mit — Zusammenfassung
|
||||
|
||||
Die Kachel-Typliste stand an sieben Stellen; sie steht jetzt an einer. Der
|
||||
Katalog fuehrt keine zweite Liste mehr und blendet Kacheln gesperrter Module
|
||||
aus, eine Kachel ohne Bauteil erklaert sich mit einem Satz statt leer zu
|
||||
bleiben. Die neun vorhandenen Kacheln verhalten sich unveraendert.
|
||||
|
||||
## So fuegt man kuenftig eine Modul-Kachel hinzu
|
||||
|
||||
Vorher sieben Stellen, jetzt drei (plus das Uebliche an Text und Maßen):
|
||||
|
||||
1. **Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/<name>-widget.tsx`,
|
||||
nimmt `WidgetProps` (`instanceId`, `config`, `isEditMode`).
|
||||
2. **Typ eintragen** — in `WIDGET_TYPES` in `packages/shared/src/index.ts`. Gehoert die Kachel zu
|
||||
einem Modul, zusaetzlich `WIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>'` in derselben Datei.
|
||||
Das ist die einzige Liste — die API validiert per `@IsIn` gegen genau sie.
|
||||
3. **Anmelden** — `registerWidget('<typ>', <Name>Widget)` in `apps/web/src/app/(portal)/page.tsx`.
|
||||
|
||||
Dazu wie bei jeder Oberflaeche: Uebersetzungsschluessel `<typ>.name` und `<typ>.description` unter
|
||||
`widgets` in **de.json und en.json**, ein Inline-SVG-Symbol und die Groessenvorgaben in
|
||||
`WIDGET_CONSTRAINTS` — Symbol und Maße in `widget-registry.tsx`.
|
||||
|
||||
Eine Kachel mit `moduleSlug` verschwindet danach **von selbst** aus Katalog und Dashboard, wenn der
|
||||
Benutzer das Modul nicht nutzen darf. Vergisst man eine der drei Stellen, schlaegt der
|
||||
Deckungsgleichheits-Test in `widget-registry.test.tsx` fehl, statt dass die Kachel im Katalog fehlt
|
||||
oder die API mit 400 antwortet.
|
||||
|
||||
Dieselbe Liste steht als Abschnitt „Eine Kachel zum Modul" in
|
||||
`docs/anleitung-entwicklung.md`.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Aufgabe 1 — `56c07c3`** (`refactor`)
|
||||
|
||||
- `packages/shared/src/index.ts`: `WIDGET_TYPES`, `WidgetType`, `WIDGET_MODULE_SLUGS`.
|
||||
- `create-widget.dto.ts`: `@IsIn([...WIDGET_TYPES])` statt handgepflegter Liste.
|
||||
- `widget-module-map.ts`: liest `WIDGET_MODULE_SLUGS` statt einer eigenen Kopie; die oeffentliche
|
||||
Funktion `getModuleSlugForWidgetType()` ist unveraendert, damit `dashboard.service.spec.ts`
|
||||
sie weiter mocken kann.
|
||||
- `widget-registry.tsx`: neun `wireXWidget()` → ein `registerWidget()` (idempotent; unbekannter Typ
|
||||
wirft in der Entwicklung, wird in der Produktion ignoriert). `WidgetDefinition` traegt
|
||||
`moduleSlug?`. Neue reine Funktion `visibleWidgetTypes(registry, slugs|null)`.
|
||||
- `widget-catalog-modal.tsx`: Liste kommt aus der Registry (Reihenfolge erhalten, Test darauf),
|
||||
gefiltert nach Modulzugriff; neue Prop `accessibleModuleSlugs`.
|
||||
- `(portal)/page.tsx`: neun `registerWidget`-Aufrufe; holt `/modules/active` im Muster der
|
||||
Seitenleiste (`credentials: 'include'`, Fehler still) und reicht die Slugs an den Katalog durch.
|
||||
|
||||
**Aufgabe 2 — `8be0725`** (`docs`)
|
||||
|
||||
- `widget-wrapper.tsx`: Kachel ohne Bauteil zeigt `widgets.unavailable` zentriert und grau statt des
|
||||
rohen Typnamens; Schluessel in de.json und en.json.
|
||||
- Changelog-Stichpunkt unter „Unveroeffentlicht → Geaendert"; Entwicklerdoku-Abschnitt.
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
**1. [Rule 3 — blockierend] Die Planannahme „apps/web importiert @tessera/shared bereits" war falsch**
|
||||
|
||||
- **Gefunden bei:** Aufgabe 1, vor der ersten Zeile Code.
|
||||
- **Befund:** `apps/web` hatte **keine** Abhaengigkeit auf `@tessera/shared`. Zwei Kommentare
|
||||
(`lib/app-version.ts`, `lib/desktop.ts`) dokumentierten das sogar ausdruecklich als Absicht und
|
||||
begruendeten damit gespiegelte Typen. Ohne Abhaengigkeit ist Entscheidung 1 des Plans nicht
|
||||
umsetzbar. Zudem waren **alle** bisherigen `@tessera/shared`-Importe in `apps/api` reine
|
||||
`import type` — die Typliste ist aber ein Laufzeitwert.
|
||||
- **Geprueft statt vermutet:**
|
||||
- `nest build` mit einem Laufzeit-Import: laeuft; das Ergebnis laedt `@tessera/shared` im
|
||||
fertigen `dist` tatsaechlich (nachgestellt, 9 Typen).
|
||||
- `packages/shared` liefert rohes TypeScript ohne Bauschritt — in `node:24-alpine` direkt
|
||||
geprueft: Node 24 laedt es per nativem Type-Stripping (`OK [ 'clock', 'xframe' ] {}`).
|
||||
- Die alte Gegenbegruendung ist ueberholt: der Web-Dockerfile kopiert `packages/shared` in
|
||||
deps- **und** builder-Stufe bereits. Es aendert sich nur das Lockfile (3 Zeilen).
|
||||
- `pnpm --filter @tessera/web build` laeuft durch — ohne `transpilePackages`.
|
||||
- **Umsetzung:** `@tessera/shared: workspace:*` in `apps/web/package.json`. Die beiden Kommentare,
|
||||
deren Begruendung dadurch unwahr wurde, sagen jetzt den aktuellen Stand; die Typ-Spiegel selbst
|
||||
blieben bewusst unangetastet (nicht Teil dieser Aufgabe).
|
||||
- **Nebenwirkung fuer die Zukunft:** `packages/shared/src/index.ts` darf nur noch loeschbare Syntax
|
||||
enthalten — kein `enum`, kein `namespace`, keine Parameter-Eigenschaften. Steht als Warnung in
|
||||
der Datei.
|
||||
|
||||
**2. [Abweichung vom Auftrag des Orchestrators] Keine gemeinsame Hilfsfunktion fuer `/modules/active`**
|
||||
|
||||
Der Auftrag nannte „dieselbe Hilfsfunktion wie die Seitenleiste". Eine solche gibt es nicht: die
|
||||
Seitenleiste hat einen eingebauten `fetch`, und `lib/api.ts#getActiveModules` ist serverseitig
|
||||
(Cookie-Header, kein `credentials`). Die Dashboard-Seite benutzt daher dasselbe **Muster** wie die
|
||||
Seitenleiste. Eine Hilfsfunktion herauszuloesen haette `sidebar.tsx` angefasst — ausserhalb dieser
|
||||
Aufgabe.
|
||||
|
||||
## Bewusst offen gelassen
|
||||
|
||||
- **`widget-settings-panel.tsx`** — der Einstellungs-Zweig je Typ bleibt wie er war. Den generisch
|
||||
zu machen ist ein eigener Umbau (so im Plan festgelegt). Die Datei wurde nicht angefasst.
|
||||
- **Die Typ-Spiegel** in `lib/app-version.ts` und `lib/desktop.ts` koennten jetzt echte Importe
|
||||
werden. Nicht gemacht, nur die Kommentare richtiggestellt.
|
||||
- **Katalog aktualisiert sich nicht live**, wenn im Marketplace gerade ein Modul freigeschaltet
|
||||
wird — die Seitenleiste tut das ueber `sidebarRefreshKey`, die Dashboard-Seite holt die Liste nur
|
||||
beim Aufbau. Heute ohne Wirkung (keine Kachel hat einen `moduleSlug`); mit Proxmox reicht ein
|
||||
Neuladen der Seite. Bewusst so, weil der Auffrisch-Ausloeser einen `biome-ignore` erzwungen
|
||||
haette, den die Qualitaetsregeln dieser Aufgabe ausschliessen.
|
||||
|
||||
## Keine Stubs
|
||||
|
||||
Es wurden keine Platzhalter, leeren Rueckgaben oder „coming soon"-Texte eingebaut.
|
||||
`WIDGET_MODULE_SLUGS` ist leer — das ist kein Stub, sondern der korrekte Zustand: alle neun Kacheln
|
||||
sind Plattform-Kacheln. Die erste Modul-Kachel (Proxmox) traegt sich dort ein.
|
||||
|
||||
## Bedrohungsmodell
|
||||
|
||||
| ID | Stand |
|
||||
|---|---|
|
||||
| T-M1H-01 | Akzeptiert wie geplant. Der Katalogfilter ist Komfort; im Code an drei Stellen so kommentiert. Durchsetzung bleibt `DashboardService.getWidgets` (unveraendert, 85 Tests gruen). |
|
||||
| T-M1H-02 | Unveraendert — diese Aufgabe schwaecht nichts ab. Fuer Proxmox bleibt `@UseModule(slug)` verbindlich. |
|
||||
| T-M1H-03 | Mitigiert. Reine Konstantenliste, keine Laufzeitdaten; die API validiert weiterhin `@IsIn` gegen genau diese Liste — jetzt nachweislich (Test validiert alle neun Typen und lehnt einen unbekannten ab). |
|
||||
| T-M1H-04 | Mitigiert. `accessibleModuleSlugs === null` blendet Kacheln MIT `moduleSlug` aus; Test im Katalog und in `visibleWidgetTypes`. |
|
||||
|
||||
## Zahlen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Commits | 2 (`56c07c3`, `8be0725`), Basis `ee2b025` |
|
||||
| Dateien geaendert | 20 (1 neu) |
|
||||
| Zeilen | +610 / −150 (gemessen: `git diff --shortstat ee2b025 HEAD`) |
|
||||
| Neue Tests | 29 (Registry 9, Katalog 3, Seite 2, `widget-module-map.spec.ts` 14, Wrapper 1) |
|
||||
| API-Tests | 1202 gruen (76 Dateien) |
|
||||
| Web-Tests | 659 gruen (81 Dateien) |
|
||||
| type-check | sauber (4 Pakete) |
|
||||
| lint | api 74 / web 53 Warnungen — **unveraendert** zur Basis |
|
||||
| `as unknown as` | api 27 / web 6 — **unveraendert** |
|
||||
| neue `any` / `!` / `biome-ignore` | 0 / 0 / 0 |
|
||||
| Next.js-Produktionsbau | laeuft |
|
||||
| `nest build` | laeuft |
|
||||
|
||||
## Browser-Pruefliste
|
||||
|
||||
Der Umbau ist verhaltensneutral — die Pruefung soll vor allem bestaetigen, dass **nichts** anders
|
||||
aussieht. Lokalen Stack neu bauen (`--build`), dann im Portal:
|
||||
|
||||
1. **Dashboard oeffnen.** Alle bisherigen Kacheln stehen an ihrem Platz und funktionieren wie
|
||||
vorher (Uhr laeuft, Kalender zeigt Termine, Bilderrahmen wechselt, XFrame laedt).
|
||||
2. **Stift → „Widget hinzufuegen".** Der Katalog zeigt **neun** Kacheln in genau dieser Reihenfolge:
|
||||
Uhr, Suchleiste, Kalender, Notiz, Taschenrechner, Favoriten, Stoppuhr, Bilderrahmen, XFrame.
|
||||
Namen und Beschreibungen unveraendert.
|
||||
3. **Eine Kachel anlegen** (z. B. Stoppuhr) — sie erscheint, laesst sich ziehen, vergroessern und
|
||||
wieder entfernen. Kein 400-Fehler.
|
||||
4. **Groessen pruefen:** eine frisch angelegte Kachel hat dieselbe Startgroesse wie frueher, und
|
||||
sie laesst sich nicht kleiner ziehen als bisher.
|
||||
5. **Einstellungen → Dashboard:** die Einstellungen je Kachel sind unveraendert da (dieser Bereich
|
||||
wurde bewusst nicht angefasst).
|
||||
6. **Sprache auf Englisch umstellen** — der Katalog bleibt vollstaendig, keine rohen Schluessel wie
|
||||
`clock.name` sichtbar.
|
||||
7. *(optional, zeigt das Neue)* Der Hinweis bei einer nicht verfuegbaren Kachel laesst sich heute
|
||||
nur kuenstlich ausloesen — er greift erst mit der ersten Modul-Kachel. Wer ihn sehen will: in der
|
||||
Datenbank den `widgetType` einer vorhandenen Kachel auf `proxmox` setzen und die Seite neu laden;
|
||||
die Kachel zeigt dann „Diese Kachel steht nicht zur Verfuegung — das zugehoerige Modul ist nicht
|
||||
freigegeben." statt leer zu bleiben. Danach zuruecksetzen.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/src/dashboard/widget-module-map.spec.ts` vorhanden.
|
||||
- Commits `56c07c3` und `8be0725` in `git log` gefunden.
|
||||
- `git diff --diff-filter=D ee2b025..HEAD` — keine geloeschten Dateien.
|
||||
- `git rev-list --count ee2b025..HEAD` = 2, gemessen.
|
||||
- `.planning/**` nicht committet.
|
||||
|
||||
## Rundgang durch den Orchestrator (22.09.2026, lokaler Stack aus 8be0725)
|
||||
|
||||
Bestanden, keine Abweichung zum Stand vorher:
|
||||
|
||||
- Dashboard zeigt die bestehenden Kacheln (Kalender, Notizen, Favoriten, Bilderrahmen, XFrame) unveraendert, keine Konsolenfehler.
|
||||
- Katalog zeigt **neun** Kacheln in der alten Reihenfolge: Uhr, Suchleiste, Kalender, Notizen, Taschenrechner, Favoriten, Stoppuhr, Bilderrahmen, XFrame; Namen und Beschreibungen unveraendert, keine rohen Schluessel.
|
||||
- Stoppuhr angelegt → erscheint (396x160 px), wird gespeichert (`stopwatch` in `GET /dashboard/widgets`), kein 400; danach wieder entfernt, Liste sauber.
|
||||
- `/modules/active` wird beim Seitenaufbau abgerufen (4x 200) — der Katalogfilter hat seine Datenquelle.
|
||||
- Der Hinweis bei nicht verfuegbarer Kachel liess sich nicht echt ausloesen (es gibt noch keine Modul-Kachel); er ist durch den Test in `widget-wrapper.test.tsx` gedeckt und greift mit Proxmox.
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
---
|
||||
phase: quick-260922-vdk
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260922-VDK]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 55000
|
||||
raw_tokens: 55000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Ein Dashboard, das beim Öffnen leer ist, misst die verfügbare Breite, sobald die erste Kachel erscheint: das Raster füllt den Inhaltsbereich bis zum rechten Rand, rechts bleibt kein toter Streifen, in den sich keine Kachel ziehen lässt."
|
||||
- "Die Messung überlebt den Wechsel Leerzustand → gefüllt, weil sie am eingehängten Knoten selbst hängt (Ref-Rückruf) und nicht an einem Effekt, der nur beim ersten Einhängen läuft."
|
||||
- "Gemessen wird synchron in der Commit-Phase, bevor gezeichnet wird — der bisherige Zwischenzustand mit dem angenommenen Startwert wird nie sichtbar (auch nicht auf dem heute schon funktionierenden Pfad „Neuladen mit Kacheln“)."
|
||||
- "Ändert sich die Fenstergröße, folgt die Rasterbreite; ein Fenster-Horcher ist das Sicherheitsnetz für den Fall, dass der ResizeObserver nichts meldet."
|
||||
- "Das Ziehverhalten bleibt exakt wie heute: FREE_PLACEMENT_COMPACTOR mit preventCollision, ein belegtes Feld bleibt blockiert, nichts wird zur Seite geschoben (Nutzerentscheidung 22.09.2026); Leerzustand, BREAKPOINTS, COLS, rowHeight, margin und applyConstraintMinima sind unverändert."
|
||||
- "Alle Tore grün: 12 Tests in dashboard-grid.test.tsx (10 alte unverändert + 2 neue), Web gesamt ≥ 661 Tests in 81 Dateien, `tsc --noEmit` 4/4, Biome 5/5 mit weiterhin genau 53 Warnungen in web, `as unknown as` in web weiterhin 6."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/dashboard-grid.tsx — Messung über den Ref-Rückruf `measureRef` (synchrone Erstmessung + ResizeObserver am jeweils eingehängten Knoten, Trennen im null-Zweig), `applyWidth`-Wächter, Fenster-Horcher; deutscher Kommentarblock `quick-260922-vdk` mit dem Warum"
|
||||
- "apps/web/src/components/dashboard/dashboard-grid.test.tsx — zwei neue Fälle (Leerzustand → gefüllt misst 1000; resize-Ereignis misst 1600) und `vi.unstubAllGlobals()` im bestehenden `afterEach`"
|
||||
- "CHANGELOG.md — neuer Abschnitt „### Behoben“ unter „Unveröffentlicht“ mit einem Stichpunkt in Alltagssprache"
|
||||
key_links:
|
||||
- "Kachel hinzufügen → `widgets.length` wird 1 → der Zweig mit dem Raster rendert → React hängt den `<div>` ein → `measureRef(node)` → synchrone Messung + `observer.observe(node)` → `setWidth` noch vor dem Zeichnen → `Responsive width` → Spaltenbreite und Breakpoint stimmen"
|
||||
- "Letzte Kachel entfernt → Knoten wird ausgehängt → `measureRef(null)` → `observerRef.current.disconnect()` (kein zurückgegebener Aufräum-Rückgabewert, damit React den null-Aufruf beibehält) → kein weiterlaufender Beobachter"
|
||||
- "`window` resize → Horcher → `nodeRef.current.getBoundingClientRect().width` → `applyWidth` (verwirft 0 und nicht endliche Werte) → `setWidth`"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-vdk: Das Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus
|
||||
|
||||
<objective>
|
||||
Auf einem Dashboard, das beim Öffnen leer ist, bleibt das Raster für die ganze Sitzung bei der angenommenen Breite von 1200 Pixeln stehen. Die erste hinzugefügte Kachel rechnet deshalb mit 20 statt 24 Spalten und 51,6 px Spaltenbreite, das Raster endet bei 1200 px und rechts davon liegt ein toter Bereich (gemessen: ~460 px bei 1920 px Bildschirmbreite), in den sich keine Kachel ziehen lässt. Diese Aufgabe hängt die Messung an den Knoten statt an den ersten Einhäng-Zeitpunkt, misst vor dem ersten Zeichnen und ergänzt einen Fenster-Horcher als Netz.
|
||||
|
||||
Purpose: Fehlerbehebung aus einer bereits abgeschlossenen Messung — die Ursache steht fest, dieser Plan setzt nur noch um und sichert sie mit einem Regressionstest ab.
|
||||
Output: geänderte `dashboard-grid.tsx` mit deutschem Warum-Kommentar, zwei neue Tests (zuerst rot), ein Changelog-Stichpunkt, alle Tore grün, Prüfliste für den Browser-Rundgang im SUMMARY.
|
||||
</objective>
|
||||
|
||||
## Befund (gemessen, nicht neu zu untersuchen)
|
||||
|
||||
Der bisherige Code legt den Beobachter in einem Effekt mit leerer Abhängigkeitsliste an und bricht ab, wenn der Ref noch leer ist. Hängt `DashboardGrid` ein, während das Dashboard null Kacheln hat, greift der frühe Rücksprung in den Leerzustand **vor** dem `<div>` mit dem Ref: der Ref ist leer, der Effekt bricht ab — und läuft wegen der leeren Abhängigkeitsliste nie wieder, auch nicht, wenn später Kacheln erscheinen und der `<div>` tatsächlich entsteht. Die Breite bleibt für die ganze Sitzung beim Startwert.
|
||||
|
||||
Belege (bestätigt, nicht zu wiederholen):
|
||||
|
||||
- Echter Linux-Client (Tessera-1.3.0.AppImage, WebKitGTK), leeres Dashboard, eine Kalender-Kachel hinzugefügt: Kachel 469 px breit in einem 1000 px breiten Container — das ist die Rechnung für 1200.
|
||||
- Derselbe Client nach einem Neuladen mit vorhandener Kachel: 459 px in 1176 px, also richtig — weil die Ladeschranke in `(portal)/page.tsx` das Raster aus- und wieder einhängt, der Ref beim Einhängen also existiert.
|
||||
- Screenshot des Nutzers (1920×1045): Spaltenbreite 51,5 px, Platzhalter klebt an Spalte 13, rechte Rasterkante bei x = 1459 bei einem Inhaltsbereich bis ~1920.
|
||||
|
||||
`react-grid-layout` vergleicht den Breakpoint strikt größer als (`width > breakpoint`), 1200 ist damit **nicht** `lg`, sondern `md` → 20 Spalten.
|
||||
|
||||
## Gebundene Entscheidungen (nicht neu verhandeln)
|
||||
|
||||
1. **Ref-Rückruf statt Einmal-Effekt.** Die Messung hängt am jeweils eingehängten Knoten und überlebt Aus- und Einhängen.
|
||||
2. **Synchron vor dem ersten Zeichnen.** Der Ref-Rückruf läuft in der Commit-Phase; die dort ausgelöste Zustandsänderung wird vor dem Zeichnen abgearbeitet. Ein zusätzlicher `useLayoutEffect` ist damit überflüssig.
|
||||
3. **Fenster-Horcher als Netz**, zusätzlich zum ResizeObserver, nicht statt seiner.
|
||||
4. **Verhalten sonst unverändert.** `FREE_PLACEMENT_COMPACTOR` mit `preventCollision` bleibt (Nutzerentscheidung 22.09.2026: ein belegtes Feld bleibt blockiert, nichts wird zur Seite geschoben). Leerzustand, `BREAKPOINTS`, `COLS`, `rowHeight`, `margin`, `applyConstraintMinima` bleiben wortgleich.
|
||||
5. **Startwert 1200 bleibt.** Er lebt nur noch bis zur Commit-Phase desselben Einhängens. Genau diesen Übergang 1200 → gemessen macht der Pfad „Neuladen mit Kacheln“ heute schon in Produktion, und er ist nachweislich richtig (459 px in 1176 px) — ein anderer Startwert würde eine bisher unerprobte Breakpoint-Folge einführen, ohne etwas zu verbessern.
|
||||
6. **Nur `apps/web`.** Keine API, kein Prisma, kein Docker, keine neuen Pakete (ResizeObserver und resize sind Browser-Schnittstellen).
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
@apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
@apps/web/src/test/fake-resize-observer.ts
|
||||
@apps/web/src/test/setup.ts
|
||||
</context>
|
||||
|
||||
## Schnittstellen, die der Executor kennen muss
|
||||
|
||||
- `apps/web/src/test/fake-resize-observer.ts` exportiert `stubResizeObserver({ width, height }): void`; es ersetzt den globalen ResizeObserver per `vi.stubGlobal` durch eine Klasse, deren `observe()` den Rückruf **sofort und synchron** mit `new DOMRectReadOnly(0, 0, width, height)` aufruft. Zurückgesetzt wird nur durch `vi.unstubAllGlobals()` — `vi.restoreAllMocks()` reicht dafür nicht.
|
||||
- `apps/web/src/test/setup.ts` legt global einen ResizeObserver-Ersatz an, der immer 1200×800 meldet. Ohne `stubResizeObserver` misst jeder Test also 1200 — der neue Test wäre damit blind für genau diesen Fehler.
|
||||
- `dashboard-grid.test.tsx` ersetzt `Responsive` durch einen Durchreicher, der die Props in `captured.props` ablegt (`vi.hoisted`, Mock per `importOriginal`, damit `noCompactor` echt bleibt). Die gemessene Breite ist dadurch als `captured.props?.width` prüfbar.
|
||||
- jsdom liefert für `getBoundingClientRect()` ohne Zutun 0 — die synchrone Erstmessung schlägt im Test also nicht durch, der gestubbte Beobachter liefert den Wert. Für den Fenster-Test wird `Element.prototype.getBoundingClientRect` gezielt überschrieben.
|
||||
- `new DOMRect(0, 0, w, h)` gibt es in jsdom (die Datei `fake-resize-observer.ts` nutzt bereits `DOMRectReadOnly`) — damit braucht der Test **keinen** Cast, und der Zähler `as unknown as` bleibt bei 6.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Messung an den Knoten hängen (Ref-Rückruf, synchrone Erstmessung, Fenster-Horcher) — Ende-zu-Ende „leeres Dashboard → erste Kachel füllt die volle Breite“, zuerst rot</name>
|
||||
<files>apps/web/src/components/dashboard/dashboard-grid.test.tsx, apps/web/src/components/dashboard/dashboard-grid.tsx</files>
|
||||
<behavior>
|
||||
Zuerst die Tests, beide müssen vor der Änderung rot sein:
|
||||
|
||||
- **Test 10 — Leerzustand → gefüllt:** `stubResizeObserver({ width: 1000, height: 800 })`; `captured.props` auf null setzen; mit `widgets: []` und leeren Layouts rendern → die Überschrift des Leerzustands steht da und `captured.props` ist weiterhin null (das Raster ist nicht eingehängt); danach **`rerender`** derselben Instanz mit einer Uhr-Kachel und einem passenden `lg`-Eintrag → `captured.props?.width` ist 1000. Rot vorher: der Wert bleibt 1200.
|
||||
- **Test 11 — Fenstergröße:** `stubResizeObserver({ width: 1000, height: 800 })`; mit einer Uhr-Kachel rendern → Breite 1000; danach `Element.prototype.getBoundingClientRect` per `vi.spyOn(...).mockReturnValue(new DOMRect(0, 0, 1600, 800))` überschreiben und `window.dispatchEvent(new Event('resize'))` in `act(...)` auslösen → `captured.props?.width` ist 1600. Rot vorher: der Wert bleibt 1000, weil es keinen Fenster-Horcher gibt.
|
||||
|
||||
Die zehn bestehenden Fälle bleiben unverändert und grün — insbesondere Test 4 (Raster-Konstanten), Test 7 (Compactor-Pin mit preventCollision) und Test 9/9b (Minima).
|
||||
</behavior>
|
||||
<action>
|
||||
1. **`dashboard-grid.test.tsx`** (zuerst, rot): `act` zusätzlich aus `@testing-library/react` importieren, `stubResizeObserver` aus `@/test/fake-resize-observer`. Im bestehenden `afterEach` nach `vi.restoreAllMocks()` eine Zeile `vi.unstubAllGlobals()` ergänzen (ohne sie bliebe der gestubbte Beobachter für alle folgenden Dateien stehen). Am Ende des bestehenden `describe('DashboardGrid', …)` die zwei Fälle aus dem `<behavior>`-Block anfügen, benannt „quick-260922-vdk Test 10: …“ und „quick-260922-vdk Test 11: …“, Stil und Kommentarsprache wie die Nachbarfälle (deutsch, mit dem Warum in einer Zeile). Rot-Lauf ausführen und die Fehlermeldungen für das SUMMARY festhalten.
|
||||
|
||||
2. **`dashboard-grid.tsx`**: `useCallback` zum Import aus `react` hinzufügen. Die bisherige Kombination aus `containerRef` und dem Effekt mit leerer Abhängigkeitsliste ersetzen durch:
|
||||
- zwei Refs: `nodeRef` (Typ `HTMLDivElement | null`, hält den gerade eingehängten Knoten für den Fenster-Horcher) und `observerRef` (Typ `ResizeObserver | null`, hält den laufenden Beobachter);
|
||||
- `applyWidth` als `useCallback` mit leerer Abhängigkeitsliste: nimmt eine Zahl, ruft `setWidth` nur bei endlichem Wert größer 0 auf. React verwirft gleiche Werte selbst, ein zusätzlicher Vergleich ist unnötig und eine Rückkopplungsschleife damit ausgeschlossen (T-VDK-01);
|
||||
- `measureRef` als `useCallback` über `applyWidth`, Signatur nimmt `HTMLDivElement | null` und gibt **nichts** zurück: erst einen eventuell laufenden Beobachter trennen und `observerRef` leeren, dann `nodeRef` auf den Knoten setzen, bei `null` zurückspringen, sonst `applyWidth(node.getBoundingClientRect().width)` (das ist die synchrone Erstmessung in der Commit-Phase), dann einen neuen `ResizeObserver` anlegen, der `entries[0].contentRect.width` an `applyWidth` weitergibt, ihn auf den Knoten setzen und in `observerRef` merken. Wichtig: keine Aufräumfunktion zurückgeben — React 19 ruft den Rückruf sonst beim Aushängen nicht mehr mit `null` auf, und genau dieser Zweig ist hier der Aufräumpfad;
|
||||
- einen `useEffect` über `applyWidth`, der `resize` am `window` anmeldet und im Aufräumschritt wieder abmeldet; der Horcher misst `nodeRef` erneut, wenn dort ein Knoten liegt;
|
||||
- am Raster-`<div>` `ref={measureRef}` setzen (exakt dieser Name, ein Tor prüft ihn).
|
||||
|
||||
Alle vier Hooks stehen **vor** dem frühen Rücksprung in den Leerzustand, damit die Hook-Reihenfolge stabil bleibt — derselbe Grund, den der Kommentar bei `effectiveLayouts` bereits festhält.
|
||||
|
||||
3. **Warum-Kommentar** über der Messung, deutsch, im Stil der vorhandenen Blöcke (`quick-260916-dyv`, `quick-260916-bwo`), Präfix `quick-260922-vdk:`. Inhalt in eigenen Worten: der frühe Rücksprung in den Leerzustand rendert den gemessenen Knoten gar nicht erst, ein Effekt mit leerer Abhängigkeitsliste sieht ihn deshalb nie wieder; der Ref-Rückruf folgt dem Knoten über Aus- und Einhängen hinweg und misst in der Commit-Phase, also vor dem Zeichnen; die Folge der alten Annahme war ein `md`-Breakpoint mit 20 Spalten (strikter Größer-Vergleich in RGL), 51,6 px Spaltenbreite und ein toter Streifen rechts; der Fenster-Horcher ist ein Netz für Fälle, in denen der Beobachter nichts meldet; der Wächter verwirft 0 und nicht endliche Werte, damit eine kurzzeitig zusammengefallene Fläche das Raster nicht auf Null setzt. Den Startwert-Absatz (Entscheidung 5 oben) mit aufnehmen.
|
||||
|
||||
4. Nichts anderes anfassen: Leerzustand, `BREAKPOINTS`, `COLS`, `rowHeight`, `margin`, das fehlende `containerPadding`, `dragConfig`, `resizeConfig`, `FREE_PLACEMENT_COMPACTOR`, `applyConstraintMinima` und die `data-grid`-Erzeugung bleiben wortgleich.
|
||||
|
||||
Commit: `fix(quick-260922-vdk): Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus` (Wortlaut frei, Stil der Nachbarcommits, Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/dashboard-grid.test.tsx && pnpm --filter @tessera/web exec tsc --noEmit && grep -q "ref={measureRef}" apps/web/src/components/dashboard/dashboard-grid.tsx && grep -q "addEventListener('resize'" apps/web/src/components/dashboard/dashboard-grid.tsx && grep -q "unstubAllGlobals" apps/web/src/components/dashboard/dashboard-grid.test.tsx && grep -q "quick-260922-vdk" apps/web/src/components/dashboard/dashboard-grid.tsx</automated>
|
||||
</verify>
|
||||
<done>`dashboard-grid.test.tsx` hat 12 grüne Fälle (10 alte wortgleich, 2 neue); die zwei neuen waren nachweislich zuerst rot, die Rot-Meldungen („1200 statt 1000“ bzw. „1000 statt 1600“) stehen im SUMMARY. `tsc --noEmit` ohne Befund. Die Messung hängt am Ref-Rückruf `measureRef`, trennt den Beobachter im null-Zweig, misst synchron bei jedem Einhängen und hat einen Fenster-Horcher. Compactor, Konstanten, Leerzustand und `applyConstraintMinima` sind unverändert (Tests 4/7/9/9b belegen es). Ein Commit mit Scope `quick-260922-vdk`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog-Stichpunkt, volle Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
|
||||
<files>CHANGELOG.md</files>
|
||||
<action>
|
||||
1. **`CHANGELOG.md`**: unter „## Unveröffentlicht“ nach der bestehenden Liste „### Geändert“ einen neuen Abschnitt „### Behoben“ anlegen (Reihenfolge wie in 1.3.0: Neu, Geändert, Behoben) mit genau einem Stichpunkt in Alltagssprache, Tonlage der Nachbarzeilen, ohne Fachbegriffe: „Dashboard: war das Dashboard beim Öffnen leer, nutzte die erste hinzugefügte Kachel nur einen Teil der Breite — rechts blieb ein toter Streifen, in den sich keine Kachel ziehen ließ; das Raster misst die verfügbare Breite jetzt in jedem Fall und folgt auch einer Änderung der Fenstergröße“.
|
||||
|
||||
2. **Volle Tore** ausführen und die Zahlen ins SUMMARY schreiben: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/web test`. Ausgangsmessung dieses Plans (22.09., vor der Änderung): Web 81 Dateien / 659 Tests grün, Biome web genau 53 Warnungen, `as unknown as` in web 6. Erwartet nachher: 81 Dateien / 661 Tests, Warnungen und Zähler unverändert. Weicht eine Zahl ab, im SUMMARY benennen statt stillschweigend anpassen.
|
||||
|
||||
3. **Prüfliste** für den Orchestrator ins SUMMARY schreiben (Browser, lokal, Playwright-MCP — **nicht** auf dem Testserver), Punkt für Punkt abhakbar; ausdrücklich dazuschreiben, dass Breiten am DOM gemessen werden (`getBoundingClientRect` der Elemente), **nie** per `fetch` aus der Seite heraus:
|
||||
(a) Dashboard eines Benutzers ohne Kacheln öffnen (oder alle Kacheln entfernen und neu laden) → Leerzustand mit Bildmarke;
|
||||
(b) Bearbeiten → „Widget hinzufügen“ → Uhr: die Kachel erscheint, und beim Ziehen reicht der Platzhalter bis an den rechten Rand des Inhaltsbereichs — kein toter Streifen;
|
||||
(c) messen: Breite von `.react-grid-layout` gleicht der Breite des umgebenden Inhaltsbereichs (Abweichung höchstens der Rand von 8 px); vorher lag sie bei 1200 px unabhängig von der Fensterbreite;
|
||||
(d) eine zweite Kachel auf ein belegtes Feld ziehen → sie springt zurück, nichts wird zur Seite geschoben (unverändert gewollt); Größe ziehen stoppt am Nachbarn;
|
||||
(e) Fenster schmaler und wieder breiter ziehen → das Raster folgt, Kacheln bleiben heil;
|
||||
(f) Seite mit vorhandenen Kacheln neu laden → weiterhin richtig (der bisher schon funktionierende Pfad), und beim ersten Zeichnen ist kein Sprung von schmal auf breit zu sehen;
|
||||
(g) letzte Kachel entfernen → Leerzustand erscheint, danach eine neue Kachel hinzufügen → wieder volle Breite (der Beobachter wurde sauber getrennt und neu angehängt).
|
||||
|
||||
Commit: `docs(quick-260922-vdk): Changelog - Dashboard-Raster misst seine Breite auch aus dem Leerzustand` (nur CHANGELOG.md; Akte und STATE macht der Orchestrator; Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'rechts blieb ein toter Streifen' CHANGELOG.md && awk '/^## Unveröffentlicht/{f=1} /^## 1\.3\.0/{f=0} f&&/^### Behoben/{n++} END{exit !(n==1)}' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/web lint 2>&1 | grep -q 'Found 53 warnings' && test "$(grep -rn 'as unknown as' apps/web/src --include=*.ts --include=*.tsx | wc -l)" -eq 6 && pnpm --filter @tessera/web test</automated>
|
||||
<human-check>Am Ende der Aufgabe: der Orchestrator geht die siebenpunktige Prüfliste im Browser durch (lokal, Playwright-MCP). Entscheidend ist Punkt (b)/(c): auf einem beim Öffnen leeren Dashboard füllt die erste Kachel den Inhaltsbereich bis zum rechten Rand, und die gemessene Rasterbreite stimmt mit der Breite des Inhaltsbereichs überein.</human-check>
|
||||
</verify>
|
||||
<done>Der Changelog trägt unter „Unveröffentlicht“ genau einen neuen Abschnitt „### Behoben“ mit dem einen Stichpunkt. `pnpm type-check` 4/4 und `pnpm lint` 5/5 ohne Befund der Stufe `error`, Biome web weiterhin genau 53 Warnungen, `as unknown as` in web weiterhin 6, Web-Tests 81 Dateien / 661 grün. Die siebenpunktige Prüfliste steht im SUMMARY. Insgesamt genau zwei Commits mit Scope `quick-260922-vdk` (`git log --oneline -2`).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
|
||||
|
||||
## Vertrauensgrenzen
|
||||
|
||||
| Grenze | Beschreibung |
|
||||
|---|---|
|
||||
| Browser-Layout → React-Zustand | Gemessene Breiten (ResizeObserver, `getBoundingClientRect`, `resize`) werden zu Zustand und steuern die Rastergeometrie; die Werte sind nicht vertrauenswürdig im Sinne von „immer sinnvoll“ (0 während eines Übergangs, nicht endlich bei zusammengefallener Fläche) |
|
||||
| Knoten-Lebensdauer → Beobachter-Lebensdauer | Der ResizeObserver hängt an einem Knoten, der beim Wechsel Leerzustand ↔ gefüllt aus- und eingehängt wird |
|
||||
| Neue Vertrauensgrenze | Keine. Kein Netzverkehr, keine API, keine Benutzereingabe, keine Persistenz in diesem Plan |
|
||||
|
||||
## STRIDE-Register
|
||||
|
||||
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|
||||
|---|---|---|---|---|---|
|
||||
| T-VDK-01 | Denial of Service (Rückkopplung Messung → Zustand → Layout → Messung, bis der Tab steht) | `applyWidth` / ResizeObserver-Rückruf in `dashboard-grid.tsx` | medium | mitigate | `applyWidth` ruft `setWidth` nur bei endlichem Wert größer 0; React verwirft gleiche Werte (Object.is) und rendert dann gar nicht neu — der Beobachter meldet nach dem Einschwingen denselben Wert und die Kette endet. Zusätzlich beobachtet der Beobachter den äußeren `<div>`, dessen Breite nicht von der Rastergeometrie abhängt. |
|
||||
| T-VDK-02 | Denial of Service (Ressourcenleck: Beobachter oder Fenster-Horcher überleben das Aushängen, bei jedem Wechsel Leerzustand ↔ gefüllt einer mehr) | `measureRef` null-Zweig, `useEffect`-Aufräumschritt | medium | mitigate | Der Ref-Rückruf trennt den laufenden Beobachter als erste Handlung und gibt bewusst **keine** Aufräumfunktion zurück, damit React ihn beim Aushängen mit `null` aufruft; der Fenster-Horcher meldet sich im Aufräumschritt des Effekts ab. Punkt (g) der Prüfliste geht den Wechsel im Browser durch. |
|
||||
| T-VDK-03 | Denial of Service (Messung auf einer zusammengefallenen Fläche setzt die Breite auf 0 → Raster unbedienbar, Kacheln unerreichbar) | Synchrone Erstmessung in jsdom-losen Übergängen, `getBoundingClientRect` | low | mitigate | Wächter „größer 0 und endlich“; bleibt eine Messung aus, gilt weiter der letzte gültige Wert statt 0. |
|
||||
| T-VDK-04 | Tampering (stille Verhaltensänderung beim Ziehen: ein belegtes Feld würde plötzlich nachgeben) | `FREE_PLACEMENT_COMPACTOR`, `dragConfig`, `resizeConfig` | medium | mitigate | Diese Stellen werden nicht angefasst; Test 7 pinnt den Compactor als echten `noCompactor` plus `preventCollision: true`, Test 6 die Zieh-Konfiguration, Test 4 die Raster-Konstanten — alle laufen im Tor der Aufgabe 1 mit. Prüfliste (d) belegt es zusätzlich im Browser. |
|
||||
| T-VDK-05 | Information Disclosure | — | low | accept | Es werden nur Layout-Maße des eigenen Fensters gelesen; nichts verlässt den Browser, nichts wird geloggt oder gespeichert. |
|
||||
| T-VDK-06 | Repudiation | — | low | accept | Reine Anzeige-Geometrie ohne Fremdwirkung; kein Audit-Log nötig, ASVS 1 genügt. |
|
||||
| T-VDK-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — `ResizeObserver`, `getBoundingClientRect`, `window`-Ereignisse und `DOMRect` sind Browser-Schnittstellen, `act` und `vi.spyOn` sind bereits vorhanden. Will der Executor doch etwas installieren: Stopp und Rückfrage an den Orchestrator, keine Installation ohne ausdrückliche Freigabe. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Automatisch (Executor, in den `<verify>`-Blöcken): gezielter Testlauf von `dashboard-grid.test.tsx` (12 Fälle), `tsc --noEmit`, Struktur-Tore auf Ref-Rückruf, Fenster-Horcher, `unstubAllGlobals` und den Warum-Kommentar; danach Changelog-Tore, `pnpm type-check` 4/4, `pnpm lint` 5/5, Biome web genau 53 Warnungen, `as unknown as` web 6, voller Web-Testlauf.
|
||||
|
||||
Rot-Nachweis (Aufgabe 1): beide neuen Fälle laufen vor der Änderung rot; die Meldungen gehören ins SUMMARY.
|
||||
|
||||
Manuell (Orchestrator, Prüfliste aus Aufgabe 2 Punkt 3, lokal im Browser, nicht auf dem Testserver): leeres Dashboard → erste Kachel füllt die Breite; gemessene Rasterbreite gleicht der Breite des Inhaltsbereichs; belegtes Feld bleibt blockiert; Fenstergröße; Neuladen mit Kacheln unverändert; Leerzustand → gefüllt → Leerzustand → gefüllt.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Auf einem beim Öffnen leeren Dashboard füllt die erste hinzugefügte Kachel den Inhaltsbereich; rechts kein toter Streifen (Test 10 + Prüfliste b/c)
|
||||
- [ ] Die Messung folgt dem Knoten über Aus- und Einhängen und läuft synchron vor dem Zeichnen (Ref-Rückruf `measureRef`, Struktur-Tor + Prüfliste f/g)
|
||||
- [ ] Fenstergröße ändern misst neu (Test 11 + Prüfliste e)
|
||||
- [ ] Ziehverhalten unverändert: belegtes Feld bleibt blockiert, nichts wird verschoben (Tests 4/6/7/9/9b grün, Prüfliste d)
|
||||
- [ ] 12 Fälle in `dashboard-grid.test.tsx`, Web gesamt 81 Dateien / 661 Tests grün
|
||||
- [ ] `pnpm type-check` 4/4, `pnpm lint` 5/5, Biome web 53 Warnungen, `as unknown as` web 6
|
||||
- [ ] Changelog: genau ein Stichpunkt unter „Unveröffentlicht → Behoben“
|
||||
- [ ] Genau zwei Commits mit Scope `quick-260922-vdk`
|
||||
- [ ] Nur `apps/web` und `CHANGELOG.md` angefasst; keine neuen Pakete
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
`.planning/quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/260922-vdk-SUMMARY.md` schreiben, wenn beide Aufgaben fertig sind — mit Rot-Meldungen der zwei neuen Tests, den gemessenen Zahlen (Tests, Warnungen, Zähler) und der siebenpunktigen Prüfliste für den Browser-Rundgang.
|
||||
</output>
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
---
|
||||
phase: quick-260922-vdk
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [react, react-grid-layout, ref-callback, resize-observer, dashboard, vitest]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "Dashboard-Raster misst seine Breite auch beim Wechsel Leerzustand -> gefuellt (Ref-Rueckruf statt Einmal-Effekt)"
|
||||
- "Fenster-Horcher als Netz zusaetzlich zum ResizeObserver"
|
||||
affects: [dashboard-grid, dashboard]
|
||||
|
||||
actuals:
|
||||
tokens: 2392
|
||||
tasks: 2
|
||||
commits: 2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Ref-Rueckruf (callback ref) statt useEffect mit leerer Abhaengigkeitsliste, wenn eine Messung den tatsaechlich eingehaengten Knoten ueber Aus-/Einhaengen hinweg verfolgen muss"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Ref-Rueckruf measureRef ersetzt containerRef + Einmal-Effekt; misst synchron in der Commit-Phase, kein useLayoutEffect noetig"
|
||||
- "Kein Aufraeum-Rueckgabewert aus measureRef, damit React 19 den Rueckruf beim Aushaengen mit null aufruft (das ist der Aufraeumpfad)"
|
||||
- "Fenster-Horcher zusaetzlich zum ResizeObserver, nicht als Ersatz"
|
||||
- "Startwert bleibt 1200 (nur bis zur ersten Commit-Phase relevant)"
|
||||
- "FREE_PLACEMENT_COMPACTOR/preventCollision, BREAKPOINTS, COLS, rowHeight, margin, applyConstraintMinima unveraendert"
|
||||
|
||||
patterns-established:
|
||||
- "quick-260922-vdk Kommentarblock in dashboard-grid.tsx erklaert das Warum der Ref-Rueckruf-Messung"
|
||||
|
||||
requirements-completed: [QUICK-260922-VDK]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus (Ref-Rueckruf, synchrone Erstmessung, Fenster-Horcher)"
|
||||
requirement: "QUICK-260922-VDK"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260922-vdk Test 10: Leerzustand -> gefuellt misst die tatsaechliche Breite statt beim Startwert 1200 stehenzubleiben"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260922-vdk Test 11: Fenstergroesse aendert sich -> der Fenster-Horcher misst neu"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Die Unit-Tests beweisen die gemessene Breite in jsdom; ob im echten Browser rechts kein toter Streifen mehr bleibt und die Rasterbreite sichtbar der Breite des Inhaltsbereichs entspricht, verlangt einen Browser-Rundgang (Prueflliste unten)."
|
||||
- id: D2
|
||||
description: "Ziehverhalten unveraendert: belegtes Feld bleibt blockiert, nichts wird zur Seite geschoben"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260916-dyv Test 7: Compactor-Pin"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260916-dyv Test 6: dragConfig-Pin"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: ~9min
|
||||
completed: 2026-09-22
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-vdk: Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus Summary
|
||||
|
||||
**Messung an einem Ref-Rueckruf (`measureRef`) statt an einem Einmal-Effekt: der Beobachter folgt dem eingehaengten `<div>` ueber Leerzustand <-> gefuellt hinweg, misst synchron in der Commit-Phase, plus Fenster-Horcher als Netz — 20-Spalten/`md`-Fehlmessung nach 1200 px behoben.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~9 min
|
||||
- **Completed:** 2026-09-22
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 3
|
||||
|
||||
## Accomplishments
|
||||
- Dashboard-Raster misst jetzt in jedem Fall die tatsaechliche Breite des Inhaltsbereichs, auch wenn `DashboardGrid` mit null Kacheln einhaengt und die erste Kachel erst spaeter erscheint
|
||||
- Fenster-Horcher als Sicherheitsnetz zusaetzlich zum ResizeObserver
|
||||
- Zwei neue Regressionstests, nachweislich zuerst rot
|
||||
- Ziehverhalten (`FREE_PLACEMENT_COMPACTOR` mit `preventCollision`), Raster-Konstanten und Leerzustand unveraendert (per Test 4/6/7/9/9b bestaetigt)
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Aufgabe 1: Messung an den Knoten haengen (Ref-Rueckruf, synchrone Erstmessung, Fenster-Horcher)** - `d9f2af3` (fix)
|
||||
2. **Aufgabe 2: Changelog-Stichpunkt, volle Tore, Zaehler, Pruefliste** - `cf67c8a` (docs)
|
||||
|
||||
_Ledger: `plan_head_before` = `3e8c0f4` (letzter Commit vor diesem Plan — die bereits committete Akte). `git rev-list --count 3e8c0f4..HEAD` = 2, deckt sich mit den zwei oben genannten Commits._
|
||||
|
||||
**Plan metadata:** wird vom Orchestrator nach diesem SUMMARY committet (STATE.md/ROADMAP.md ausserhalb dieses Plans).
|
||||
|
||||
## Rot-Nachweis (Aufgabe 1, vor der Aenderung)
|
||||
|
||||
Testlauf vor dem Umbau von `dashboard-grid.tsx` (nur die Test-Datei war schon geaendert):
|
||||
|
||||
```
|
||||
FAIL src/components/dashboard/dashboard-grid.test.tsx > DashboardGrid > quick-260922-vdk Test 10: ...
|
||||
AssertionError: expected 1200 to be 1000 // Object.is equality
|
||||
|
||||
FAIL src/components/dashboard/dashboard-grid.test.tsx > DashboardGrid > quick-260922-vdk Test 11: ...
|
||||
AssertionError: expected 1000 to be 1600 // Object.is equality
|
||||
```
|
||||
|
||||
10 von 12 Faellen gruen, genau die zwei neuen rot — wie erwartet: Test 10 blieb beim Startwert 1200 (der frueh zurueckspringende Leerzustand rendert den gemessenen Knoten nie, der Effekt mit leerer Abhaengigkeitsliste sieht ihn also nie), Test 11 blieb bei 1000, weil es vorher keinen Fenster-Horcher gab.
|
||||
|
||||
Nach dem Umbau: alle 12 Faelle gruen (`pnpm --filter @tessera/web exec vitest run src/components/dashboard/dashboard-grid.test.tsx`).
|
||||
|
||||
## Gemessene Zahlen (nachher, alle Tore)
|
||||
|
||||
| Tor | Ausgangsmessung (22.09., vor der Aenderung) | Nachher (gemessen) |
|
||||
|---|---|---|
|
||||
| `dashboard-grid.test.tsx` | 10 Faelle | 12 Faelle, alle gruen |
|
||||
| Web Tests gesamt | 81 Dateien / 659 Tests | 81 Dateien / 661 Tests, alle gruen |
|
||||
| `pnpm type-check` | — | 4/4 ohne Befund |
|
||||
| `pnpm lint` | — | 5/5 ohne Befund der Stufe `error` |
|
||||
| Biome web Warnungen | 53 | 53 (unveraendert) |
|
||||
| `as unknown as` in web | 6 | 6 (unveraendert) |
|
||||
|
||||
Keine Abweichung — alle erwarteten Zahlen aus dem Plan treffen exakt zu.
|
||||
|
||||
## Files Created/Modified
|
||||
- `apps/web/src/components/dashboard/dashboard-grid.tsx` - `measureRef`-Ref-Rueckruf ersetzt `containerRef` + Einmal-Effekt; `applyWidth`-Wächter (verwirft 0/nicht-endliche Werte); Fenster-Horcher (`resize`-Listener); deutscher Warum-Kommentarblock `quick-260922-vdk`
|
||||
- `apps/web/src/components/dashboard/dashboard-grid.test.tsx` - zwei neue Faelle (Test 10: Leerzustand -> gefuellt misst 1000; Test 11: resize-Ereignis misst 1600), `stubResizeObserver`-Import, `vi.unstubAllGlobals()` im `afterEach`
|
||||
- `CHANGELOG.md` - neuer Abschnitt „### Behoben" unter „## Unveroeffentlicht" mit einem Stichpunkt
|
||||
|
||||
## Decisions Made
|
||||
Keine neuen Entscheidungen — die im Plan gebundenen Entscheidungen (Ref-Rueckruf statt Einmal-Effekt, synchron vor dem ersten Zeichnen, Fenster-Horcher als Netz, Ziehverhalten unveraendert, Startwert 1200 bleibt, nur `apps/web`) wurden wortgleich umgesetzt.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP, nicht Testserver)
|
||||
|
||||
Breiten werden am DOM gemessen (`getBoundingClientRect` der Elemente), **nie** per `fetch` aus der Seite heraus — das taeuscht in beide Richtungen.
|
||||
|
||||
- [ ] (a) Dashboard eines Benutzers ohne Kacheln oeffnen (oder alle Kacheln entfernen und neu laden) -> Leerzustand mit Bildmarke erscheint
|
||||
- [ ] (b) Bearbeiten -> „Widget hinzufuegen" -> Uhr: die Kachel erscheint, und beim Ziehen reicht der Platzhalter bis an den rechten Rand des Inhaltsbereichs — kein toter Streifen
|
||||
- [ ] (c) messen: Breite von `.react-grid-layout` gleicht der Breite des umgebenden Inhaltsbereichs (Abweichung hoechstens der Rand von 8 px); vorher lag sie bei 1200 px unabhaengig von der Fensterbreite
|
||||
- [ ] (d) eine zweite Kachel auf ein belegtes Feld ziehen -> sie springt zurueck, nichts wird zur Seite geschoben (unveraendert gewollt); Groesse ziehen stoppt am Nachbarn
|
||||
- [ ] (e) Fenster schmaler und wieder breiter ziehen -> das Raster folgt, Kacheln bleiben heil
|
||||
- [ ] (f) Seite mit vorhandenen Kacheln neu laden -> weiterhin richtig (der bisher schon funktionierende Pfad), und beim ersten Zeichnen ist kein Sprung von schmal auf breit zu sehen
|
||||
- [ ] (g) letzte Kachel entfernen -> Leerzustand erscheint, danach eine neue Kachel hinzufuegen -> wieder volle Breite (der Beobachter wurde sauber getrennt und neu angehaengt)
|
||||
|
||||
Entscheidend: Punkt (b)/(c) — auf einem beim Oeffnen leeren Dashboard fuellt die erste Kachel den Inhaltsbereich bis zum rechten Rand, und die gemessene Rasterbreite stimmt mit der Breite des Inhaltsbereichs ueberein.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Vertrauensgrenze, keine neuen Pakete. Alle sechs T-VDK-Punkte aus dem Plan-Threat-Model sind mit Tests bzw. Struktur-Toren abgedeckt (siehe Rot-Nachweis und gemessene Zahlen oben); nichts Neues gefunden.
|
||||
|
||||
## Next Phase Readiness
|
||||
Kein laufender Meilenstein, keine Folge-Phase direkt abhaengig. Naechster Schritt laut STATE.md bleibt: Widget-Modul-Kopplung (`WIDGET_MODULE_MAP`) und danach das Proxmox-Modul — unabhaengig von dieser Quick-Aufgabe.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- FOUND: apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
- FOUND: CHANGELOG.md
|
||||
- FOUND: .planning/quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/260922-vdk-SUMMARY.md
|
||||
- FOUND commit: d9f2af3
|
||||
- FOUND commit: cf67c8a
|
||||
|
||||
---
|
||||
*Phase: quick-260922-vdk*
|
||||
*Completed: 2026-09-22*
|
||||
|
||||
## Rundgang durch den Orchestrator (22.09.2026, echter Linux-Client, nicht der Browser)
|
||||
|
||||
Der Nutzer hat den Fehler im **Linux-Client** gemeldet und ausdruecklich gesagt, ein
|
||||
Browser-Test bringe nichts. Geprueft wurde deshalb im echten Paket: `Tessera-1.3.0.AppImage`
|
||||
aus dem Gitea-Release, auf dem Entwicklungsrechner gestartet (`DISPLAY=:10`,
|
||||
`WEBKIT_INSPECTOR_HTTP_SERVER=127.0.0.1:9230`), gesteuert ueber den WebKit-Remote-Inspektor
|
||||
(Treiber `scratchpad/wk.mjs`, Target-Protokoll: `Target.sendMessageToTarget` +
|
||||
`Runtime.evaluate`). Server: lokaler Stack.
|
||||
|
||||
**Ausgangsmessung VOR dem Fix** (gleiche Sitzung, gleicher Client):
|
||||
|
||||
| Weg | Bereich | Kachel | Rasterbreite laut Rechnung |
|
||||
|-----|---------|--------|----------------------------|
|
||||
| Neuladen MIT Kachel | 1176 px | 459 px | 1176 px — richtig |
|
||||
| Seitenleiste auf/zu | 1000 ↔ 1176 px | folgt | richtig |
|
||||
| **Leeres Dashboard, dann Kachel hinzufuegen** | 1000 px | **469 px** | **1200 px — falsch** |
|
||||
|
||||
Der dritte Weg ist der Fehlerfall: `DashboardGrid` haengt mit null Kacheln ein, der
|
||||
gemessene `<div>` existiert nicht, der Einmal-Effekt bricht ab und laeuft nie wieder.
|
||||
|
||||
**Nach dem Fix, derselbe Weg** (Kachel entfernt, gespeichert, neu geladen -> leeres
|
||||
Dashboard -> Kalender hinzugefuegt):
|
||||
|
||||
- `width`-Eigenschaft an `Responsive`: **1000** (= echte Bereichsbreite), Breakpoint `md`,
|
||||
20 Spalten — aus dem React-Fiber ausgelesen, nicht geraten.
|
||||
- Kachel `style.width`: **389 px** = `8 × 41,6 + 56`, exakt der Sollwert fuer 1000 px.
|
||||
- Ziehen nach rechts (synthetische Maus-Ereignisse, jeweils mit Wartezeit, damit React
|
||||
dazwischen rendert): Platzhalter laeuft 8 → 107 → 256 → 405 → 554 → **603** und bleibt
|
||||
dort. `1000 − 8 − 389 = 603` — **der Platzhalter erreicht jetzt exakt den rechten Rand**.
|
||||
Die Kachel wird auch dort abgelegt (`tileX 603`).
|
||||
|
||||
**Messfalle, dokumentiert damit sie niemanden noch einmal kostet:** `getBoundingClientRect()`
|
||||
und `getComputedStyle().width` lieferten im Client weiter 469 px, obwohl `style.width`
|
||||
bereits 389 px war. Grund: `.react-grid-item` hat `transition: width .2s`, und das
|
||||
Client-Fenster lag im Hintergrund — WebKitGTK friert die Animationsuhr dann ein, der
|
||||
Uebergang bleibt auf dem Startwert stehen (`getAnimations()` meldete eine laufende
|
||||
`CSSTransition` auf `width`). **Im Client gegen die gesetzten Werte messen
|
||||
(`style.width`, `style.transform`) oder gegen die React-Eigenschaften, nie gegen die
|
||||
gemalte Box** — sonst misst man die eingefrorene Animation statt des Ergebnisses.
|
||||
|
||||
**Nicht angefasst, wie zugesagt:** belegte Plaetze bleiben gesperrt, nichts weicht aus
|
||||
(`FREE_PLACEMENT_COMPACTOR` mit `preventCollision` unveraendert) — ausdrueckliche Ansage des
|
||||
Nutzers am 22.09.
|
||||
|
||||
**Testreste der Pruefung:** lokaler Testbenutzer `clienttest` und die zeitweise auf
|
||||
`NODE_ENV=development` gesetzte lokale API (WebKitGTK nimmt `secure`-Kekse ueber `http` nicht
|
||||
an, Chromium macht fuer `localhost` eine Ausnahme) — beides nach der Pruefung wieder
|
||||
zurueckgebaut.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
created: 2026-09-22
|
||||
title: DashboardImage — Spalte "data" entfernen und "storagePath" auf NOT NULL setzen (Stufe 2 der Umstellung aus quick-260922-hk4)
|
||||
area: apps/api/prisma
|
||||
severity: cleanup
|
||||
trigger: erst wenn alpha UND live je einmal mit einer Version >= der Freigabe nach 1.3.0 gelaufen sind — dann hat der Bootstrap-Umzug auf beiden Servern gearbeitet und die Bytes liegen im Dateibereich.
|
||||
relates_to: quick-260922-hk4 (.planning/quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/)
|
||||
---
|
||||
|
||||
## Worum es geht
|
||||
|
||||
Die Bilder des Bilderrahmen-Widgets sind mit `quick-260922-hk4` aus der
|
||||
Datenbank in den Dateibereich gezogen (`user-files/dashboard-images/
|
||||
<userId>/<id>.<ext>`, Pfad in der Spalte `storagePath`). Die Umstellung ist
|
||||
BEWUSST ZWEISTUFIG:
|
||||
|
||||
- **Stufe 1, ausgeliefert** (Migration `20260922120000_dashboard_image_to_disk`):
|
||||
`storagePath` dazu (NULLbar), `data` wird NULLbar — aber NICHT gelöscht.
|
||||
Der Umzug der vorhandenen Zeilen passiert beim ersten Start automatisch
|
||||
(`DashboardImagesService.onApplicationBootstrap()`).
|
||||
- **Stufe 2, dieser Zettel:** `data` löschen, `storagePath` auf NOT NULL.
|
||||
|
||||
Warum nicht sofort: `prisma migrate deploy` läuft VOR dem Anwendungsstart.
|
||||
Ein sofortiges `DROP COLUMN "data"` hätte die Bytes vernichtet, bevor der
|
||||
Umzug beim Start sie lesen konnte (T-HK4-03).
|
||||
|
||||
## Was zu tun ist
|
||||
|
||||
1. **Vorbedingung prüfen** (auf BEIDEN Servern, alpha und live):
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM "DashboardImage" WHERE "storagePath" IS NULL;
|
||||
```
|
||||
|
||||
Muss überall `0` sein. Ist sie es nicht, ist der Umzug dort noch nicht
|
||||
gelaufen (Server noch auf einer älteren Version) — dann NICHT ausliefern.
|
||||
|
||||
2. **Neue Migration** `20260922120100_dashboard_image_drop_data`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;
|
||||
ALTER TABLE "DashboardImage" DROP COLUMN "data";
|
||||
```
|
||||
|
||||
3. **Schema** `apps/api/prisma/schema.prisma`: Feld `data Bytes?` entfernen,
|
||||
`storagePath String?` → `storagePath String`.
|
||||
|
||||
4. **Dienst** `apps/api/src/dashboard/dashboard-images.service.ts`:
|
||||
`onApplicationBootstrap()` samt `forSystem()`-Aufruf entfernt sich damit
|
||||
— der Umzug hat seine Arbeit getan. Danach:
|
||||
- Eintrag `apps/api/src/dashboard/dashboard-images.service.ts` aus
|
||||
`FORSYSTEM_ALLOWED_CALL_SITES` in `apps/api/src/prisma/rls-access-inventory.spec.ts`
|
||||
wieder ENTFERNEN (die Liste ist ein „genau", ein veralteter Eintrag
|
||||
macht die Spec rot).
|
||||
- In `docs/mandantentrennung-zugriffsklassifikation.md` den Stand der Zeile
|
||||
`dashboard-images.service.ts`/`dashboardImage` von `system-gebunden`
|
||||
zurück auf `gebunden` setzen und die Zahlen der Bereichszeile
|
||||
`dashboard` sowie die Summe neu messen (Gate-Schleife, nicht
|
||||
abschreiben).
|
||||
- Die Tests 18, 21, 22 und 23 der Dienst-Spec (Zeile ohne `storagePath`,
|
||||
Bootstrap-Umzug) entfallen mit dem Umzug.
|
||||
- Die Regel `system_read_policy` auf `"DashboardImage"` (angelegt in
|
||||
20260922120000) kann bleiben oder mit `DROP POLICY` fallen — bleibt sie,
|
||||
gehört sie in der Klassifikation erwähnt; fällt sie, ist die
|
||||
Aufzählung „fünf/sechs Tabellen" dort nachzuziehen.
|
||||
|
||||
5. **Prüfen**, dass die Datenbank kleiner wird:
|
||||
|
||||
```sql
|
||||
SELECT pg_size_pretty(pg_total_relation_size('"DashboardImage"'));
|
||||
```
|
||||
|
||||
(Nach dem DROP zusätzlich `VACUUM FULL "DashboardImage";`, sonst gibt
|
||||
PostgreSQL den Platz nicht ans Dateisystem zurück.)
|
||||
|
||||
## Was passiert, wenn es liegen bleibt
|
||||
|
||||
Nichts Schlimmes: die Spalte steht leer herum und kostet je neuer Zeile
|
||||
nichts. Der Gewinn der Umstellung (kleiner `pg_dump`) ist bereits da, weil
|
||||
neue Uploads keine Bytes mehr in die Zeile schreiben. Nur die Bytes der
|
||||
ALTEN Bilder bleiben bis dahin doppelt vorhanden — einmal in der Datei,
|
||||
einmal in der Spalte.
|
||||
@@ -4,9 +4,23 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
## 1.3.1 – 2026-09-23
|
||||
|
||||
### Geändert
|
||||
|
||||
- Bilderrahmen: hochgeladene Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten Start automatisch um
|
||||
- Dashboard: Kacheln, die zu einem Modul gehören, erscheinen nur noch für Benutzer, die dieses Modul nutzen dürfen; eine nicht mehr freigegebene Kachel erklärt das jetzt, statt leer zu bleiben
|
||||
|
||||
### Behoben
|
||||
|
||||
- Dashboard: war das Dashboard beim Öffnen leer, nutzte die erste hinzugefügte Kachel nur einen Teil der Breite — rechts blieb ein toter Streifen, in den sich keine Kachel ziehen ließ; das Raster misst die verfügbare Breite jetzt in jedem Fall und folgt auch einer Änderung der Fenstergröße
|
||||
|
||||
## 1.3.0 – 2026-09-22
|
||||
|
||||
### Neu
|
||||
|
||||
- Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel anzeigen; optionaler Titel und automatisches Neuladen (1 Minute bis 1 Stunde); die eingebettete Seite kann Tessera nicht verlassen; „In neuem Tab öffnen“ führt jederzeit zur Seite selbst — manche Webseiten lassen sich nicht einbetten, der Rahmen bleibt dann leer; Einstellungen unter Einstellungen → Dashboard
|
||||
- Dashboard-Widget „XFrame“: nur einen Ausschnitt der Webseite zeigen – den Rahmen in einer Vorschau verschieben und an den Ecken ziehen oder Links/Oben/Breite/Höhe eintippen; die Kachel zeigt genau diesen Ausschnitt und passt ihn an ihre Größe an; Vergrößerung der ganzen Seite (50 bis 150 %); „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen – der Ausschnitt ist eine Position auf der Seite und kann verrutschen, wenn die Seite ihren Aufbau ändert
|
||||
- Dashboard-Widget „Bilderrahmen“: eigene Bilder hochladen (PNG, JPEG, GIF, WebP; höchstens 5 MB je Bild, bis zu 30 Bilder) oder Bilder per https-Adresse einbinden; Bildausschnitt ganz sichtbar oder formatfüllend, Wechselintervall, Reihenfolge oder Zufall, Bildunterschrift; Klick zeigt das Bild groß; Verwaltung unter Einstellungen → Dashboard
|
||||
- Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus mit den Pfeilen „Nach oben“/„Nach unten“ festlegen
|
||||
- Desktop-App: das Symbol im Infobereich zeigt den verbundenen Tessera-Server – im Hinweistext und als erste Zeile des Menüs; in der App auch unter Einstellungen → Desktop-App als „Verbunden mit: …“
|
||||
@@ -22,6 +36,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
### Behoben
|
||||
|
||||
- Desktop-App: die Knöpfe „Herunterladen“ unter Einstellungen → Desktop-App waren in der App ohne Funktion (Windows und Linux) – der Download öffnet sich jetzt im Browser des Systems
|
||||
- Desktop-App: der Update-Eintrag im Menü des Infobereich-Symbols bleibt nicht mehr stumm ausgegraut – schlägt die Update-Prüfung fehl, steht der Grund im Eintrag (z. B. „HTTP 401“, wenn ein Passwortschutz am Proxy die Anfrage abweist) und ein Klick prüft erneut; die App prüft außerdem alle vier Stunden, nicht mehr nur beim Start
|
||||
- Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler oder internen Adressen – das Symbol wird jetzt trotz Zertifikatsfehler geholt, bei internen Adressen versucht es der Browser direkt
|
||||
|
||||
## 1.2.0 – 2026-09-17
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- quick-260922-hk4 — Bilderrahmen-Bilder wandern aus der Datenbank in den
|
||||
-- Dateibereich (Volume `user-files`).
|
||||
--
|
||||
-- Warum: gesichert wird von Hand per `pg_dump` (docs/anleitung-betrieb.md
|
||||
-- Kap. 6). Jedes Bild waechst in diesen Abzug hinein — 30 Bilder à 5 MiB je
|
||||
-- Benutzer sind im Extremfall 150 MB PRO BENUTZER, gegen eine heute 18 MB
|
||||
-- grosse Datenbank (gemessen 22.09.2026 auf alpha). Die Bytes liegen ab
|
||||
-- dieser Version unter
|
||||
-- `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`; die Zeile
|
||||
-- haelt nur noch den relativen Pfad in "storagePath" — dasselbe Muster wie
|
||||
-- `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
|
||||
-- (dkv-export.service.ts). Der Dateiname ist IMMER servergeneriert (die
|
||||
-- UUID der Zeile plus die Endung aus dem an den Magic Bytes ERKANNTEN
|
||||
-- Mime-Typ); kein Byte aus der Anfrage, insbesondere nicht
|
||||
-- "originalName", geht je in einen Pfad (T-HK4-01, Muster T-07-09).
|
||||
--
|
||||
-- ZWEISTUFIG, UND WARUM DIESE MIGRATION "data" NICHT LOESCHT (T-HK4-03):
|
||||
-- Vorhandene Zeilen tragen ihre Bytes noch in "data". Der Umzug auf die
|
||||
-- Platte passiert beim ersten Start dieser Version automatisch
|
||||
-- (DashboardImagesService.onApplicationBootstrap, liest systemgebunden ueber
|
||||
-- alle Mandanten, schreibt je Zeile mandantengebunden zurueck) — der Nutzer
|
||||
-- muss nichts ausfuehren. Wuerde diese Migration die Spalte sofort
|
||||
-- loeschen, laufen Migration und Umzug im selben Start in der falschen
|
||||
-- Reihenfolge ("migrate deploy" laeuft VOR dem Anwendungsstart) und die
|
||||
-- Bytes waeren weg, bevor sie jemand gelesen hat. Deshalb:
|
||||
-- Stufe 1 (diese Migration): "storagePath" dazu (NULLbar), "data" bleibt
|
||||
-- stehen und wird NULLbar, damit neue Uploads sie leer lassen.
|
||||
-- Stufe 2 (spaetere Freigabe, Migration
|
||||
-- 20260922120100_dashboard_image_drop_data, vorgemerkt in
|
||||
-- .planning/todos/pending/): "storagePath" SET NOT NULL und
|
||||
-- DROP COLUMN "data" — erst, wenn alpha UND live einmal mit
|
||||
-- einer Version >= dieser gelaufen sind.
|
||||
--
|
||||
-- Das Prisma-Modell behaelt in Stufe 1 bewusst `data Bytes?` (optional).
|
||||
-- Damit bleibt der Bootstrap-Umzug typisiert und braucht kein rohes SQL;
|
||||
-- die Spalte verschwindet aus Modell und Tabelle gemeinsam in Stufe 2.
|
||||
--
|
||||
-- Rechte/Regeln: "tenant_isolation_policy" aus 20260921120000 bleibt
|
||||
-- unveraendert. Kein DROP POLICY.
|
||||
|
||||
-- Relativer Pfad zur Monorepo-Wurzel, z. B.
|
||||
-- "user-files/dashboard-images/<userId>/<id>.png". Stufe 2 macht die Spalte
|
||||
-- NOT NULL.
|
||||
ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;
|
||||
|
||||
-- Neue Uploads schreiben keine Bytes mehr in die Zeile; die Spalte bleibt
|
||||
-- fuer die Dauer von Stufe 1 als Sicherheitsnetz erhalten.
|
||||
ALTER TABLE "DashboardImage" ALTER COLUMN "data" DROP NOT NULL;
|
||||
|
||||
-- Systemkontext-Leserecht (Muster 20260914120000_rls_system_context_read):
|
||||
-- der Bootstrap-Umzug liest die noch nicht umgezogenen Zeilen ueber ALLE
|
||||
-- Mandanten (`forSystem()`), bevor er je Zeile mandantengebunden
|
||||
-- zurueckschreibt. Ohne diese Regel saehe er nach dem Scharfschalten der
|
||||
-- Datenbankrolle (Etappe 4, Schalter heute AUS) NULL Zeilen und stellte die
|
||||
-- Arbeit stumm ein — genau die Falle, die 20260914120000 fuer die fuenf
|
||||
-- Hintergrunddienst-Tabellen geschlossen hat. Permissiv und NUR FOR SELECT:
|
||||
-- Schreiben bleibt allein der Mandantenregel unterstellt.
|
||||
CREATE POLICY system_read_policy ON "DashboardImage"
|
||||
FOR SELECT USING (is_system_context());
|
||||
@@ -212,12 +212,16 @@ model WidgetInstance {
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers,
|
||||
// als bytea in der Datenbank (kein Docker-Volume, die Sicherung deckt es mit
|
||||
// ab). Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
||||
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
|
||||
// Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
||||
// Benutzer) und die Magic-Byte-Erkennung leben in
|
||||
// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
|
||||
// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
|
||||
//
|
||||
// quick-260922-hk4: die Bytes liegen jetzt im Dateibereich
|
||||
// (user-files/dashboard-images/<userId>/<id>.<ext>), die Zeile haelt nur
|
||||
// noch den relativen Pfad — Muster User.avatarPath. Die Datenbanksicherung
|
||||
// (pg_dump) bleibt dadurch klein.
|
||||
model DashboardImage {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
@@ -225,7 +229,14 @@ model DashboardImage {
|
||||
originalName String
|
||||
mimeType String
|
||||
size Int
|
||||
data Bytes
|
||||
// Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die
|
||||
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern
|
||||
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit
|
||||
// 20260922120100 (vorgemerkt in .planning/todos/pending/).
|
||||
data Bytes?
|
||||
// Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der
|
||||
// Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL.
|
||||
storagePath String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
|
||||
@@ -1,33 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts
|
||||
* (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma`
|
||||
* unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert,
|
||||
* welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt
|
||||
* damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag).
|
||||
* Bindung an forTenant()/forSystem() — dasselbe Muster wie
|
||||
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
|
||||
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
|
||||
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
|
||||
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
|
||||
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
|
||||
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
|
||||
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
|
||||
* unterscheidet beide ueber `via`.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
||||
prisma.__makeBoundClient(tenantId, userId),
|
||||
),
|
||||
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
|
||||
}));
|
||||
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
|
||||
/**
|
||||
* dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen).
|
||||
* dashboard-images.service.spec — quick-260921-pi9 (Bilderrahmen),
|
||||
* erweitert in quick-260922-hk4 (Bilder auf der Festplatte statt in der
|
||||
* Datenbank).
|
||||
*
|
||||
* Elf Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload
|
||||
* ohne Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE
|
||||
* Richtungen (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND
|
||||
* fremder Mandant -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes,
|
||||
* Loeschen eigen/fremd, und der Nachweis, dass jede Methode
|
||||
* Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload ohne
|
||||
* Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE Richtungen
|
||||
* (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND fremder Mandant
|
||||
* -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, Loeschen
|
||||
* eigen/fremd, und der Nachweis, dass jede Methode
|
||||
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
||||
* Argument aufruft.
|
||||
*
|
||||
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
|
||||
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
||||
* desktop.service.spec.ts) ueber den Testschalter
|
||||
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
||||
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
||||
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
||||
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
||||
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
|
||||
* Umzug beim Start (T-HK4-03).
|
||||
*/
|
||||
|
||||
interface ImageRow {
|
||||
@@ -37,11 +57,13 @@ interface ImageRow {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
data: Uint8Array;
|
||||
data: Uint8Array | null;
|
||||
storagePath: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
via: 'tenant' | 'system';
|
||||
tenantId: string;
|
||||
userId: string | undefined;
|
||||
model: string;
|
||||
@@ -55,11 +77,31 @@ interface FakePrisma {
|
||||
__rows: ImageRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
__makeSystemClient(): { dashboardImage: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]);
|
||||
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||
|
||||
/** Ablageort der Testdateien — echtes Verzeichnis, kein fs-Mock. */
|
||||
let imagesDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.DASHBOARD_IMAGES_DIR;
|
||||
|
||||
beforeAll(() => {
|
||||
imagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-dashboard-images-'));
|
||||
process.env.DASHBOARD_IMAGES_DIR = imagesDir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(imagesDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.DASHBOARD_IMAGES_DIR;
|
||||
} else {
|
||||
process.env.DASHBOARD_IMAGES_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
return {
|
||||
id: overrides.id ?? 'img-1',
|
||||
@@ -68,11 +110,30 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
originalName: overrides.originalName ?? 'foto.png',
|
||||
mimeType: overrides.mimeType ?? 'image/png',
|
||||
size: overrides.size ?? PNG.length,
|
||||
data: overrides.data ?? Uint8Array.from(PNG),
|
||||
data: overrides.data === undefined ? null : overrides.data,
|
||||
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine Zeile MIT passender Datei auf der Platte an — der Normalfall
|
||||
* nach dem Upload (die Tests 8-12 pruefen Besitz und Bindung, nicht die
|
||||
* Ablage).
|
||||
*/
|
||||
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
||||
const row = makeRow(overrides);
|
||||
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
|
||||
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, bytes);
|
||||
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
|
||||
}
|
||||
|
||||
function storedFile(userId: string, id: string, ext = 'png'): string {
|
||||
return path.join(imagesDir, userId, `${id}.${ext}`);
|
||||
}
|
||||
|
||||
function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
||||
if (!select) return row;
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -86,9 +147,20 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
const dashboardImage: ModelMethods = {
|
||||
findMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { tenantId: string; userId: string }; select?: Record<string, boolean> };
|
||||
const args = raw as {
|
||||
where: { tenantId?: string; userId?: string; storagePath?: string | null };
|
||||
select?: Record<string, boolean>;
|
||||
};
|
||||
const where = args.where ?? {};
|
||||
return rows
|
||||
.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId)
|
||||
.filter((r) => {
|
||||
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
||||
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.map((r) => pick(r, args.select));
|
||||
@@ -98,11 +170,18 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
||||
}),
|
||||
create: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { data: Omit<ImageRow, 'id' | 'createdAt'>; 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') });
|
||||
rows.push(created);
|
||||
return pick(created, args.select);
|
||||
}),
|
||||
update: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string }; data: Partial<ImageRow> };
|
||||
const row = rows.find((r) => r.id === args.where.id);
|
||||
if (!row) throw new Error(`update: Zeile '${args.where.id}' gibt es nicht`);
|
||||
Object.assign(row, args.data);
|
||||
return row;
|
||||
}),
|
||||
findUnique: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string } };
|
||||
return rows.find((r) => r.id === args.where.id) ?? null;
|
||||
@@ -116,19 +195,26 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
};
|
||||
|
||||
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
}
|
||||
|
||||
const fake: FakePrisma = {
|
||||
dashboardImage,
|
||||
__rows: rows,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
return wrap('tenant', tenantId, userId);
|
||||
},
|
||||
__makeSystemClient() {
|
||||
return wrap('system', '', undefined);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
@@ -156,6 +242,7 @@ function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): Uplo
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
vi.mocked(forSystem).mockClear();
|
||||
});
|
||||
|
||||
describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
@@ -172,6 +259,7 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
}
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
||||
expect(call.select.data).toBeUndefined();
|
||||
expect(call.select.storagePath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
|
||||
@@ -234,29 +322,60 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
});
|
||||
|
||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ 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);
|
||||
});
|
||||
|
||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(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 () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1' })]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10b: getBytes — Datei fehlt, aber die alte Spalte `data` traegt die Bytes noch: wiederherstellen statt 404', async () => {
|
||||
// Fall aus dem Browser-Rundgang 22.09.2026: ein `pg_dump` von vor dem Umzug
|
||||
// traegt die Bytes noch, das Volume `user-files` wird getrennt gesichert —
|
||||
// wer nur den Abzug zurueckspielt, haette sonst Zeilen ohne Datei.
|
||||
const row = makeRow({
|
||||
id: 'img-alt',
|
||||
data: PNG,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-alt.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(false);
|
||||
|
||||
const result = await makeService(prisma).getBytes('img-alt', 'user-1', 'tenant-1');
|
||||
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-1', 'img-alt')).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10c: getBytes — Datei fehlt UND `data` ist leer -> 404', async () => {
|
||||
const row = makeRow({
|
||||
id: 'img-weg',
|
||||
data: null,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-weg.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
await expect(makeService(prisma).getBytes('img-weg', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'eigen' }),
|
||||
makeRow({ id: 'fremd-user', userId: 'user-2' }),
|
||||
makeRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
||||
makeStoredRow({ id: 'eigen' }),
|
||||
makeStoredRow({ id: 'fremd-user', userId: 'user-2' }),
|
||||
makeStoredRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
||||
]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||
@@ -267,12 +386,12 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
});
|
||||
|
||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
|
||||
const prisma = makeFakePrisma([]);
|
||||
const service = makeService(prisma);
|
||||
await service.list('user-1', 'tenant-1');
|
||||
await service.upload(user, file(PNG, 'image/png'));
|
||||
await service.getBytes('img-1', 'user-1', 'tenant-1');
|
||||
await service.remove('img-1', 'user-1', 'tenant-1');
|
||||
const created = await service.upload(user, file(PNG, 'image/png'));
|
||||
await service.getBytes(created.id, 'user-1', 'tenant-1');
|
||||
await service.remove(created.id, 'user-1', 'tenant-1');
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
|
||||
for (const call of vi.mocked(forTenant).mock.calls) {
|
||||
@@ -280,12 +399,174 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(call[1]).toBe('tenant-1');
|
||||
expect(call[2]).toBe('user-1');
|
||||
}
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten.
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
|
||||
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
|
||||
// UUID der Zeile erst nach `create` feststeht (hk4).
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.via).toBe('tenant');
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
expect(c.userId).toBe('user-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)', () => {
|
||||
it('Test 13: upload schreibt die Datei unter <dir>/<userId>/<id>.png und speichert den relativen Pfad in der Zeile', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const result = await makeService(prisma).upload(user, file(PNG, 'image/png'));
|
||||
|
||||
const onDisk = storedFile('user-1', result.id);
|
||||
expect(fs.existsSync(onDisk)).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`);
|
||||
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
|
||||
expect(prisma.__rows[0].data).toBeNull();
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(createArgs.data.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = makeService(prisma);
|
||||
const boeserName = '../../../etc/passwd.png';
|
||||
const result = await service.upload(user, file(JPEG, 'image/png', boeserName));
|
||||
|
||||
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
const stored = prisma.__rows[0].storagePath ?? '';
|
||||
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
||||
expect(stored).not.toContain('passwd');
|
||||
expect(stored).not.toContain('..');
|
||||
expect(fs.existsSync(storedFile('user-1', result.id, 'jpg'))).toBe(true);
|
||||
// Der Anzeigename bleibt in der Zeile erhalten, nur eben als Text.
|
||||
expect(result.originalName).toBe(boeserName);
|
||||
});
|
||||
|
||||
it('Test 15: scheitert das Schreiben, wird die eben angelegte Zeile wieder geloescht und 500 geworfen (T-HK4-04)', async () => {
|
||||
const blocker = path.join(imagesDir, 'blockade');
|
||||
fs.writeFileSync(blocker, 'ich bin eine Datei, kein Verzeichnis');
|
||||
const vorher = process.env.DASHBOARD_IMAGES_DIR;
|
||||
process.env.DASHBOARD_IMAGES_DIR = path.join(blocker, 'unmoeglich');
|
||||
try {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
|
||||
InternalServerErrorException,
|
||||
);
|
||||
expect(prisma.dashboardImage.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.dashboardImage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
} finally {
|
||||
process.env.DASHBOARD_IMAGES_DIR = vorher;
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
||||
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||
const prisma = makeFakePrisma([
|
||||
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(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
||||
const onDisk = storedFile('user-1', 'weg');
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
expect(fs.existsSync(onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 20: fehlt die Datei beim Loeschen, gelingt das Loeschen trotzdem (eine Dateileiche ist harmloser als eine haengende Loeschung)', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'nur-zeile', storagePath: 'user-files/dashboard-images/user-1/nur-zeile.png' }),
|
||||
]);
|
||||
await expect(makeService(prisma).remove('nur-zeile', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'nur-zeile',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
|
||||
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
|
||||
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
|
||||
const fremderMandant = makeRow({
|
||||
id: 'alt-2',
|
||||
userId: 'user-9',
|
||||
tenantId: 'tenant-2',
|
||||
mimeType: 'image/jpeg',
|
||||
data: Uint8Array.from(JPEG),
|
||||
});
|
||||
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
|
||||
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
|
||||
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
|
||||
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
|
||||
|
||||
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
|
||||
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
|
||||
// gebunden ist.
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
|
||||
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
|
||||
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
|
||||
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
};
|
||||
expect(findManyArgs.where.storagePath).toBeNull();
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
|
||||
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
|
||||
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
|
||||
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
|
||||
|
||||
// Die bereits umgezogene Zeile wird nicht angefasst.
|
||||
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
|
||||
});
|
||||
|
||||
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
|
||||
const service = makeService(prisma);
|
||||
await service.onApplicationBootstrap();
|
||||
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
type OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
@@ -10,20 +19,53 @@ import {
|
||||
|
||||
/**
|
||||
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
|
||||
* (quick-260921-pi9).
|
||||
* (quick-260921-pi9), seit quick-260922-hk4 im Dateibereich statt in der
|
||||
* Datenbank.
|
||||
*
|
||||
* Ein Bild gehoert dem hochladenden Benutzer: Besitz = gleicher Mandant UND
|
||||
* gleicher Benutzer. Die Besitzpruefung in `getBytes`/`remove` (Zeile holen,
|
||||
* `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen, sonst 404)
|
||||
* ist NICHT dekorativ: die RLS-Regel auf `DashboardImage` (Migration
|
||||
* 20260921120000, mit Benutzerdimension) wirkt erst, wenn die Anwendung als
|
||||
* Rolle ohne Umgehungsrecht verbindet — der Schalter ist heute AUS
|
||||
* (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der Vergleich
|
||||
* hier der einzige wirksame Schutz gegen Quer-Lesen und Quer-Loeschen; die
|
||||
* `forTenant()`-Bindung je Methode LEGT eine Mandantengrenze obendrauf, sie
|
||||
* ersetzt den Vergleich nicht (Muster dashboard.service.ts). Nach dem
|
||||
* Scharfschalten liefert `findUnique` fuer eine fremde Zeile bereits `null`
|
||||
* — die Antwort bleibt 404, nur der Weg dorthin aendert sich.
|
||||
* WO DIE BYTES LIEGEN (hk4): unter
|
||||
* `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`, die Zeile
|
||||
* haelt nur noch den relativen Pfad in `storagePath` — dasselbe Muster wie
|
||||
* `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
|
||||
* (dkv-export.service.ts). Grund ist die Sicherung: gesichert wird von Hand
|
||||
* per `pg_dump` (docs/anleitung-betrieb.md Kap. 6), und 30 Bilder à 5 MiB je
|
||||
* Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug. Das
|
||||
* Volume `user-files` wird daneben gesichert. Geschwindigkeit war NICHT das
|
||||
* Argument (ein Bild wird je Browser einmal taeglich geladen).
|
||||
*
|
||||
* DER DATEINAME KOMMT IMMER VOM SERVER (T-HK4-01, Muster T-07-09 aus
|
||||
* `dkv-export.service.ts`): er ist die UUID der Zeile plus die Endung aus
|
||||
* dem an den Magic Bytes ERKANNTEN Mime-Typ. `originalName` ist reiner
|
||||
* Anzeigetext und erscheint weder im Pfad noch in einem Header (T-PI9-06).
|
||||
* `absoluteImagePath()` prueft zusaetzlich, dass der aus der Zeile
|
||||
* gelesene Pfad im Bilderverzeichnis liegt — ein Wert aus der Datenbank
|
||||
* wird nie ungeprueft an `path.join` gereicht.
|
||||
*
|
||||
* EIN EIGENER ORDNER JE BENUTZER IST KEIN SCHUTZ: wer welches Bild sehen
|
||||
* darf, entscheidet weiterhin dieser Dienst. Die Datei wird nie direkt
|
||||
* ausgeliefert, nur ueber `GET /dashboard/images/:id` mit Besitzpruefung
|
||||
* (T-HK4-02); das Volume haengt in keinem Webserver.
|
||||
*
|
||||
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
|
||||
* die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert
|
||||
* das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim
|
||||
* Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
|
||||
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
|
||||
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
|
||||
* 404 und die Kachel zeigt „Bild nicht verfügbar".
|
||||
*
|
||||
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
||||
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
|
||||
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
|
||||
* sonst 404) ist NICHT dekorativ: die RLS-Regel auf `DashboardImage`
|
||||
* (Migration 20260921120000, mit Benutzerdimension) wirkt erst, wenn die
|
||||
* Anwendung als Rolle ohne Umgehungsrecht verbindet — der Schalter ist
|
||||
* heute AUS (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der
|
||||
* Vergleich hier der einzige wirksame Schutz gegen Quer-Lesen und
|
||||
* Quer-Loeschen; die `forTenant()`-Bindung je Methode LEGT eine
|
||||
* Mandantengrenze obendrauf, sie ersetzt den Vergleich nicht (Muster
|
||||
* dashboard.service.ts). Nach dem Scharfschalten liefert `findUnique` fuer
|
||||
* eine fremde Zeile bereits `null` — die Antwort bleibt 404, nur der Weg
|
||||
* dorthin aendert sich.
|
||||
*
|
||||
* Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
|
||||
* Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
|
||||
@@ -31,9 +73,7 @@ import {
|
||||
* Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08):
|
||||
* `file.mimetype` und `originalname` behauptet der Browser; gespeichert und
|
||||
* spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was
|
||||
* `detectImageMime` an den Bytes erkannt hat. Der Dateiname wird nur als
|
||||
* Anzeigetext gefuehrt (auf 255 Zeichen gekuerzt) und erscheint nie in
|
||||
* einem HTTP-Header (T-PI9-06).
|
||||
* `detectImageMime` an den Bytes erkannt hat.
|
||||
*
|
||||
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
|
||||
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
|
||||
@@ -47,7 +87,10 @@ import {
|
||||
|
||||
const ORIGINAL_NAME_MAX = 255;
|
||||
|
||||
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */
|
||||
/** Ablageort unterhalb der Monorepo-Wurzel, so wie er in der Zeile steht. */
|
||||
const STORAGE_PREFIX = 'user-files/dashboard-images/';
|
||||
|
||||
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — nie Bytes, nie Pfad. */
|
||||
const META_SELECT = {
|
||||
id: true,
|
||||
originalName: true,
|
||||
@@ -64,10 +107,124 @@ export interface DashboardImageMeta {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest das Bilderverzeichnis relativ zur Monorepo-Wurzel auf — Muster
|
||||
* `resolveAvatarsDir()` (user.controller.ts): zur Laufzeit ist
|
||||
* `__dirname` = apps/api/dist/dashboard/, also vier Ebenen hoch.
|
||||
*
|
||||
* `DASHBOARD_IMAGES_DIR` ist ein Testschalter (Muster `DESKTOP_DIST_DIR`,
|
||||
* desktop.service.ts) und im Betrieb nie gesetzt; die Tests zeigen damit
|
||||
* auf ein Wegwerfverzeichnis unter `os.tmpdir()`, statt `fs` nachzubauen.
|
||||
*/
|
||||
export function resolveDashboardImagesDir(): string {
|
||||
const override = process.env.DASHBOARD_IMAGES_DIR;
|
||||
if (override !== undefined && override !== '') {
|
||||
return path.resolve(override);
|
||||
}
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'dashboard-images');
|
||||
}
|
||||
|
||||
/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */
|
||||
function extensionFor(mimeType: string): string | null {
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/gif':
|
||||
return 'gif';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Relativer Pfad, wie er in der Zeile steht (`storagePath`). */
|
||||
function relativeStoragePath(userId: string, id: string, extension: string): string {
|
||||
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
|
||||
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
|
||||
* die erwartete Form hat oder aus dem Verzeichnis herausfuehren wuerde
|
||||
* (T-HK4-01). Der Aufrufer behandelt `null` wie eine fehlende Datei.
|
||||
*/
|
||||
function absoluteImagePath(storagePath: string): string | null {
|
||||
const normalized = storagePath.split('\\').join('/');
|
||||
if (!normalized.startsWith(STORAGE_PREFIX)) return null;
|
||||
|
||||
const base = resolveDashboardImagesDir();
|
||||
const absolute = path.resolve(base, normalized.slice(STORAGE_PREFIX.length));
|
||||
if (absolute !== base && !absolute.startsWith(base + path.sep)) return null;
|
||||
return absolute;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardImagesService {
|
||||
export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(DashboardImagesService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der
|
||||
* Betreiber nichts von Hand ausfuehren muss.
|
||||
*
|
||||
* ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die
|
||||
* SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data`
|
||||
* NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges
|
||||
* DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte.
|
||||
* Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live
|
||||
* einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in
|
||||
* `.planning/todos/pending/`).
|
||||
*
|
||||
* GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster
|
||||
* `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle
|
||||
* Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben
|
||||
* wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer
|
||||
* dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet
|
||||
* (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in
|
||||
* 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim
|
||||
* DKV-Planer.
|
||||
*
|
||||
* Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein
|
||||
* zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile
|
||||
* wird protokolliert und haelt den Start nicht auf.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const pending = await systemPrisma.dashboardImage.findMany({
|
||||
where: { storagePath: null },
|
||||
select: { id: true, userId: true, tenantId: true, mimeType: true, data: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
let moved = 0;
|
||||
for (const row of pending) {
|
||||
if (row.data === null) continue;
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||
const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: row.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
moved += 1;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (moved > 0) {
|
||||
this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
||||
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -80,7 +237,13 @@ export class DashboardImagesService {
|
||||
|
||||
/**
|
||||
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
|
||||
* begrenzt, gespeichert wird der erkannte Typ.
|
||||
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
|
||||
* die Zeile haelt den Pfad.
|
||||
*
|
||||
* Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der
|
||||
* Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des
|
||||
* Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine
|
||||
* Zeile ohne Datei.
|
||||
*/
|
||||
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
||||
if (!file) {
|
||||
@@ -102,26 +265,41 @@ export class DashboardImagesService {
|
||||
);
|
||||
}
|
||||
|
||||
return tenantPrisma.dashboardImage.create({
|
||||
const created = await tenantPrisma.dashboardImage.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId,
|
||||
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
||||
mimeType,
|
||||
size: file.buffer.length,
|
||||
// Befund am Typsystem (TS 5.9 + Prisma 6): `Bytes` verlangt
|
||||
// `Uint8Array<ArrayBuffer>`, multers `Buffer` ist aber ueber
|
||||
// `ArrayBufferLike` getypt (koennte ein SharedArrayBuffer sein) und
|
||||
// wird ohne Zusicherung abgelehnt. `new Uint8Array(buffer)` kopiert in
|
||||
// einen frischen ArrayBuffer — hoechstens 5 MiB, einmal je Upload —
|
||||
// und ist damit ehrlich getypt statt zugesichert.
|
||||
data: new Uint8Array(file.buffer),
|
||||
},
|
||||
select: META_SELECT,
|
||||
});
|
||||
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: created.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id: created.id } });
|
||||
throw new InternalServerErrorException('Das Bild konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
/** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
|
||||
* 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad
|
||||
* (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404.
|
||||
*/
|
||||
async getBytes(
|
||||
id: string,
|
||||
userId: string,
|
||||
@@ -132,10 +310,51 @@ export class DashboardImagesService {
|
||||
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
return { mimeType: row.mimeType, data: row.data };
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
if (absolute === null) {
|
||||
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
|
||||
/** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */
|
||||
try {
|
||||
const data = await fs.readFile(absolute);
|
||||
return { mimeType: row.mimeType, data };
|
||||
} catch (error) {
|
||||
// Selbstheilung waehrend der Umstellung (T-HK4-03): fehlt die Datei,
|
||||
// steckt aber noch die alte Spalte `data` in der Zeile, wird die Datei
|
||||
// daraus neu geschrieben und ausgeliefert. Der Fall ist real: ein
|
||||
// `pg_dump` aus der Zeit vor dem Umzug traegt die Bytes noch, das
|
||||
// Volume `user-files` wird getrennt gesichert — wer nur den Abzug
|
||||
// zurueckspielt, haette sonst Zeilen ohne Datei. Nach dem Entfernen der
|
||||
// Spalte (eigenes Todo) faellt dieser Zweig ersatzlos weg.
|
||||
if (row.data !== null) {
|
||||
try {
|
||||
await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||
this.logger.log(`Bilderrahmen-Bild ${id} aus der Datenbank wiederhergestellt`);
|
||||
return { mimeType: row.mimeType, data: row.data };
|
||||
} catch (writeError) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${id} konnte nicht wiederhergestellt werden: ${
|
||||
writeError instanceof Error ? writeError.message : String(writeError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.logger.warn(
|
||||
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||
* protokolliert und geschluckt (T-HK4-04).
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
|
||||
@@ -143,6 +362,47 @@ export class DashboardImagesService {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
await fs.unlink(absolute);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Datei des geloeschten Bilderrahmen-Bildes ${id} konnte nicht entfernt werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Bytes an den servergenerierten Ort und liefert den
|
||||
* relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht
|
||||
* dabei (`recursive: true`).
|
||||
*/
|
||||
private async writeImageFile(
|
||||
userId: string,
|
||||
id: string,
|
||||
mimeType: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<string> {
|
||||
const extension = extensionFor(mimeType);
|
||||
if (extension === null) {
|
||||
throw new Error(`Unbekannter Bildtyp '${mimeType}'`);
|
||||
}
|
||||
|
||||
const storagePath = relativeStoragePath(userId, id, extension);
|
||||
const absolute = absoluteImagePath(storagePath);
|
||||
if (absolute === null) {
|
||||
throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, bytes);
|
||||
return storagePath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { WIDGET_TYPES } from '@tessera/shared';
|
||||
|
||||
/**
|
||||
* DTO for creating a new widget instance on a user's dashboard.
|
||||
* widgetType must be one of the nine supported types
|
||||
* ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3).
|
||||
*
|
||||
* quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus
|
||||
* `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine
|
||||
* Registry und seinen Katalog ableitet. Vorher stand die Liste hier ein
|
||||
* zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog
|
||||
* angebotene Kachel mit 400 ab.
|
||||
*
|
||||
* config is optional and defaults to {} on the model.
|
||||
*/
|
||||
export class CreateWidgetDto {
|
||||
@IsString()
|
||||
@IsIn([
|
||||
'clock',
|
||||
'search',
|
||||
'calendar',
|
||||
'note',
|
||||
'calculator',
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
])
|
||||
@IsIn([...WIDGET_TYPES])
|
||||
widgetType!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { WIDGET_MODULE_SLUGS, WIDGET_TYPES } from '@tessera/shared';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
import { WIDGET_MODULE_MAP, getModuleSlugForWidgetType } from './widget-module-map';
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Web und API lesen dieselbe Tabelle. Liefen sie
|
||||
* auseinander, wuerde der Katalog eine Kachel anbieten, die der Server
|
||||
* danach wieder herausfiltert (oder umgekehrt).
|
||||
*/
|
||||
describe('widget-module-map (quick-260922-m1h)', () => {
|
||||
it('WIDGET_MODULE_MAP ist die Tabelle aus @tessera/shared, keine zweite Kopie', () => {
|
||||
expect(WIDGET_MODULE_MAP).toBe(WIDGET_MODULE_SLUGS);
|
||||
});
|
||||
|
||||
it('jeder Schluessel der Tabelle ist ein bekannter Widget-Typ', () => {
|
||||
for (const type of Object.keys(WIDGET_MODULE_MAP)) {
|
||||
expect(WIDGET_TYPES).toContain(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
|
||||
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Kern des Umbaus: die `@IsIn`-Whitelist ist keine handgepflegte zweite
|
||||
* Liste mehr. Vergisst kuenftig jemand einen Eintrag in `packages/shared`,
|
||||
* schlaegt dieser Test fehl, statt die API eine gueltige Kachel mit 400
|
||||
* ablehnen zu lassen.
|
||||
*/
|
||||
describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => {
|
||||
async function validateType(widgetType: string) {
|
||||
const dto = plainToInstance(CreateWidgetDto, { widgetType });
|
||||
return validate(dto);
|
||||
}
|
||||
|
||||
it.each([...WIDGET_TYPES])('akzeptiert den Typ "%s"', async (widgetType) => {
|
||||
await expect(validateType(widgetType)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('lehnt einen Typ ab, der nicht in WIDGET_TYPES steht', async () => {
|
||||
const errors = await validateType('gibt-es-nicht');
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('widgetType');
|
||||
expect(errors[0].constraints).toHaveProperty('isIn');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
|
||||
|
||||
/**
|
||||
* Zuordnung Widget-Typ → Modul (D-22, PERM-07).
|
||||
*
|
||||
@@ -8,24 +10,29 @@
|
||||
* Schlüssel sind Werte von `WidgetInstance.widgetType`, Werte sind
|
||||
* Modul-Slugs aus `Module.slug`.
|
||||
*
|
||||
* quick-260922-m1h: Die Tabelle selbst steht seit diesem Umbau in
|
||||
* `packages/shared/src/index.ts` als `WIDGET_MODULE_SLUGS` — EINE Tabelle
|
||||
* für beide Seiten, damit der Katalogfilter im Web
|
||||
* (`visibleWidgetTypes`, Komfort) und dieser Server-Filter (verbindlich)
|
||||
* nicht auseinanderlaufen. Hier steht nur noch der Lesezugriff; die
|
||||
* öffentliche Schnittstelle dieser Datei bleibt unverändert, weil
|
||||
* dashboard.service.spec.ts sie gezielt mockt.
|
||||
*
|
||||
* Bewusst eine TypeScript-Konstante statt einer Spalte auf
|
||||
* `WidgetInstance`: eine Migration auf einer bereits befüllten Tabelle
|
||||
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
||||
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||
*
|
||||
* Die Tabelle ist am Ende dieser Phase bewusst leer: alle sieben heute
|
||||
* registrierten Widget-Typen (clock/search/calendar/note/calculator/
|
||||
* favorites/stopwatch, siehe apps/web/src/components/dashboard/
|
||||
* widget-registry.tsx) sind Plattform-Widgets ohne Modulbezug. Das
|
||||
* einzige bislang geplante modulgebundene Widget steht in
|
||||
* .planning/REQUIREMENTS.md unter "Future Requirements (deferred)" und
|
||||
* wird in dieser Phase bewusst nicht registriert.
|
||||
* Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen
|
||||
* (clock/search/calendar/note/calculator/favorites/stopwatch/
|
||||
* picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste
|
||||
* modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein.
|
||||
*/
|
||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = {};
|
||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
||||
|
||||
/**
|
||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle sieben
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle neun
|
||||
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||
* damit Tests sie gezielt mocken können.
|
||||
*/
|
||||
|
||||
@@ -150,8 +150,24 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
||||
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
|
||||
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
|
||||
* Summe: 4 Dateien, 5 Aufrufe.
|
||||
*
|
||||
* SIEBTER FALL (quick-260922-hk4): `dashboard-images.service.ts`, EIN
|
||||
* Aufruf, ausschliesslich in `onApplicationBootstrap()` — der einmalige
|
||||
* Umzug der Bilderrahmen-Bilder aus der Spalte `data` in den Dateibereich.
|
||||
* Ein Startpfad hat keinen Mandanten im Ruecken und muss die noch nicht
|
||||
* umgezogenen Zeilen ALLER Mandanten sehen; die passende Regel
|
||||
* `system_read_policy ... FOR SELECT` auf "DashboardImage" legt die
|
||||
* Migration 20260922120000 an. Dieselbe Datei bedient daneben Anfragewege
|
||||
* (`list`/`upload`/`getBytes`/`remove`) — die bleiben ausnahmslos
|
||||
* mandantengebunden, und auch der Umzug SCHREIBT je Zeile ueber
|
||||
* `forTenant(prisma, row.tenantId, row.userId)`, nie ueber den
|
||||
* Systemklienten. Praezedenz fuer "ein Dienst mit Anfrageweg UND
|
||||
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
|
||||
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
|
||||
* Summe neu: 5 Dateien, 6 Aufrufe.
|
||||
*/
|
||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
|
||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||
|
||||
@@ -12,13 +12,24 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
|
||||
/// Standardtext des Update-Eintrags im Tray-Menü, bevor eine neuere Version
|
||||
/// gefunden wurde. Der Eintrag installiert das Update seit quick-260917-kgc
|
||||
/// direkt in der App (Download, Signaturpruefung, Installation, Neustart);
|
||||
/// den Browser oeffnet er nur noch als Rueckfall. `spawn_version_check`
|
||||
/// setzt den Eintrag nach einem Serverwechsel hierauf zurück, damit kein
|
||||
/// Hinweis des alten Servers stehen bleibt.
|
||||
const UPDATE_ITEM_DEFAULT: &str = "Update installieren";
|
||||
/// Menuetext des Update-Eintrags, solange eine Pruefung laeuft (gesperrt).
|
||||
/// `spawn_version_check` setzt den Eintrag zu Beginn hierauf, auch nach einem
|
||||
/// Serverwechsel, damit kein Hinweis des alten Servers stehen bleibt. Der
|
||||
/// Eintrag installiert ein gefundenes Update seit quick-260917-kgc direkt in
|
||||
/// der App (Download, Signaturpruefung, Installation, Neustart).
|
||||
const UPDATE_ITEM_CHECKING: &str = "Suche nach Updates…";
|
||||
|
||||
/// Menuetext, wenn die Pruefung kein Update ergab. Der Eintrag bleibt
|
||||
/// anklickbar und startet die Pruefung erneut (quick-260922-frg): der Nutzer
|
||||
/// soll nie vor einem stumm ausgegrauten Eintrag stehen. Auch der Starttext
|
||||
/// ohne gespeicherte Server-Adresse (Klick ohne Adresse tut nichts).
|
||||
const UPDATE_ITEM_NONE: &str = "Kein Update verfügbar – erneut prüfen";
|
||||
|
||||
/// Abstand der wiederholten Update-Pruefung. Die Tray-App laeuft tagelang
|
||||
/// durch; mit der Pruefung nur beim Start hat ein Client ein neues Paket auf
|
||||
/// dem Server nie gesehen (Befund 22.09.2026, quick-260922-frg). Vier
|
||||
/// Stunden reichen fuer den Beta-Kanal und belasten den Server nicht.
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(4 * 3600);
|
||||
|
||||
/// Menuetext, wenn die gespeicherte Server-Adresse mit `http://` beginnt:
|
||||
/// das Updater-Plugin verweigert im Release-Bau unverschluesselte Endpunkte
|
||||
@@ -37,6 +48,15 @@ struct VersionResponse {
|
||||
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
|
||||
struct PendingUpdate(Mutex<Option<(Update, String)>>);
|
||||
|
||||
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
|
||||
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
|
||||
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
|
||||
/// dieselbe Meldung erscheinen (T-FRG-02). Nur ein ABWEICHENDER Text wird
|
||||
/// erneut gemeldet; ein Erfolg (`Ok(..)`) leert den Wert, damit ein spaeterer
|
||||
/// Fehler wieder sichtbar wird. Eigener `app.manage`-Typ, damit
|
||||
/// `spawn_version_check` seine Signatur behaelt.
|
||||
struct LastCheckNotice(Mutex<String>);
|
||||
|
||||
/// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse.
|
||||
/// Die API ist vom Client nur ueber den Web-Ursprung erreichbar
|
||||
/// (Next.js-Rewrite `/api-proxy/*`, siehe 18-01) -- niemals direkt unter dem
|
||||
@@ -76,7 +96,7 @@ fn with_client_marker(url: &tauri::Url, version: &str, commit: &str, os: &str) -
|
||||
/// aeltere Middleware weiterhin als Desktop-App erkannt wird. Die Werte
|
||||
/// gehen NUR in die Navigation, niemals in den gespeicherten Server-Wert
|
||||
/// (`server_url` im Store bleibt die reine Adresse). Der Browser-Rueckfall
|
||||
/// des Tray-Eintrags "Update installieren" (`open_download_page`) bekommt
|
||||
/// einer fehlgeschlagenen Update-Installation (`open_download_page`) bekommt
|
||||
/// den Marker bewusst NICHT -- er oeffnet im System-Browser, dort muessen
|
||||
/// die Download-Links sichtbar bleiben.
|
||||
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
|
||||
@@ -179,6 +199,78 @@ fn update_endpoint(server: &str) -> Option<tauri::Url> {
|
||||
Some(url)
|
||||
}
|
||||
|
||||
/// Baut die Update-Adresse mit ERSETZTEN Platzhaltern -- dieselbe Anfrage,
|
||||
/// die das Plugin stellt (`update_endpoint`, gleiche Kodierung ueber
|
||||
/// `query_pairs_mut`), nur mit konkreten Werten. `spawn_version_check` stellt
|
||||
/// sie nach `Err(ReleaseNotFound)` einmal selbst, um den Statuscode zu
|
||||
/// erfahren: das Plugin verschluckt einen Nicht-2xx-Status (updater.rs
|
||||
/// Z. 529-559 loggt nur, `last_error` bleibt leer, Ergebnis ist
|
||||
/// `ReleaseNotFound`). Reine Funktion, darum in den Tests mit festen Werten
|
||||
/// geprueft.
|
||||
fn diagnostic_update_url(server: &str, target: &str, arch: &str, current: &str) -> String {
|
||||
let base = server.trim_end_matches('/');
|
||||
match tauri::Url::parse(&api_url(server, "/desktop/update")) {
|
||||
Ok(mut url) => {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("target", target)
|
||||
.append_pair("arch", arch)
|
||||
.append_pair("current", current)
|
||||
.append_pair("base", base);
|
||||
url.into()
|
||||
}
|
||||
// Gespeicherte Adressen sind immer parsebar (parse_server_url);
|
||||
// reine Absicherung, damit die Funktion kein Option liefern muss.
|
||||
Err(_) => api_url(server, "/desktop/update"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Liefert (Menuetext, Benachrichtigungstext) fuer eine fehlgeschlagene
|
||||
/// Update-Pruefung. `status` ist der Antwortcode des Servers, wenn er
|
||||
/// geantwortet hat (`Some`), sonst `None` (keine Verbindung, TLS, Timeout).
|
||||
/// 401/403 bekommen die Proxy-Erklaerung: Befund 22.09.2026 -- der Nginx
|
||||
/// Proxy Manager vor alpha verlangt Basic-Auth, die Webansicht beantwortet
|
||||
/// das Passwortfenster, der Updater (eigener `reqwest`-Client) kann es nicht.
|
||||
/// Zugangsdaten gehoeren nicht in ausgelieferte Clients (T-FRG-03), darum
|
||||
/// wird der Grund angezeigt, nicht umgangen. Alle Texte enden auf
|
||||
/// "– erneut prüfen", weil der Klick die Pruefung wiederholt.
|
||||
fn check_failure_labels(status: Option<u16>) -> (String, String) {
|
||||
match status {
|
||||
Some(code @ (401 | 403)) => (
|
||||
format!("Update-Prüfung fehlgeschlagen (HTTP {code}) – erneut prüfen"),
|
||||
format!(
|
||||
"Der Server hat die Update-Anfrage mit HTTP {code} abgewiesen. Meist steht ein \
|
||||
Passwortschutz oder eine Zugriffsliste am vorgeschalteten Proxy davor, die die \
|
||||
App für Updates nicht durchlaufen kann. Anmeldung und Arbeiten in der App sind \
|
||||
davon nicht betroffen."
|
||||
),
|
||||
),
|
||||
Some(code) => (
|
||||
format!("Update-Prüfung fehlgeschlagen (HTTP {code}) – erneut prüfen"),
|
||||
format!("Der Server hat auf die Update-Anfrage mit HTTP {code} geantwortet statt mit Paketdaten."),
|
||||
),
|
||||
None => (
|
||||
"Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen".to_string(),
|
||||
"Der Server war für die Update-Prüfung nicht erreichbar. Die App prüft in vier \
|
||||
Stunden erneut – oder über den Menüeintrag."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stellt die Update-Anfrage ein zweites Mal mit eigenem `reqwest`-Client
|
||||
/// (Timeout 8 s, Muster `check_server`) und liest NUR den Statuscode -- der
|
||||
/// Rumpf wird nicht ausgewertet, Redirects folgen dem Standard (T-FRG-01:
|
||||
/// Ziel ist die vom Nutzer gespeicherte Adresse, kein Geheimnis in der
|
||||
/// Anfrage). `None`, wenn keine Antwort kam.
|
||||
async fn probe_update_status(url: String) -> Option<u16> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(8))
|
||||
.build()
|
||||
.ok()?;
|
||||
let resp = client.get(url).send().await.ok()?;
|
||||
Some(resp.status().as_u16())
|
||||
}
|
||||
|
||||
/// Leitet (Menuetext, Benachrichtigungstext) aus Client- und Server-Version
|
||||
/// ab: eine neue Basis X.Y.Z wird als Version genannt (auch bei Beta), bei
|
||||
/// gleicher Basis der Beta-Stempel.
|
||||
@@ -321,14 +413,57 @@ async fn check_for_update(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Meldet eine fehlgeschlagene Update-Pruefung: Menuetext mit Grund, Eintrag
|
||||
/// wieder anklickbar (Klick = erneut pruefen), Benachrichtigung nur, wenn
|
||||
/// der Text vom zuletzt gemeldeten abweicht (`LastCheckNotice`, T-FRG-02).
|
||||
fn report_check_failure(app: &AppHandle, update_item: &MenuItem<Wry>, status: Option<u16>) {
|
||||
let (menu_text, body) = check_failure_labels(status);
|
||||
let _ = update_item.set_text(&menu_text);
|
||||
let _ = update_item.set_enabled(true);
|
||||
let changed = app
|
||||
.state::<LastCheckNotice>()
|
||||
.0
|
||||
.lock()
|
||||
.map(|mut last| {
|
||||
if *last == body {
|
||||
false
|
||||
} else {
|
||||
*last = body.clone();
|
||||
true
|
||||
}
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if changed {
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Tessera-Update")
|
||||
.body(body)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
/// Leert den zuletzt gemeldeten Fehlertext -- nach einer gelungenen Pruefung
|
||||
/// soll ein spaeterer Fehler wieder gemeldet werden.
|
||||
fn clear_check_notice(app: &AppHandle) {
|
||||
if let Ok(mut last) = app.state::<LastCheckNotice>().0.lock() {
|
||||
last.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem
|
||||
/// Serverwechsel erneut laufen kann (async, non-blocking wie bisher). Setzt
|
||||
/// den Update-Eintrag zuerst auf den Standardtext zurück und leert den
|
||||
/// abgelegten Update-Stand -- nach einem Wechsel darf kein Hinweis des alten
|
||||
/// Servers stehen bleiben.
|
||||
/// Serverwechsel, beim Klick auf den Eintrag und alle
|
||||
/// `UPDATE_CHECK_INTERVAL` erneut laufen kann (async, non-blocking wie
|
||||
/// bisher). Setzt den Update-Eintrag zuerst auf "Suche nach Updates…"
|
||||
/// (gesperrt) und leert den abgelegten Update-Stand -- nach einem Wechsel
|
||||
/// darf kein Hinweis des alten Servers stehen bleiben. Jeder Ausgang der
|
||||
/// Pruefung endet in einem sichtbaren, anklickbaren Zustand (Update gefunden
|
||||
/// / kein Update / fehlgeschlagen mit Grund); nur der http-Fall bleibt
|
||||
/// gesperrt. Ein fehlgeschlagener Check war bisher vom Zustand "kein Update"
|
||||
/// nicht unterscheidbar (quick-260922-frg).
|
||||
fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
let update_item = app.state::<TrayItems>().update.clone();
|
||||
let _ = update_item.set_text(UPDATE_ITEM_DEFAULT);
|
||||
let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
|
||||
let _ = update_item.set_enabled(false);
|
||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||
*pending = None;
|
||||
@@ -365,8 +500,13 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||
*pending = Some((update, menu_text));
|
||||
}
|
||||
clear_check_notice(&app);
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = update_item.set_text(UPDATE_ITEM_NONE);
|
||||
let _ = update_item.set_enabled(true);
|
||||
clear_check_notice(&app);
|
||||
}
|
||||
Ok(None) => {}
|
||||
// Der Release-Bau verweigert http-Endpunkte (config.rs
|
||||
// `validate_endpoints`), der Debug-Bau warnt nur. Keine
|
||||
// `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-06): der
|
||||
@@ -374,15 +514,33 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => {
|
||||
let _ = update_item.set_text(UPDATE_ITEM_INSECURE);
|
||||
}
|
||||
Err(_) => {}
|
||||
// Der Server hat geantwortet, aber nicht mit 2xx/204: das Plugin
|
||||
// verschluckt den Statuscode (updater.rs Z. 529-559). Dieselbe
|
||||
// Anfrage einmal selbst stellen und nur den Status lesen -- so
|
||||
// steht im Eintrag "HTTP 401", wenn ein Passwortschutz am Proxy
|
||||
// die Anfrage abweist (Befund 22.09.2026).
|
||||
Err(tauri_plugin_updater::Error::ReleaseNotFound) => {
|
||||
let status = probe_update_status(diagnostic_update_url(
|
||||
&server_url,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
.await;
|
||||
report_check_failure(&app, &update_item, status);
|
||||
}
|
||||
// Keine Verbindung, TLS, Timeout (Reqwest/Network/Io) oder
|
||||
// unlesbares Manifest: keine zweite Anfrage, Status unbekannt.
|
||||
Err(_) => report_check_failure(&app, &update_item, None),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Oeffnet die Seite Einstellungen → Desktop-App im System-Browser -- der
|
||||
/// bisherige Weg des Tray-Eintrags, jetzt Rueckfall, wenn kein Update
|
||||
/// abgelegt ist oder die Installation in der App fehlschlaegt. Bewusst ohne
|
||||
/// `desktop=1` (siehe `with_desktop_marker`).
|
||||
/// bisherige Weg des Tray-Eintrags, seit quick-260922-frg NUR noch Rueckfall,
|
||||
/// wenn die Installation in der App fehlschlaegt (ein Klick ohne abgelegtes
|
||||
/// Update prueft stattdessen erneut). Bewusst ohne `desktop=1` (siehe
|
||||
/// `with_desktop_marker`).
|
||||
fn open_download_page(app: &AppHandle) {
|
||||
if let Some(server) = stored_server_url(app) {
|
||||
let target = format!("{}/settings/general/desktop", server.trim_end_matches('/'));
|
||||
@@ -555,6 +713,39 @@ pub fn run() {
|
||||
get_client_info
|
||||
])
|
||||
.setup(|app| {
|
||||
// Hauptfenster im Code statt in tauri.conf.json (`app.windows` ist
|
||||
// leer), weil nur der Builder einen Download-Handler annimmt. Ohne
|
||||
// Handler verpufft ein Klick auf "Herunterladen" (Einstellungen ->
|
||||
// Desktop-App) in der Webansicht: webkit2gtk verwirft den Download
|
||||
// still, WebView2 zeigte ebenfalls nichts (Nutzer, 22.09.2026,
|
||||
// Windows und Linux). Der Handler bricht den Download in der App ab
|
||||
// und reicht die Adresse an den System-Browser weiter -- dort gibt
|
||||
// es Fortschritt, Speicherort und ein Passwortfenster fuer einen
|
||||
// vorgeschalteten Proxy. Masse/Zentrierung wie bisher in der
|
||||
// Konfiguration; Fensterzustand stellt tauri_plugin_window_state
|
||||
// weiterhin her.
|
||||
tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"main",
|
||||
tauri::WebviewUrl::App("setup.html".into()),
|
||||
)
|
||||
.title("Tessera")
|
||||
.inner_size(1280.0, 800.0)
|
||||
.center()
|
||||
.decorations(true)
|
||||
.resizable(true)
|
||||
.on_download(|webview, event| {
|
||||
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
|
||||
let _ = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
.open_url(url.to_string(), None::<&str>);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.build()?;
|
||||
|
||||
let server_url = stored_server_url(app.handle());
|
||||
|
||||
if let Some(url) = &server_url {
|
||||
@@ -566,13 +757,16 @@ pub fn run() {
|
||||
}
|
||||
|
||||
// Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen ·
|
||||
// Server-Adresse ändern... · Update installieren · — ·
|
||||
// Server-Adresse ändern... · Suche nach Updates… · — ·
|
||||
// Autostart-Haken · — · Beenden.
|
||||
// "connected" ist nicht anklickbar; "update" bleibt gesperrt, bis
|
||||
// die Versionspruefung eine neuere Version findet, und heisst dann
|
||||
// "Auf Version X.Y.Z aktualisieren" bzw. "Auf Beta-Stand ...
|
||||
// aktualisieren"; "autostart" spiegelt den tatsaechlichen
|
||||
// Systemzustand beim Start.
|
||||
// "connected" ist nicht anklickbar; "update" startet gesperrt als
|
||||
// "Suche nach Updates…", weil die Erstpruefung unten sofort
|
||||
// anlaeuft, und heisst danach "Auf Version X.Y.Z aktualisieren"
|
||||
// bzw. "Auf Beta-Stand ... aktualisieren", "Kein Update verfügbar
|
||||
// – erneut prüfen" oder "Update-Prüfung fehlgeschlagen (...) –
|
||||
// erneut prüfen" (alle anklickbar). Ohne gespeicherte Adresse
|
||||
// "Kein Update verfügbar – erneut prüfen" (Klick tut nichts);
|
||||
// "autostart" spiegelt den tatsaechlichen Systemzustand beim Start.
|
||||
let connected =
|
||||
MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1)
|
||||
.enabled(false)
|
||||
@@ -580,8 +774,13 @@ pub fn run() {
|
||||
let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?;
|
||||
let change_server =
|
||||
MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…").build(app)?;
|
||||
let update = MenuItemBuilder::with_id("update", UPDATE_ITEM_DEFAULT)
|
||||
.enabled(false)
|
||||
let update_text = if server_url.is_some() {
|
||||
UPDATE_ITEM_CHECKING
|
||||
} else {
|
||||
UPDATE_ITEM_NONE
|
||||
};
|
||||
let update = MenuItemBuilder::with_id("update", update_text)
|
||||
.enabled(server_url.is_none())
|
||||
.build(app)?;
|
||||
let autostart_label = if cfg!(target_os = "windows") {
|
||||
"Mit Windows starten"
|
||||
@@ -610,6 +809,7 @@ pub fn run() {
|
||||
update: update.clone(),
|
||||
});
|
||||
app.manage(PendingUpdate(Mutex::new(None)));
|
||||
app.manage(LastCheckNotice(Mutex::new(String::new())));
|
||||
|
||||
let autostart_for_menu = autostart.clone();
|
||||
|
||||
@@ -637,8 +837,12 @@ pub fn run() {
|
||||
}
|
||||
"update" => {
|
||||
// `take()` verhindert Doppelklick-Downloads, der
|
||||
// gesperrte Eintrag ebenso; ohne abgelegten Stand
|
||||
// bleibt der Browser-Weg (Einstellungen → Desktop-App).
|
||||
// gesperrte Eintrag ebenso. Ohne abgelegten Stand
|
||||
// ("Kein Update verfügbar" / "Update-Prüfung
|
||||
// fehlgeschlagen") prueft der Klick erneut -- der
|
||||
// Browser-Weg (`open_download_page`) ist nur noch
|
||||
// Rueckfall einer fehlgeschlagenen Installation.
|
||||
// Ohne gespeicherte Adresse gibt es nichts zu pruefen.
|
||||
let pending = app
|
||||
.state::<PendingUpdate>()
|
||||
.0
|
||||
@@ -649,7 +853,11 @@ pub fn run() {
|
||||
Some((update, menu_text)) => {
|
||||
spawn_update_install(app.clone(), update, menu_text)
|
||||
}
|
||||
None => open_download_page(app),
|
||||
None => {
|
||||
if let Some(url) = stored_server_url(app) {
|
||||
spawn_version_check(app.clone(), url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"autostart" => {
|
||||
@@ -696,6 +904,34 @@ pub fn run() {
|
||||
spawn_version_check(app.handle().clone(), url);
|
||||
}
|
||||
|
||||
// Wiederholte Pruefung alle UPDATE_CHECK_INTERVAL. Bewusst ein
|
||||
// schlichter Betriebssystem-Thread mit `sleep` statt
|
||||
// `tokio::time`: das Tauri-Async-Runtime braeuchte dafuer das
|
||||
// `time`-Feature bzw. ein eigenes Crate, und ein schlafender
|
||||
// Thread kostet nichts. Je Durchlauf wird die Adresse frisch
|
||||
// gelesen (Serverwechsel zaehlt). Haelt `PendingUpdate` bereits
|
||||
// ein Update, wird der Durchlauf uebersprungen: die Pruefung
|
||||
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
|
||||
// Waehrend einer Installation ist der Stand per `take()` leer;
|
||||
// faellt die 4-h-Marke genau in den Download, wird nur der
|
||||
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert).
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || loop {
|
||||
std::thread::sleep(UPDATE_CHECK_INTERVAL);
|
||||
let pending = handle
|
||||
.state::<PendingUpdate>()
|
||||
.0
|
||||
.lock()
|
||||
.map(|guard| guard.is_some())
|
||||
.unwrap_or(false);
|
||||
if pending {
|
||||
continue;
|
||||
}
|
||||
if let Some(url) = stored_server_url(&handle) {
|
||||
spawn_version_check(handle.clone(), url);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
@@ -1008,4 +1244,63 @@ mod tests {
|
||||
Err("Diese Adresse ist ungültig.".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_failure_labels_401_nennt_proxy_passwortschutz() {
|
||||
let (menu_text, body) = check_failure_labels(Some(401));
|
||||
assert!(menu_text.contains("HTTP 401"));
|
||||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||||
assert!(body.contains("HTTP 401"));
|
||||
assert!(body.contains("Passwortschutz"));
|
||||
assert!(body.contains("Zugriffsliste"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_failure_labels_403_gleiche_erklaerung() {
|
||||
let (menu_text, body) = check_failure_labels(Some(403));
|
||||
assert!(menu_text.contains("HTTP 403"));
|
||||
assert!(body.contains("HTTP 403"));
|
||||
assert!(body.contains("Passwortschutz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_failure_labels_anderer_status_ohne_proxy_erklaerung() {
|
||||
let (menu_text, body) = check_failure_labels(Some(502));
|
||||
assert!(menu_text.contains("HTTP 502"));
|
||||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||||
assert!(!body.contains("Passwortschutz"));
|
||||
assert!(body.contains("statt mit Paketdaten"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_failure_labels_ohne_status_keine_verbindung() {
|
||||
let (menu_text, body) = check_failure_labels(None);
|
||||
assert!(menu_text.contains("keine Verbindung"));
|
||||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||||
assert!(body.contains("vier Stunden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_update_url_ersetzt_platzhalter_und_kodiert_base() {
|
||||
assert_eq!(
|
||||
diagnostic_update_url("https://alpha.example", "windows", "x86_64", "1.2.0"),
|
||||
"https://alpha.example/api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_update_url_trimmt_schlussstrich() {
|
||||
assert_eq!(
|
||||
diagnostic_update_url("https://alpha.example/", "linux", "x86_64", "1.2.0"),
|
||||
"https://alpha.example/api-proxy/desktop/update?target=linux&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_item_konstanten() {
|
||||
assert_eq!(UPDATE_ITEM_CHECKING, "Suche nach Updates…");
|
||||
assert_eq!(UPDATE_ITEM_NONE, "Kein Update verfügbar – erneut prüfen");
|
||||
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
||||
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,7 @@
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Tessera",
|
||||
"url": "setup.html",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"center": true,
|
||||
"decorations": true,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"lint": "biome lint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tessera/shared": "workspace:*",
|
||||
"@uiw/react-md-editor": "4.1.1",
|
||||
"fflate": "^0.8.3",
|
||||
"html-to-image": "1.11.13",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// quick-260916-dyv: Dashboard-Seite — feste Aktionsleiste unten rechts
|
||||
@@ -47,8 +47,16 @@ vi.mock('@/components/dashboard/dashboard-grid', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// quick-260922-m1h: Der Katalog bekommt die zugaenglichen Modul-Slugs als
|
||||
// Prop von dieser Seite — die Attrappe merkt sie sich, damit der Test sie
|
||||
// pruefen kann, ohne den echten Dialog zu rendern.
|
||||
const catalogProps: { accessibleModuleSlugs?: readonly string[] | null } = {};
|
||||
|
||||
vi.mock('@/components/dashboard/widget-catalog-modal', () => ({
|
||||
WidgetCatalogModal: () => null,
|
||||
WidgetCatalogModal: (p: { accessibleModuleSlugs: readonly string[] | null }) => {
|
||||
catalogProps.accessibleModuleSlugs = p.accessibleModuleSlugs;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/dashboard/widgets/clock-widget', () => ({ ClockWidget: () => null }));
|
||||
@@ -62,6 +70,17 @@ vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureF
|
||||
vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }));
|
||||
|
||||
beforeEach(() => {
|
||||
catalogProps.accessibleModuleSlugs = undefined;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'tools' },
|
||||
{ id: 'm2', slug: 'tender-radar', name: 'Tender', category: 'tools' },
|
||||
],
|
||||
})),
|
||||
);
|
||||
mockStore.isEditMode = false;
|
||||
mockStore.isLoading = false;
|
||||
mockStore.error = null;
|
||||
@@ -71,6 +90,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('DashboardPage (quick-260916-dyv)', () => {
|
||||
@@ -117,3 +137,35 @@ describe('DashboardPage (quick-260916-dyv)', () => {
|
||||
expect(mockStore.setEditMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage: zugaengliche Module fuer den Katalog (quick-260922-m1h)', () => {
|
||||
it('Test 4: holt GET /modules/active mit Sitzungs-Keks und reicht die Slugs an den Katalog durch', async () => {
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(catalogProps.accessibleModuleSlugs).toEqual(['domaincheck', 'tender-radar']);
|
||||
});
|
||||
|
||||
const call = vi.mocked(fetch).mock.calls[0];
|
||||
expect(String(call[0])).toContain('/modules/active');
|
||||
expect(call[1]).toMatchObject({ credentials: 'include' });
|
||||
});
|
||||
|
||||
it('Test 5: fail-closed — schlaegt der Abruf fehl, bleibt die Liste unbekannt (null)', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('Netzwerk weg');
|
||||
}),
|
||||
);
|
||||
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalled();
|
||||
});
|
||||
expect(catalogProps.accessibleModuleSlugs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wirePictureFrameWidget, wireXframeWidget } 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';
|
||||
@@ -18,20 +18,34 @@ import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
|
||||
// Wire widget components into the registry (deferred to avoid circular deps)
|
||||
wireClockWidget(ClockWidget);
|
||||
wireSearchWidget(SearchWidget);
|
||||
wireCalendarWidget(CalendarWidget);
|
||||
wireNoteWidget(NoteWidget);
|
||||
wireCalculatorWidget(CalculatorWidget);
|
||||
wireStopwatchWidget(StopwatchWidget);
|
||||
wireFavoritesWidget(FavoritesWidget);
|
||||
wirePictureFrameWidget(PictureFrameWidget);
|
||||
wireXframeWidget(XframeWidget);
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
// Anmeldung der Kachel-Komponenten an der Registry. Steht hier und nicht in
|
||||
// der Registry selbst, weil die Komponenten ueber den Wrapper wieder die
|
||||
// Registry importieren — ein Import aus der Registry heraus waere ein
|
||||
// Zirkelimport. Seit quick-260922-m1h EINE Funktion statt neun `wireXWidget`.
|
||||
registerWidget('clock', ClockWidget);
|
||||
registerWidget('search', SearchWidget);
|
||||
registerWidget('calendar', CalendarWidget);
|
||||
registerWidget('note', NoteWidget);
|
||||
registerWidget('calculator', CalculatorWidget);
|
||||
registerWidget('stopwatch', StopwatchWidget);
|
||||
registerWidget('favorites', FavoritesWidget);
|
||||
registerWidget('picture-frame', PictureFrameWidget);
|
||||
registerWidget('xframe', XframeWidget);
|
||||
|
||||
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
||||
interface ActiveModule {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('widgets');
|
||||
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
|
||||
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
|
||||
// "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed.
|
||||
const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState<string[] | null>(null);
|
||||
|
||||
const {
|
||||
layouts,
|
||||
@@ -51,6 +65,28 @@ export default function DashboardPage() {
|
||||
loadDashboard();
|
||||
}, [loadDashboard]);
|
||||
|
||||
// Zugaengliche Module holen — gleiches Muster wie die Seitenleiste
|
||||
// (`components/layout/sidebar.tsx`): derselbe Endpunkt, derselbe
|
||||
// Sitzungs-Keks, Fehler still. Der Abruf steht hier und nicht im Dialog,
|
||||
// damit der Dialog ein reines Anzeige-Bauteil bleibt.
|
||||
const fetchAccessibleModules = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/modules/active`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const modules: ActiveModule[] = await res.json();
|
||||
setAccessibleModuleSlugs(modules.map((m) => m.slug));
|
||||
} catch {
|
||||
// still: die Liste bleibt null, der Katalog zeigt dann nur
|
||||
// Plattform-Kacheln (fail-closed).
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccessibleModules();
|
||||
}, [fetchAccessibleModules]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
@@ -117,6 +153,7 @@ export default function DashboardPage() {
|
||||
<WidgetCatalogModal
|
||||
isOpen={catalogOpen}
|
||||
onClose={() => setCatalogOpen(false)}
|
||||
accessibleModuleSlugs={accessibleModuleSlugs}
|
||||
onAddWidget={(type: WidgetType) => {
|
||||
addWidget(type);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||
|
||||
// Mock CSS imports that vitest cannot resolve
|
||||
vi.mock('react-grid-layout/css/styles.css', () => ({}));
|
||||
@@ -84,6 +85,10 @@ vi.mock('@/lib/stores/dashboard-store', () => ({
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
// quick-260922-vdk: stubResizeObserver ersetzt den globalen ResizeObserver
|
||||
// per vi.stubGlobal — vi.restoreAllMocks() setzt das nicht zurueck, ohne
|
||||
// diese Zeile bliebe der gestubbte Beobachter fuer alle folgenden Dateien stehen.
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('DashboardGrid', () => {
|
||||
@@ -387,4 +392,64 @@ describe('DashboardGrid', () => {
|
||||
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
|
||||
expect(document.querySelector('.widget-drag-handle')).toBeNull();
|
||||
});
|
||||
|
||||
it('quick-260922-vdk Test 10: Leerzustand -> gefuellt misst die tatsaechliche Breite statt beim Startwert 1200 stehenzubleiben', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
|
||||
const { rerender } = render(
|
||||
<DashboardGrid
|
||||
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
|
||||
widgets={[]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Leerzustand: der gemessene Knoten ist gar nicht eingehaengt.
|
||||
expect(screen.getByText('No active widgets')).toBeInTheDocument();
|
||||
expect(captured.props).toBeNull();
|
||||
|
||||
// Erste Kachel erscheint -> der Raster-<div> wird eingehaengt -> measureRef
|
||||
// misst synchron vor dem Zeichnen. Vor der Aenderung bleibt width bei 1200.
|
||||
rerender(
|
||||
<DashboardGrid
|
||||
layouts={{ lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }}
|
||||
widgets={[{ id: 'inst-1', widgetType: 'clock', config: {} }]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured.props?.width).toBe(1000);
|
||||
});
|
||||
|
||||
it('quick-260922-vdk Test 11: Fenstergroesse aendert sich -> der Fenster-Horcher misst neu', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={{ lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }}
|
||||
widgets={[{ id: 'inst-1', widgetType: 'clock', config: {} }]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured.props?.width).toBe(1000);
|
||||
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800));
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
// Vor der Aenderung gibt es keinen Fenster-Horcher: width bliebe bei 1000.
|
||||
expect(captured.props?.width).toBe(1600);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { 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 'react-grid-layout/css/styles.css';
|
||||
@@ -125,19 +125,79 @@ export function DashboardGrid({
|
||||
onRemoveWidget,
|
||||
}: DashboardGridProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(1200);
|
||||
|
||||
// Measure container width (v2 requires explicit width — Pitfall 1)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
setWidth(entries[0].contentRect.width);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
|
||||
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
|
||||
//
|
||||
// Warum: der fruehe Ruecksprung in den Leerzustand rendert das gemessene
|
||||
// <div> gar nicht erst. Haengt DashboardGrid mit null Kacheln ein, sieht der
|
||||
// alte Effekt (leere Abhaengigkeitsliste, laeuft genau einmal beim
|
||||
// Einhaengen) den Ref als leer, bricht ab und laeuft nie wieder — auch nicht,
|
||||
// wenn spaeter die erste Kachel erscheint und das <div> tatsaechlich
|
||||
// entsteht. Die Breite blieb dann fuer die ganze Sitzung beim Startwert
|
||||
// 1200, react-grid-layout vergleicht den Breakpoint strikt groesser als
|
||||
// (`width > breakpoint`), 1200 ist damit NICHT `lg` sondern `md` -> 20 statt
|
||||
// 24 Spalten, 51,6 statt 50 px Spaltenbreite, ein toter Streifen rechts.
|
||||
//
|
||||
// Der Ref-Rueckruf `measureRef` folgt dem Knoten ueber Aus- und Einhaengen
|
||||
// hinweg (Leerzustand <-> gefuellt) und laeuft in der Commit-Phase — die dort
|
||||
// ausgeloeste Zustandsaenderung wird vor dem Zeichnen abgearbeitet, ein
|
||||
// zusaetzlicher useLayoutEffect ist damit ueberfluessig. Der Startwert 1200
|
||||
// lebt deshalb nur noch bis zur Commit-Phase desselben Einhaengens; genau
|
||||
// diesen Uebergang macht der Pfad "Neuladen mit vorhandenen Kacheln" heute
|
||||
// schon in Produktion und er ist nachweislich richtig (459 px in 1176 px
|
||||
// gemessen) — ein anderer Startwert wuerde eine bisher unerprobte
|
||||
// Breakpoint-Folge einfuehren, ohne etwas zu verbessern.
|
||||
//
|
||||
// Der Fenster-Horcher ist ein zusaetzliches Netz fuer Faelle, in denen der
|
||||
// ResizeObserver nichts meldet — nicht sein Ersatz. `applyWidth` verwirft 0
|
||||
// und nicht endliche Werte (T-VDK-01/T-VDK-03), damit eine kurzzeitig
|
||||
// zusammengefallene Flaeche das Raster nicht auf Null setzt und keine
|
||||
// Rueckkopplungsschleife entsteht; React verwirft gleiche Werte selbst.
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const applyWidth = useCallback((next: number) => {
|
||||
if (Number.isFinite(next) && next > 0) {
|
||||
setWidth(next);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const measureRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
// Ein eventuell laufender Beobachter zuerst trennen — auch der
|
||||
// null-Zweig (Aushaengen) durchlaeuft diese Zeilen, das ist hier der
|
||||
// Aufraeumpfad (T-VDK-02).
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
nodeRef.current = node;
|
||||
if (!node) return;
|
||||
|
||||
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
|
||||
applyWidth(node.getBoundingClientRect().width);
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
applyWidth(entries[0].contentRect.width);
|
||||
});
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
|
||||
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
|
||||
},
|
||||
[applyWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (nodeRef.current) {
|
||||
applyWidth(nodeRef.current.getBoundingClientRect().width);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [applyWidth]);
|
||||
|
||||
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
|
||||
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
|
||||
// stabil bleibt.
|
||||
@@ -162,7 +222,7 @@ export function DashboardGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<div ref={measureRef}>
|
||||
<Responsive
|
||||
width={width}
|
||||
breakpoints={BREAKPOINTS}
|
||||
|
||||
@@ -36,12 +36,17 @@ vi.mock('next-intl', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { WIDGET_TYPES } from '@tessera/shared';
|
||||
import { WIDGET_REGISTRY } from './widget-registry';
|
||||
import { WidgetCatalogModal } from './widget-catalog-modal';
|
||||
|
||||
// Alle neun Kacheln sind heute Plattform-Kacheln ohne moduleSlug, also zeigt
|
||||
// der Katalog sie auch bei leerer Modulliste vollstaendig an.
|
||||
const baseProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onAddWidget: vi.fn(),
|
||||
accessibleModuleSlugs: [] as string[],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
@@ -130,3 +135,50 @@ describe('WidgetCatalogModal', () => {
|
||||
expect(screen.getByRole('button', { name: 'Schließen' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Der Katalog fuehrt keine zweite Typliste mehr — er leitet
|
||||
* sie aus der Registry ab und filtert nach Modulzugriff.
|
||||
*/
|
||||
describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => {
|
||||
it('zeigt alle neun Kacheln in der Reihenfolge der Registry', () => {
|
||||
render(<WidgetCatalogModal {...baseProps} />);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
|
||||
const cards = Array.from(
|
||||
dialog.querySelectorAll<HTMLButtonElement>('button[data-widget-type]'),
|
||||
);
|
||||
|
||||
expect(cards.map((c) => c.getAttribute('data-widget-type'))).toEqual([
|
||||
...WIDGET_TYPES,
|
||||
]);
|
||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('eine Kachel MIT moduleSlug fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
|
||||
// Die Registry traegt heute keine Modul-Kachel — fuer den Nachweis am
|
||||
// echten Bauteil wird clock voruebergehend zu einer gemacht.
|
||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
||||
try {
|
||||
const { rerender } = render(<WidgetCatalogModal {...baseProps} />);
|
||||
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
|
||||
|
||||
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||
expect(screen.getByRole('button', { name: /Uhr/ })).toBeInTheDocument();
|
||||
} finally {
|
||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('fail-closed: schlaegt der Modulabruf fehl (null), verschwinden Kacheln MIT moduleSlug, Plattform-Kacheln bleiben', () => {
|
||||
WIDGET_REGISTRY.clock.moduleSlug = 'proxmox';
|
||||
try {
|
||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={null} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument();
|
||||
} finally {
|
||||
WIDGET_REGISTRY.clock.moduleSlug = undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,28 +2,30 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WIDGET_REGISTRY, type WidgetType } from './widget-registry';
|
||||
import { WIDGET_REGISTRY, type WidgetType, visibleWidgetTypes } from './widget-registry';
|
||||
|
||||
interface WidgetCatalogModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddWidget: (type: WidgetType) => void;
|
||||
/**
|
||||
* Slugs der Module, die der angemeldete Benutzer nutzen darf — geholt von
|
||||
* der Dashboard-Seite ueber `GET /modules/active` (quick-260922-m1h).
|
||||
* `null` heisst "noch unbekannt oder Abruf fehlgeschlagen": dann bleiben
|
||||
* Kacheln MIT `moduleSlug` ausgeblendet (fail-closed).
|
||||
*
|
||||
* Der Abruf steht bewusst NICHT in diesem Dialog, damit er ein reines
|
||||
* Anzeige-Bauteil bleibt und ohne Netzwerk-Attrappe testbar ist.
|
||||
*/
|
||||
accessibleModuleSlugs: readonly string[] | null;
|
||||
}
|
||||
|
||||
const WIDGET_TYPES: WidgetType[] = [
|
||||
'clock',
|
||||
'search',
|
||||
'calendar',
|
||||
'note',
|
||||
'calculator',
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
];
|
||||
|
||||
/**
|
||||
* Modal dialog showing available widget types as selectable cards.
|
||||
*
|
||||
* quick-260922-m1h: Die Liste kommt aus WIDGET_REGISTRY (Reihenfolge der
|
||||
* Registry-Definition) statt aus einer zweiten, hier gepflegten Liste — eine
|
||||
* neue Kachel musste sonst an zwei Stellen eingetragen werden.
|
||||
* Click on a card adds the widget to the dashboard and closes the modal.
|
||||
* Escape to close, click outside to close, focus trap (D-01 flow).
|
||||
*/
|
||||
@@ -31,6 +33,7 @@ export function WidgetCatalogModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAddWidget,
|
||||
accessibleModuleSlugs,
|
||||
}: WidgetCatalogModalProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -107,13 +110,14 @@ export function WidgetCatalogModal({
|
||||
|
||||
{/* 2x2 grid of widget type cards */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{WIDGET_TYPES.map((type) => {
|
||||
{visibleWidgetTypes(WIDGET_REGISTRY, accessibleModuleSlugs).map((type) => {
|
||||
const def = WIDGET_REGISTRY[type];
|
||||
const Icon = def.icon;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
data-widget-type={type}
|
||||
onClick={() => {
|
||||
onAddWidget(type);
|
||||
onClose();
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WIDGET_TYPES } from '@tessera/shared';
|
||||
import {
|
||||
WIDGET_CONSTRAINTS,
|
||||
WIDGET_REGISTRY,
|
||||
type WidgetDefinition,
|
||||
type WidgetProps,
|
||||
type WidgetType,
|
||||
registerWidget,
|
||||
visibleWidgetTypes,
|
||||
} from './widget-registry';
|
||||
|
||||
/**
|
||||
* DASH-11: Every WidgetType entry in WIDGET_CONSTRAINTS must have
|
||||
@@ -82,3 +91,118 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
expect(counted).toBe(36);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
|
||||
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
|
||||
* Erwartungsliste dieses Tests muessen dieselben Schluessel in derselben
|
||||
* Reihenfolge tragen. Dieser Test faengt kuenftig jede vergessene Stelle.
|
||||
*/
|
||||
describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
|
||||
it('WIDGET_TYPES aus @tessera/shared, Registry-Schluessel und Constraints-Schluessel sind deckungsgleich (gleiche Reihenfolge)', () => {
|
||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('die neun erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
||||
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
|
||||
});
|
||||
|
||||
it('jeder Registry-Eintrag traegt seinen eigenen Typ als `type`', () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
expect(WIDGET_REGISTRY[type].type).toBe(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('heute traegt keine der neun Kacheln einen moduleSlug (alle sind Plattform-Kacheln)', () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerWidget (quick-260922-m1h)', () => {
|
||||
function makeComponent(): (props: WidgetProps) => null {
|
||||
return () => null;
|
||||
}
|
||||
|
||||
it('meldet eine Komponente fuer ihren Typ an', () => {
|
||||
const before = WIDGET_REGISTRY.clock.component;
|
||||
const component = makeComponent();
|
||||
|
||||
registerWidget('clock', component);
|
||||
|
||||
expect(WIDGET_REGISTRY.clock.component).toBe(component);
|
||||
|
||||
WIDGET_REGISTRY.clock.component = before;
|
||||
});
|
||||
|
||||
it('ist idempotent: eine zweite Anmeldung desselben Typs ist ein No-Op (wie die alten wireX-Flags)', () => {
|
||||
const before = WIDGET_REGISTRY.note.component;
|
||||
const first = makeComponent();
|
||||
const second = makeComponent();
|
||||
|
||||
registerWidget('note', first);
|
||||
registerWidget('note', second);
|
||||
|
||||
expect(WIDGET_REGISTRY.note.component).toBe(first);
|
||||
|
||||
WIDGET_REGISTRY.note.component = before;
|
||||
});
|
||||
|
||||
it('ein unbekannter Typ wirft in der Entwicklung', () => {
|
||||
expect(() =>
|
||||
// Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der
|
||||
// Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde).
|
||||
registerWidget('proxmox' as WidgetType, makeComponent()),
|
||||
).toThrow(/proxmox/);
|
||||
});
|
||||
|
||||
it('ein unbekannter Typ wird in der Produktion still ignoriert', () => {
|
||||
const previous = process.env.NODE_ENV;
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
|
||||
expect(() => registerWidget('proxmox' as WidgetType, makeComponent())).not.toThrow();
|
||||
|
||||
vi.stubEnv('NODE_ENV', previous ?? 'test');
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Der Katalogfilter als reine Funktion — so testbar ohne
|
||||
* eine echte Modul-Kachel zu erfinden (Muster picture-frame-config.ts).
|
||||
* WICHTIG (T-M1H-01): Dieser Filter ist Komfort. Die verbindliche
|
||||
* Durchsetzung bleibt serverseitig in DashboardService.getWidgets.
|
||||
*/
|
||||
describe('visibleWidgetTypes (quick-260922-m1h)', () => {
|
||||
const testRegistry: Record<string, Pick<WidgetDefinition, 'moduleSlug'>> = {
|
||||
clock: {},
|
||||
proxmox: { moduleSlug: 'proxmox' },
|
||||
note: {},
|
||||
};
|
||||
|
||||
it('behaelt die Reihenfolge der Registry bei', () => {
|
||||
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
|
||||
expect(visibleWidgetTypes(testRegistry, [])).toEqual(['clock', 'note']);
|
||||
});
|
||||
|
||||
it('eine Kachel mit moduleSlug fehlt, wenn der Slug nicht in den zugaenglichen Modulen steht', () => {
|
||||
expect(visibleWidgetTypes(testRegistry, ['domaincheck'])).toEqual(['clock', 'note']);
|
||||
});
|
||||
|
||||
it('eine Kachel mit moduleSlug erscheint, wenn der Slug in den zugaenglichen Modulen steht', () => {
|
||||
expect(visibleWidgetTypes(testRegistry, ['domaincheck', 'proxmox'])).toEqual([
|
||||
'clock',
|
||||
'proxmox',
|
||||
'note',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fail-closed: ist die Modulliste unbekannt (null, z. B. fehlgeschlagener Abruf), verschwinden alle Kacheln MIT moduleSlug', () => {
|
||||
expect(visibleWidgetTypes(testRegistry, null)).toEqual(['clock', 'note']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
|
||||
|
||||
/**
|
||||
* Supported widget types for the dashboard.
|
||||
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
|
||||
* `packages/shared/src/index.ts` — dieselbe Liste, gegen die die API in
|
||||
* `create-widget.dto.ts` mit `@IsIn` validiert. Vorher stand sie an sieben
|
||||
* Stellen; vergass man eine, fehlte die Kachel im Katalog oder die API
|
||||
* lehnte sie mit 400 ab.
|
||||
*
|
||||
* Hier weiter-exportiert, weil ein knappes Dutzend Web-Dateien den Typ seit
|
||||
* jeher von der Registry bezieht (dashboard-grid, widget-wrapper,
|
||||
* dashboard-store, widget-settings-panel ...).
|
||||
*
|
||||
* clock/search/calendar/note: implemented in previous plans.
|
||||
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere
|
||||
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
|
||||
@@ -9,16 +19,7 @@ import type { ComponentType } from 'react';
|
||||
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||
*/
|
||||
export type WidgetType =
|
||||
| 'clock'
|
||||
| 'search'
|
||||
| 'calendar'
|
||||
| 'note'
|
||||
| 'calculator'
|
||||
| 'favorites'
|
||||
| 'stopwatch'
|
||||
| 'picture-frame'
|
||||
| 'xframe';
|
||||
export type { WidgetType };
|
||||
|
||||
/**
|
||||
* Props contract that every widget component must accept.
|
||||
@@ -76,6 +77,12 @@ export interface WidgetDefinition {
|
||||
type: WidgetType;
|
||||
nameKey: string;
|
||||
descriptionKey: string;
|
||||
/**
|
||||
* Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
|
||||
* `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel
|
||||
* und immer sichtbar — der heutige Zustand fuer alle neun Kacheln.
|
||||
*/
|
||||
moduleSlug?: string;
|
||||
/** Inline SVG icon as React component */
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
minW: number;
|
||||
@@ -318,7 +325,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'clock.description',
|
||||
icon: ClockIcon,
|
||||
...WIDGET_CONSTRAINTS.clock,
|
||||
component: PlaceholderWidget, // Replaced via wireClockWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.clock,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
search: {
|
||||
type: 'search',
|
||||
@@ -326,7 +334,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'search.description',
|
||||
icon: SearchIcon,
|
||||
...WIDGET_CONSTRAINTS.search,
|
||||
component: PlaceholderWidget, // Replaced via wireSearchWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.search,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
calendar: {
|
||||
type: 'calendar',
|
||||
@@ -334,7 +343,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'calendar.description',
|
||||
icon: CalendarIcon,
|
||||
...WIDGET_CONSTRAINTS.calendar,
|
||||
component: PlaceholderWidget, // Replaced via wireCalendarWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.calendar,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
note: {
|
||||
type: 'note',
|
||||
@@ -342,7 +352,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'note.description',
|
||||
icon: NoteIcon,
|
||||
...WIDGET_CONSTRAINTS.note,
|
||||
component: PlaceholderWidget, // Replaced via wireNoteWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.note,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
calculator: {
|
||||
type: 'calculator',
|
||||
@@ -350,7 +361,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'calculator.description',
|
||||
icon: CalculatorIcon,
|
||||
...WIDGET_CONSTRAINTS.calculator,
|
||||
component: PlaceholderWidget, // Replaced via wireCalculatorWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.calculator,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
favorites: {
|
||||
type: 'favorites',
|
||||
@@ -358,7 +370,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'favorites.description',
|
||||
icon: FavoritesIcon,
|
||||
...WIDGET_CONSTRAINTS.favorites,
|
||||
component: PlaceholderWidget, // Replaced via wireFavoritesWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.favorites,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
stopwatch: {
|
||||
type: 'stopwatch',
|
||||
@@ -366,7 +379,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'stopwatch.description',
|
||||
icon: StopwatchIcon,
|
||||
...WIDGET_CONSTRAINTS.stopwatch,
|
||||
component: PlaceholderWidget, // Replaced via wireStopwatchWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.stopwatch,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
'picture-frame': {
|
||||
type: 'picture-frame',
|
||||
@@ -374,7 +388,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'pictureFrame.description',
|
||||
icon: PictureFrameIcon,
|
||||
...WIDGET_CONSTRAINTS['picture-frame'],
|
||||
component: PlaceholderWidget, // Replaced via wirePictureFrameWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS['picture-frame'],
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
xframe: {
|
||||
type: 'xframe',
|
||||
@@ -382,82 +397,76 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
descriptionKey: 'xframe.description',
|
||||
icon: XframeIcon,
|
||||
...WIDGET_CONSTRAINTS.xframe,
|
||||
component: PlaceholderWidget, // Replaced via wireXframeWidget()
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.xframe,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
};
|
||||
|
||||
// Wire actual widget components lazily to avoid circular deps
|
||||
// (imports are deferred so widget-registry can be imported by tests without
|
||||
// pulling in the entire React tree)
|
||||
/**
|
||||
* Meldet die Komponente einer Kachel an ihrem Registry-Eintrag an
|
||||
* (quick-260922-m1h — ersetzt die vormals neun `wireXWidget()`-Funktionen
|
||||
* mit je eigenem Bool-Flag).
|
||||
*
|
||||
* Aufgerufen wird sie in `apps/web/src/app/(portal)/page.tsx` und NICHT
|
||||
* hier: die Komponenten duerfen nicht aus der Registry heraus importiert
|
||||
* werden, sonst entsteht ein Zirkelimport (jede Kachel importiert ueber den
|
||||
* Wrapper wieder die Registry). Die Seite ist die Stelle, an der beides
|
||||
* zusammenkommt.
|
||||
*
|
||||
* Mehrfachanmeldung desselben Typs ist ein No-Op — die erste gewinnt, genau
|
||||
* wie die alten Flags. Ein Typ, der nicht in `WIDGET_TYPES` steht, wirft in
|
||||
* der Entwicklung (dann fehlt der Eintrag in `packages/shared`) und wird in
|
||||
* der Produktion ignoriert, damit eine vergessene Kachel nicht das ganze
|
||||
* Dashboard mitreisst.
|
||||
*/
|
||||
const registeredTypes = new Set<WidgetType>();
|
||||
|
||||
let clockWired = false;
|
||||
export function wireClockWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!clockWired) {
|
||||
WIDGET_REGISTRY.clock.component = component;
|
||||
clockWired = true;
|
||||
export function registerWidget(type: WidgetType, component: ComponentType<WidgetProps>) {
|
||||
const definition: WidgetDefinition | undefined = WIDGET_REGISTRY[type];
|
||||
|
||||
if (!definition) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
throw new Error(
|
||||
`registerWidget: unbekannter Widget-Typ "${type}". Fehlt der Typ in WIDGET_TYPES (packages/shared/src/index.ts)?`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (registeredTypes.has(type)) return;
|
||||
|
||||
definition.component = component;
|
||||
registeredTypes.add(type);
|
||||
}
|
||||
|
||||
let searchWired = false;
|
||||
export function wireSearchWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!searchWired) {
|
||||
WIDGET_REGISTRY.search.component = component;
|
||||
searchWired = true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Welche Kacheln der Katalog zeigen darf (quick-260922-m1h).
|
||||
*
|
||||
* Eine Kachel ohne `moduleSlug` ist immer sichtbar; eine Kachel MIT
|
||||
* `moduleSlug` nur, wenn der Slug unter den zugaenglichen Modulen steht.
|
||||
* `accessibleModuleSlugs === null` heisst "Modulliste unbekannt" (Abruf
|
||||
* laeuft noch oder ist fehlgeschlagen) — dann sind Modul-Kacheln
|
||||
* ausgeblendet, fail-closed wie serverseitig.
|
||||
*
|
||||
* ACHTUNG (T-M1H-01): Das hier ist reiner Komfort — es verhindert nur, dass
|
||||
* jemand eine Kachel anlegt, die ihm danach kommentarlos wieder verschwindet.
|
||||
* Die verbindliche Durchsetzung bleibt serverseitig in
|
||||
* `DashboardService.getWidgets` (fail-closed) und im Modul-Guard der
|
||||
* jeweiligen Daten-Endpunkte. Diesen Filter zu umgehen bringt nichts.
|
||||
*
|
||||
* Reine Funktion mit der Registry als Parameter, damit sie ohne eine echte
|
||||
* Modul-Kachel testbar ist (Muster `picture-frame-config.ts`).
|
||||
*/
|
||||
export function visibleWidgetTypes<T extends string>(
|
||||
registry: Record<T, Pick<WidgetDefinition, 'moduleSlug'>>,
|
||||
accessibleModuleSlugs: readonly string[] | null,
|
||||
): T[] {
|
||||
const types = Object.keys(registry) as T[];
|
||||
|
||||
let calendarWired = false;
|
||||
export function wireCalendarWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!calendarWired) {
|
||||
WIDGET_REGISTRY.calendar.component = component;
|
||||
calendarWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let noteWired = false;
|
||||
export function wireNoteWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!noteWired) {
|
||||
WIDGET_REGISTRY.note.component = component;
|
||||
noteWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let calculatorWired = false;
|
||||
export function wireCalculatorWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!calculatorWired) {
|
||||
WIDGET_REGISTRY.calculator.component = component;
|
||||
calculatorWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let favoritesWired = false;
|
||||
export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!favoritesWired) {
|
||||
WIDGET_REGISTRY.favorites.component = component;
|
||||
favoritesWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let stopwatchWired = false;
|
||||
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!stopwatchWired) {
|
||||
WIDGET_REGISTRY.stopwatch.component = component;
|
||||
stopwatchWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let pictureFrameWired = false;
|
||||
export function wirePictureFrameWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!pictureFrameWired) {
|
||||
WIDGET_REGISTRY['picture-frame'].component = component;
|
||||
pictureFrameWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let xframeWired = false;
|
||||
export function wireXframeWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!xframeWired) {
|
||||
WIDGET_REGISTRY.xframe.component = component;
|
||||
xframeWired = true;
|
||||
}
|
||||
return types.filter((type) => {
|
||||
const slug = registry[type].moduleSlug;
|
||||
if (slug === undefined) return true;
|
||||
if (accessibleModuleSlugs === null) return false;
|
||||
return accessibleModuleSlugs.includes(slug);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,15 +3,21 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
|
||||
// eine alte Link-Kachel vor dem Einspielen der Migration) muessen weiterhin
|
||||
// ohne Absturz als grauer Text gerendert werden.
|
||||
// ohne Absturz gerendert werden.
|
||||
// quick-260922-m1h: Statt des rohen Typnamens steht dort jetzt ein Satz, der
|
||||
// den Fall erklaert — derselbe Fall tritt kuenftig auf, wenn eine Kachel zu
|
||||
// einem Modul gehoert, das dem Benutzer nicht freigegeben ist.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useTranslations: () => (key: string) =>
|
||||
key === 'unavailable'
|
||||
? 'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.'
|
||||
: key,
|
||||
}));
|
||||
|
||||
import { WidgetWrapper } from './widget-wrapper';
|
||||
|
||||
describe('WidgetWrapper', () => {
|
||||
it('unbekannter Widget-Typ (z. B. eine alte Link-Kachel vor der Migration) rendert als grauer Text ohne Absturz', () => {
|
||||
it('unbekannter/gesperrter Widget-Typ erklaert sich mit einem Hinweistext statt leer oder als roher Typname zu rendern', () => {
|
||||
render(
|
||||
<WidgetWrapper
|
||||
widget={{ id: 'w-alt', widgetType: 'link', config: {} }}
|
||||
@@ -23,7 +29,29 @@ describe('WidgetWrapper', () => {
|
||||
const article = screen.getByRole('article');
|
||||
expect(article).toHaveAttribute('aria-label', 'link');
|
||||
|
||||
const fallback = screen.getByText('link');
|
||||
expect(fallback.className).toContain('text-muted-foreground');
|
||||
const hint = screen.getByText(
|
||||
'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.',
|
||||
);
|
||||
expect(hint.className).toContain('text-muted-foreground');
|
||||
expect(hint.className).toContain('text-center');
|
||||
|
||||
// Der rohe Typname steht nicht mehr im Rumpf der Kachel.
|
||||
expect(screen.queryByText('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('eine bekannte Kachel rendert weiterhin ihre Komponente, nicht den Hinweis', () => {
|
||||
render(
|
||||
<WidgetWrapper
|
||||
widget={{ id: 'w-uhr', widgetType: 'clock', config: {} }}
|
||||
isEditMode={false}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,8 +111,14 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{widget.widgetType}
|
||||
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
|
||||
Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
|
||||
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
|
||||
stand hier der rohe Typname, der dem Anwender nichts sagte. */
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('unavailable')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
XFRAME_CROP_DEFAULT,
|
||||
XFRAME_PAGE_WIDTH,
|
||||
XFRAME_RELOAD_MAX,
|
||||
XFRAME_RELOAD_MIN,
|
||||
XFRAME_RELOAD_OPTIONS,
|
||||
XFRAME_SANDBOX,
|
||||
XFRAME_TITLE_MAX,
|
||||
XFRAME_ZOOM_OPTIONS,
|
||||
clampXframeCrop,
|
||||
isHttpsUrl,
|
||||
resolveXframeConfig,
|
||||
} from './xframe-config';
|
||||
@@ -19,7 +23,14 @@ import {
|
||||
*/
|
||||
describe('resolveXframeConfig (quick-260921-qd3)', () => {
|
||||
it('Test 1: leere Konfiguration -> keine Adresse, leerer Titel, nie neu laden', () => {
|
||||
expect(resolveXframeConfig({})).toEqual({ url: null, title: '', reloadSeconds: 0 });
|
||||
expect(resolveXframeConfig({})).toEqual({
|
||||
url: null,
|
||||
title: '',
|
||||
reloadSeconds: 0,
|
||||
crop: null,
|
||||
zoom: 100,
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 2: https-Adresse bleibt (getrimmt)', () => {
|
||||
@@ -64,7 +75,101 @@ describe('resolveXframeConfig (quick-260921-qd3)', () => {
|
||||
it('Test 8: alle drei Felder zusammen', () => {
|
||||
expect(
|
||||
resolveXframeConfig({ url: 'https://a.de/', title: ' Board ', reloadSeconds: 1800 }),
|
||||
).toEqual({ url: 'https://a.de/', title: 'Board', reloadSeconds: 1800 });
|
||||
).toEqual({
|
||||
url: 'https://a.de/',
|
||||
title: 'Board',
|
||||
reloadSeconds: 1800,
|
||||
crop: null,
|
||||
zoom: 100,
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-ge2: Ausschnitt, Zoom, readOnly. Die Werte landen in
|
||||
* Inline-Styles (T-GE2-05) — der Resolver klemmt und weist nie ab: ein
|
||||
* manipulierter Wert ergibt hoechstens einen anderen Ausschnitt, nie
|
||||
* `scale(NaN)` oder einen 10⁹-px-Rahmen.
|
||||
*/
|
||||
describe('resolveXframeConfig — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
|
||||
it('Test 13: crop fehlt / null / String / unvollstaendig / Nicht-Zahl -> null', () => {
|
||||
expect(resolveXframeConfig({}).crop).toBeNull();
|
||||
expect(resolveXframeConfig({ crop: null }).crop).toBeNull();
|
||||
expect(resolveXframeConfig({ crop: 'x' }).crop).toBeNull();
|
||||
expect(resolveXframeConfig({ crop: { x: 1 } }).crop).toBeNull();
|
||||
expect(resolveXframeConfig({ crop: { x: 'a', y: 0, w: 500, h: 300 } }).crop).toBeNull();
|
||||
expect(resolveXframeConfig({ crop: { x: Number.NaN, y: 0, w: 500, h: 300 } }).crop).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 14: gueltiger Ausschnitt bleibt unveraendert; x wird verschoben, wenn x + w > 1280 (nie abgewiesen)', () => {
|
||||
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 1280, h: 720 } }).crop).toEqual({ x: 0, y: 0, w: 1280, h: 720 });
|
||||
expect(resolveXframeConfig({ crop: { x: 500, y: 0, w: 1000, h: 300 } }).crop).toEqual({ x: 280, y: 0, w: 1000, h: 300 });
|
||||
});
|
||||
|
||||
it('Test 15: Breite und Hoehe geklemmt — w 50 -> 100, w 5000 -> 1280 (x -> 0), h 10 -> 60, h 9999 -> 4000', () => {
|
||||
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 50, h: 300 } }).crop).toEqual({ x: 0, y: 0, w: 100, h: 300 });
|
||||
expect(resolveXframeConfig({ crop: { x: 300, y: 0, w: 5000, h: 300 } }).crop).toEqual({ x: 0, y: 0, w: 1280, h: 300 });
|
||||
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 500, h: 10 } }).crop).toEqual({ x: 0, y: 0, w: 500, h: 60 });
|
||||
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 500, h: 9999 } }).crop).toEqual({ x: 0, y: 0, w: 500, h: 4000 });
|
||||
});
|
||||
|
||||
it('Test 16: negative Position -> 0; Nachkommastellen gerundet', () => {
|
||||
expect(resolveXframeConfig({ crop: { x: -5, y: -1, w: 500, h: 300 } }).crop).toEqual({ x: 0, y: 0, w: 500, h: 300 });
|
||||
expect(resolveXframeConfig({ crop: { x: 10.6, y: 2.4, w: 500.4, h: 300.5 } }).crop).toEqual({
|
||||
x: 11,
|
||||
y: 2,
|
||||
w: 500,
|
||||
h: 301,
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 17: zoom fehlt / "abc" / NaN -> 100; 70 -> 60, 200 -> 150, 0 -> 50, 10 -> 50, 125 -> 125', () => {
|
||||
expect(resolveXframeConfig({}).zoom).toBe(100);
|
||||
expect(resolveXframeConfig({ zoom: 'abc' }).zoom).toBe(100);
|
||||
expect(resolveXframeConfig({ zoom: Number.NaN }).zoom).toBe(100);
|
||||
expect(resolveXframeConfig({ zoom: 70 }).zoom).toBe(60);
|
||||
expect(resolveXframeConfig({ zoom: 200 }).zoom).toBe(150);
|
||||
expect(resolveXframeConfig({ zoom: 0 }).zoom).toBe(50);
|
||||
expect(resolveXframeConfig({ zoom: 10 }).zoom).toBe(50);
|
||||
expect(resolveXframeConfig({ zoom: 125 }).zoom).toBe(125);
|
||||
});
|
||||
|
||||
it('Test 18: readOnly nur bei echtem true; "true", 1, fehlt -> false', () => {
|
||||
expect(resolveXframeConfig({ readOnly: true }).readOnly).toBe(true);
|
||||
expect(resolveXframeConfig({ readOnly: 'true' }).readOnly).toBe(false);
|
||||
expect(resolveXframeConfig({ readOnly: 1 }).readOnly).toBe(false);
|
||||
expect(resolveXframeConfig({}).readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 19: Konstanten — Zoom-Stufen, Seitenbreite 1280, Vorgabe-Ausschnitt 0/0/1280/720', () => {
|
||||
expect(XFRAME_ZOOM_OPTIONS).toEqual([50, 60, 75, 90, 100, 125, 150]);
|
||||
expect(XFRAME_PAGE_WIDTH).toBe(1280);
|
||||
expect(XFRAME_CROP_DEFAULT).toEqual({ x: 0, y: 0, w: 1280, h: 720 });
|
||||
});
|
||||
|
||||
it('Test 20: alle sechs Felder zusammen', () => {
|
||||
expect(
|
||||
resolveXframeConfig({
|
||||
url: 'https://a.de/',
|
||||
title: 'Board',
|
||||
reloadSeconds: 60,
|
||||
crop: { x: 100, y: 200, w: 800, h: 400 },
|
||||
zoom: 75,
|
||||
readOnly: true,
|
||||
}),
|
||||
).toEqual({
|
||||
url: 'https://a.de/',
|
||||
title: 'Board',
|
||||
reloadSeconds: 60,
|
||||
crop: { x: 100, y: 200, w: 800, h: 400 },
|
||||
zoom: 75,
|
||||
readOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 21: clampXframeCrop ist exportiert — { 1200, 0, 200, 100 } -> x 1080', () => {
|
||||
expect(clampXframeCrop({ x: 1200, y: 0, w: 200, h: 100 })).toEqual({ x: 1080, y: 0, w: 200, h: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -41,12 +41,50 @@ export const XFRAME_SANDBOX =
|
||||
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
|
||||
export { isHttpsUrl };
|
||||
|
||||
/** Virtuelle Layoutbreite der eingebetteten Seite in Pixeln — Konstante, keine UI. */
|
||||
export const XFRAME_PAGE_WIDTH = 1280;
|
||||
export const XFRAME_CROP_MIN_W = 100;
|
||||
export const XFRAME_CROP_MIN_H = 60;
|
||||
/** Speichergrenze fuer getippte Werte; die Vorschau zeigt 3000 (xframe-crop.ts). */
|
||||
export const XFRAME_CROP_MAX_H = 4000;
|
||||
/** Ausschnitt in Seitenpixeln bei XFRAME_PAGE_WIDTH. */
|
||||
export interface XframeCrop {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
export const XFRAME_CROP_DEFAULT: XframeCrop = { x: 0, y: 0, w: XFRAME_PAGE_WIDTH, h: 720 };
|
||||
/** Vergroesserung der ganzen Seite in Prozent (nur ohne Ausschnitt). */
|
||||
export const XFRAME_ZOOM_OPTIONS: number[] = [50, 60, 75, 90, 100, 125, 150];
|
||||
export const XFRAME_ZOOM_DEFAULT = 100;
|
||||
|
||||
export interface XframeConfig {
|
||||
/** null = keine gueltige https-Adresse -> Leerzustand. */
|
||||
url: string | null;
|
||||
title: string;
|
||||
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
|
||||
reloadSeconds: number;
|
||||
/** null = ganze Seite; sonst geklemmter Ausschnitt (quick-260922-ge2). */
|
||||
crop: XframeCrop | null;
|
||||
/** Stufe aus XFRAME_ZOOM_OPTIONS, wirkt nur bei crop === null. */
|
||||
zoom: number;
|
||||
/** Transparente Flaeche ueber dem Rahmen im Ansichtsmodus. */
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EINE Klemmregel fuer Resolver, Zahlenfelder und Ziehen: runden, Breite
|
||||
* [100, 1280], Hoehe [60, 4000], Position >= 0, dann x so verschieben, dass
|
||||
* der Ausschnitt in der Seite bleibt (nie abweisen, T-GE2-05).
|
||||
*/
|
||||
export function clampXframeCrop(raw: XframeCrop): XframeCrop {
|
||||
const w = Math.min(XFRAME_PAGE_WIDTH, Math.max(XFRAME_CROP_MIN_W, Math.round(raw.w)));
|
||||
const h = Math.min(XFRAME_CROP_MAX_H, Math.max(XFRAME_CROP_MIN_H, Math.round(raw.h)));
|
||||
let x = Math.max(0, Math.round(raw.x));
|
||||
const y = Math.max(0, Math.round(raw.y));
|
||||
if (x + w > XFRAME_PAGE_WIDTH) x = XFRAME_PAGE_WIDTH - w;
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
function resolveUrl(raw: unknown): string | null {
|
||||
@@ -75,10 +113,44 @@ function resolveReload(raw: unknown): number {
|
||||
return best;
|
||||
}
|
||||
|
||||
function finiteNumber(raw: unknown): raw is number {
|
||||
return typeof raw === 'number' && Number.isFinite(raw);
|
||||
}
|
||||
|
||||
/** Typwaechter statt Cast: ein Nicht-null-Objekt darf als Schluessel-Wert-Menge gelesen werden. */
|
||||
function isRecord(raw: unknown): raw is Record<string, unknown> {
|
||||
return typeof raw === 'object' && raw !== null;
|
||||
}
|
||||
|
||||
/** Nur ein Nicht-null-Objekt mit vier endlichen Zahlen x, y, w, h -> geklemmt; alles andere -> null. */
|
||||
function resolveCrop(raw: unknown): XframeCrop | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const { x, y, w, h } = raw;
|
||||
if (!finiteNumber(x) || !finiteNumber(y) || !finiteNumber(w) || !finiteNumber(h)) return null;
|
||||
return clampXframeCrop({ x, y, w, h });
|
||||
}
|
||||
|
||||
/** Groesste Zoom-Stufe <= n (Muster resolveReload); unter 50 -> 50; nicht endlich -> 100. */
|
||||
function resolveZoom(raw: unknown): number {
|
||||
if (!finiteNumber(raw)) return XFRAME_ZOOM_DEFAULT;
|
||||
let best = XFRAME_ZOOM_OPTIONS[0];
|
||||
for (const option of XFRAME_ZOOM_OPTIONS) {
|
||||
if (option <= raw && option > best) best = option;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function resolveReadOnly(raw: unknown): boolean {
|
||||
return raw === true;
|
||||
}
|
||||
|
||||
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
|
||||
return {
|
||||
url: resolveUrl(config.url),
|
||||
title: resolveTitle(config.title),
|
||||
reloadSeconds: resolveReload(config.reloadSeconds),
|
||||
crop: resolveCrop(config.crop),
|
||||
zoom: resolveZoom(config.zoom),
|
||||
readOnly: resolveReadOnly(config.readOnly),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { XFRAME_PREVIEW_PAGE_HEIGHT, applyCropDrag, computeCropLayout } from './xframe-crop';
|
||||
|
||||
/**
|
||||
* xframe-crop.test — NEU (quick-260922-ge2).
|
||||
*
|
||||
* Reine Geometrie ohne React: Einpassen (contain) und Zentrieren des
|
||||
* Ausschnitts in der Kachel, Layouthoehe des Rahmens, Nullkachel ohne
|
||||
* Division durch 0 (T-GE2-05); Ziehmathematik der Vorschau — Verschieben
|
||||
* bei fester Groesse, Ecken bewegen nur ihre zwei Kanten, die Gegenecke
|
||||
* bleibt stehen, Mindestgroesse an der bewegten Kante.
|
||||
*/
|
||||
const FULL = { x: 0, y: 0, w: 1280, h: 720 };
|
||||
const START = { x: 100, y: 100, w: 400, h: 300 };
|
||||
|
||||
describe('computeCropLayout (quick-260922-ge2)', () => {
|
||||
it('Test 1: breitenbegrenzt — 1280x720 in 640x720 -> scale 0.5, left 0, top 180', () => {
|
||||
expect(computeCropLayout(FULL, 640, 720)).toEqual({ scale: 0.5, left: 0, top: 180, frameHeight: 3000 });
|
||||
});
|
||||
|
||||
it('Test 2: hoehenbegrenzt — 1280x720 in 1280x180 -> scale 0.25, left 480, top 0', () => {
|
||||
expect(computeCropLayout(FULL, 1280, 180)).toEqual({ scale: 0.25, left: 480, top: 0, frameHeight: 3000 });
|
||||
});
|
||||
|
||||
it('Test 3: Vergroesserung erlaubt — 200x100 in 800x400 -> scale 4', () => {
|
||||
expect(computeCropLayout({ x: 0, y: 0, w: 200, h: 100 }, 800, 400)).toEqual({
|
||||
scale: 4,
|
||||
left: 0,
|
||||
top: 0,
|
||||
frameHeight: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 4: frameHeight ist immer die Vorschauhoehe 3000 — gleiches Layout wie in der Vorschau (vh-Masse)', () => {
|
||||
expect(computeCropLayout({ x: 0, y: 0, w: 1280, h: 100 }, 640, 360).frameHeight).toBe(3000);
|
||||
expect(computeCropLayout({ x: 0, y: 3000, w: 1280, h: 400 }, 640, 360).frameHeight).toBe(3000);
|
||||
});
|
||||
|
||||
it('Test 5: ungemessene Kachel (0x0 oder 640x0) -> scale 0, left 0, top 0 (nichts rendern)', () => {
|
||||
expect(computeCropLayout(FULL, 0, 0)).toEqual({ scale: 0, left: 0, top: 0, frameHeight: 3000 });
|
||||
expect(computeCropLayout(FULL, 640, 0)).toEqual({ scale: 0, left: 0, top: 0, frameHeight: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCropDrag (quick-260922-ge2)', () => {
|
||||
it('Test 6: move um +100/+50 verschiebt bei fester Groesse', () => {
|
||||
expect(applyCropDrag('move', START, 100, 50)).toEqual({ x: 200, y: 150, w: 400, h: 300 });
|
||||
});
|
||||
|
||||
it('Test 7: move ueber den Rand -> an der Kante geklemmt (x 880 = 1280 - 400; -500/-500 -> 0/0)', () => {
|
||||
expect(applyCropDrag('move', START, 2000, 0)).toEqual({ x: 880, y: 100, w: 400, h: 300 });
|
||||
expect(applyCropDrag('move', START, -500, -500)).toEqual({ x: 0, y: 0, w: 400, h: 300 });
|
||||
});
|
||||
|
||||
it('Test 8: se um +100/+100 -> groesser, obere linke Ecke bleibt', () => {
|
||||
expect(applyCropDrag('se', START, 100, 100)).toEqual({ x: 100, y: 100, w: 500, h: 400 });
|
||||
});
|
||||
|
||||
it('Test 9: nw um +350/+10 -> Mindestbreite 100 an der bewegten Kante, rechte Kante 500 bleibt', () => {
|
||||
expect(applyCropDrag('nw', START, 350, 10)).toEqual({ x: 400, y: 110, w: 100, h: 290 });
|
||||
});
|
||||
|
||||
it('Test 10: ne um +2000/0 -> rechte Kante 1280 (w 1180), linke Kante bleibt', () => {
|
||||
expect(applyCropDrag('ne', START, 2000, 0)).toEqual({ x: 100, y: 100, w: 1180, h: 300 });
|
||||
});
|
||||
|
||||
it('Test 11: sw um 0/+5000 -> untere Kante an der Vorschauhoehe 3000 (h 2900)', () => {
|
||||
expect(XFRAME_PREVIEW_PAGE_HEIGHT).toBe(3000);
|
||||
expect(applyCropDrag('sw', START, 0, 5000)).toEqual({ x: 100, y: 100, w: 400, h: 2900 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* xframe-crop — reine Geometrie des XFrame-Ausschnitts (quick-260922-ge2),
|
||||
* ohne React, fuer Kachel UND Vorschau.
|
||||
*
|
||||
* Einpassen (contain) und Zentrieren: der Ausschnitt muss in der Kachel
|
||||
* immer GANZ sichtbar sein, und das Seitenverhaeltnis der Kachel stimmt
|
||||
* praktisch nie mit dem des Ausschnitts ueberein — deshalb der kleinere
|
||||
* der beiden Massstaebe (darf > 1 sein: ein kleiner Ausschnitt in einer
|
||||
* grossen Kachel wird vergroessert) und die Zentrierung im Rest.
|
||||
*
|
||||
* `frameHeight` ist IMMER die Vorschauhoehe (3000 Seitenpixel), nicht
|
||||
* "Unterkante des Ausschnitts": viele Seiten setzen Masse relativ zur
|
||||
* Fensterhoehe (`vh`, `100vh`-Zentrierung, `margin: 15vh` bei example.com).
|
||||
* Haetten Vorschau und Kachel verschiedene Rahmenhoehen, laege derselbe
|
||||
* Inhalt an verschiedenen Stellen — der in der Vorschau gewaehlte Ausschnitt
|
||||
* zeigte in der Kachel etwas anderes (Browser-Rundgang 22.09.2026: Ueberschrift
|
||||
* in der Vorschau bei y 450, in der Kachel mit 720 px Rahmen bei y 108).
|
||||
* Gleiche Hoehe = gleiches Layout; der Rest wird ohnehin weggeschnitten.
|
||||
*
|
||||
* Ziehmathematik der Vorschau: `move` verschiebt bei fester Groesse und
|
||||
* klemmt an den Seitenraendern; die Eckmodi bewegen nur die zwei Kanten
|
||||
* ihrer Ecke — die gegenueberliegende Ecke bleibt stehen, damit sich der
|
||||
* Rahmen so verhaelt, wie man es von jedem Bildeditor kennt; die
|
||||
* Mindestgroesse wird an der BEWEGTEN Kante gehalten. Das Ergebnis geht
|
||||
* durch `clampXframeCrop` (eine Klemmregel fuer alles, T-GE2-05).
|
||||
*/
|
||||
|
||||
import {
|
||||
XFRAME_CROP_MIN_H,
|
||||
XFRAME_CROP_MIN_W,
|
||||
XFRAME_PAGE_WIDTH,
|
||||
type XframeCrop,
|
||||
clampXframeCrop,
|
||||
} from './xframe-config';
|
||||
|
||||
/**
|
||||
* Hoehe der Seite in Seitenpixeln — in der Vorschau (Stage, Ziehen) UND als
|
||||
* Layouthoehe des Rahmens in der Kachel (siehe Kopfkommentar); 4000 bleibt
|
||||
* die Speichergrenze fuer `crop.y + crop.h`.
|
||||
*/
|
||||
export const XFRAME_PREVIEW_PAGE_HEIGHT = 3000;
|
||||
|
||||
export type XframeDragMode = 'move' | 'nw' | 'ne' | 'sw' | 'se';
|
||||
|
||||
export interface XframeCropLayout {
|
||||
/** 0 = Kachel noch nicht gemessen -> nichts rendern. */
|
||||
scale: number;
|
||||
left: number;
|
||||
top: number;
|
||||
frameHeight: number;
|
||||
}
|
||||
|
||||
export function computeCropLayout(crop: XframeCrop, tileW: number, tileH: number): XframeCropLayout {
|
||||
const frameHeight = XFRAME_PREVIEW_PAGE_HEIGHT;
|
||||
if (tileW <= 0 || tileH <= 0) return { scale: 0, left: 0, top: 0, frameHeight };
|
||||
const scale = Math.min(tileW / crop.w, tileH / crop.h);
|
||||
return {
|
||||
scale,
|
||||
left: (tileW - crop.w * scale) / 2,
|
||||
top: (tileH - crop.h * scale) / 2,
|
||||
frameHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCropDrag(mode: XframeDragMode, start: XframeCrop, dxPage: number, dyPage: number): XframeCrop {
|
||||
let left = start.x;
|
||||
let top = start.y;
|
||||
let right = start.x + start.w;
|
||||
let bottom = start.y + start.h;
|
||||
|
||||
if (mode === 'move') {
|
||||
left = Math.min(Math.max(0, left + dxPage), XFRAME_PAGE_WIDTH - start.w);
|
||||
top = Math.min(Math.max(0, top + dyPage), Math.max(0, XFRAME_PREVIEW_PAGE_HEIGHT - start.h));
|
||||
return clampXframeCrop({ x: left, y: top, w: start.w, h: start.h });
|
||||
}
|
||||
|
||||
if (mode === 'nw' || mode === 'sw') {
|
||||
left = Math.min(Math.max(0, left + dxPage), right - XFRAME_CROP_MIN_W);
|
||||
} else {
|
||||
right = Math.max(Math.min(XFRAME_PAGE_WIDTH, right + dxPage), left + XFRAME_CROP_MIN_W);
|
||||
}
|
||||
if (mode === 'nw' || mode === 'ne') {
|
||||
top = Math.min(Math.max(0, top + dyPage), bottom - XFRAME_CROP_MIN_H);
|
||||
} else {
|
||||
bottom = Math.max(Math.min(XFRAME_PREVIEW_PAGE_HEIGHT, bottom + dyPage), top + XFRAME_CROP_MIN_H);
|
||||
}
|
||||
return clampXframeCrop({ x: left, y: top, w: right - left, h: bottom - top });
|
||||
}
|
||||
@@ -6,6 +6,7 @@ vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||
import { XFRAME_SANDBOX } from './xframe-config';
|
||||
import { XframeWidget } from './xframe-widget';
|
||||
|
||||
@@ -34,11 +35,14 @@ function frame(): HTMLIFrameElement {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// quick-260922-ge2: Kachelkoerper misst 640x360 (deterministische Geometrie)
|
||||
stubResizeObserver({ width: 640, height: 360 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('XframeWidget (quick-260921-qd3)', () => {
|
||||
@@ -151,3 +155,114 @@ describe('XframeWidget (quick-260921-qd3)', () => {
|
||||
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-ge2: Ausschnitt (Clip + verschobener, skalierter Rahmen bei
|
||||
* gemessener Kachel 640x360), Zoom-Zweig (Prozentmasse + scale), 100 % wie
|
||||
* heute, readOnly-Flaeche nur im Ansichtsmodus (T-GE2-02), Sandbox in jedem
|
||||
* Zweig unveraendert (T-GE2-01).
|
||||
*/
|
||||
describe('XframeWidget — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
|
||||
it('Test 13: Ausschnitt 0/0/1280/720 in 640x360 -> data-tile-size, Clip 640x360 bei 0/0, Rahmen 1280x720 scale(0.5), Sandbox unveraendert', () => {
|
||||
const { container } = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 } });
|
||||
const body = container.querySelector('[data-tile-size]');
|
||||
expect(body?.getAttribute('data-tile-size')).toBe('640x360');
|
||||
const clip = screen.getByTestId('xframe-crop-clip');
|
||||
expect(clip.style.position).toBe('absolute');
|
||||
expect(clip.style.left).toBe('0px');
|
||||
expect(clip.style.top).toBe('0px');
|
||||
expect(clip.style.width).toBe('640px');
|
||||
expect(clip.style.height).toBe('360px');
|
||||
expect(clip.style.overflow).toBe('hidden');
|
||||
const f = frame();
|
||||
expect(clip.contains(f)).toBe(true);
|
||||
expect(f.style.left).toBe('0px');
|
||||
expect(f.style.top).toBe('0px');
|
||||
expect(f.style.width).toBe('1280px');
|
||||
expect(f.style.height).toBe('3000px');
|
||||
expect(f.style.transform).toBe('scale(0.5)');
|
||||
expect(f.style.transformOrigin).toBe('0 0');
|
||||
expect(f.className).not.toContain('h-full');
|
||||
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(f.getAttribute('allow')).toBe('');
|
||||
expect(f.getAttribute('loading')).toBe('lazy');
|
||||
expect(f.getAttribute('data-reload-nonce')).toBe('0');
|
||||
expect(f.getAttribute('src')).toBe(URL_A);
|
||||
});
|
||||
|
||||
it('Test 14: Ausschnitt 200/100/800/400 in 640x360 -> Clip 640x320 bei 0/20, Rahmen um -160/-80 verschoben, scale(0.8)', () => {
|
||||
renderWidget({ url: URL_A, crop: { x: 200, y: 100, w: 800, h: 400 } });
|
||||
const clip = screen.getByTestId('xframe-crop-clip');
|
||||
expect(clip.style.left).toBe('0px');
|
||||
expect(clip.style.top).toBe('20px');
|
||||
expect(clip.style.width).toBe('640px');
|
||||
expect(clip.style.height).toBe('320px');
|
||||
const f = frame();
|
||||
expect(f.style.left).toBe('-160px');
|
||||
expect(f.style.top).toBe('-80px');
|
||||
expect(f.style.height).toBe('3000px');
|
||||
expect(f.style.transform).toBe('scale(0.8)');
|
||||
});
|
||||
|
||||
it('Test 15: zoom 60 ohne Ausschnitt -> kein Clip, Rahmen 166.67 % mit scale(0.6), absolut positioniert', () => {
|
||||
renderWidget({ url: URL_A, zoom: 60 });
|
||||
expect(screen.queryByTestId('xframe-crop-clip')).not.toBeInTheDocument();
|
||||
const f = frame();
|
||||
expect(f.style.width).toBe('166.67%');
|
||||
expect(f.style.height).toBe('166.67%');
|
||||
expect(f.style.transform).toBe('scale(0.6)');
|
||||
expect(f.style.transformOrigin).toBe('0 0');
|
||||
expect(f.className).toContain('absolute');
|
||||
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||
});
|
||||
|
||||
it('Test 16: zoom 100 und ohne zoom -> wie heute (kein style, h-full w-full, kein Clip); Ausschnitt + zoom 60 -> Zoom ignoriert', () => {
|
||||
renderWidget({ url: URL_A, zoom: 100 });
|
||||
expect(frame().style.transform).toBe('');
|
||||
expect(frame().className).toContain('h-full');
|
||||
expect(frame().className).toContain('w-full');
|
||||
expect(screen.queryByTestId('xframe-crop-clip')).not.toBeInTheDocument();
|
||||
cleanup();
|
||||
renderWidget({ url: URL_A });
|
||||
expect(frame().style.transform).toBe('');
|
||||
expect(frame().getAttribute('style')).toBeNull();
|
||||
expect(frame().className).toContain('h-full');
|
||||
cleanup();
|
||||
renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 }, zoom: 60 });
|
||||
expect(screen.getByTestId('xframe-crop-clip')).toBeInTheDocument();
|
||||
expect(frame().style.transform).toBe('scale(0.5)');
|
||||
expect(frame().style.transform).not.toBe('scale(0.6)');
|
||||
});
|
||||
|
||||
it('Test 17: readOnly im Ansichtsmodus -> Flaeche aria-hidden, absolute inset-0, NACH dem Rahmen; kein Edit-Overlay; Link z-10 bleibt', () => {
|
||||
renderWidget({ url: URL_A, readOnly: true }, false);
|
||||
const overlay = screen.getByTestId('xframe-readonly-overlay');
|
||||
expect(overlay.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(overlay.className).toContain('absolute');
|
||||
expect(overlay.className).toContain('inset-0');
|
||||
expect(frame().compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.queryByTestId('xframe-edit-overlay')).not.toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
|
||||
expect(link.className).toContain('z-10');
|
||||
});
|
||||
|
||||
it('Test 18: readOnly im Bearbeitungsmodus -> nur Edit-Overlay; ohne readOnly im Ansichtsmodus -> keine Flaeche', () => {
|
||||
renderWidget({ url: URL_A, readOnly: true }, true);
|
||||
expect(screen.getByTestId('xframe-edit-overlay')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('xframe-readonly-overlay')).not.toBeInTheDocument();
|
||||
cleanup();
|
||||
renderWidget({ url: URL_A }, false);
|
||||
expect(screen.queryByTestId('xframe-readonly-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 19: Ausschnitt im Bearbeitungsmodus -> Clip UND Edit-Overlay; mit Titel bleibt die Kopfleiste', () => {
|
||||
const { container } = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 } }, true);
|
||||
expect(screen.getByTestId('xframe-crop-clip')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('xframe-edit-overlay')).toBeInTheDocument();
|
||||
cleanup();
|
||||
const second = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 }, title: 'Board' });
|
||||
expect(second.container.querySelector('h2')).toHaveTextContent('Board');
|
||||
expect(container.querySelector('h2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
import { XFRAME_PAGE_WIDTH, XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
import { computeCropLayout } from './xframe-crop';
|
||||
|
||||
/**
|
||||
* XframeWidget — eine Webseite per https-Adresse als eingebetteter Rahmen
|
||||
@@ -38,11 +39,36 @@ import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
* versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“
|
||||
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
|
||||
* Rasters und startet im Bearbeitungsmodus kein Ziehen).
|
||||
*
|
||||
* Ausschnitt (quick-260922-ge2): cross-origin kann Tessera die Seite nicht
|
||||
* von aussen scrollen oder zuschneiden. Deshalb wird der <iframe> SELBST
|
||||
* mit `left/top` verschoben und mit `transform: scale` skaliert, und ein
|
||||
* Clip-`div` (overflow hidden) schneidet ihn auf den Ausschnitt. Der Rahmen
|
||||
* behaelt die feste Layoutbreite `XFRAME_PAGE_WIDTH` (1280): sie haelt den
|
||||
* Seitenaufbau ueber alle Kachelgroessen stabil — sonst braeche die Seite
|
||||
* bei jeder Kachelgroesse anders um und der in den Einstellungen gewaehlte
|
||||
* Ausschnitt verrutschte. Die Kachelgroesse liefert ein ResizeObserver am
|
||||
* Koerper (nur im Ausschnitt-Modus angehaengt); `computeCropLayout` passt
|
||||
* den Ausschnitt ein (contain) und zentriert ihn. Ohne Ausschnitt gilt die
|
||||
* Vergroesserung `zoom` (Prozentmasse + scale); 100 % rendert wie bisher.
|
||||
*
|
||||
* „Nur anzeigen“ (`readOnly`, T-GE2-02): eine transparente Flaeche NACH dem
|
||||
* Rahmen sperrt Zeigerereignisse — Klicken und Scrollen in der Seite. Das
|
||||
* ist Bedienkomfort, KEINE Sicherheitsmassnahme: die Seite laedt und laeuft
|
||||
* weiter (Skripte, Cookies, Neuladen), die Sicherheitsgrenze bleibt die
|
||||
* Sandbox. Im Bearbeitungsmodus liegt nur die Bearbeitungsflaeche, nie
|
||||
* beide.
|
||||
*/
|
||||
export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config]);
|
||||
const { url, title, reloadSeconds, crop, zoom, readOnly } = useMemo(
|
||||
() => resolveXframeConfig(config),
|
||||
[config],
|
||||
);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
const [tile, setTile] = useState({ w: 0, h: 0 });
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const hasCrop = crop !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (url === null || reloadSeconds === 0 || isEditMode) return;
|
||||
@@ -50,6 +76,18 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
return () => clearInterval(timer);
|
||||
}, [url, reloadSeconds, isEditMode]);
|
||||
|
||||
// Kachelgroesse nur im Ausschnitt-Modus messen (Muster dashboard-grid.tsx).
|
||||
useEffect(() => {
|
||||
const el = bodyRef.current;
|
||||
if (!el || !hasCrop) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const rect = entries[0].contentRect;
|
||||
setTile({ w: rect.width, h: rect.height });
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasCrop]);
|
||||
|
||||
if (url === null) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||
@@ -59,6 +97,21 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
}
|
||||
|
||||
const openLabel = t('xframe.openInNewTab');
|
||||
const frameKey = `${url}#${reloadNonce}`;
|
||||
const frameAttrs = {
|
||||
src: url,
|
||||
title: title !== '' ? title : url,
|
||||
sandbox: XFRAME_SANDBOX,
|
||||
allow: '',
|
||||
referrerPolicy: 'no-referrer' as const,
|
||||
loading: 'lazy' as const,
|
||||
'data-testid': 'xframe-frame',
|
||||
'data-reload-nonce': reloadNonce,
|
||||
};
|
||||
const layout = crop === null ? null : computeCropLayout(crop, tile.w, tile.h);
|
||||
const zoomFactor = zoom / 100;
|
||||
// 60 % -> 166.67 %, 150 % -> 66.67 %: der Rahmen wird groesser gelegt und dann verkleinert.
|
||||
const zoomPct = `${Math.round(10000 / zoomFactor) / 100}%`;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
@@ -75,22 +128,61 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className="relative min-h-0 flex-1 overflow-hidden"
|
||||
data-tile-size={hasCrop ? `${Math.round(tile.w)}x${Math.round(tile.h)}` : undefined}
|
||||
>
|
||||
{crop !== null && layout !== null ? (
|
||||
layout.scale > 0 && (
|
||||
<div
|
||||
data-testid="xframe-crop-clip"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: layout.left,
|
||||
top: layout.top,
|
||||
width: crop.w * layout.scale,
|
||||
height: crop.h * layout.scale,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
key={`${url}#${reloadNonce}`}
|
||||
src={url}
|
||||
title={title !== '' ? title : url}
|
||||
sandbox={XFRAME_SANDBOX}
|
||||
allow=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
data-testid="xframe-frame"
|
||||
data-reload-nonce={reloadNonce}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
key={frameKey}
|
||||
{...frameAttrs}
|
||||
className="border-0 bg-background"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -crop.x * layout.scale,
|
||||
top: -crop.y * layout.scale,
|
||||
width: XFRAME_PAGE_WIDTH,
|
||||
height: layout.frameHeight,
|
||||
transform: `scale(${layout.scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : zoomFactor !== 1 ? (
|
||||
<iframe
|
||||
key={frameKey}
|
||||
{...frameAttrs}
|
||||
className="absolute left-0 top-0 border-0 bg-background"
|
||||
style={{
|
||||
width: zoomPct,
|
||||
height: zoomPct,
|
||||
transform: `scale(${zoomFactor})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<iframe key={frameKey} {...frameAttrs} className="h-full w-full border-0 bg-background" />
|
||||
)}
|
||||
{isEditMode && (
|
||||
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />
|
||||
)}
|
||||
{!isEditMode && readOnly && (
|
||||
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-readonly-overlay" />
|
||||
)}
|
||||
{title === '' && (
|
||||
<NewTabLink
|
||||
url={url}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||
|
||||
/**
|
||||
* xframe-config-form.test — NEU (quick-260921-qd3). Texte aus der echten
|
||||
@@ -22,17 +24,31 @@ import { XframeConfigForm } from './xframe-config-form';
|
||||
const texts = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
|
||||
const URL_A = 'https://intern.example/board';
|
||||
|
||||
type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||||
type CropKey = 'x' | 'y' | 'w' | 'h';
|
||||
|
||||
function renderForm(config: Record<string, unknown>) {
|
||||
const onChange = vi.fn();
|
||||
render(<XframeConfigForm config={config} onChange={onChange} />);
|
||||
const url = screen.getByLabelText(texts.urlLabel) as HTMLInputElement;
|
||||
const title = screen.getByLabelText(texts.titleLabel) as HTMLInputElement;
|
||||
const reload = screen.getByLabelText(texts.reloadLabel) as HTMLSelectElement;
|
||||
return { onChange, url, title, reload };
|
||||
const cropEnable = screen.getByLabelText(texts.cropEnable) as HTMLInputElement;
|
||||
const readOnly = screen.getByLabelText(texts.readOnly) as HTMLInputElement;
|
||||
const rect = () => screen.getByTestId('xframe-crop-rect');
|
||||
const handle = (corner: Corner) => screen.getByTestId(`xframe-crop-handle-${corner}`);
|
||||
const numberField = (k: CropKey) => screen.getByLabelText(texts[`crop${k.toUpperCase()}`]) as HTMLInputElement;
|
||||
return { onChange, url, title, reload, cropEnable, readOnly, rect, handle, numberField };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// quick-260922-ge2: Vorschau 640 px breit -> p = 0.5 (50 Bildschirm-px = 100 Seiten-px)
|
||||
stubResizeObserver({ width: 640, height: 420 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('XframeConfigForm (quick-260921-qd3)', () => {
|
||||
@@ -139,3 +155,159 @@ describe('XframeConfigForm (quick-260921-qd3)', () => {
|
||||
expect(onChange).toHaveBeenCalledWith({ reloadSeconds: 600 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* quick-260922-ge2: Ausschnitt (Checkbox -> EIN Aufruf mit Ausschnitt +
|
||||
* readOnly), Vorschau mit derselben Sandbox und pointer-events none
|
||||
* (T-GE2-01, T-GE2-03), Ziehen des Rahmens und der Ecken -> Entwurf folgt,
|
||||
* EIN Aufruf beim Loslassen; Zahlenfelder klemmen; Zoom nur ohne
|
||||
* Ausschnitt; „Nur anzeigen“; dauerhafter Hinweis.
|
||||
*
|
||||
* jsdom kennt kein setPointerCapture — Move/Up feuern auf DEMSELBEN
|
||||
* Element; Griffe stoppen die Propagation, sonst zaehlt der Rahmen mit.
|
||||
*/
|
||||
const CROP = { x: 100, y: 100, w: 400, h: 300 };
|
||||
|
||||
describe('XframeConfigForm — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
|
||||
it('Test 9: ohne Ausschnitt -> Checkbox aus, keine Vorschau, Zoom-Auswahl mit sieben Stufen; Einschalten -> EIN Aufruf mit Ausschnitt + readOnly', () => {
|
||||
const { onChange, cropEnable } = renderForm({ url: URL_A });
|
||||
expect(cropEnable.checked).toBe(false);
|
||||
expect(screen.queryByTestId('xframe-crop-preview')).not.toBeInTheDocument();
|
||||
const zoom = screen.getByLabelText(texts.zoomLabel) as HTMLSelectElement;
|
||||
expect(Array.from(zoom.options).map((o) => o.value)).toEqual(['50', '60', '75', '90', '100', '125', '150']);
|
||||
expect(Array.from(zoom.options).map((o) => o.textContent)).toEqual([
|
||||
'50 %',
|
||||
'60 %',
|
||||
'75 %',
|
||||
'90 %',
|
||||
'100 %',
|
||||
'125 %',
|
||||
'150 %',
|
||||
]);
|
||||
expect(zoom.value).toBe('100');
|
||||
fireEvent.click(cropEnable);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ crop: { x: 0, y: 0, w: 1280, h: 720 }, readOnly: true });
|
||||
});
|
||||
|
||||
it('Test 10: mit Ausschnitt -> Vorschau-Rahmen mit Sandbox, no-referrer, pointer-events none, 1280x3000 scale(0.5); Rahmen 50/50/200x150, vier Griffe, keine Zoom-Auswahl; Ausschalten -> { crop: null }', () => {
|
||||
const { onChange, cropEnable, rect } = renderForm({ url: URL_A, crop: CROP });
|
||||
expect(cropEnable.checked).toBe(true);
|
||||
expect(screen.getByTestId('xframe-crop-preview')).toBeInTheDocument();
|
||||
const preview = screen.getByTestId('xframe-crop-preview-frame') as HTMLIFrameElement;
|
||||
expect(preview.getAttribute('src')).toBe(URL_A);
|
||||
expect(preview.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||
expect(preview.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(preview.getAttribute('allow')).toBe('');
|
||||
expect(preview.style.pointerEvents).toBe('none');
|
||||
expect(preview.style.width).toBe('1280px');
|
||||
expect(preview.style.height).toBe('3000px');
|
||||
expect(preview.style.transform).toBe('scale(0.5)');
|
||||
const r = rect();
|
||||
// <fieldset> traegt die Gruppen-Rolle implizit (Biome useSemanticElements statt div role="group")
|
||||
expect(r.tagName).toBe('FIELDSET');
|
||||
expect(screen.getByRole('group', { name: texts.cropRectangle })).toBe(r);
|
||||
expect(r.style.left).toBe('50px');
|
||||
expect(r.style.top).toBe('50px');
|
||||
expect(r.style.width).toBe('200px');
|
||||
expect(r.style.height).toBe('150px');
|
||||
for (const corner of ['nw', 'ne', 'sw', 'se'] as const) {
|
||||
expect(screen.getByTestId(`xframe-crop-handle-${corner}`)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByLabelText(texts.zoomLabel)).not.toBeInTheDocument();
|
||||
fireEvent.click(cropEnable);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ crop: null });
|
||||
});
|
||||
|
||||
it('Test 11: Zahlenfelder — Breite 2000 + Blur klemmt auf 1280 (x -> 0); Hoehe 10 + Enter -> 60; "abc" und unveraendert -> kein Aufruf', () => {
|
||||
const { onChange, numberField } = renderForm({ url: URL_A, crop: CROP });
|
||||
expect(numberField('x').value).toBe('100');
|
||||
expect(numberField('w').value).toBe('400');
|
||||
fireEvent.change(numberField('w'), { target: { value: '2000' } });
|
||||
fireEvent.blur(numberField('w'));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenLastCalledWith({ crop: { x: 0, y: 100, w: 1280, h: 300 } });
|
||||
fireEvent.change(numberField('h'), { target: { value: '10' } });
|
||||
fireEvent.keyDown(numberField('h'), { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(onChange).toHaveBeenLastCalledWith({ crop: { x: 100, y: 100, w: 400, h: 60 } });
|
||||
fireEvent.change(numberField('x'), { target: { value: 'abc' } });
|
||||
fireEvent.blur(numberField('x'));
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
fireEvent.change(numberField('x'), { target: { value: '100' } });
|
||||
fireEvent.blur(numberField('x'));
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Test 12: Rahmen ziehen -> Entwurf folgt der Maus ohne Aufruf, Loslassen -> EIN Aufruf mit verschobenem Ausschnitt', () => {
|
||||
const { onChange, rect } = renderForm({ url: URL_A, crop: CROP });
|
||||
fireEvent.pointerDown(rect(), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(rect(), { clientX: 150, clientY: 125, pointerId: 1 });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(rect().style.left).toBe('100px');
|
||||
expect(rect().style.top).toBe('75px');
|
||||
fireEvent.pointerUp(rect(), { clientX: 150, clientY: 125, pointerId: 1 });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ crop: { x: 200, y: 150, w: 400, h: 300 } });
|
||||
});
|
||||
|
||||
it('Test 13: Ecke unten rechts +50/+50 -> groesser, Gegenecke bleibt, genau ein Aufruf; Ecke oben links -50/-50 -> 0/0/500x400', () => {
|
||||
const { onChange, handle } = renderForm({ url: URL_A, crop: CROP });
|
||||
fireEvent.pointerDown(handle('se'), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(handle('se'), { clientX: 150, clientY: 150, pointerId: 1 });
|
||||
fireEvent.pointerUp(handle('se'), { clientX: 150, clientY: 150, pointerId: 1 });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ crop: { x: 100, y: 100, w: 500, h: 400 } });
|
||||
cleanup();
|
||||
const second = renderForm({ url: URL_A, crop: CROP });
|
||||
fireEvent.pointerDown(second.handle('nw'), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(second.handle('nw'), { clientX: 50, clientY: 50, pointerId: 1 });
|
||||
fireEvent.pointerUp(second.handle('nw'), { clientX: 50, clientY: 50, pointerId: 1 });
|
||||
expect(second.onChange).toHaveBeenCalledTimes(1);
|
||||
expect(second.onChange).toHaveBeenCalledWith({ crop: { x: 0, y: 0, w: 500, h: 400 } });
|
||||
});
|
||||
|
||||
it('Test 14: Klick ohne Bewegung -> kein Aufruf; pointerCancel nach Bewegung -> kein Aufruf, Rahmen wieder bei 50px', () => {
|
||||
const { onChange, rect } = renderForm({ url: URL_A, crop: CROP });
|
||||
fireEvent.pointerDown(rect(), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerUp(rect(), { clientX: 100, clientY: 100, pointerId: 1 });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.pointerDown(rect(), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
|
||||
fireEvent.pointerMove(rect(), { clientX: 150, clientY: 125, pointerId: 1 });
|
||||
expect(rect().style.left).toBe('100px');
|
||||
fireEvent.pointerCancel(rect(), { pointerId: 1 });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(rect().style.left).toBe('50px');
|
||||
expect(rect().style.top).toBe('50px');
|
||||
});
|
||||
|
||||
it('Test 15: „Nur anzeigen“ — angehakt bei readOnly true, Klick -> { readOnly: false }; sonst aus, Klick -> { readOnly: true }; Hinweis immer sichtbar', () => {
|
||||
const { onChange, readOnly } = renderForm({ url: URL_A, readOnly: true });
|
||||
expect(readOnly.checked).toBe(true);
|
||||
expect(screen.getByText(texts.cropHint)).toBeInTheDocument();
|
||||
fireEvent.click(readOnly);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ readOnly: false });
|
||||
cleanup();
|
||||
const second = renderForm({ url: URL_A });
|
||||
expect(second.readOnly.checked).toBe(false);
|
||||
expect(screen.getByText(texts.cropHint)).toBeInTheDocument();
|
||||
fireEvent.click(second.readOnly);
|
||||
expect(second.onChange).toHaveBeenCalledWith({ readOnly: true });
|
||||
});
|
||||
|
||||
it('Test 16: Ausschnitt ohne Adresse -> Leerhinweis der Vorschau, kein Vorschau-Rahmen', () => {
|
||||
renderForm({ crop: CROP });
|
||||
expect(screen.getByTestId('xframe-crop-preview-empty')).toHaveTextContent(texts.cropPreviewEmpty);
|
||||
expect(screen.queryByTestId('xframe-crop-preview-frame')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(texts.cropPreviewHint)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 17: Zoom-Auswahl 60 -> onChange({ zoom: 60 }) sofort', () => {
|
||||
const { onChange } = renderForm({ url: URL_A });
|
||||
fireEvent.change(screen.getByLabelText(texts.zoomLabel), { target: { value: '60' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ zoom: 60 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { type KeyboardEvent, useState } from 'react';
|
||||
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
XFRAME_CROP_DEFAULT,
|
||||
XFRAME_PAGE_WIDTH,
|
||||
XFRAME_RELOAD_OPTIONS,
|
||||
XFRAME_SANDBOX,
|
||||
XFRAME_TITLE_MAX,
|
||||
XFRAME_ZOOM_OPTIONS,
|
||||
type XframeCrop,
|
||||
clampXframeCrop,
|
||||
isHttpsUrl,
|
||||
resolveXframeConfig,
|
||||
} from '@/components/dashboard/widgets/xframe-config';
|
||||
import {
|
||||
XFRAME_PREVIEW_PAGE_HEIGHT,
|
||||
type XframeDragMode,
|
||||
applyCropDrag,
|
||||
} from '@/components/dashboard/widgets/xframe-crop';
|
||||
|
||||
interface XframeConfigFormProps {
|
||||
config: Record<string, unknown>;
|
||||
@@ -17,6 +28,9 @@ interface XframeConfigFormProps {
|
||||
const FIELD_CLASS =
|
||||
'h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||
const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
|
||||
const CHECKBOX_CLASS = 'h-4 w-4 rounded border-border text-primary';
|
||||
/** Sichtbare Hoehe der Vorschau in Bildschirmpixeln (eigener Bildlauf darin). */
|
||||
const PREVIEW_HEIGHT_PX = 420;
|
||||
|
||||
/**
|
||||
* Einstellungen des XFrame-Widgets im WidgetSettingsPanel
|
||||
@@ -34,10 +48,22 @@ const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
|
||||
* Der Hinweis, dass manche Webseiten das Einbetten verweigern, steht
|
||||
* dauerhaft: verweigertes Einbetten ist cross-origin nicht zuverlaessig
|
||||
* erkennbar, die Kachel bietet dafuer immer „In neuem Tab öffnen“.
|
||||
*
|
||||
* Ausschnitt (quick-260922-ge2): die Vorschau ist DERSELBE <iframe> wie in
|
||||
* der Kachel mit derselben Sandbox, `allow=""` und no-referrer (T-GE2-01),
|
||||
* bei fester Seitenbreite 1280 auf die Vorschaubreite skaliert und mit
|
||||
* `pointer-events: none` (T-GE2-03: die Vorschau ist nur zum Sehen —
|
||||
* Zeigerereignisse gehen an Rahmen und Griffe, nie an die Fremdseite).
|
||||
* Ziehen laeuft ueber Pointer-Events mit Capture (Wächter, weil jsdom kein
|
||||
* `setPointerCapture` kennt), aus Deltas statt `getBoundingClientRect`
|
||||
* (Bildlauf im Vorschaubehaelter stoert so nicht); der Entwurf bleibt lokal,
|
||||
* beim Loslassen gibt es genau EIN `onChange` — und nur bei Aenderung.
|
||||
* Der Ausschnitt ist eine Position auf der Seite, kein Inhalt: der Hinweis
|
||||
* dazu steht dauerhaft unter den Feldern.
|
||||
*/
|
||||
export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const { url, title, reloadSeconds } = resolveXframeConfig(config);
|
||||
const { url, title, reloadSeconds, crop, zoom, readOnly } = resolveXframeConfig(config);
|
||||
|
||||
const [urlDraft, setUrlDraft] = useState(url ?? '');
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
@@ -63,13 +89,6 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
if (next !== title) onChange({ title: next });
|
||||
};
|
||||
|
||||
const onEnter = (commit: () => void) => (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
};
|
||||
|
||||
const reloadLabel = (seconds: number): string => {
|
||||
if (seconds === 0) return t('xframe.reloadOff');
|
||||
if (seconds === 60) return t('xframe.reloadMinute');
|
||||
@@ -77,6 +96,17 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
return t('xframe.reloadMinutes', { n: seconds / 60 });
|
||||
};
|
||||
|
||||
// Einschalten setzt Ausschnitt UND „Nur anzeigen“ in EINEM Aufruf (ein
|
||||
// PATCH); Ausschalten laesst readOnly unberuehrt.
|
||||
const toggleCrop = (checked: boolean) => {
|
||||
if (checked) onChange({ crop: XFRAME_CROP_DEFAULT, readOnly: true });
|
||||
else onChange({ crop: null });
|
||||
};
|
||||
|
||||
const commitCrop = (next: XframeCrop) => {
|
||||
if (crop === null || !sameCrop(next, crop)) onChange({ crop: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Adresse (nur https) */}
|
||||
@@ -143,6 +173,285 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Ausschnitt ein/aus (quick-260922-ge2) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="xframe-crop-enable"
|
||||
type="checkbox"
|
||||
className={CHECKBOX_CLASS}
|
||||
checked={crop !== null}
|
||||
onChange={(e) => toggleCrop(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="xframe-crop-enable" className="text-sm text-foreground">
|
||||
{t('xframe.cropEnable')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{crop !== null ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">{t('xframe.cropPreviewHint')}</p>
|
||||
<CropPreview
|
||||
url={url}
|
||||
crop={crop}
|
||||
onCommit={commitCrop}
|
||||
rectangleLabel={t('xframe.cropRectangle')}
|
||||
emptyText={t('xframe.cropPreviewEmpty')}
|
||||
/>
|
||||
{/* key: State-Reset, damit die Felder nach Ziehen/Speichern die neuen Werte zeigen */}
|
||||
<CropNumberFields key={`${crop.x}-${crop.y}-${crop.w}-${crop.h}`} crop={crop} onCommit={commitCrop} />
|
||||
</>
|
||||
) : (
|
||||
/* Vergroesserung nur fuer die ganze Seite */
|
||||
<div>
|
||||
<label htmlFor="xframe-zoom" className={LABEL_CLASS}>
|
||||
{t('xframe.zoomLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="xframe-zoom"
|
||||
value={String(zoom)}
|
||||
onChange={(e) => onChange({ zoom: Number(e.target.value) })}
|
||||
className={FIELD_CLASS}
|
||||
>
|
||||
{XFRAME_ZOOM_OPTIONS.map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{t('xframe.zoomOption', { n })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* „Nur anzeigen“ — Bedienkomfort, keine Sicherheitsmassnahme (T-GE2-02) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="xframe-readonly"
|
||||
type="checkbox"
|
||||
className={CHECKBOX_CLASS}
|
||||
checked={readOnly}
|
||||
onChange={(e) => onChange({ readOnly: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="xframe-readonly" className="text-sm text-foreground">
|
||||
{t('xframe.readOnly')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p id="xframe-crop-hint" className="text-xs text-muted-foreground">
|
||||
{t('xframe.cropHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function onEnter(commit: () => void) {
|
||||
return (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sameCrop(a: XframeCrop, b: XframeCrop): boolean {
|
||||
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||
}
|
||||
|
||||
// jsdom (29.x) kennt PointerEvent, aber kein Element.setPointerCapture —
|
||||
// im Browser wird gefangen, im Test feuern Move/Up auf demselben Element.
|
||||
function capturePointer(el: HTMLElement, id: number) {
|
||||
if (typeof el.setPointerCapture === 'function') el.setPointerCapture(id);
|
||||
}
|
||||
|
||||
function releasePointer(el: HTMLElement, id: number) {
|
||||
if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(id);
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
mode: XframeDragMode;
|
||||
startX: number;
|
||||
startY: number;
|
||||
start: XframeCrop;
|
||||
}
|
||||
|
||||
const HANDLE_CLASS: Record<Exclude<XframeDragMode, 'move'>, string> = {
|
||||
nw: '-left-1.5 -top-1.5 cursor-nwse-resize',
|
||||
ne: '-right-1.5 -top-1.5 cursor-nesw-resize',
|
||||
sw: '-left-1.5 -bottom-1.5 cursor-nesw-resize',
|
||||
se: '-right-1.5 -bottom-1.5 cursor-nwse-resize',
|
||||
};
|
||||
|
||||
interface CropPreviewProps {
|
||||
url: string | null;
|
||||
crop: XframeCrop;
|
||||
onCommit: (next: XframeCrop) => void;
|
||||
rectangleLabel: string;
|
||||
emptyText: string;
|
||||
}
|
||||
|
||||
/** Vorschau der Seite bei 1280 px Breite mit verschieb- und ziehbarem Rahmen. */
|
||||
function CropPreview({ url, crop, onCommit, rectangleLabel, emptyText }: CropPreviewProps) {
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [draft, setDraft] = useState<XframeCrop | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = previewRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
setWidth(entries[0].contentRect.width);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Massstab Bildschirm/Seite; vor der Messung 0.5 (640 px Vorschau).
|
||||
const p = width > 0 ? width / XFRAME_PAGE_WIDTH : 0.5;
|
||||
const c = draft ?? crop;
|
||||
|
||||
const dragProps = (mode: XframeDragMode) => ({
|
||||
onPointerDown: (e: PointerEvent<HTMLElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
// Griffe: sonst startet der Rahmen zusaetzlich ein Verschieben.
|
||||
if (mode !== 'move') e.stopPropagation();
|
||||
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: crop };
|
||||
capturePointer(e.currentTarget, e.pointerId);
|
||||
},
|
||||
onPointerMove: (e: PointerEvent<HTMLElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.mode !== mode) return;
|
||||
setDraft(applyCropDrag(drag.mode, drag.start, (e.clientX - drag.startX) / p, (e.clientY - drag.startY) / p));
|
||||
},
|
||||
onPointerUp: (e: PointerEvent<HTMLElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.mode !== mode) return;
|
||||
releasePointer(e.currentTarget, e.pointerId);
|
||||
dragRef.current = null;
|
||||
const next = draft ?? drag.start;
|
||||
setDraft(null);
|
||||
onCommit(clampXframeCrop(next));
|
||||
},
|
||||
onPointerCancel: (e: PointerEvent<HTMLElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.mode !== mode) return;
|
||||
releasePointer(e.currentTarget, e.pointerId);
|
||||
dragRef.current = null;
|
||||
setDraft(null);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={previewRef}
|
||||
data-testid="xframe-crop-preview"
|
||||
className="relative overflow-x-hidden overflow-y-auto rounded border border-border bg-background"
|
||||
style={{ height: PREVIEW_HEIGHT_PX }}
|
||||
>
|
||||
{url === null ? (
|
||||
<p data-testid="xframe-crop-preview-empty" className="p-3 text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
data-testid="xframe-crop-stage"
|
||||
className="relative"
|
||||
style={{ width: XFRAME_PAGE_WIDTH * p, height: XFRAME_PREVIEW_PAGE_HEIGHT * p }}
|
||||
>
|
||||
<iframe
|
||||
data-testid="xframe-crop-preview-frame"
|
||||
src={url}
|
||||
title={rectangleLabel}
|
||||
sandbox={XFRAME_SANDBOX}
|
||||
allow=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
className="border-0"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: XFRAME_PAGE_WIDTH,
|
||||
height: XFRAME_PREVIEW_PAGE_HEIGHT,
|
||||
transform: `scale(${p})`,
|
||||
transformOrigin: '0 0',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* <fieldset> statt div role="group" (Biome useSemanticElements): implizite
|
||||
Gruppen-Rolle, Name per aria-label; Preflight nimmt Rand und Innenabstand. */}
|
||||
<fieldset
|
||||
aria-label={rectangleLabel}
|
||||
data-testid="xframe-crop-rect"
|
||||
className="absolute m-0 min-w-0 cursor-move touch-none border-2 border-primary p-0"
|
||||
style={{ left: c.x * p, top: c.y * p, width: c.w * p, height: c.h * p }}
|
||||
{...dragProps('move')}
|
||||
>
|
||||
{(Object.keys(HANDLE_CLASS) as Array<keyof typeof HANDLE_CLASS>).map((corner) => (
|
||||
<div
|
||||
key={corner}
|
||||
aria-hidden="true"
|
||||
data-testid={`xframe-crop-handle-${corner}`}
|
||||
className={`absolute h-3 w-3 touch-none bg-primary ${HANDLE_CLASS[corner]}`}
|
||||
{...dragProps(corner)}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CROP_KEYS: Array<keyof XframeCrop> = ['x', 'y', 'w', 'h'];
|
||||
const CROP_LABEL_KEYS: Record<keyof XframeCrop, string> = {
|
||||
x: 'xframe.cropX',
|
||||
y: 'xframe.cropY',
|
||||
w: 'xframe.cropW',
|
||||
h: 'xframe.cropH',
|
||||
};
|
||||
|
||||
/** Links/Oben/Breite/Hoehe als Zahlenfelder; Uebernahme bei Blur/Enter, geklemmt. */
|
||||
function CropNumberFields({ crop, onCommit }: { crop: XframeCrop; onCommit: (next: XframeCrop) => void }) {
|
||||
const t = useTranslations('widgets');
|
||||
const [drafts, setDrafts] = useState<Record<keyof XframeCrop, string>>({
|
||||
x: String(crop.x),
|
||||
y: String(crop.y),
|
||||
w: String(crop.w),
|
||||
h: String(crop.h),
|
||||
});
|
||||
|
||||
const commit = (k: keyof XframeCrop) => {
|
||||
const n = Number(drafts[k]);
|
||||
if (drafts[k].trim() === '' || !Number.isFinite(n)) return;
|
||||
onCommit(clampXframeCrop({ ...crop, [k]: n }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{CROP_KEYS.map((k) => (
|
||||
<div key={k}>
|
||||
<label htmlFor={`xframe-crop-${k}`} className={LABEL_CLASS}>
|
||||
{t(CROP_LABEL_KEYS[k])}
|
||||
</label>
|
||||
<input
|
||||
id={`xframe-crop-${k}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={drafts[k]}
|
||||
onChange={(e) => setDrafts((d) => ({ ...d, [k]: e.target.value }))}
|
||||
onBlur={() => commit(k)}
|
||||
onKeyDown={onEnter(() => commit(k))}
|
||||
aria-describedby="xframe-crop-unit-hint"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p id="xframe-crop-unit-hint" className="mt-1 text-xs text-muted-foreground">
|
||||
{t('xframe.cropUnitHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ export interface AppVersionInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Spiegel von `VersionResponse` aus `packages/shared`: `apps/web` haengt
|
||||
* nicht von `@tessera/shared` ab, und ein neuer Import wuerde Lockfile und
|
||||
* die deps-Stufe des Dockerfiles aendern. Die API-Wahrheit bleibt dort.
|
||||
* Spiegel von `VersionResponse` aus `packages/shared`. Die Begruendung
|
||||
* "apps/web haengt nicht von @tessera/shared ab" gilt seit quick-260922-m1h
|
||||
* nicht mehr — die Kachel-Typliste wird von dort importiert. Dieser Spiegel
|
||||
* bleibt trotzdem stehen: ihn aufzuloesen war nicht Teil jenes Umbaus und
|
||||
* braucht einen eigenen Durchgang. Die API-Wahrheit bleibt in packages/shared.
|
||||
*/
|
||||
export interface ApiVersionInfo {
|
||||
name: string;
|
||||
|
||||
@@ -17,8 +17,9 @@ export type DesktopPlatform = 'windows' | 'linux';
|
||||
|
||||
/**
|
||||
* Spiegel von `DesktopLatestFile`/`DesktopLatestResponse` aus
|
||||
* `packages/shared`: `apps/web` haengt nicht von `@tessera/shared` ab
|
||||
* (gleiche Begruendung wie in `app-version.ts`). Die API-Wahrheit bleibt in
|
||||
* `packages/shared` (gleicher Stand wie in `app-version.ts`: seit
|
||||
* quick-260922-m1h waere ein Import moeglich, der Spiegel wurde aber bewusst
|
||||
* nicht mit aufgeloest). Die API-Wahrheit bleibt in
|
||||
* `apps/api/src/desktop/desktop.service.ts`.
|
||||
*/
|
||||
export interface DesktopFileInfo {
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
|
||||
"widgetSaveError": "Änderungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.",
|
||||
"unavailable": "Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.",
|
||||
"clock": {
|
||||
"name": "Uhr",
|
||||
"description": "Zeigt die aktuelle Uhrzeit an",
|
||||
@@ -382,7 +383,20 @@
|
||||
"reloadMinute": "Jede Minute",
|
||||
"reloadMinutes": "Alle {n} Minuten",
|
||||
"reloadHour": "Jede Stunde",
|
||||
"embedHint": "Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem."
|
||||
"embedHint": "Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem.",
|
||||
"cropEnable": "Nur einen Ausschnitt der Seite anzeigen",
|
||||
"cropHint": "Der Ausschnitt ist eine Position auf der Seite. Ändert die Seite ihren Aufbau, kann der Ausschnitt verrutschen und muss neu gesetzt werden.",
|
||||
"cropPreviewHint": "Vorschau der Seite bei 1280 Pixel Breite. Ziehen Sie den Rahmen an die gewünschte Stelle; an den Ecken ändern Sie seine Größe. Nach unten scrollen zeigt mehr von der Seite.",
|
||||
"cropPreviewEmpty": "Die Vorschau erscheint, sobald eine Adresse eingetragen ist.",
|
||||
"cropRectangle": "Ausschnitt – ziehen zum Verschieben, Ecken zum Ändern der Größe",
|
||||
"cropX": "Links",
|
||||
"cropY": "Oben",
|
||||
"cropW": "Breite",
|
||||
"cropH": "Höhe",
|
||||
"cropUnitHint": "Werte in Pixeln der Seite bei 1280 Pixel Breite",
|
||||
"zoomLabel": "Vergrößerung der ganzen Seite",
|
||||
"zoomOption": "{n} %",
|
||||
"readOnly": "Nur anzeigen – Klicks und Scrollen im Rahmen sperren"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"saveChanges": "Save changes",
|
||||
"layoutLoadError": "Could not load dashboard. Please reload the page.",
|
||||
"widgetSaveError": "Could not save changes. Please try again.",
|
||||
"unavailable": "This tile is not available — the module it belongs to is not enabled for you.",
|
||||
"clock": {
|
||||
"name": "Clock",
|
||||
"description": "Shows the current time",
|
||||
@@ -382,7 +383,20 @@
|
||||
"reloadMinute": "Every minute",
|
||||
"reloadMinutes": "Every {n} minutes",
|
||||
"reloadHour": "Every hour",
|
||||
"embedHint": "Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page."
|
||||
"embedHint": "Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page.",
|
||||
"cropEnable": "Show only a section of the page",
|
||||
"cropHint": "The section is a position on the page. If the page changes its layout, the section may shift and has to be set again.",
|
||||
"cropPreviewHint": "Preview of the page at 1280 pixels wide. Drag the frame to the desired spot; use the corners to change its size. Scroll down to see more of the page.",
|
||||
"cropPreviewEmpty": "The preview appears once an address is set.",
|
||||
"cropRectangle": "Section – drag to move, corners to resize",
|
||||
"cropX": "Left",
|
||||
"cropY": "Top",
|
||||
"cropW": "Width",
|
||||
"cropH": "Height",
|
||||
"cropUnitHint": "Values in page pixels at 1280 pixels wide",
|
||||
"zoomLabel": "Zoom of the full page",
|
||||
"zoomOption": "{n} %",
|
||||
"readOnly": "View only – block clicks and scrolling inside the frame"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -136,6 +136,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'Bildausschnitt',
|
||||
// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen
|
||||
'neuem',
|
||||
// XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“
|
||||
'Ausschnitt',
|
||||
'Absenderadresse',
|
||||
'muss',
|
||||
'musste',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* fake-resize-observer — file-scoped ResizeObserver stub with an explicit
|
||||
* size (quick-260922-ge2).
|
||||
*
|
||||
* setup.ts already polyfills ResizeObserver globally for jsdom, but it
|
||||
* always reports 1200x800. Components whose geometry depends on the
|
||||
* measured size (XFrame crop clip, crop preview) need deterministic,
|
||||
* test-chosen numbers — so a test file overrides the global with this
|
||||
* stub and restores it via `vi.unstubAllGlobals()` in `afterEach`.
|
||||
*
|
||||
* The stub class is structurally a ResizeObserver (observe / unobserve /
|
||||
* disconnect); the single `as ResizeObserverEntry` cast is enough because
|
||||
* jsdom provides `DOMRectReadOnly` and the callback only reads
|
||||
* `contentRect`; no double cast through `unknown` is needed.
|
||||
*/
|
||||
export function stubResizeObserver(size: { width: number; height: number }): void {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
private readonly cb: ResizeObserverCallback;
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
this.cb = cb;
|
||||
}
|
||||
observe() {
|
||||
this.cb([{ contentRect: new DOMRectReadOnly(0, 0, size.width, size.height) } as ResizeObserverEntry], this);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt |
|
||||
| Stoppuhr | Zeitmessung mit Rundenzeiten |
|
||||
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
|
||||
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem |
|
||||
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
|
||||
|
||||
Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen und XFrame gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||
|
||||
@@ -152,7 +152,7 @@ Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. G
|
||||
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
|
||||
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
|
||||
|
||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern.
|
||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert.
|
||||
|
||||
**Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen.
|
||||
|
||||
|
||||
@@ -283,8 +283,11 @@ Restores stattfinden.
|
||||
- **Verschlüsselungsschlüssel** `TESSERA_ENCRYPTION_KEY`: liegt nur in `.env` auf
|
||||
dem Host, **nicht** im Datenbank-Dump. Getrennt sichern (siehe Kapitel 2/3) – ohne
|
||||
ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos.
|
||||
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, generierte
|
||||
DKV-Exporte unter `user-files/`, siehe `apps/api/src/user/user.controller.ts` und
|
||||
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, Bilder des
|
||||
Bilderrahmen-Widgets unter `user-files/dashboard-images/<Benutzerkennung>/`,
|
||||
generierte DKV-Exporte unter `user-files/`, siehe
|
||||
`apps/api/src/user/user.controller.ts`,
|
||||
`apps/api/src/dashboard/dashboard-images.service.ts` und
|
||||
`apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien liegen im benannten
|
||||
Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`. Der
|
||||
Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen:
|
||||
@@ -296,7 +299,11 @@ Restores stattfinden.
|
||||
user-files:
|
||||
```
|
||||
|
||||
Damit überstehen Avatare und DKV-Exporte ein `--force-recreate` von `api`. Wie bei
|
||||
Damit überstehen Avatare, Bilderrahmen-Bilder und DKV-Exporte ein
|
||||
`--force-recreate` von `api`. Seit den Bilderrahmen-Bildern (Version nach 1.3.0)
|
||||
gehört dieses Volume zwingend zur Sicherung: `pg_dump` allein enthält diese
|
||||
Bilder nicht mehr — genau das ist der Zweck der Umstellung, der
|
||||
Datenbank-Abzug bleibt dadurch klein. Wie bei
|
||||
`pgdata` zeigt `docker volume ls` das Volume mit vorangestelltem Projektnamen an
|
||||
(`<projekt>_user-files`). Gesichert werden die Dateien weiterhin mit
|
||||
`docker compose cp api:/app/user-files ./user-files-backup`, alternativ über eine
|
||||
|
||||
@@ -400,6 +400,40 @@ Für ein Modul mit Unterrouten (Einstellungsseite, Verwaltungsansicht) orientier
|
||||
`dkv-fleet` oder `tender-radar` — beide haben zusätzliche `settings/page.tsx` bzw. weitere
|
||||
Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden.
|
||||
|
||||
### Eine Kachel zum Modul
|
||||
|
||||
Ein Modul kann zusätzlich als Kachel auf dem Dashboard erscheinen. Seit
|
||||
`quick-260922-m1h` sind dafür **drei** Stellen nötig (vorher waren es sieben):
|
||||
|
||||
1. **Die Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/<name>-widget.tsx`,
|
||||
nimmt die `WidgetProps` aus `widget-registry.tsx` (`instanceId`, `config`, `isEditMode`) entgegen.
|
||||
2. **Den Typ eintragen** — in `WIDGET_TYPES` in `packages/shared/src/index.ts`. Gehört die Kachel zu
|
||||
einem Modul, zusätzlich `WIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>'` in derselben Datei. Das ist
|
||||
die einzige Liste: die API validiert `POST /dashboard/widgets` per `@IsIn` gegen genau sie, und
|
||||
das Frontend leitet Registry und Katalog davon ab.
|
||||
3. **Anmelden** — `registerWidget('<typ>', <Name>Widget)` in `apps/web/src/app/(portal)/page.tsx`,
|
||||
neben den übrigen Aufrufen. Der Aufruf steht dort und nicht in der Registry, weil die Kachel über
|
||||
den Wrapper wieder die Registry importiert — ein Import aus der Registry heraus wäre ein
|
||||
Zirkelimport.
|
||||
|
||||
Dazu kommen wie bei jeder Oberfläche die **Übersetzungsschlüssel** (`<typ>.name` und
|
||||
`<typ>.description` unter `widgets` in `de.json` **und** `en.json`), ein **Symbol** als Inline-SVG
|
||||
und die **Größenvorgaben** in `WIDGET_CONSTRAINTS` (`minW`/`minH` = kleinste noch bedienbare Kachel,
|
||||
`defaultW`/`defaultH` = Startgröße) — beides in `widget-registry.tsx`. Ein Test in
|
||||
`widget-registry.test.tsx` prüft, dass `WIDGET_TYPES`, Registry und Constraints deckungsgleich sind;
|
||||
vergisst man eine Stelle, schlägt er fehl.
|
||||
|
||||
**Was eine Kachel mit `moduleSlug` automatisch tut:** Sie verschwindet für Benutzer, die das Modul
|
||||
nicht nutzen dürfen — aus dem Katalog („Widget hinzufügen", `visibleWidgetTypes`) und aus dem
|
||||
Dashboard selbst. Ist die Modulliste unbekannt, weil ihr Abruf fehlschlug, bleibt die Kachel
|
||||
ebenfalls verborgen (fail-closed). Eine bereits angelegte Kachel eines gesperrten Moduls rendert
|
||||
nicht mehr leer, sondern zeigt den Hinweis `widgets.unavailable`.
|
||||
|
||||
**Wie beim Modul-Gate gilt auch hier:** Der Katalogfilter ist Komfort, nicht Zugriffskontrolle. Die
|
||||
verbindliche Prüfung sitzt serverseitig in `DashboardService.getWidgets()` (filtert Kacheln
|
||||
gesperrter Module fail-closed aus `GET /dashboard/widgets`) — und die Daten, die eine Modul-Kachel
|
||||
anzeigt, holt sie über die Endpunkte ihres Moduls, die `@UseModule('<slug>')` tragen müssen.
|
||||
|
||||
## Mandantentrennung
|
||||
|
||||
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
|
||||
|
||||
@@ -168,14 +168,14 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
||||
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
|
||||
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
|
||||
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
||||
| dashboard | 1 | 18 | 0 | **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||
| dashboard | 1 | 21 | 1 | **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
|
||||
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
||||
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
||||
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
|
||||
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
||||
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
||||
| **Summe** | **61** | **187** | **5** | **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
| **Summe** | **61** | **190** | **6** | **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare)
|
||||
|
||||
@@ -673,7 +673,7 @@ werden.
|
||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
|
||||
| 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/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | 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 | system-gebunden | **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.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
|
||||
@@ -808,7 +808,10 @@ werden.
|
||||
Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext
|
||||
(Etappe 3c) — erledigt (260914-eym):** Migration
|
||||
`20260914120000_rls_system_context_read` (`is_system_context()`,
|
||||
`system_read_policy … FOR SELECT` auf fünf Tabellen), Schwesterhelfer
|
||||
`system_read_policy … FOR SELECT` auf fünf Tabellen; seit 260922-hk4
|
||||
kommt "DashboardImage" als sechste dazu, angelegt in der Migration
|
||||
20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder),
|
||||
Schwesterhelfer
|
||||
`forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste;
|
||||
siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||
"## Systemkontext (Etappe 3c, 260914-eym)" und den Regelschluss je Fall
|
||||
|
||||
@@ -76,3 +76,46 @@ export interface DesktopLatestResponse {
|
||||
buildTime: string;
|
||||
files: Partial<Record<DesktopPlatform, DesktopLatestFile>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-Kacheln: EINE Typliste fuer Web und API (quick-260922-m1h).
|
||||
*
|
||||
* Vorher stand dieselbe Liste an sieben Stellen (Union-Typ, Constraints,
|
||||
* Registry, Katalog-Liste, `@IsIn`-Whitelist ...). Vergass man eine, fehlte
|
||||
* die Kachel im Katalog oder die API lehnte sie mit 400 ab. Seit m1h leiten
|
||||
* beide Seiten von hier ab: `apps/web/src/components/dashboard/
|
||||
* widget-registry.tsx` (Registry + Katalog) und
|
||||
* `apps/api/src/dashboard/dto/create-widget.dto.ts` (`@IsIn`).
|
||||
*
|
||||
* ACHTUNG: Dies ist der erste LAUFZEIT-Import aus `@tessera/shared` (alle
|
||||
* uebrigen sind `import type`). `packages/shared` liefert rohes TypeScript
|
||||
* (`main: src/index.ts`, kein Bauschritt); die API laedt es im Betrieb ueber
|
||||
* das native Type-Stripping von Node 24. Deshalb darf diese Datei nur
|
||||
* loeschbare Syntax enthalten — keine `enum`, kein `namespace`, keine
|
||||
* Parameter-Eigenschaften.
|
||||
*/
|
||||
export const WIDGET_TYPES = [
|
||||
'clock',
|
||||
'search',
|
||||
'calendar',
|
||||
'note',
|
||||
'calculator',
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
] as const;
|
||||
|
||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Kachel → Modul-Slug. Eine Kachel ohne Eintrag ist immer sichtbar; eine
|
||||
* Kachel MIT Eintrag erscheint nur fuer Benutzer, die das Modul nutzen
|
||||
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
|
||||
* serverseitig in `DashboardService.getWidgets` (fail-closed).
|
||||
*
|
||||
* Heute bewusst leer: alle neun Kacheln sind Plattform-Kacheln ohne
|
||||
* Modulbezug. Die erste modulgebundene Kachel (Proxmox) traegt hier ihren
|
||||
* Slug ein.
|
||||
*/
|
||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
|
||||
|
||||
Generated
+3
@@ -181,6 +181,9 @@ importers:
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@tessera/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@uiw/react-md-editor':
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
|
||||
Reference in New Issue
Block a user