Compare commits
7 Commits
573d070041
...
8686b1a673
| Author | SHA1 | Date | |
|---|---|---|---|
| 8686b1a673 | |||
| 20a9eb2c8c | |||
| d63d9f5563 | |||
| 8bf3601a18 | |||
| c3b45974f5 | |||
| c080580459 | |||
| 737974b653 |
+6
-5
@@ -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-21 - Quick 260921-oxm: IMAP-Einstellung STARTTLS erzwingt jetzt wirklich Verschluesselung (die bisher gesetzte Option existiert in imapflow gar nicht), Outlook-Anhaenge werden ueber die Content-Disposition wieder erkannt
|
||||
Last activity: 2026-09-21 - Quick 260921-pi9: Dashboard-Widget „Bilderrahmen“ (Upload in der Datenbank oder https-Adresse, Diashow mit Grossansicht), im Browser zehnpunktig nachgewiesen; Quick 260921-qd3 (XFrame) laeuft
|
||||
|
||||
Progress: [██████████] 99%
|
||||
|
||||
@@ -455,6 +455,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260921-ldf | **Der Wackeltest war ein echter Produktfehler — nachgewiesen, nicht vermutet.** CI-Lauf 395 war rot; durchgefallen war ein Test aus quick-260914-m97, rund einmal in 17 vollen Laeufen, isoliert nie. Symptom: Vorschaubild da, Haekchen "Bildschirmfoto anhaengen" aus. **Ursache:** der Fehler-melden-Dialog war dauerhaft eingehaengt, sein `useState(screenshot !== null)` lief damit genau einmal — beim allerersten Laden der Seite, als noch kein Bild existierte — und der richtige Wert wurde erst von einem `useEffect` nachgezogen, der bauartbedingt nach dem Commit laeuft. **Beleg, deterministisch statt statistisch:** ein MutationObserver ueber jeden einzelnen DOM-Commit zeigt gegen den alten Stand, ohne jede kuenstliche Verzoegerung: `COMMIT dialog=true img=ja box=AUS` gefolgt von `COMMIT dialog=true img=ja box=AN`. Der falsche Zustand entsteht bei JEDEM Oeffnen, nicht nur unter Last, und haelt zwei Makrotask-Runden — dazwischen darf der Browser zeichnen, ein Nutzer kann es also sehen. **Ehrliche Einordnung der Tragweite:** die Korrektur kommt binnen Millisekunden, lange bevor jemand "Senden" treffen kann. Der befuerchtete Fall (Bild gesehen, abgeschickt, Bild fehlt) ist NICHT erreichbar; es bleibt ein kurzes Flackern. Repariert wurde trotzdem der Produktcode, nicht der Test — wer einen wirklich vorhandenen falschen Zustand im Test wegberuhigt, laesst ihn stehen. **Zwei Teilursachen, einzeln reicht keine:** der Dialog wird nur noch eingehaengt, solange er offen ist (frischer Mount je Oeffnen, der zuruecksetzende Effekt entfaellt), und das Haekchen wird beim Rendern abgeleitet statt nachgezogen. Dieselbe Ursache lag an einer zweiten Stelle: nach einem Versand stand beim erneuten Oeffnen zwei Runden lang der alte Danke-Bildschirm im DOM. **Zur Statistik, weil es der Kern der Sache ist:** 20 volle Laeufe ohne Fehlschlag gelten ausdruecklich NICHT als Beweis — bei der Ausgangsrate 1:17 waeren sie auch ohne Reparatur zu rund 30 Prozent zu erwarten. Tragend ist, dass der falsche Zwischenzustand nicht mehr existiert und die neuen Tests gegen den alten Stand 5 von 5 rot sind. Kein `retry`, kein hoeheres Zeitlimit — die Ursache war nie blosse Zeit. **Zwei Konstruktionsfehler des Tests mitbehoben:** das `expect` innerhalb der Attrappe (wirft es, landet der Fehler mitten im `await` von `captureScreenshot`, dessen `catch` still `null` liefert — der Test waere viel spaeter mit "kein Vorschaubild" durchgefallen, also in die falsche Richtung zeigend) und die per `Object.defineProperty` gesetzte `document.body`-Groesse, die `cleanup()` ueberlebte und alle zwoelf folgenden Tests derselben Datei 3200x1000 sehen liess. **Widerlegt unterwegs:** der Verdacht auf den dynamischen Import von `html-to-image` — er loest auf, bevor ein zuvor gesetzter `setTimeout(0)` feuert, ueberschreitet also keine Makrotask-Grenze. **Zahlen:** Warnungen 399 unveraendert, web-Tests 529 → 531, api 72/1143 unveraendert, type-check 4/4, lint 5/5. | 2026-09-21 | c0ab5b5,de7fdb7,9f02fcc,a6181e2 | [260921-ldf-wackeltest-fehler-melden-haekchen-bildsc](./quick/260921-ldf-wackeltest-fehler-melden-haekchen-bildsc/) |
|
||||
| 260921-m34 | **288 `any` im Backend beurteilt: 15 bleiben, mit Urteil je Stelle.** Drei Durchgaenge. **Der groesste Posten war ein einziges Missverstaendnis:** 105 Stellen trugen `forTenant(...) as any`, obwohl `prisma.$extends()` laengst einen getypten Klienten liefert — die Zusicherung war nie noetig. Entfernen ergab genau EINEN Folgefehler, und der war selbst ein Befund (eine Handannotation, die nur existierte, um unter dem ungetypten Klienten eine Meldung zu umgehen, und falsch geworden war). **Aufgabe 2 war die sicherheitsrelevante:** ein gemeinsamer Typ `AuthUser` fuer die Aufrufer-Identitaet. Die `tenantId`-Frage wurde HERGELEITET, nicht nach Bequemlichkeit entschieden — `string | undefined` erzeugt 8 Fehler, `string` keinen, und das war ausdruecklich kein Argument. Belege: Pflichtspalte in `schema.prisma:38`, Bestandstyp `SessionUser`, und der Super-Admin-Zweig in `TenantGuard`. Der dritte Beleg widerlegt `string` NICHT, weil der Waechter sein Anfrageobjekt ungetypt holt und `AuthUser` gar nicht liest — der Zweig kann also nicht zu totem Code werden. Dass es ihn gibt, steht trotzdem im Typsystem: `AuthenticatedRequest.tenantId` ist `string | null | undefined`, das `null` stammt nur von dort, mit Warnkommentar. `tenant.guard.ts` ueber den ganzen Lauf 0 geaenderte Zeilen (Tor). **Aufgabe 3 ist zugleich das Urteilsregister:** typisiert 252, auf `unknown` umgestellt 21, bleibt 15 — jede der 15 mit Begruendung im Code (6 node-forge, wo die mitgelieferten Typen die Bibliothek nachweislich falsch beschreiben; 3 Cron; 4 `withTenantTransaction`, wo der genaue Typ eine bewusst unvollstaendige Test-Attrappe braeche; 2 imapflow). Null war ausdruecklich NICHT das Ziel. **Vier Befunde gemeldet statt still repariert** — zwei davon brauchen eine Entscheidung des Nutzers: (B-06, sicherheitsrelevant) `imap.provider.ts:402` setzt `requireTLS`, das es in imapflow 1.4.3 NIRGENDS gibt (vom Orchestrator unabhaengig nachgeprueft: kein Treffer im ganzen Paket). Die Option wird still verworfen, die Einstellung "STARTTLS" erzwingt also nichts; die Bibliothek faellt dann auf ihr Standardverhalten zurueck und setzt laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — sie nennt das selbst eine Downgrade-Angriffsflaeche. Richtig waere `doSTARTTLS: true`. Die `as any`-Zusicherung hatte das verdeckt. (B-05) `imap.provider.ts:78` liest `.parameters` von einer Zeichenkette (imapflow deklariert `disposition: string`, die Parameter liegen in `dispositionParameters`) — zur Laufzeit immer `undefined`, Outlook-Anhaenge als `application/octet-stream` werden ueber Content-Disposition nicht erkannt; betrifft den DKV-Rechnungseinzug. Dazu (B-04) eine Falle im RLS-Erkenner (er zaehlt jede `select:`-Angabe ausserhalb eines Modellaufrufs als Verstoss) — Erkenner NICHT aufgeweicht, Typ anders hergeleitet; und (B-07) httpntlm liefert den Rumpf als Zeichenkette, nicht als Buffer. **Zahlen:** Diagnosen 399 → 125, `any` im Quellcode 288 → 15, `apps/web` 1 → 0, Disziplin-Zaehler unveraendert (`as unknown as` 33, `noNonNullAssertion` 56, Unterdrueckungen 1, `ts-expect-error` 0), api 72/1143, web 73/531, type-check 4/4, lint 5/5, RLS-Waechter 30/30. | 2026-09-21 | b188946,f2fc39f,7c9d7c1,52668c2,32591b6,3892c5f,d8fb9ae | [260921-m34-288-any-im-backend-einzeln-beurteilen-un](./quick/260921-m34-288-any-im-backend-einzeln-beurteilen-un/) |
|
||||
| 260921-oxm | **IMAP: STARTTLS erzwingt jetzt wirklich, Outlook-Anhaenge werden erkannt.** Die zwei Befunde aus m34, beide mit Entscheidung des Nutzers behoben. **(B-06, Sicherheit)** `imap.provider.ts` setzte `requireTLS` — eine Option, die es in imapflow 1.4.3 NIRGENDS gibt (Orchestrator: kein Treffer im ganzen Paket). Sie wurde still verworfen, die Bibliothek fiel auf ihr Standardverhalten zurueck und setzte laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — Benutzername und Kennwort gingen dann im Klartext. Ersetzt durch `doSTARTTLS`, nachgeprueft in `imap-flow.d.ts:81` und `imap-flow.js:1183`. Bei implizitem TLS wird ausdruecklich `false` gesetzt, nicht weggelassen: die Bibliothek wirft bei `secure=true` zusammen mit `doSTARTTLS=true`. **Gewollte Verhaltensaenderung:** ein auf STARTTLS eingestelltes Postfach, dessen Server das nicht anbietet, meldet ab jetzt einen Verbindungsfehler statt still im Klartext zu verbinden. **(B-05)** `imap.provider.ts:78` las `.parameters` von einer Zeichenkette — imapflow fuehrt die Parameter in `dispositionParameters` (`imap-flow.d.ts:450`), der Ausdruck war zur Laufzeit immer leer. Anhaenge als `application/octet-stream` (typisch Outlook) wurden ueber die Content-Disposition nicht erkannt; betraf den DKV-Rechnungseinzug. Nachgeprueft: imapflow schreibt die Schluessel klein und setzt RFC-2231-Fortsetzungen selbst zusammen — dafuer war nichts zu tun. **Rot-dann-gruen belegt:** gegen den Stand mit Tests aber ohne Reparatur scheiterten genau 3 von 12 Faellen, danach 12/12. Zwei der fuenf neuen Faelle sind absichtlich von Anfang an gruen — sie sichern ab, dass B-05 nicht zu viel einsammelt. **Die `as any`-Zusicherung konnte ersatzlos entfallen** (sie existierte nur wegen der erfundenen Option); alle sechs uebergebenen Felder sind jetzt deklariert. **Zahlen:** `any` im Backend 15 → 13, `as unknown as` 33 → 27 (Testdoppel-Einhaengung in einen Helfer gezogen statt fuenf neue Umdeutungen), kein Zaehler gestiegen, api-Tests 1143 → 1148, web 73/531, type-check 4/4, lint 5/5. | 2026-09-21 | 7691d1f,d0266bf,6def539 | [260921-oxm-imap-starttls-wirklich-erzwingen-und-anh](./quick/260921-oxm-imap-starttls-wirklich-erzwingen-und-anh/) |
|
||||
| 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` | `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/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -496,8 +497,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-09-21T04:50:00Z
|
||||
Resumed: 2026-09-21 — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 55aa287, CI-Lauf 387 fuer 55aa287 erfolgreich (Beta-Images gebaut). Push und CI aus dem letzten Stopp-Punkt sind damit erledigt.
|
||||
Stopped at: Warte auf Nutzerentscheidung, womit weitergearbeitet wird. Offen fuer den User: alpha pullen (web+api) und danach am Windows-VM-Client die echte Fehlermeldung schicken (Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile pruefen); eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf. Technisch offen im Ledger: WINDOWS #35 (Biome laeuft nicht — biome.json:3 `organizeImports` ist in Biome 2.5.0 unbekannt, `biome check` bricht mit Konfigurationsfehler ab, reproduziert 2026-09-21) und WINDOWS #36 (403-Antworten bleiben in handleSubmit/handleDelete ohne sichtbare Reaktion).
|
||||
Last session: 2026-09-21T16:45:00Z
|
||||
Resumed: 2026-09-21 (abends) — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 573d070, CI-Lauf 400 fuer 573d070 erfolgreich (Beta-Images gebaut). WINDOWS #35 und #36 sind im Ledger auf fixed; der Fehler- und Lint-Rueckstand ist abgearbeitet.
|
||||
Stopped at: Warte auf Nutzerentscheidung. Naechster Auftrag laut Stopp-Punkt: zwei neue Dashboard-Widgets — das erste ein Bilderrahmen (Produktfragen geklaert, siehe stopped_at im Kopf), das zweite hat der Nutzer noch nicht benannt. Offen beim Nutzer: alpha ziehen, Windows-Client pruefen, Freigabe 1.3.0 auf Zuruf.
|
||||
Resume file: None
|
||||
Last activity: 2026-09-21 - Quick 260921-oxm: IMAP-Einstellung STARTTLS erzwingt jetzt wirklich Verschluesselung (die bisher gesetzte Option existiert in imapflow gar nicht), Outlook-Anhaenge werden ueber die Content-Disposition wieder erkannt
|
||||
Last activity: 2026-09-21 - Quick 260921-pi9: Dashboard-Widget „Bilderrahmen“ (Upload in der Datenbank oder https-Adresse, Diashow mit Grossansicht), im Browser zehnpunktig nachgewiesen; Quick 260921-qd3 (XFrame) laeuft
|
||||
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
---
|
||||
phase: quick-260921-pi9
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260921-PI9]
|
||||
|
||||
files_modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
|
||||
- apps/api/src/dashboard/dashboard-image-rules.ts
|
||||
- apps/api/src/dashboard/dashboard-image-rules.spec.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||
- apps/api/src/dashboard/dashboard-images.controller.ts
|
||||
- apps/api/src/dashboard/dashboard-images.controller.spec.ts
|
||||
- apps/api/src/dashboard/dashboard.module.ts
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- apps/web/src/lib/dashboard-images-api.ts
|
||||
- apps/web/src/lib/dashboard-images-api.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx
|
||||
- apps/web/src/components/settings/picture-frame-config-form.tsx
|
||||
- apps/web/src/components/settings/picture-frame-config-form.test.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/app/(portal)/page.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Im Widget-Katalog gibt es „Bilderrahmen“ (en „Picture frame“); eine frisch platzierte Kachel zeigt den Hinweis „Noch keine Bilder — über die Einstellungen hinzufügen“ im Stil der anderen leeren Widgets."
|
||||
- "Unter Einstellungen → Dashboard → Bilderrahmen kann der Benutzer Bilder hochladen (PNG/JPEG/GIF/WebP, je Datei höchstens 5 MiB, je Benutzer höchstens 30) ODER eine https-Webadresse eintragen; http-, data- oder javascript-Adressen werden mit deutscher Meldung abgewiesen, eine umbenannte Nicht-Bild-Datei ebenso (Erkennung an den Magic Bytes, nicht am Dateinamen oder am gemeldeten MIME-Typ)."
|
||||
- "Jeder Eintrag hat Vorschaubild, Bildunterschrift, Entfernen und Pfeile Nach oben/Nach unten; das Entfernen eines hochgeladenen Bildes löscht es auch auf dem Server (best effort); ein Eintrag, dessen Bild nicht mehr existiert, wird als „Bild nicht verfügbar“ angezeigt und in der Kachel übersprungen."
|
||||
- "Die Kachel zeigt die Bilder gemäß Einstellung ganz sichtbar (`object-contain`) oder formatfüllend (`object-cover`), wechselt im eingestellten Intervall (5–3600 s, 0 = kein Wechsel, Voreinstellung 30 s) in Reihenfolge oder zufällig (Zufall wählt bei mehr als einem Bild nie das aktuelle erneut), zeigt die Bildunterschrift als Streifen am unteren Rand; Fremdbilder lädt ausschließlich der Browser (`<img referrerPolicy=\"no-referrer\">`), der Server ruft nie eine Webadresse ab."
|
||||
- "Klick auf das Bild (nur außerhalb des Bearbeitungsmodus) öffnet eine Großansicht mit Bildunterschrift; Escape, Klick auf den Hintergrund oder der Schließen-Knopf schließen sie, der Fokus kehrt zum Bild zurück, der Bildwechsel pausiert solange. Im Bearbeitungsmodus bleibt die ganze Karte der Ziehgriff, ein Klick öffnet nichts."
|
||||
- "Hochgeladene Bilder gehören dem hochladenden Benutzer: `GET/DELETE /dashboard/images/:id` liefern für eine fremde Kennung (anderer Benutzer ODER anderer Mandant) 404, nie 403; die Auslieferung trägt `Content-Type` aus dem gespeicherten, per Magic Bytes bestimmten Typ, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`."
|
||||
- "Alle Tore bleiben grün: `pnpm type-check` 4/4, `pnpm lint` 5/5, API-Tests mindestens 1170 (heute 1148), Web-Tests mindestens 560 (heute 531), RLS-Wächter 30/30; keine neue `any`, `as unknown as` in apps/api/src bleibt 27, `noNonNullAssertion` bleibt 56."
|
||||
artifacts:
|
||||
- "apps/api/prisma/schema.prisma — Modell `DashboardImage` (id, userId, tenantId, originalName, mimeType, size, data Bytes, createdAt; @@index userId, tenantId)"
|
||||
- "apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql — CREATE TABLE + Indizes + ENABLE/FORCE ROW LEVEL SECURITY + `tenant_isolation_policy` mit Benutzerdimension"
|
||||
- "apps/api/src/dashboard/dashboard-image-rules.ts — reine Regeln: `detectImageMime(buffer)`, `DASHBOARD_IMAGE_MAX_BYTES`, `DASHBOARD_IMAGE_MAX_COUNT`"
|
||||
- "apps/api/src/dashboard/dashboard-images.service.ts — `list`, `upload`, `getBytes`, `remove`, alle über `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`"
|
||||
- "apps/api/src/dashboard/dashboard-images.controller.ts — `@Controller('dashboard/images')`: `GET /`, `POST /` (FileInterceptor `image`), `GET /:id` (Binär), `DELETE /:id`"
|
||||
- "apps/web/src/components/dashboard/widgets/picture-frame-config.ts — Typen `PictureFrameEntry`/`PictureFrameConfig`, `resolvePictureFrameConfig`, `isHttpsUrl`, `pickNextIndex`, Intervall-Grenzen"
|
||||
- "apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx + picture-frame-lightbox.tsx — Kachel mit Wechsel, Bildunterschrift, Großansicht"
|
||||
- "apps/web/src/components/settings/picture-frame-config-form.tsx — Bildverwaltung im WidgetSettingsPanel"
|
||||
- "apps/web/src/lib/dashboard-images-api.ts — `fetchDashboardImages`, `uploadDashboardImage`, `deleteDashboardImage`, `dashboardImageSrc`"
|
||||
- "apps/web/src/messages/de.json + en.json — Namensraum `widgets.pictureFrame`"
|
||||
- "CHANGELOG.md — Stichpunkt unter „Unveröffentlicht → Neu“"
|
||||
key_links:
|
||||
- "Datei-Eingabe im Einstellungsformular -> `uploadDashboardImage(file)` (FormData-Feld `image`) -> `POST /dashboard/images` -> `detectImageMime` + Zähler -> `DashboardImage`-Zeile -> Antwort `{ id, … }` -> `onChange({ images: [...alt, { kind: 'upload', imageId }] })` -> `PATCH /dashboard/widgets/:id/config` (bestehend, flache Zusammenführung: `images` immer als GANZES Array senden)"
|
||||
- "Kachel: `resolvePictureFrameConfig(config)` -> sichtbare Einträge -> `<img src={kind === 'upload' ? dashboardImageSrc(imageId) : url}>` -> `/api-proxy/dashboard/images/:id` (Next-Rewrite aus next.config.ts, Cookies laufen mit) -> `GET /dashboard/images/:id` -> Besitzprüfung -> Bytes"
|
||||
- "Wächter: neues Modell mit `tenantId` -> `rls-coverage.spec.ts` verlangt ENABLE + POLICY in einer Migration; neue (Datei, Modell)-Fundstelle `dashboard-images.service.ts`/`dashboardImage` -> `rls-access-inventory.spec.ts` verlangt eine Zeile in docs/mandantentrennung-zugriffsklassifikation.md"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260921-pi9: Dashboard-Widget „Bilderrahmen“
|
||||
|
||||
<objective>
|
||||
Ein neues Dashboard-Widget „Bilderrahmen“ (Widget-Typ `picture-frame`, Übersetzungs-Namensraum `widgets.pictureFrame`): Bilder werden hochgeladen (in PostgreSQL als `bytea`, dem Benutzer gehörend, 5 MiB je Datei, 30 je Benutzer) ODER als https-Webadresse eingebunden (der Browser lädt sie direkt, der Server ruft nie etwas ab). Einstellungen: Bildausschnitt, Wechselintervall, Reihenfolge/Zufall, Bildunterschrift je Eintrag; Klick zeigt das Bild groß. Stil und Bedienmuster wie die bestehenden Widgets.
|
||||
|
||||
Purpose: erstes der zwei vom Nutzer gewünschten neuen Widgets (STATE.md „NAECHSTER AUFTRAG“); die Produktfragen sind geklärt, die technischen Entscheidungen hat der Orchestrator getroffen (siehe Kasten unten) — dieser Plan setzt sie um, ohne sie neu zu öffnen.
|
||||
Output: API-Modell + Migration + Endpunkte mit Tests, Web-Widget + Einstellungsformular + Großansicht + Übersetzungen mit Tests, Changelog-Eintrag; alle Tore grün.
|
||||
</objective>
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator, nicht neu verhandeln)
|
||||
|
||||
1. Speicherung als `bytea` im neuen Prisma-Modell `DashboardImage` (kein Docker-Volume; Sicherung deckt es mit ab). Migration als SQL-Datei, angewendet mit `prisma migrate deploy` — nie `db push`.
|
||||
2. Grenzen: 5 MiB je Datei (`limits.fileSize`), 30 Bilder je Benutzer (Dienst zählt je Mandant+Benutzer). Erlaubt PNG/JPEG/GIF/WebP, entschieden über Magic Bytes; alles andere 400 mit deutscher Meldung.
|
||||
3. Endpunkte unter `dashboard/images` (angemeldet): `GET` (eigene Liste, nur Metadaten), `POST` (multipart-Feld `image`), `GET :id` (Binär mit den genannten Headern), `DELETE :id`. Besitz = gleicher Mandant UND gleicher Benutzer; fremde Kennung → 404. Statische Route vor `:id`.
|
||||
4. Widget-Konfiguration im bestehenden Config-JSON: `images: Array<{ kind: 'upload', imageId, caption? } | { kind: 'url', url, caption? }>`, `fit: 'contain' | 'cover'`, `intervalSeconds` (0 = kein Wechsel, sonst 5–3600, Voreinstellung 30), `order: 'sequence' | 'random'`. **Befund am Code:** die API prüft Widget-Konfigurationen NICHT inhaltlich — `UpdateWidgetConfigDto` trägt nur `@IsObject()`, `DashboardService.updateWidgetConfig` führt flach zusammen (`{ ...alt, ...neu }`). Es gibt also keine serverseitige Stelle, die erweitert werden könnte; die https-Prüfung läuft deshalb **web-seitig zweifach**: im Formular (Eingabe abweisen) UND beim Rendern (`resolvePictureFrameConfig` lässt jede Nicht-https-Adresse weg). Ein manipulierter Config-Wert schadet damit nur dem eigenen Dashboard und wird dort nicht einmal gerendert.
|
||||
5. Klick auf das Bild nur außerhalb des Bearbeitungsmodus → Großansicht (Escape / Hintergrund / Schließen-Knopf; Fokus-Handhabung wie `widget-catalog-modal.tsx`: Dialog bei Öffnen fokussieren, zusätzlich Fokus-Rückgabe an den Auslöser). Im Bearbeitungsmodus kein Knopf → die ganze Karte bleibt Ziehgriff (`widget-wrapper.tsx`).
|
||||
6. Wechsel per Timer; Zufall wählt bei >1 Bild nie das aktuelle; Timer beim Aushängen geräumt; pausiert bei offener Großansicht.
|
||||
7. Leerzustand: „Noch keine Bilder — über die Einstellungen hinzufügen“, Stil `flex h-full items-center justify-center text-sm text-muted-foreground` (wie `PlaceholderWidget`/Favoriten-`empty`).
|
||||
8. Bildverwaltung im **WidgetSettingsPanel** (Einstellungen → Dashboard, je Instanz aufklappbar) — das ist die „Einstellungen“-Stelle dieser App; einen Dialog je Widget gibt es nicht. Vorschaubilder über `/api-proxy/dashboard/images/:id` (Muster `FavoriteIcon`, favorites-widget.tsx).
|
||||
9. Texte Deutsch mit „Sie“, plus Englisch; keine kundenspezifischen Vorgaben.
|
||||
10. Katalogname „Bilderrahmen“ / „Picture frame“, Beschreibung kurz („Bilder hochladen oder verlinken, als Diashow“ / „Upload or link images as a slideshow“).
|
||||
|
||||
## Ausgangsmessung (21.09.2026, 573d070)
|
||||
|
||||
| Größe | Wert |
|
||||
|---|---:|
|
||||
| API-Tests | 1148 |
|
||||
| Web-Tests | 531 |
|
||||
| `as unknown as` in apps/api/src | 27 |
|
||||
| `as unknown as` in apps/web/src | 6 |
|
||||
| `lint/style/noNonNullAssertion` in apps/api/src | 56 |
|
||||
| `lint/suspicious/noExplicitAny` in apps/api/src | 13 (jede begründet) |
|
||||
| RLS-Wächter (`src/prisma`) | 30/30 |
|
||||
|
||||
<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/api/src/dashboard/dashboard.service.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/dashboard/dashboard.controller.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/bug-reports/bug-reports.controller.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/bug-reports/bug-reports.service.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/favorites.controller.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
</context>
|
||||
|
||||
## Hinweise für den Executor
|
||||
|
||||
- **Lokale Datenbank ohne Host-Port.** Migration anwenden: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`; `DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`; danach `pnpm --filter @tessera/api exec prisma generate`. Läuft der Container nicht: `docker compose up -d db`. **Nie** auf den Testserver deployen, **nie** `prisma db push`.
|
||||
- **Tore vor jedem Commit:** `pnpm type-check`, `pnpm lint`, die betroffenen Vitest-Dateien; am Ende `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollständig.
|
||||
- **Wächter:** `rls-coverage.spec.ts` liest Schema und Migrationen (Regex `ALTER TABLE "X" ENABLE ROW LEVEL SECURITY` und `CREATE POLICY \w+ ON "X"`); `rls-access-inventory.spec.ts` verlangt `forTenant(` nur in der Zuweisungsform `const X = forTenant(`, jede `select:`-Angabe innerhalb eines Modellaufrufs, und je (Datei, Modell) eine Zeile in `docs/mandantentrennung-zugriffsklassifikation.md` (Tabelle „| Datei | Modell | Klasse | Stand | Begründung |“, Zeile ~660).
|
||||
- **Prisma 6: `Bytes` ist `Uint8Array`, nicht `Buffer`.** `data: file.buffer` beim Anlegen geht (Buffer ist eine Uint8Array-Unterklasse); bei der Auslieferung `res.send(Buffer.from(row.data.buffer, row.data.byteOffset, row.data.byteLength))` — keine Zusicherung nötig.
|
||||
- **multer:** `LIMIT_FILE_SIZE` bildet Nest auf 413 mit englischer Meldung ab (Muster T-M97-03 in bug-reports.controller.ts) — die deutsche Meldung für „zu groß“ entsteht im Web-Klienten aus dem Status 413.
|
||||
- **Kein `any`**, keine neue `as unknown as`, kein `!`. Multipart-Datei als bestehender Typ `UploadedFileLike` (`apps/api/src/auth/types/auth-user.ts`), Aufrufer als `AuthUser` über `@CurrentUser()`.
|
||||
- **Commits:** je Aufgabe genau ein Commit, Stil wie `git log --oneline -15`, Scope `quick-260921-pi9`, deutsche Betreffzeile. Die Akte/STATE-Commit macht der Orchestrator.
|
||||
- **Schema-Tor (Prisma erkannt):** der `[BLOCKING]`-Schritt „Migration anwenden + `prisma generate`“ steht in Aufgabe 1 VOR dem Dienstcode; ohne ihn wären Typprüfung und Tests falsch-grün.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: API — Modell, Migration, Regeln, Dienst, Controller (Ende-zu-Ende „Bild hochladen und wieder abrufen“)</name>
|
||||
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql, apps/api/src/dashboard/dashboard-image-rules.ts, apps/api/src/dashboard/dashboard-image-rules.spec.ts, apps/api/src/dashboard/dashboard-images.service.ts, apps/api/src/dashboard/dashboard-images.service.spec.ts, apps/api/src/dashboard/dashboard-images.controller.ts, apps/api/src/dashboard/dashboard-images.controller.spec.ts, apps/api/src/dashboard/dashboard.module.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<behavior>
|
||||
- `detectImageMime`: PNG-Signatur (`89 50 4E 47 0D 0A 1A 0A`) → `image/png`; `FF D8 FF` → `image/jpeg`; `GIF87a`/`GIF89a` → `image/gif`; `RIFF????WEBP` (Bytes 0–3 `RIFF`, 8–11 `WEBP`) → `image/webp`; leerer Puffer, Textdatei, SVG-Text, PDF (`%PDF`) → `null`; ein Puffer, der mit `RIFF` beginnt, aber ohne `WEBP` an Stelle 8 → `null`.
|
||||
- Dienst `upload`: keine Datei → `BadRequestException('Bitte wählen Sie eine Bilddatei aus.')`; `detectImageMime === null` → `BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.')` — auch wenn `file.mimetype` „image/png“ behauptet; Zähler `count({ where: { tenantId, userId } })` ≥ 30 → `BadRequestException('Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.')`; sonst `create` mit `mimeType` aus der Erkennung (NICHT aus `file.mimetype`), `originalName` auf 255 Zeichen gekürzt, `size = buffer.length`, Antwort nur Metadaten (`id, originalName, mimeType, size, createdAt`).
|
||||
- Dienst `list`: `findMany({ where: { tenantId, userId }, select: { id, originalName, mimeType, size, createdAt }, orderBy: { createdAt: 'asc' } })` — `data` wird nie mitgeladen.
|
||||
- Dienst `getBytes`/`remove`: `findUnique({ where: { id } })`; fehlt die Zeile ODER `row.userId !== userId` ODER `row.tenantId !== tenantId` → `NotFoundException` (nie Forbidden); `remove` löscht danach.
|
||||
- Jede Methode holt ihren Klienten in der Zuweisungsform `const tenantPrisma = forTenant(this.prisma, tenantId, userId)` (Spec-Attrappe wie in dashboard.service.spec.ts: `forTenant: vi.fn((prisma, tenantId) => prisma.__makeBoundClient(tenantId))`, ein vergessener Aufruf fällt im Test auf).
|
||||
- Controller: `GET /dashboard/images` → `list`; `POST` mit `FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } })` → `upload(user, file)`; `GET /dashboard/images/:id` setzt `Content-Type` = gespeicherter `mimeType`, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline` (ohne Dateinamen — `originalName` gehört nie in einen Header), zusätzlich `Content-Security-Policy: default-src 'none'; sandbox` (Muster `getIcon`), dann `res.send(Buffer)`; `DELETE /:id` → `remove`, Antwort `{ id }`. Kein `@Roles`-Dekorator (alle angemeldeten Rollen, Muster bug-reports). Mandant/Benutzer ausschließlich aus `@CurrentUser()`.
|
||||
- `CreateWidgetDto`: `@IsIn([...])` enthält zusätzlich `'picture-frame'`.
|
||||
</behavior>
|
||||
<action>
|
||||
**Schritt A — Schema und Migration.** In `schema.prisma` neben `WidgetInstance` das Modell `DashboardImage` anlegen: `id String @id @default(uuid())`, `userId String`, `tenantId String`, `originalName String`, `mimeType String`, `size Int`, `data Bytes`, `createdAt DateTime @default(now())`, `@@index([userId])`, `@@index([tenantId])` (keine Relation, wie `WidgetInstance`). Migration `20260921120000_dashboard_image/migration.sql` von Hand schreiben (Muster CREATE TABLE: `20260708090000_add_favorite_link`, Muster RLS: `20260909140000_rls_remaining_tenant_tables` Abschnitt FavoriteLink plus Benutzerdimension aus `20260911120000`): deutscher Kopfkommentar (Zweck, Grenzen, Besitz), `CREATE TABLE "DashboardImage" (… "data" BYTEA NOT NULL …)`, beide Indizes, `ALTER TABLE "DashboardImage" ENABLE ROW LEVEL SECURITY;`, `ALTER TABLE "DashboardImage" FORCE ROW LEVEL SECURITY;`, `CREATE POLICY tenant_isolation_policy ON "DashboardImage" USING ("tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id()));`. Rechte für `tessera_app` kommen über `ALTER DEFAULT PRIVILEGES` aus `20260909130000_rls_app_role` automatisch — nichts zu tun, im Kopfkommentar erwähnen.
|
||||
|
||||
**Schritt B [BLOCKING] — Migration anwenden und Klient erzeugen** (Befehle aus den Executor-Hinweisen: `prisma migrate deploy` gegen die Container-IP, dann `prisma generate`). Erst danach gibt es `tenantPrisma.dashboardImage` im Typsystem. Prüfen: `pnpm --filter @tessera/api exec vitest run src/prisma/rls-coverage.spec.ts` muss grün sein (Test 1/2 sehen das neue Modell und die neue Policy).
|
||||
|
||||
**Schritt C — Regeln zuerst, rot.** `dashboard-image-rules.ts` mit `export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024`, `DASHBOARD_IMAGE_MAX_COUNT = 30`, `export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'`, `export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null`. Spec mit den Fällen aus `<behavior>` (mindestens 8), vor der Umsetzung rot.
|
||||
|
||||
**Schritt D — Dienst und Controller, rot dann grün.** `dashboard-images.service.ts` (`@Injectable() DashboardImagesService`, Konstruktor `private readonly prisma: PrismaService`) mit `list(userId, tenantId)`, `upload(user: AuthUser, file: UploadedFileLike | undefined)`, `getBytes(id, userId, tenantId)` → `{ mimeType, data }`, `remove(id, userId, tenantId)`. Dateikopf-Kommentar wie in dashboard.service.ts: warum die Besitzprüfung zusätzlich zur RLS-Regel nicht dekorativ ist (Schalter heute aus). Spec mit `makeFakePrisma`-Muster aus dashboard.service.spec.ts, mindestens 10 Fälle (Liste ohne `data`; Upload ohne Datei; PNG mit behauptetem `text/plain`-mimetype gelingt und speichert `image/png`; Textdatei mit behauptetem `image/png` scheitert; Zähler 30 blockt, 29 lässt durch; fremder Benutzer → 404; fremder Mandant → 404; eigenes Bild liefert Bytes; Löschen eigen/fremd; `forTenant` mit `(prisma, tenantId, userId)` aufgerufen). `dashboard-images.controller.ts` (`@Controller('dashboard/images')`, Reihenfolge `@Get()` → `@Post()` → `@Get(':id')` → `@Delete(':id')`, `@Res() res: Response` aus `express` wie favorites.controller.ts). Controller-Spec (Muster bug-reports.controller.spec.ts, mindestens 4 Fälle): Interceptor-Grenzen `fileSize === DASHBOARD_IMAGE_MAX_BYTES`, `files === 1` über die Nest-Metadaten oder den Aufruf; die vier Header der Binärantwort inklusive `Content-Disposition: inline` ohne Dateinamen; kein `@Roles`-Metadatum; Pfad `dashboard/images`. Beide in `dashboard.module.ts` registrieren (`controllers`, `providers`). `create-widget.dto.ts` um `'picture-frame'` erweitern.
|
||||
|
||||
**Schritt E — Wächter-Dokument.** In `docs/mandantentrennung-zugriffsklassifikation.md` in der (Datei, Modell)-Tabelle eine Zeile `| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | … |` mit Begründung (Bilder eines Benutzers, `tenantId`-Spalte, Benutzerdimension in der Policy seit 20260921120000, Besitzprüfung zusätzlich in `getBytes`/`remove`, Liste/Zähler mit explizitem `where: { tenantId, userId }`). Die Bereichstabelle (Zeile „| dashboard | 1 | 12 | 0 |“) um die neuen gebundenen Treffer erhöhen — die Zahl mit der dort genannten Schleife messen, nicht schätzen.
|
||||
|
||||
Commit: `feat(quick-260921-pi9): Bilderrahmen-API - Bilder je Benutzer in der Datenbank, Magic-Byte-Pruefung, 5 MiB / 30 Stueck` (Wortlaut frei, Stil beachten).
|
||||
</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 && test "$(grep -rn 'as unknown as' apps/api/src --include=*.ts | wc -l)" -eq 27 && STAT=$(git show --stat --format= HEAD) && printf '%s' "$STAT" | grep -q 'migrations/20260921120000_dashboard_image/migration.sql'</automated>
|
||||
</verify>
|
||||
<done>Migration lokal angewendet (`prisma migrate status` meldet keine ausstehende Migration), `prisma generate` gelaufen. `dashboard-image-rules.spec.ts` ≥ 8, `dashboard-images.service.spec.ts` ≥ 10, `dashboard-images.controller.spec.ts` ≥ 4 Fälle — alle grün, davon die Regel- und Diensttests nachweislich zuerst rot (Rot-Lauf im SUMMARY nennen). RLS-Wächter `src/prisma` weiterhin 30/30 inklusive der neuen Zeile im Klassifikationsdokument. Ein Rundgang mit `curl` gegen die laufende lokale API (Cookie aus einer Anmeldung): `POST` mit einer PNG-Datei liefert 201 mit Metadaten, `GET /dashboard/images` listet sie ohne `data`, `GET /dashboard/images/<id>` liefert die Bytes mit den vier Headern, eine Textdatei als `.png` liefert 400 mit der deutschen Meldung, eine 6-MiB-Datei 413, eine erfundene Kennung 404. Zähler `as unknown as` = 27, keine neue `any`, kein `!`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Web — Widget, Großansicht, Einstellungsformular, Katalog, Übersetzungen</name>
|
||||
<files>apps/web/src/lib/dashboard-images-api.ts, apps/web/src/lib/dashboard-images-api.test.ts, apps/web/src/components/dashboard/widgets/picture-frame-config.ts, apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts, apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx, apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx, apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx, apps/web/src/components/settings/picture-frame-config-form.tsx, apps/web/src/components/settings/picture-frame-config-form.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<behavior>
|
||||
- `resolvePictureFrameConfig({})` → `{ images: [], fit: 'contain', intervalSeconds: 30, order: 'sequence' }`; `intervalSeconds` 3 → 5, 9999 → 3600, 0 → 0, `'abc'` → 30; `fit: 'x'` → `'contain'`; `order: 'x'` → `'sequence'`; Einträge ohne gültiges `kind`, Upload ohne `imageId`-String, URL mit `http://`, `javascript:`, `data:` oder ohne Parser-Erfolg (`new URL` wirft) werden weggelassen; `caption` nur übernommen, wenn String, auf 200 Zeichen gekürzt.
|
||||
- `isHttpsUrl('https://a.de/b.jpg')` true; `'http://…'`, `'HTTPS://'`-Schreibweise → true (Protokoll kleingeschrieben vergleichen); `'ftp://'`, `'javascript:alert(1)'`, `'nicht-url'` false.
|
||||
- `pickNextIndex(current, count, order, random)`: `count ≤ 1` → 0; `sequence` → `(current + 1) % count`; `random` → mit gestelltem `random` nie `current` (über 200 Ziehungen bei `count = 3` kommt `current` nie heraus).
|
||||
- Widget: leere Konfiguration → Text `pictureFrame.empty`; ein URL-Eintrag → `<img>` mit `src` = URL, `referrerPolicy="no-referrer"`, Klasse `object-contain`, bei `fit: 'cover'` `object-cover`, `alt` = Bildunterschrift oder `''`; Bildunterschrift als Streifen am unteren Rand; Upload-Eintrag → `src="/api-proxy/dashboard/images/<id>"`; mit `intervalSeconds: 5` und zwei Bildern zeigt `vi.advanceTimersByTime(5000)` das zweite, `unmount()` räumt den Timer (kein `setState` nach dem Aushängen, `vi.getTimerCount()` 0); `intervalSeconds: 0` wechselt nie; `onError` am `<img>` nimmt den Eintrag aus dem Umlauf (bei zwei Einträgen bleibt nur der andere; sind alle kaputt: Text `pictureFrame.unavailable`); Klick auf das Bild bei `isEditMode: false` öffnet `role="dialog"` mit großem Bild und Unterschrift, Escape schließt, Klick auf den Hintergrund-Knopf schließt, Schließen-Knopf schließt, danach hat der Bild-Knopf wieder den Fokus; bei offener Großansicht läuft `advanceTimersByTime` ohne Bildwechsel; bei `isEditMode: true` gibt es keinen Knopf (kein `role="button"` im Widget) und kein Klick öffnet etwas.
|
||||
- Einstellungsformular: zeigt Auswahl Bildausschnitt (`contain`/`cover`), Intervall (Auswahl mit Werten 0/5/10/15/30/60/120/300/600/1800/3600), Reihenfolge (`sequence`/`random`) — jede Änderung ruft `onChange` mit dem einen Feld; die Liste zeigt je Eintrag Vorschau (`<img>` mit `referrerPolicy="no-referrer"`, bei `onError` stattdessen Text `pictureFrame.unavailable`), Unterschrift-Feld (Entwurf, Übernahme bei Blur/Enter → `onChange({ images })` mit dem ganzen Array), Pfeile hoch/runter (oberster Eintrag ohne „hoch“, unterster ohne „runter“, wie Favoriten), Entfernen; Entfernen eines Upload-Eintrags ruft `deleteDashboardImage(imageId)` (Fehler verschluckt) UND `onChange` mit dem verkürzten Array; „Webadresse hinzufügen“ mit `http://` zeigt `role="alert"` `pictureFrame.urlInvalid` und ruft `onChange` nicht; mit https ruft `onChange({ images: [...alt, { kind: 'url', url }] })`; Datei wählen ruft `uploadDashboardImage(file)` und danach `onChange({ images: [...alt, { kind: 'upload', imageId: '<id aus Antwort>' }] })`; wirft der Upload, erscheint dessen Meldung als `role="alert"` und `onChange` bleibt aus; bei 30 Einträgen ist „Bild hochladen“ deaktiviert mit Hinweis `pictureFrame.limitReached`.
|
||||
- `dashboard-images-api`: `uploadDashboardImage` sendet `POST ${API_URL}/dashboard/images` mit `credentials: 'include'` und einem `FormData`, dessen Feld `image` die Datei ist (kein `Content-Type`-Header von Hand); Status 413 → `Error('Die Datei ist zu groß – erlaubt sind höchstens 5 MB.')`; Status 400 mit `{ message: string }` → `Error(message)`; sonst allgemeine Meldung; `dashboardImageSrc('a b')` → `/api-proxy/dashboard/images/a%20b`.
|
||||
</behavior>
|
||||
<action>
|
||||
**Reihenfolge: reine Helfer zuerst (rot → grün), dann Widget, dann Formular, zuletzt Verdrahtung.**
|
||||
|
||||
1. `picture-frame-config.ts`: Typen `PictureFrameEntry` (Vereinigung mit `kind`-Unterscheider, siehe Entscheidung 4), `PictureFrameConfig`, Konstanten `PICTURE_FRAME_INTERVAL_MIN = 5`, `PICTURE_FRAME_INTERVAL_MAX = 3600`, `PICTURE_FRAME_INTERVAL_DEFAULT = 30`, `PICTURE_FRAME_INTERVAL_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]`, `PICTURE_FRAME_MAX_IMAGES = 30`, `PICTURE_FRAME_CAPTION_MAX = 200`; Funktionen `isHttpsUrl`, `resolvePictureFrameConfig`, `pickNextIndex`, `entryKey(entry, index)` (stabiler React-Schlüssel `upload:<imageId>` bzw. `url:<url>:<index>`). Ohne React-Import, damit der Test schlank bleibt (Muster `clock-font-size.ts`, `calendar-month.ts`).
|
||||
|
||||
2. `dashboard-images-api.ts` nach dem Muster `favorites-api.ts` (`API_URL` aus `NEXT_PUBLIC_API_URL`, `credentials: 'include'`): `DashboardImageMeta`, `fetchDashboardImages()`, `uploadDashboardImage(file: File)`, `deleteDashboardImage(id)`, `dashboardImageSrc(id)`. Test mit `vi.stubGlobal('fetch', …)`.
|
||||
|
||||
3. `picture-frame-widget.tsx` (`'use client'`, Props `WidgetProps`): `resolvePictureFrameConfig(config)` per `useMemo`; Zustand `index`, `brokenKeys: string[]`, `lightboxOpen`; sichtbare Einträge = alle ohne kaputte Schlüssel; `useEffect` mit `setInterval` nur wenn `intervalSeconds > 0 && visible.length > 1 && !lightboxOpen`, Räumung in der Aufräumfunktion; `random` über `pickNextIndex(…, Math.random)`. Darstellung: Rumpf `relative h-full w-full overflow-hidden`, `<img className={fit === 'cover' ? 'h-full w-full object-cover' : 'h-full w-full object-contain'} referrerPolicy="no-referrer" loading="lazy" alt={caption ?? ''} onError=…>`, Unterschrift als `absolute inset-x-0 bottom-0 bg-black/50 px-2 py-1 text-xs text-white truncate` (nur wenn vorhanden). Außerhalb des Bearbeitungsmodus liegt Bild+Streifen in einem `<button type="button" aria-label={t('pictureFrame.open')} className="block h-full w-full cursor-zoom-in">`; im Bearbeitungsmodus in einem `<div>` (kein Handler — die Karte ist der Griff, Entscheidung 5). Leerzustand und „alle kaputt“ als zentrierter grauer Text (Entscheidung 7). Keine `dangerouslySetInnerHTML`. Kommentar im Dateikopf: warum der Server nie eine Adresse abruft (T-PI9-05).
|
||||
|
||||
4. `picture-frame-lightbox.tsx`: Props `{ src, caption, onClose }`; Aufbau wie `widget-catalog-modal.tsx` (`fixed inset-0 z-50`, Hintergrund als `<button aria-label={t('pictureFrame.close')} className="fixed inset-0 bg-black/80">`, Dialog `role="dialog" aria-modal="true" tabIndex={-1}` mit `ref.focus()` beim Einhängen, `keydown`-Escape-Listener mit Aufräumung), Schließen-Knopf oben rechts, `<img className="max-h-[85vh] max-w-[90vw] object-contain" referrerPolicy="no-referrer">`, Unterschrift darunter. Fokus-Rückgabe: das Widget merkt sich den Bild-Knopf per `useRef` und ruft nach dem Schließen `.focus()`.
|
||||
|
||||
5. `picture-frame-config-form.tsx` (`PictureFrameConfigForm({ config, onChange })`, Muster `ClockConfig`/`FavoritesConfig`: Labels `mb-1 block text-sm text-foreground`, Felder `h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground`): drei `<select>` (Bildausschnitt, Intervall mit sprechenden Texten aus `pictureFrame.intervalOff`/Sekunden/Minuten, Reihenfolge); Eintragsliste mit Vorschau 48×48 (`object-cover rounded bg-muted`), Unterschrift-Eingabe (Entwurf/Übernahme wie `commitFontSize`), Pfeil- und Entfernen-Knöpfe als echte `<button>` mit `aria-label` aus `pictureFrame.moveUpButton`/`moveDownButton`/`removeButton`; darunter versteckte `<input type="file" accept="image/png,image/jpeg,image/gif,image/webp">` hinter einem Knopf `pictureFrame.uploadButton` (`disabled` ab 30 Einträgen) und eine Zeile Texteingabe + Knopf `pictureFrame.urlAddButton` mit `isHttpsUrl`-Prüfung; Fehler als `<p role="alert" className="text-xs text-destructive">`. `onChange` bekommt bei Listenänderungen IMMER das vollständige `images`-Array (serverseitig flache Zusammenführung). Im `widget-settings-panel.tsx` einen Zweig `widget.widgetType === 'picture-frame'` ergänzen (gleiche Form wie die anderen fünf).
|
||||
|
||||
6. Verdrahtung: `widget-registry.tsx` — `WidgetType` um `'picture-frame'`, `WIDGET_CONSTRAINTS['picture-frame'] = { minW: 4, minH: 4, defaultW: 8, defaultH: 8 }` (Begründung im Kommentar: Bild braucht Fläche, 8×8 = ein Quadrat wie die halbe Notiz), `PictureFrameIcon` (Inline-SVG: Rahmen `rect 3 3 18 18 rx 2`, `circle 8.5 8.5 r 1.5`, `polyline 21 15 16 10 5 21`), Registry-Eintrag mit `nameKey: 'pictureFrame.name'`, `descriptionKey: 'pictureFrame.description'`, `wirePictureFrameWidget`. `widget-catalog-modal.tsx` — `WIDGET_TYPES` ergänzen. `(portal)/page.tsx` — Import + `wirePictureFrameWidget(PictureFrameWidget)`. Tests nachziehen: `widget-registry.test.tsx` (Typliste und erwartete Constraints-Tabelle), `page.test.tsx` (`vi.mock` des neuen Widget-Moduls wie Zeile 59), `widget-catalog-modal.test.tsx` (Übersetzungsattrappe um `pictureFrame.name`/`description`, falls die Attrappe alle Namen aufzählt).
|
||||
|
||||
7. Übersetzungen `de.json`/`en.json`, Namensraum `widgets.pictureFrame` mit genau diesen Schlüsseln: `name` („Bilderrahmen“/„Picture frame“), `description`, `empty` („Noch keine Bilder — über die Einstellungen hinzufügen“), `unavailable` („Bild nicht verfügbar“), `open` („Bild groß anzeigen“), `close` („Großansicht schließen“), `fitLabel`, `fitContain` („Ganz sichtbar“), `fitCover` („Formatfüllend“), `intervalLabel` („Wechselintervall“), `intervalOff` („Kein Wechsel“), `intervalSeconds` („{n} Sekunden“), `intervalMinutes` („{n} Minuten“), `orderLabel`, `orderSequence` („Reihenfolge“), `orderRandom` („Zufall“), `imagesLabel` („Bilder“), `captionPlaceholder` („Bildunterschrift (optional)“), `uploadButton` („Bild hochladen“), `uploadHint` („PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder“), `urlPlaceholder` („https://…“), `urlAddButton` („Webadresse hinzufügen“), `urlInvalid` („Bitte geben Sie eine vollständige https-Adresse ein.“), `removeButton` („Bild entfernen“), `moveUpButton` („Nach oben“), `moveDownButton` („Nach unten“), `limitReached` („Die Höchstzahl von 30 Bildern ist erreicht.“), `uploadFailed` („Das Bild konnte nicht hochgeladen werden.“). Siezen, englische Entsprechungen in gleicher Tonlage.
|
||||
|
||||
Commit: `feat(quick-260921-pi9): Bilderrahmen-Widget - Diashow mit Grossansicht, Bildverwaltung in den Einstellungen`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/components/settings src/lib/dashboard-images-api.test.ts "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/web lint && test "$(grep -rn 'dangerouslySetInnerHTML' apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx apps/web/src/components/settings/picture-frame-config-form.tsx | wc -l)" -eq 0 && node -e "const d=require('./apps/web/src/messages/de.json').widgets.pictureFrame,e=require('./apps/web/src/messages/en.json').widgets.pictureFrame;const m=Object.keys(d).filter(k=>!(k in e));if(m.length){console.error('en fehlt:',m);process.exit(1)}"</automated>
|
||||
</verify>
|
||||
<done>`picture-frame-config.test.ts` ≥ 10, `picture-frame-widget.test.tsx` ≥ 10, `picture-frame-config-form.test.tsx` ≥ 8, `dashboard-images-api.test.ts` ≥ 4 Fälle — alle grün, die Helfer-Tests nachweislich zuerst rot; bestehende Registry-/Katalog-/Seiten-Tests grün mit dem achten Typ. Beide Sprachdateien tragen denselben Schlüsselsatz unter `widgets.pictureFrame`. Kette nachgewiesen (Tests): Datei wählen → `uploadDashboardImage` → `onChange` mit neuem Upload-Eintrag; https-Adresse → `onChange` mit URL-Eintrag; http-Adresse → Fehler, kein `onChange`; Kachel rendert Upload-Eintrag über `/api-proxy/dashboard/images/<id>` und URL-Eintrag direkt mit `referrerPolicy="no-referrer"`; Wechsel, Pause bei Großansicht, Räumung beim Aushängen; kein Knopf im Bearbeitungsmodus. `as unknown as` in apps/web/src bleibt 6, keine `any`, kein `!`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 3: Changelog, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
|
||||
<files>CHANGELOG.md</files>
|
||||
<action>
|
||||
1. `CHANGELOG.md` unter „Unveröffentlicht → Neu“ als ERSTER Stichpunkt (kein Fließtext, Tonlage der Nachbarzeilen): „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“.
|
||||
2. Volle Tore laufen lassen: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`; Zähler messen (`as unknown as` api 27 / web 6, `grep -c '!\.' ` ist ungeeignet — `noNonNullAssertion` und `noExplicitAny` über `pnpm --filter @tessera/api exec biome lint src 2>&1 | grep -c <regel>` oder die im Repo bereits genutzte Zählweise aus 260921-oxm) und die Zahlen ins SUMMARY schreiben.
|
||||
3. Im SUMMARY eine Prüfliste für den Orchestrator (Browser, Playwright-MCP, lokal — NICHT Testserver) hinterlegen, Punkt für Punkt abhakbar: (a) Dashboard → Bearbeiten → „Widget hinzufügen“ zeigt „Bilderrahmen“ mit Symbol; platzierte Kachel zeigt den Leerhinweis; (b) Einstellungen → Dashboard → „Bilderrahmen #1“ aufklappen: PNG hochladen → Vorschau erscheint, Eintrag in `GET /dashboard/images`; (c) https-Adresse hinzufügen → Eintrag; http-Adresse → rote Meldung; (d) `.txt` als `.png` umbenannt hochladen → deutsche Meldung „Nur Bilder im Format …“; (e) Intervall 5 s, zwei Bilder → Kachel wechselt; Zufall mit drei Bildern → nie dasselbe zweimal hintereinander; Bildausschnitt umschalten → `object-cover`/`object-contain` sichtbar anders; (f) Klick auf das Bild → Großansicht mit Unterschrift, Escape schließt, Hintergrund-Klick schließt; während geöffnet kein Wechsel; (g) Bearbeitungsmodus: Klick öffnet nichts, Kachel lässt sich ziehen; (h) `curl -b <cookie> -o /dev/null -w '%{http_code}' …/dashboard/images/<id>` mit dem Cookie eines ZWEITEN Benutzers → 404; (i) Eintrag entfernen → Bild verschwindet aus `GET /dashboard/images`; (j) Netzwerk-Tab: Fremdbild wird vom Browser geladen, kein Aufruf der Fremdadresse durch die API (API-Log leer).
|
||||
Commit: `docs(quick-260921-pi9): Changelog - Bilderrahmen-Widget` (nur CHANGELOG.md; Akte/STATE macht der Orchestrator).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'Bilderrahmen' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
|
||||
</verify>
|
||||
<done>Changelog-Zeile steht unter „Unveröffentlicht → Neu“; `pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Befund der Stufe `error`; API ≥ 1170 Tests, Web ≥ 560 Tests, alle grün; Zähler unverändert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1, `ts-expect-error` 0); die zehnpunktige Prüfliste steht im SUMMARY; genau drei Code/Doku-Commits mit Scope `quick-260921-pi9` (`git log --oneline -3`).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<assumption_delta_decision>
|
||||
Detektor gedanklich ausgeführt: **feuert** (Familie `pluralization`) — bislang hatte ein Widget-Bild genau EINE Quelle (Favoriten: Server-Proxy oder Direktbild als Ersatzkette, aber ein Datensatz); hier tritt die zweite Quelle (Upload NEBEN Webadresse) als gleichwertige Variante auf.
|
||||
|
||||
- **Primäres Nomen:** die Bildquelle — `PictureFrameEntry` als Vereinigung mit `kind`-Unterscheider (`'upload' | 'url'`).
|
||||
- **Entscheidung: `promote`.** Der allgemeine Eintragstyp ist die Primärdarstellung; `imageId` ist ein Detail der Upload-Variante, `url` ein Detail der URL-Variante. Es gibt KEINE parallele Liste `imageIds: string[]` neben `urls: string[]` — eine einzige geordnete Liste `images`, damit Reihenfolge, Unterschrift und Wechsel für beide Varianten dieselbe Logik durchlaufen.
|
||||
- Invariantentest (übernommen, in `picture-frame-config.test.ts`): `resolvePictureFrameConfig` akzeptiert beide Varianten in EINER Liste und erhält deren Reihenfolge; ein Eintrag ohne bekanntes `kind` fällt weg statt die Liste zu kippen.
|
||||
|
||||
API-Coverage-Detektor: **feuert nicht** — kein externer Dienst, keine SDK-Integration; der Browser lädt Fremdbilder, die API kennt nur ihre eigenen Zeilen.
|
||||
</assumption_delta_decision>
|
||||
|
||||
<threat_model>
|
||||
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
|
||||
|
||||
## Vertrauensgrenzen
|
||||
|
||||
| Grenze | Beschreibung |
|
||||
|---|---|
|
||||
| Browser → API (multipart) | Benutzer lädt beliebige Bytes unter beliebigem Namen/MIME hoch |
|
||||
| Browser → API (`:id`) | Benutzer nennt Bildkennungen — auch fremde |
|
||||
| Config-JSON → Browser | `images[].url`/`caption` stammen aus dem vom Benutzer selbst beschreibbaren Widget-Config und landen in `<img src>`/Text |
|
||||
| Browser → Fremdhost | `<img>` ruft die Webadresse ab; der Fremdhost sieht Anfrage und ggf. Referrer |
|
||||
| API → Datenbank | `bytea` je Benutzer, Mandantentrennung über RLS (Schalter heute aus) + Anwendungsprüfung |
|
||||
|
||||
## STRIDE-Register
|
||||
|
||||
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|
||||
|---|---|---|---|---|---|
|
||||
| T-PI9-01 | Tampering | `DashboardImagesService.upload` | high | mitigate | `detectImageMime` über Magic Bytes entscheidet Annahme UND gespeicherten `mimeType`; `file.mimetype`/Dateiendung werden nie ausgewertet; SVG/HTML/PDF-Polyglotte fallen durch (kein `<svg`, kein `%PDF`, kein Text). Auslieferung mit `nosniff` + `CSP default-src 'none'; sandbox`, damit auch ein unerwartet interpretierter Inhalt kein Skript im Tessera-Origin ausführt. |
|
||||
| T-PI9-02 | Denial of Service | `POST /dashboard/images` | medium | mitigate | `FileInterceptor('image', { limits: { fileSize: 5 MiB, files: 1 } })` je Route (Muster T-M97-03); `main.ts` bleibt ohne globales Body-Limit. |
|
||||
| T-PI9-03 | Denial of Service | Zähler 30 je Benutzer | medium | mitigate | `count({ where: { tenantId, userId } })` vor `create` im selben Dienst. Restrisiko (accept, low): zwei gleichzeitige Uploads desselben Benutzers können den Zähler um wenige Bilder überschreiten — kein Schaden über den eigenen Speicher hinaus, keine Transaktion nötig. |
|
||||
| T-PI9-04 | Information Disclosure (IDOR) | `GET/DELETE /dashboard/images/:id` | high | mitigate | Klient je Aufruf `forTenant(prisma, tenantId, userId)`; Anwendungsprüfung `row.userId === userId && row.tenantId === tenantId`, sonst 404 (nie 403 — Existenz fremder Kennungen bleibt verborgen); RLS-Policy mit Benutzerdimension in der Migration; Kennungen `uuid()` (nicht erratbar). Getestet: fremder Benutzer UND fremder Mandant → 404. |
|
||||
| T-PI9-05 | Server-Side Request Forgery | URL-Einträge | high | mitigate | Die API ruft NIE eine Webadresse ab: kein Proxy-Endpunkt nimmt eine URL an, `images[].url` ist für die API ein undurchsichtiger JSON-Wert. Der Browser des Benutzers lädt das Bild selbst (`<img>`); interne Hosts sieht damit nur, wer sie ohnehin erreicht. Nachweis im Rundgang (j). |
|
||||
| T-PI9-06 | Tampering (XSS) | Unterschriften, Dateinamen | medium | mitigate | Nur React-Textknoten, kein `dangerouslySetInnerHTML` (Verify-Gate in Aufgabe 2); `originalName` erscheint in keinem HTTP-Header (`Content-Disposition: inline` ohne `filename`) und nirgends als HTML; Unterschrift auf 200 Zeichen gekürzt. |
|
||||
| T-PI9-07 | Tampering (Mixed Content / gefährliche Schemata) | `images[].url` | medium | mitigate | `isHttpsUrl` (echter `URL`-Parser, `protocol === 'https:'`) im Formular UND in `resolvePictureFrameConfig` beim Rendern — `http:`, `data:`, `javascript:`, `file:` werden nie zum `src`. Serverseitig nicht prüfbar (API kennt keine Config-Inhalte, siehe Entscheidung 4) — Risiko bleibt auf das eigene Dashboard beschränkt. |
|
||||
| T-PI9-08 | Spoofing (Content-Type) | `GET /dashboard/images/:id` | medium | mitigate | `Content-Type` ausschließlich aus dem per Magic Bytes bestimmten, gespeicherten `mimeType` (eine der vier Bild-Konstanten), `X-Content-Type-Options: nosniff`. |
|
||||
| T-PI9-09 | Information Disclosure (Referrer) | `<img>` auf Fremdhost | low | mitigate | `referrerPolicy="no-referrer"` an jedem `<img>` (Widget, Großansicht, Vorschau im Formular) — der Fremdhost erfährt die Tessera-Adresse nicht. |
|
||||
| T-PI9-10 | Information Disclosure (Caches) | Auslieferung eigener Bilder | low | mitigate | `Cache-Control: private, max-age=86400` — kein gemeinsamer Zwischenspeicher (Nginx Proxy Manager) darf die Antwort für andere ausliefern. |
|
||||
| T-PI9-11 | Elevation of Privilege | multipart-Rumpf | medium | mitigate | Mandant/Benutzer nur aus `@CurrentUser()` (Sitzungsnachweis); der Rumpf hat genau das Feld `image`, keine DTO-Felder für `tenantId`/`userId` (Muster T-M97-06). |
|
||||
| T-PI9-12 | Repudiation | Löschen/Hochladen | low | accept | Kein Audit-Log für Bilder — persönliche Inhalte ohne Fremdwirkung; Zeitstempel `createdAt` reicht für ASVS 1. |
|
||||
| T-PI9-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — multer kommt über das vorhandene `@nestjs/platform-express`, Magic-Byte-Erkennung ist eine Handvoll eigener Zeilen (kein `file-type`-Paket). Sollte der Executor dennoch ein Paket installieren wollen: Stopp, Rückfrage an den Orchestrator. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Automatisch (Executor, je Aufgabe im `<verify>`): API-Tests `src/dashboard` + `src/prisma`, Web-Tests der neuen und angefassten Dateien, `tsc --noEmit` beider Apps, Biome, Zähler `as unknown as`, Schlüsselgleichheit de/en, kein `dangerouslySetInnerHTML`, Migration im Commit.
|
||||
|
||||
Am Ende (Aufgabe 3): `pnpm type-check` 4/4, `pnpm lint` 5/5, volle Testläufe beider Apps, Disziplin-Zähler wie in der Ausgangsmessung.
|
||||
|
||||
Manuell (Orchestrator, Prüfliste aus Aufgabe 3 Punkt 3, lokal im Browser): Katalog, Upload, https/http, Nicht-Bild, Wechsel/Zufall/Ausschnitt, Großansicht, Bearbeitungsmodus, 404 für fremde Kennung, Löschen, kein Server-Abruf der Fremdadresse.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Alle sieben `must_haves.truths` erfüllt und je mit Test oder Rundgangspunkt belegt
|
||||
- [ ] Migration `20260921120000_dashboard_image` angewendet, `rls-coverage` und `rls-access-inventory` grün (30/30 in `src/prisma`)
|
||||
- [ ] Fremde Kennung → 404 (Benutzer UND Mandant), Nicht-Bild → 400 deutsch, > 5 MiB → 413, 31. Bild → 400 deutsch
|
||||
- [ ] Widget: Leerzustand, Wechsel (Reihenfolge/Zufall), Ausschnitt, Unterschrift, Großansicht mit Fokus-Rückgabe, kein Klick im Bearbeitungsmodus, Timer geräumt
|
||||
- [ ] Formular: Upload, https-Adresse, Abweisung http, Unterschrift, Pfeile, Entfernen (mit Server-Löschung), „Bild nicht verfügbar“
|
||||
- [ ] Beide Sprachdateien vollständig, Texte siezen
|
||||
- [ ] Changelog-Stichpunkt unter „Unveröffentlicht → Neu“
|
||||
- [ ] Tore grün, Zähler unverändert, keine neue `any`, drei Commits mit Scope `quick-260921-pi9`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Nach Abschluss `.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-SUMMARY.md` anlegen (Muster `260921-oxm-SUMMARY.md`): Rot-Nachweis der Helfer-/Diensttests, Zahlen der Endmessung, die zehnpunktige Browser-Prüfliste für den Orchestrator, offene Punkte.
|
||||
</output>
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
---
|
||||
phase: quick-260921-pi9
|
||||
plan: 01
|
||||
subsystem: apps/api/src/dashboard, apps/web/src/components/dashboard/widgets, apps/web/src/components/settings
|
||||
tags: [dashboard, widget, bilderrahmen, upload, bytea, magic-bytes, rls, tdd, i18n]
|
||||
status: complete
|
||||
requires:
|
||||
- "STATE.md „NAECHSTER AUFTRAG“: erstes der zwei neuen Dashboard-Widgets, Produktfragen geklaert"
|
||||
- "Migration 20260911120000 (Regelform mit Benutzerdimension)"
|
||||
provides:
|
||||
- "Widget-Typ picture-frame: Diashow aus hochgeladenen Bildern und https-Adressen mit Grossansicht"
|
||||
- "API dashboard/images: Bilder je Benutzer als bytea, Magic-Byte-Pruefung, 5 MiB / 30 Stueck, Besitz = Mandant UND Benutzer"
|
||||
- "Bildverwaltung im WidgetSettingsPanel (Einstellungen -> Dashboard)"
|
||||
affects:
|
||||
- "apps/api/prisma/schema.prisma (neues Modell DashboardImage)"
|
||||
- "apps/api/src/dashboard/*"
|
||||
- "apps/web/src/components/dashboard/widget-registry.tsx (achter Typ)"
|
||||
- "apps/web/src/messages/de.json, en.json (Namensraum widgets.pictureFrame)"
|
||||
- "docs/mandantentrennung-zugriffsklassifikation.md (neues Paar, Zahlen nachgemessen)"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Magic-Byte-Erkennung als reine Funktion (kein file-type-Paket), entscheidet Annahme UND gespeicherten Typ"
|
||||
- "Eintragstyp als Vereinigung mit kind-Unterscheider in EINER geordneten Liste (promote, kein Listenpaar)"
|
||||
- "Fremdbilder laedt nur der Browser (<img referrerPolicy=no-referrer>), die API kennt keinen URL-Proxy"
|
||||
- "Prisma-Bytes: new Uint8Array(buffer) statt Zusicherung (TS 5.9 verlangt Uint8Array<ArrayBuffer>)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
|
||||
- apps/api/src/dashboard/dashboard-image-rules.ts
|
||||
- apps/api/src/dashboard/dashboard-image-rules.spec.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||
- apps/api/src/dashboard/dashboard-images.controller.ts
|
||||
- apps/api/src/dashboard/dashboard-images.controller.spec.ts
|
||||
- apps/web/src/lib/dashboard-images-api.ts
|
||||
- apps/web/src/lib/dashboard-images-api.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx
|
||||
- apps/web/src/components/settings/picture-frame-config-form.tsx
|
||||
- apps/web/src/components/settings/picture-frame-config-form.test.tsx
|
||||
modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/dashboard/dashboard.module.ts
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/app/(portal)/page.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/messages/umlaut-dictionary.ts
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-anwender.md
|
||||
decisions:
|
||||
- "Prisma-Bytes ohne Zusicherung: new Uint8Array(file.buffer) kopiert einmal je Upload (hoechstens 5 MiB) — der Plan-Hinweis „data: file.buffer geht“ stimmt unter TS 5.9 + Prisma 6 nicht, der Compiler lehnt Buffer<ArrayBufferLike> ab"
|
||||
- "Widget-Test 11 prueft die Pause des Wechsels ueber das Verhalten (vier Intervalle vergehen, Bild bleibt), nicht ueber vi.getTimerCount(): React haelt nach einer Interaktion selbst einen Scheduler-Timer (gemessen 1), der Zaehler misst also nicht nur unseren Timer"
|
||||
- "Klassifikationsdokument: Bereichs- und Summenzeilen nachgemessen statt +6 addiert — settings (3 -> 4) und bug-reports waren seit 260914-m97 in der Summe nie mitgezaehlt, die Paarzahl der Klassen-Verteilung stand auf 72 bei tatsaechlich 73 Zeilen; jetzt 187 gebunden / 74 Paare, beides der Messung entnommen"
|
||||
- "Vorschau im Formular aria-hidden (dekorativ, die Unterschrift traegt den Sinn); das Kachelbild behaelt sein alt und damit eine Biome-Warnung der Stufe warn (onError auf <img> gilt der a11y-Regel als Interaktion — Fehlbefund)"
|
||||
metrics:
|
||||
duration: "ca. 75 min (18:35 bis 19:50 Uhr, 21.09.2026)"
|
||||
completed: 2026-09-21
|
||||
actuals:
|
||||
tokens: 36000
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 573d070
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260921-pi9: Dashboard-Widget „Bilderrahmen“ Summary
|
||||
|
||||
Ein neues Dashboard-Widget zeigt eigene Bilder als Diashow: hochgeladen (in
|
||||
der Datenbank, dem Benutzer gehoerend, 5 MiB je Datei, 30 je Benutzer) oder
|
||||
per https-Adresse eingebunden (der Browser laedt sie direkt, der Server ruft
|
||||
nie eine Adresse ab). Bildausschnitt, Wechselintervall, Reihenfolge/Zufall und
|
||||
Bildunterschrift stellt der Benutzer unter Einstellungen -> Dashboard ein; ein
|
||||
Klick zeigt das Bild gross. Alle Tore sind gruen, der curl-Rundgang lief gegen
|
||||
die lebende lokale API.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**API (Commit 737974b).** Prisma-Modell `DashboardImage` (`data Bytes`, keine
|
||||
Relation) mit handgeschriebener Migration `20260921120000_dashboard_image`:
|
||||
Tabelle, beide Indizes, `ENABLE`/`FORCE ROW LEVEL SECURITY` und
|
||||
`tenant_isolation_policy` mit Benutzerdimension von Anfang an. Die Migration
|
||||
ist lokal angewendet (`prisma migrate status`: keine ausstehende), `prisma
|
||||
generate` gelaufen. `dashboard-image-rules.ts` erkennt PNG/JPEG/GIF/WebP an
|
||||
den Magic Bytes — `file.mimetype` und Dateiendung werden nie gelesen, der
|
||||
erkannte Typ ist zugleich der gespeicherte und der spaeter ausgelieferte.
|
||||
`DashboardImagesService` (list/upload/getBytes/remove) holt je Methode
|
||||
`const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und
|
||||
Zaehler filtern explizit `{ tenantId, userId }`, `getBytes`/`remove` pruefen
|
||||
Besitz gegen Mandant UND Benutzer und antworten sonst 404 (nie 403).
|
||||
`DashboardImagesController` unter `dashboard/images`: `GET` -> `POST`
|
||||
(`FileInterceptor('image', 5 MiB, eine Datei)`) -> `GET :id` (Content-Type
|
||||
aus dem gespeicherten Typ, `Cache-Control: private, max-age=86400`,
|
||||
`X-Content-Type-Options: nosniff`, `Content-Disposition: inline` ohne
|
||||
Dateinamen, CSP `default-src 'none'; sandbox`) -> `DELETE :id`. Kein
|
||||
`@Roles`. `CreateWidgetDto` kennt `'picture-frame'`.
|
||||
|
||||
**Web (Commit c080580).** `picture-frame-config.ts`: `PictureFrameEntry` als
|
||||
Vereinigung (`upload` | `url`) in EINER Liste, `resolvePictureFrameConfig`
|
||||
laesst alles weg, was der URL-Parser nicht als `https:` erkennt (T-PI9-07:
|
||||
die API prueft Config-Inhalte nicht, deshalb entscheidet allein diese Funktion,
|
||||
was zum `src` wird), Intervall 0 oder 5..3600 s (Vorgabe 30), `pickNextIndex`
|
||||
(Zufall zieht aus count-1 Kandidaten, nie das aktuelle). `dashboard-images-api.ts`
|
||||
schickt die Datei als FormData-Feld `image` ohne eigenen Content-Type, macht
|
||||
aus 413 die deutsche Meldung, 400-Meldungen kommen bereits deutsch von der API.
|
||||
`PictureFrameWidget`: Leerhinweis im Stil der anderen Widgets, `<img
|
||||
referrerPolicy="no-referrer">` (Upload ueber `/api-proxy/dashboard/images/:id`,
|
||||
URL direkt), `object-contain`/`object-cover`, Unterschrift als Streifen, Timer
|
||||
nur bei > 1 Bild und Intervall > 0 und geschlossener Grossansicht (Raeumung im
|
||||
Cleanup), kaputte Bilder verlassen den Umlauf („Bild nicht verfuegbar“, wenn
|
||||
alle). Ausserhalb des Bearbeitungsmodus liegt das Bild in einem `<button>`
|
||||
(Grossansicht `PictureFrameLightbox`: Dialog fokussiert, Escape/Hintergrund/
|
||||
Schliessen-Knopf, danach Fokus zurueck am Bild-Knopf); im Bearbeitungsmodus ein
|
||||
`<div>` ohne Handler — die Karte bleibt der Ziehgriff. `PictureFrameConfigForm`
|
||||
im WidgetSettingsPanel: drei Auswahlfelder (senden nur ihr Feld), Eintragsliste
|
||||
mit Vorschau/Unterschrift (Entwurf, Uebernahme bei Blur/Enter)/Pfeilen/Entfernen
|
||||
(Upload wird auch serverseitig geloescht, Fehler verschluckt), Datei hochladen
|
||||
(deaktiviert ab 30), Webadresse hinzufuegen (http -> `role="alert"`, kein
|
||||
`onChange`). Listenaenderungen senden IMMER das ganze `images`-Array. Registry
|
||||
(`minW 4, minH 4, defaultW 8, defaultH 8`), Katalog, Seite, 28 Schluessel je
|
||||
Sprache unter `widgets.pictureFrame`.
|
||||
|
||||
**Doku (Commit c3b4597).** Changelog-Stichpunkt als erster unter
|
||||
„Unveroeffentlicht -> Neu“, Zeile in der Widget-Tabelle und Absatz unter
|
||||
„Dashboard > Widgets“ im Anwenderhandbuch, zwei Woerter auf der Erlaubnisliste
|
||||
des Umlaut-Waechters (siehe Deviations).
|
||||
|
||||
## Die Tests, und der Beleg dass sie rot waren
|
||||
|
||||
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|
||||
|---|---:|---|
|
||||
| `dashboard-image-rules.spec.ts` | 10 | `pnpm --filter @tessera/api exec vitest run src/dashboard/dashboard-image-rules.spec.ts` -> `Error: Cannot find module './dashboard-image-rules'`, 1 Test File failed, 10 Faelle nicht ausfuehrbar |
|
||||
| `dashboard-images.service.spec.ts` | 12 | `... vitest run src/dashboard/dashboard-images.service.spec.ts` -> `Cannot find module './dashboard-images.service'`, 1 failed |
|
||||
| `dashboard-images.controller.spec.ts` | 5 | `... vitest run src/dashboard/dashboard-images.controller.spec.ts` -> `Cannot find module './dashboard-images.controller'`, 1 failed |
|
||||
| `picture-frame-config.test.ts` | 11 | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/picture-frame-config.test.ts src/lib/dashboard-images-api.test.ts` -> `Failed to resolve import "./picture-frame-config"`, 2 Test Files failed |
|
||||
| `dashboard-images-api.test.ts` | 5 | derselbe Lauf -> `Failed to resolve import "./dashboard-images-api"` |
|
||||
| `picture-frame-widget.test.tsx` | 12 | nach der Umsetzung geschrieben (Plan verlangt Rot nur fuer Regel-/Dienst-/Helfer-Tests); erster Lauf 11/12, Test 11 wegen des React-Scheduler-Timers umgestellt (siehe decisions) |
|
||||
| `picture-frame-config-form.test.tsx` | 9 | nach der Umsetzung geschrieben; erster Lauf 9/9 |
|
||||
|
||||
Zusammen 64 neue Faelle; die bestehenden Registry-/Katalog-/Seiten-Tests laufen
|
||||
mit dem achten Typ (Constraints-Tabelle, `counted` 28 -> 32, Attrappen um
|
||||
`pictureFrame.*` und das neue Widget-Modul ergaenzt).
|
||||
|
||||
## curl-Rundgang gegen die lebende API
|
||||
|
||||
Die Container `api`/`web` lagen mit einem alten Image still; die API lief
|
||||
deshalb aus dem Quelltext (`nest build` + `node dist/main.js`) gegen eine
|
||||
eigens angelegte, leere Datenbank `tessera_pi9` auf dem lokalen db-Container
|
||||
(Migrationen angewendet, Admin per Erstanlage), danach wieder geloescht. Kein
|
||||
Zugriff auf den Testserver.
|
||||
|
||||
| Schritt | Ergebnis |
|
||||
|---|---|
|
||||
| `POST /dashboard/images` mit 4x4-PNG | 201, `{ id, originalName, mimeType: "image/png", size: 73, createdAt }` |
|
||||
| `GET /dashboard/images` | 200, Liste mit denselben fuenf Feldern, kein `data` |
|
||||
| `GET /dashboard/images/<id>` | 200, `Content-Type: image/png`, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`, `Content-Security-Policy: default-src 'none'; sandbox`; Bytes per `cmp` identisch mit der Quelle |
|
||||
| Textdatei als `.png` (`type=image/png`) | 400 `Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.` |
|
||||
| 6-MiB-Datei | 413 `File too large` (multer/Nest, im Web-Klienten deutsch) |
|
||||
| erfundene Kennung | 404 |
|
||||
| ohne Cookie | 401 |
|
||||
| Kennung mit dem Cookie eines ZWEITEN Benutzers (`GET` und `DELETE`) | 404 / 404 (Punkt (h) der Pruefliste bereits erledigt) |
|
||||
| eigener `DELETE` | 200 `{ id }`, Liste danach `[]` |
|
||||
|
||||
## Messungen (Endstand, HEAD c3b4597)
|
||||
|
||||
| Groesse | Ausgang (573d070) | Jetzt |
|
||||
|---|---:|---:|
|
||||
| `pnpm type-check` | 4/4 | 4/4 |
|
||||
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web 53, keine Stufe `error`) |
|
||||
| API-Tests | 1148 | **1175** (75 Dateien) |
|
||||
| Web-Tests | 531 | **569** (77 Dateien) |
|
||||
| `as unknown as` in apps/api/src | 27 | 27 |
|
||||
| `as unknown as` in apps/web/src | 6 | 6 |
|
||||
| `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` in den drei neuen Komponenten | – | 0 |
|
||||
| RLS-Waechter `src/prisma` | 30/30 laut Plan | 78/78 (davon `rls-coverage` + `rls-access-inventory` 35/35) |
|
||||
| de/en-Schluesselgleichheit `widgets.pictureFrame` | – | 28 = 28 |
|
||||
|
||||
Keine neue `any`, kein `!`, kein neues Paket.
|
||||
|
||||
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP)
|
||||
|
||||
- [x] (a) Dashboard -> Bearbeiten -> „Widget hinzufuegen“ zeigt „Bilderrahmen“ mit Rahmen-Symbol; die platzierte Kachel (8x8) zeigt „Noch keine Bilder — ueber die Einstellungen hinzufuegen“
|
||||
- [x] (b) Einstellungen -> Dashboard -> „Bilderrahmen #1“ aufklappen: PNG hochladen -> Vorschau erscheint in der Liste, `GET /dashboard/images` enthaelt den Eintrag ohne `data`
|
||||
- [x] (c) https-Adresse hinzufuegen -> Eintrag mit Vorschau; http-Adresse -> rote Meldung „Bitte geben Sie eine vollstaendige https-Adresse ein.“, kein Eintrag
|
||||
- [x] (d) `.txt` als `.png` umbenannt hochladen -> rote Meldung „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“
|
||||
- [x] (e) Intervall 5 s, zwei Bilder -> Kachel wechselt; Zufall mit drei Bildern -> nie dasselbe zweimal hintereinander; Bildausschnitt umschalten -> `object-cover` / `object-contain` sichtbar anders
|
||||
- [x] (f) Klick auf das Bild -> Grossansicht mit Unterschrift; Escape schliesst, Hintergrund-Klick schliesst, Schliessen-Knopf schliesst; waehrend geoeffnet kein Wechsel
|
||||
- [x] (g) Bearbeitungsmodus: Klick auf das Bild oeffnet nichts, die Kachel laesst sich an jeder Stelle ziehen
|
||||
- [x] (h) `curl -b <cookie zweiter Benutzer> -o /dev/null -w '%{http_code}' .../dashboard/images/<id>` -> 404 (bereits mit curl gegen die lokale API belegt, siehe Rundgang oben; im Browser optional wiederholen)
|
||||
- [x] (i) Eintrag entfernen -> Bild verschwindet aus `GET /dashboard/images` und aus der Kachel
|
||||
- [x] (j) Netzwerk-Tab: das Fremdbild laedt der Browser selbst (Anfrage an den Fremdhost mit `Referrer Policy: no-referrer`), im API-Log kein Aufruf der Fremdadresse
|
||||
|
||||
**Rundgang durch den Orchestrator am 21.09.2026 (lokaler Stack, Abbilder aus HEAD, Playwright-MCP):** alle zehn Punkte bestanden. Belege: (a) Katalog zeigt „Bilderrahmen“ mit Beschreibung, Leerhinweis in der Kachel; (b) `rot.png` hochgeladen, Vorschau 320 px, `GET /dashboard/images` liefert `{id, originalName, mimeType, size, createdAt}` ohne `data`; (c) `http://example.com/bild.png` → Meldung, kein Eintrag; `https://www.gstatic.com/webp/gallery/1.webp` → Eintrag mit Vorschau (eine zuvor eingetragene, serverseitig 400 liefernde Wikimedia-Adresse zeigte korrekt „Bild nicht verfügbar“); (d) Textdatei als `.png` → „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“; (e) Intervall 5 s: blau → gstatic → rot → blau im Sekundentakt gemessen; Zufall mit drei Bildern: 8 Wechsel in 42 s, nie dasselbe zweimal hintereinander; `object-cover` nach Umschalten; (f) Grossansicht nach Portal-Korrektur 8bf3601 ueber den ganzen Viewport (Hintergrund 1905x949), Escape schliesst mit Fokusrueckgabe auf „Bild groß anzeigen“, Hintergrund-Klick schliesst, waehrend geoeffnet 6 s lang kein Wechsel; (g) Bearbeitungsmodus: kein Bild-Knopf im Widget, Ziehen ueber die Bildflaeche verschiebt die Kachel (`translate` 8 → 480 px), kein Dialog; (h) siehe curl; (i) zweiten Eintrag entfernt → `GET /dashboard/images` nur noch `rot.png`, Config nur noch zwei Eintraege; (j) Netzwerk: gstatic-Abruf kommt vom Browser, API-Log ohne Treffer auf `gstatic`.
|
||||
|
||||
**Drei Befunde aus dem Rundgang, behoben in 8bf3601:** (1) die Grossansicht war auf die Kachelflaeche (531x216) beschraenkt — die Kachel liegt in einem `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr wird fuer `position: fixed` zum Bezugsrahmen; jetzt `createPortal` in `document.body` wie der Kalender-Tooltip; (2) „1 Minuten“ im Wechselintervall → ICU-Plural in de/en, Formular-Test nutzt dafuer `createTranslator` von next-intl auf der echten de.json; (3) Standardgroesse 8x8 (216 px hoch) zu flach → 8x12 wie der Kalender. Web-Tests 569 unveraendert in der Zahl (ein Fall um die Singular-Pruefung ergaenzt).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 1 - Bug] `data: file.buffer` kompiliert nicht.** Der Executor-Hinweis
|
||||
„`data: file.buffer` beim Anlegen geht“ stimmt unter TS 5.9 + Prisma 6.19
|
||||
nicht: `Bytes` verlangt `Uint8Array<ArrayBuffer>`, multers `Buffer` ist
|
||||
ueber `ArrayBufferLike` getypt und wird abgelehnt (TS2322). Statt einer
|
||||
Zusicherung kopiert `new Uint8Array(file.buffer)` einmal je Upload
|
||||
(hoechstens 5 MiB). Aufgabe 1, Commit 737974b.
|
||||
2. **[Rule 3 - Blocking] Umlaut-Waechter.** Der volle Web-Testlauf meldete die
|
||||
neuen de.json-Woerter „Bildausschnitt“ und „Webadresse“ als unbekannte
|
||||
ss-Tokens. Beide sind korrektes Deutsch und stehen jetzt auf
|
||||
`UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts` (Datei
|
||||
nicht im Plan). Aufgabe 3, Commit c3b4597.
|
||||
3. **Verify-Skript Aufgabe 1:** `git show --stat` kuerzt den Migrationspfad
|
||||
auf `.../20260921120000_dashboard_image/migration.sql`, der `grep` des
|
||||
Plans auf den vollen Pfad schlaegt deshalb fehl; mit `--stat=200` ist die
|
||||
Migration im Commit eindeutig nachgewiesen. Kein Code-Befund.
|
||||
4. **Klassifikationsdokument, mehr als die geplante eine Zeile:** die
|
||||
Nachmessung mit der Gate-Schleife ergab, dass Bereichs- und Summenzeilen
|
||||
bereits vor dieser Aufgabe um zwei Rohtreffer (settings 3 statt 4,
|
||||
bug-reports nie summiert) und die Klassen-Verteilung um ein Paar
|
||||
(bug-reports) hinterherhingen. Beides ist nachgezogen und im Dokument als
|
||||
Nachtrag 260921-pi9 begruendet; der Waechter `rls-access-inventory` prueft
|
||||
nur die Paartabelle und war davon nicht betroffen.
|
||||
5. **Widget-Test 11** misst die Pause des Wechsels ueber das Verhalten statt
|
||||
ueber `vi.getTimerCount()` (siehe decisions).
|
||||
6. **Zusatz des Orchestrators umgesetzt:** Zeile und Absatz in
|
||||
`docs/anleitung-anwender.md` im Aufgabe-3-Commit.
|
||||
|
||||
Nicht geaendert: `STATE.md`, `ROADMAP.md`, keine neue Abhaengigkeit, kein
|
||||
Deploy, kein Zugriff auf den Testserver.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine. Jede Kette ist verdrahtet: Datei -> Upload -> Config -> Kachel -> Proxy
|
||||
-> API -> Bytes; https-Adresse -> Config -> Kachel -> Browser.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Flaeche ausserhalb des `<threat_model>` des Plans: die vier Routen
|
||||
unter `dashboard/images` und die `<img>`-Fremdabrufe sind dort als T-PI9-01
|
||||
bis T-PI9-11 erfasst und mitigiert; `Content-Disposition` ohne Dateinamen und
|
||||
`Cache-Control: private` sind mit curl belegt.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle 16 neu angelegten Dateien liegen auf der Platte, die drei Commits
|
||||
737974b, c080580 und c3b4597 sind in `git log` auffindbar
|
||||
(`git rev-list --count 573d070..HEAD` = 3). Die Zahlen der Tabelle stammen
|
||||
aus tatsaechlich gelaufenen Befehlen.
|
||||
@@ -6,6 +6,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
### 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 „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: …“
|
||||
- Desktop-App: Server-Adresse nachträglich änderbar über „Server-Adresse ändern…“ im Menü des Infobereich-Symbols – ohne Neustart
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 260921-pi9 — Bilderrahmen-Widget: hochgeladene Bilder eines Benutzers.
|
||||
--
|
||||
-- Zweck: die Tabelle "DashboardImage" traegt die Bilddaten (bytea) fuer das
|
||||
-- Dashboard-Widget „Bilderrahmen“. Bilder liegen in der Datenbank statt in
|
||||
-- einem Docker-Volume, damit die bestehende Sicherung sie mit abdeckt.
|
||||
--
|
||||
-- Grenzen (durchgesetzt in der Anwendung, apps/api/src/dashboard/
|
||||
-- dashboard-image-rules.ts + dashboard-images.service.ts): hoechstens 5 MiB
|
||||
-- je Datei (multer-Limit je Route), hoechstens 30 Bilder je Benutzer
|
||||
-- (Zaehler je Mandant+Benutzer vor dem Anlegen); erlaubt sind nur PNG, JPEG,
|
||||
-- GIF und WebP, erkannt an den Magic Bytes — "mimeType" ist der ERKANNTE Typ,
|
||||
-- nie der vom Browser behauptete.
|
||||
--
|
||||
-- Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
||||
-- gleicher Benutzer). Die Regel unten traegt deshalb von Anfang an die
|
||||
-- Benutzerdimension (Form aus 20260911120000_rls_user_dimension_personal_
|
||||
-- tables); die Anwendung prueft den Besitz zusaetzlich in getBytes/remove und
|
||||
-- antwortet fuer fremde Kennungen mit 404 (nie 403).
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
|
||||
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
|
||||
--
|
||||
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirkt die Regel erst, wenn die
|
||||
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
|
||||
-- docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DashboardImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"originalName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"data" BYTEA NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DashboardImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DashboardImage_userId_idx" ON "DashboardImage"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DashboardImage_tenantId_idx" ON "DashboardImage"("tenantId");
|
||||
|
||||
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000)
|
||||
ALTER TABLE "DashboardImage" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "DashboardImage" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "DashboardImage"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
@@ -212,6 +212,26 @@ 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
|
||||
// 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).
|
||||
model DashboardImage {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tenantId String
|
||||
originalName String
|
||||
mimeType String
|
||||
size Int
|
||||
data Bytes
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model SearchProvider {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_BYTES,
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
detectImageMime,
|
||||
} from './dashboard-image-rules';
|
||||
|
||||
/**
|
||||
* dashboard-image-rules.spec — NEU (quick-260921-pi9, Bilderrahmen-Widget).
|
||||
*
|
||||
* Die Magic-Byte-Erkennung entscheidet Annahme UND gespeicherten Typ eines
|
||||
* hochgeladenen Bildes (T-PI9-01); Dateiname und behaupteter MIME-Typ werden
|
||||
* nie gelesen. Deshalb wird sie hier direkt an den Bytes geprueft: vier
|
||||
* gueltige Signaturen, und alles, was NICHT durchgehen darf (leer, Text,
|
||||
* SVG, PDF, RIFF ohne WEBP).
|
||||
*/
|
||||
function bytes(...parts: (number[] | string)[]): Uint8Array {
|
||||
const out: number[] = [];
|
||||
for (const p of parts) {
|
||||
if (typeof p === 'string') {
|
||||
for (const ch of p) out.push(ch.charCodeAt(0));
|
||||
} else {
|
||||
out.push(...p);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
describe('detectImageMime (quick-260921-pi9)', () => {
|
||||
it('Test 1: PNG-Signatur 89 50 4E 47 0D 0A 1A 0A -> image/png', () => {
|
||||
expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13]))).toBe(
|
||||
'image/png',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 2: FF D8 FF -> image/jpeg', () => {
|
||||
expect(detectImageMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('Test 3: GIF87a und GIF89a -> image/gif', () => {
|
||||
expect(detectImageMime(bytes('GIF87a', [1, 0, 1, 0]))).toBe('image/gif');
|
||||
expect(detectImageMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('Test 4: RIFF????WEBP -> image/webp', () => {
|
||||
expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe('image/webp');
|
||||
});
|
||||
|
||||
it('Test 5: leerer Puffer -> null', () => {
|
||||
expect(detectImageMime(new Uint8Array(0))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 6: Textdatei -> null, auch wenn sie lang genug ist', () => {
|
||||
expect(detectImageMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 7: SVG-Text und PDF (%PDF) -> null', () => {
|
||||
expect(detectImageMime(bytes('<svg xmlns="http://www.w3.org/2000/svg"></svg>'))).toBeNull();
|
||||
expect(detectImageMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 8: RIFF-Kopf ohne WEBP an Stelle 8 (z. B. WAVE) -> null', () => {
|
||||
expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WAVE', 'fmt '))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 9: zu kurze Puffer werfen nicht — PNG-Anfang mit 4 Bytes ist null, JPEG-Anfang mit 2 Bytes ist null', () => {
|
||||
expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
|
||||
expect(detectImageMime(bytes([0xff, 0xd8]))).toBeNull();
|
||||
expect(detectImageMime(bytes('RIFF'))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 10: Grenzen — 5 MiB je Datei, 30 Bilder je Benutzer', () => {
|
||||
expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
expect(DASHBOARD_IMAGE_MAX_COUNT).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* dashboard-image-rules — reine Regeln fuer das Bilderrahmen-Widget
|
||||
* (quick-260921-pi9). Kein Nest, kein Prisma: Grenzen und die
|
||||
* Magic-Byte-Erkennung, damit Dienst und Controller dieselben Werte
|
||||
* anwenden und die Erkennung direkt an den Bytes testbar ist.
|
||||
*
|
||||
* Warum Magic Bytes und nicht `file.mimetype` oder die Dateiendung
|
||||
* (T-PI9-01): beides behauptet der Browser, beides kann eine umbenannte
|
||||
* HTML-, SVG- oder PDF-Datei tragen. Was hier NICHT als eine der vier
|
||||
* Signaturen erkannt wird, kommt nicht in die Datenbank — und der erkannte
|
||||
* Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-PI9-08).
|
||||
*
|
||||
* Bewusst KEIN `file-type`-Paket (T-PI9-SC): vier feste Signaturen sind
|
||||
* eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
|
||||
*/
|
||||
|
||||
/** Hoechstgroesse je Datei: 5 MiB (multer `limits.fileSize` je Route). */
|
||||
export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** Hoechstzahl je Benutzer (Zaehler je Mandant+Benutzer vor dem Anlegen). */
|
||||
export const DASHBOARD_IMAGE_MAX_COUNT = 30;
|
||||
|
||||
export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
||||
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
|
||||
const GIF87A = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; // "GIF87a"
|
||||
const GIF89A = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; // "GIF89a"
|
||||
const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
|
||||
const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
|
||||
|
||||
function startsWith(buffer: Uint8Array, signature: number[], offset = 0): boolean {
|
||||
if (buffer.length < offset + signature.length) return false;
|
||||
for (let i = 0; i < signature.length; i++) {
|
||||
if (buffer[offset + i] !== signature[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt PNG, JPEG, GIF und WebP an den ersten Bytes; alles andere
|
||||
* (leer, Text, SVG, PDF, RIFF ohne WEBP an Stelle 8) ergibt `null`.
|
||||
* Wirft nie — ein zu kurzer Puffer ist schlicht kein Bild.
|
||||
*/
|
||||
export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null {
|
||||
if (startsWith(buffer, PNG_SIGNATURE)) return 'image/png';
|
||||
if (startsWith(buffer, JPEG_SIGNATURE)) return 'image/jpeg';
|
||||
if (startsWith(buffer, GIF87A) || startsWith(buffer, GIF89A)) return 'image/gif';
|
||||
if (startsWith(buffer, RIFF) && startsWith(buffer, WEBP, 8)) return 'image/webp';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen je
|
||||
* Route (T-PI9-02) am AUFRUF pruefbar sind — die echte Fabrik verschliesst
|
||||
* ihre Optionen in einer Klasse ohne lesbare Metadaten.
|
||||
*/
|
||||
const { fileInterceptorMock } = vi.hoisted(() => ({
|
||||
fileInterceptorMock: vi.fn(() => class FakeInterceptor {}),
|
||||
}));
|
||||
vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock }));
|
||||
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthUser } from '../auth/types/auth-user';
|
||||
import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules';
|
||||
import { DashboardImagesController } from './dashboard-images.controller';
|
||||
|
||||
/**
|
||||
* dashboard-images.controller.spec — NEU (quick-260921-pi9).
|
||||
*
|
||||
* Fuenf Tests an der Grenze Browser -> API: Pfad und Routenreihenfolge
|
||||
* (statisch vor `:id`), Interceptor-Grenzen (5 MiB, genau eine Datei, Feld
|
||||
* `image`), die Header der Binaerantwort (Content-Type aus dem gespeicherten
|
||||
* Typ, private Cache, nosniff, inline OHNE Dateinamen, CSP), kein
|
||||
* `@Roles`-Metadatum, und die Weitergabe von Mandant/Benutzer ausschliesslich
|
||||
* aus dem Sitzungsnachweis (T-PI9-11).
|
||||
*/
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'anna',
|
||||
role: 'USER',
|
||||
tenantId: 'tenant-1',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
return {
|
||||
list: vi.fn(async () => []),
|
||||
upload: vi.fn(async () => ({ id: 'new' })),
|
||||
getBytes: vi.fn(async () => ({
|
||||
mimeType: 'image/webp',
|
||||
data: Uint8Array.from([0x52, 0x49, 0x46, 0x46]),
|
||||
})),
|
||||
remove: vi.fn(async (id: string) => ({ id })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const headers: Record<string, string> = {};
|
||||
return {
|
||||
headers,
|
||||
setHeader: vi.fn((name: string, value: string) => {
|
||||
headers[name] = value;
|
||||
}),
|
||||
send: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// Kein vi.clearAllMocks() vor den Tests: der Interceptor-Aufruf geschieht
|
||||
// beim Laden der Klasse (Dekorator), Test 2 liest genau diesen Aufruf.
|
||||
describe('DashboardImagesController (quick-260921-pi9)', () => {
|
||||
it('Test 1: Pfad dashboard/images; Reihenfolge list -> upload -> getImage -> remove (statische Routen vor :id)', () => {
|
||||
expect(Reflect.getMetadata('path', DashboardImagesController)).toBe('dashboard/images');
|
||||
const proto = DashboardImagesController.prototype;
|
||||
const order = Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor');
|
||||
expect(order).toEqual(['list', 'upload', 'getImage', 'remove']);
|
||||
expect(Reflect.getMetadata('path', proto.getImage)).toBe(':id');
|
||||
expect(Reflect.getMetadata('path', proto.remove)).toBe(':id');
|
||||
expect(Reflect.getMetadata('path', proto.list)).toBe('/');
|
||||
expect(Reflect.getMetadata('path', proto.upload)).toBe('/');
|
||||
});
|
||||
|
||||
it('Test 2: FileInterceptor nimmt das Feld image mit fileSize 5 MiB und files 1 (T-PI9-02)', () => {
|
||||
expect(fileInterceptorMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileInterceptorMock.mock.calls[0]).toEqual([
|
||||
'image',
|
||||
{ limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } },
|
||||
]);
|
||||
expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('Test 3: Binaerantwort traegt Content-Type aus dem gespeicherten Typ, Cache private, nosniff, inline ohne Dateinamen, CSP; Bytes als Buffer', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardImagesController(service as never);
|
||||
const res = makeRes();
|
||||
|
||||
await controller.getImage('img-1', user, res as never);
|
||||
|
||||
expect(service.getBytes).toHaveBeenCalledWith('img-1', 'user-1', 'tenant-1');
|
||||
expect(res.headers['Content-Type']).toBe('image/webp');
|
||||
expect(res.headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
|
||||
expect(res.headers['Content-Disposition']).toBe('inline');
|
||||
expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox");
|
||||
const sent = res.send.mock.calls[0][0] as unknown;
|
||||
expect(Buffer.isBuffer(sent)).toBe(true);
|
||||
expect((sent as Buffer).equals(Buffer.from([0x52, 0x49, 0x46, 0x46]))).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 4: kein @Roles-Metadatum auf einer der vier Routen — alle angemeldeten Rollen', () => {
|
||||
const proto = DashboardImagesController.prototype;
|
||||
for (const name of ['list', 'upload', 'getImage', 'remove'] as const) {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 5: list/upload/remove reichen Mandant und Benutzer nur aus dem Sitzungsnachweis weiter; remove antwortet { id }', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardImagesController(service as never);
|
||||
const file = { buffer: Buffer.alloc(0), originalname: 'x.png', mimetype: 'image/png', size: 0 };
|
||||
|
||||
await controller.list(user);
|
||||
expect(service.list).toHaveBeenCalledWith('user-1', 'tenant-1');
|
||||
|
||||
await controller.upload(user, file);
|
||||
expect(service.upload).toHaveBeenCalledWith(user, file);
|
||||
|
||||
await expect(controller.remove('img-9', user)).resolves.toEqual({ id: 'img-9' });
|
||||
expect(service.remove).toHaveBeenCalledWith('img-9', 'user-1', 'tenant-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
|
||||
/**
|
||||
* Bilder des Bilderrahmen-Widgets (quick-260921-pi9).
|
||||
*
|
||||
* Offen fuer ALLE angemeldeten Rollen: bewusst KEIN Rollen-Dekorator
|
||||
* (Muster bug-reports.controller.ts — der globale `JwtAuthGuard` verlangt
|
||||
* weiterhin eine Sitzung). Mandant und Benutzer kommen NUR aus dem
|
||||
* Sitzungsnachweis (`@CurrentUser()`), nie aus dem Rumpf (T-PI9-11); der
|
||||
* Multipart-Rumpf hat genau das Feld `image`.
|
||||
*
|
||||
* Groessenlimit JE ROUTE (T-PI9-02): `FileInterceptor` nimmt genau eine
|
||||
* Datei bis 5 MiB; multers `LIMIT_FILE_SIZE` bildet Nest auf 413 ab (die
|
||||
* deutsche Meldung dafuer entsteht im Web-Klienten aus dem Status).
|
||||
* `main.ts` bleibt ohne globales Body-Limit.
|
||||
*
|
||||
* Routen (statische VOR `:id`, sonst 404-Shadowing):
|
||||
* - GET /dashboard/images — eigene Bilder, nur Metadaten
|
||||
* - POST /dashboard/images — Bild hochladen (multipart-Feld `image`)
|
||||
* - GET /dashboard/images/:id — Bytes eines eigenen Bildes
|
||||
* - DELETE /dashboard/images/:id — eigenes Bild loeschen
|
||||
*/
|
||||
@Controller('dashboard/images')
|
||||
export class DashboardImagesController {
|
||||
constructor(private readonly service: DashboardImagesService) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AuthUser) {
|
||||
return this.service.list(user.id, user.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } }),
|
||||
)
|
||||
async upload(@CurrentUser() user: AuthUser, @UploadedFile() file?: UploadedFileLike) {
|
||||
return this.service.upload(user, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binaerantwort mit den Headern aus T-PI9-06/08/10: der Typ kommt aus der
|
||||
* gespeicherten Magic-Byte-Erkennung, `nosniff` verbietet dem Browser das
|
||||
* Umdeuten, `Content-Disposition: inline` traegt bewusst KEINEN Dateinamen
|
||||
* (`originalName` gehoert in keinen Header), `private` haelt gemeinsame
|
||||
* Zwischenspeicher (Nginx Proxy Manager) fern, die CSP laesst ein direkt im
|
||||
* Tab geoeffnetes Bild ohne Skript und ohne Tessera-Origin laufen (Muster
|
||||
* favorites.controller.ts `getIcon`).
|
||||
*
|
||||
* Prisma 6 liefert `Bytes` als `Uint8Array` — `Buffer.from(...)` ueber
|
||||
* denselben Speicher, keine Kopie, keine Zusicherung.
|
||||
*/
|
||||
@Get(':id')
|
||||
async getImage(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { mimeType, data } = await this.service.getBytes(id, user.id, user.tenantId);
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Cache-Control', 'private, max-age=86400');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
|
||||
res.send(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.remove(id, user.id, user.tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { 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).
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
||||
prisma.__makeBoundClient(tenantId, userId),
|
||||
),
|
||||
}));
|
||||
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
|
||||
/**
|
||||
* dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen).
|
||||
*
|
||||
* 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
|
||||
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
||||
* Argument aufruft.
|
||||
*/
|
||||
|
||||
interface ImageRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
data: Uint8Array;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
tenantId: string;
|
||||
userId: string | undefined;
|
||||
model: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||
|
||||
interface FakePrisma {
|
||||
dashboardImage: ModelMethods;
|
||||
__rows: ImageRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||
|
||||
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
return {
|
||||
id: overrides.id ?? 'img-1',
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
originalName: overrides.originalName ?? 'foto.png',
|
||||
mimeType: overrides.mimeType ?? 'image/png',
|
||||
size: overrides.size ?? PNG.length,
|
||||
data: overrides.data ?? Uint8Array.from(PNG),
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
|
||||
function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
||||
if (!select) return row;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (select[key]) out[key] = row[key as keyof ImageRow];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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> };
|
||||
return rows
|
||||
.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId)
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.map((r) => pick(r, args.select));
|
||||
}),
|
||||
count: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { tenantId: string; userId: string } };
|
||||
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 created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
||||
rows.push(created);
|
||||
return pick(created, args.select);
|
||||
}),
|
||||
findUnique: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string } };
|
||||
return rows.find((r) => r.id === args.where.id) ?? null;
|
||||
}),
|
||||
delete: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string } };
|
||||
const idx = rows.findIndex((r) => r.id === args.where.id);
|
||||
if (idx === -1) return null;
|
||||
const [removed] = rows.splice(idx, 1);
|
||||
return removed;
|
||||
}),
|
||||
};
|
||||
|
||||
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 fake;
|
||||
}
|
||||
|
||||
function makeService(prisma: FakePrisma) {
|
||||
// Der Dienst verlangt einen PrismaService; die Attrappe deckt genau das
|
||||
// Modell ab, das der Dienst anfasst (nur ueber den gebundenen Klienten).
|
||||
// `as never` statt einer Doppelzusicherung ueber unknown (Muster
|
||||
// tender-mail.service.spec.ts; der Zusicherungs-Zaehler bleibt bei 27).
|
||||
return new DashboardImagesService(prisma as never);
|
||||
}
|
||||
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'anna',
|
||||
role: 'USER',
|
||||
tenantId: 'tenant-1',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): UploadedFileLike {
|
||||
return { buffer, mimetype, originalname, size: buffer.length };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
it('Test 1: list liefert nur eigene Zeilen, nur Metadaten (nie data), aelteste zuerst', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'b', createdAt: new Date('2026-03-01') }),
|
||||
makeRow({ id: 'a', createdAt: new Date('2026-01-01') }),
|
||||
makeRow({ id: 'fremd', userId: 'user-2' }),
|
||||
]);
|
||||
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
||||
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
||||
for (const r of result) {
|
||||
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
}
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
||||
expect(call.select.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, undefined)).rejects.toThrow(
|
||||
new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'),
|
||||
);
|
||||
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 3: PNG-Bytes mit behauptetem text/plain gelingen und speichern image/png (Magic Bytes, nicht mimetype)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const result = await makeService(prisma).upload(user, file(PNG, 'text/plain', 'irgendwas.txt'));
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(result.originalName).toBe('irgendwas.txt');
|
||||
expect(result.size).toBe(PNG.length);
|
||||
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
expect(prisma.__rows[0].userId).toBe('user-1');
|
||||
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
|
||||
});
|
||||
|
||||
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
|
||||
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
||||
);
|
||||
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 5: Zaehler — 30 eigene Bilder blocken das 31., 29 lassen das 30. durch', async () => {
|
||||
const thirty = Array.from({ length: 30 }, (_, i) => makeRow({ id: `i${i}` }));
|
||||
const full = makeFakePrisma(thirty);
|
||||
await expect(makeService(full).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.',
|
||||
),
|
||||
);
|
||||
expect(full.dashboardImage.create).not.toHaveBeenCalled();
|
||||
|
||||
const almost = makeFakePrisma(thirty.slice(0, 29));
|
||||
await expect(makeService(almost).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
||||
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
|
||||
const prisma = makeFakePrisma(foreign);
|
||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
const countArgs = vi.mocked(prisma.dashboardImage.count).mock.calls[0][0] as { where: unknown };
|
||||
expect(countArgs.where).toEqual({ tenantId: 'tenant-1', userId: 'user-1' });
|
||||
});
|
||||
|
||||
it('Test 7: originalName wird auf 255 Zeichen gekuerzt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const result = await makeService(prisma).upload(user, file(PNG, 'image/png', 'x'.repeat(400)));
|
||||
expect(result.originalName).toHaveLength(255);
|
||||
});
|
||||
|
||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ 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 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 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 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' }),
|
||||
]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
|
||||
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
expect(prisma.__rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
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 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');
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
|
||||
for (const call of vi.mocked(forTenant).mock.calls) {
|
||||
expect(call[0]).toBe(prisma);
|
||||
expect(call[1]).toBe('tenant-1');
|
||||
expect(call[2]).toBe('user-1');
|
||||
}
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten.
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
expect(c.userId).toBe('user-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
type DashboardImageMime,
|
||||
detectImageMime,
|
||||
} from './dashboard-image-rules';
|
||||
|
||||
/**
|
||||
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
|
||||
* (quick-260921-pi9).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
|
||||
* Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
|
||||
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
|
||||
* um wenige Bilder ueberschreiten — Restrisiko bewusst angenommen, es
|
||||
* betrifft nur den eigenen Speicher.
|
||||
*
|
||||
* Der Dienst ruft NIE eine Webadresse ab (T-PI9-05): URL-Eintraege des
|
||||
* Widgets sind fuer die API undurchsichtige Config-Werte, der Browser des
|
||||
* Benutzers laedt sie selbst.
|
||||
*/
|
||||
|
||||
const ORIGINAL_NAME_MAX = 255;
|
||||
|
||||
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */
|
||||
const META_SELECT = {
|
||||
id: true,
|
||||
originalName: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
export interface DashboardImageMeta {
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardImagesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
||||
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
return tenantPrisma.dashboardImage.findMany({
|
||||
where: { tenantId, userId },
|
||||
select: META_SELECT,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
|
||||
* begrenzt, gespeichert wird der erkannte Typ.
|
||||
*/
|
||||
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
|
||||
}
|
||||
|
||||
const mimeType: DashboardImageMime | null = detectImageMime(file.buffer);
|
||||
if (mimeType === null) {
|
||||
throw new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.');
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, user.tenantId, user.id);
|
||||
const existing = await tenantPrisma.dashboardImage.count({
|
||||
where: { tenantId: user.tenantId, userId: user.id },
|
||||
});
|
||||
if (existing >= DASHBOARD_IMAGE_MAX_COUNT) {
|
||||
throw new BadRequestException(
|
||||
`Sie haben die Höchstzahl von ${DASHBOARD_IMAGE_MAX_COUNT} Bildern erreicht. Bitte löschen Sie zuerst ein Bild.`,
|
||||
);
|
||||
}
|
||||
|
||||
return 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,
|
||||
});
|
||||
}
|
||||
|
||||
/** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */
|
||||
async getBytes(
|
||||
id: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<{ mimeType: string; data: Uint8Array }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
|
||||
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
return { mimeType: row.mimeType, data: row.data };
|
||||
}
|
||||
|
||||
/** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */
|
||||
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 } });
|
||||
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardImagesController } from './dashboard-images.controller';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
/**
|
||||
@@ -9,6 +11,8 @@ import { DashboardService } from './dashboard.service';
|
||||
* Provides:
|
||||
* - DashboardService: CRUD for per-user dashboard layouts and widget instances
|
||||
* - DashboardController: REST API for layout and widget operations
|
||||
* - DashboardImagesService/-Controller: Bilder des Bilderrahmen-Widgets
|
||||
* (quick-260921-pi9), je Benutzer in der Datenbank
|
||||
*
|
||||
* Imports ModuleRegistryModule so DashboardService can inject
|
||||
* ModuleAccessService for the D-22 widget-module filter (Plan 15-05).
|
||||
@@ -17,8 +21,8 @@ import { DashboardService } from './dashboard.service';
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
controllers: [DashboardController, DashboardImagesController],
|
||||
providers: [DashboardService, DashboardImagesService],
|
||||
exports: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
|
||||
@@ -2,12 +2,23 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for creating a new widget instance on a user's dashboard.
|
||||
* widgetType must be one of the seven supported types.
|
||||
* widgetType must be one of the nine supported types
|
||||
* ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3).
|
||||
* config is optional and defaults to {} on the model.
|
||||
*/
|
||||
export class CreateWidgetDto {
|
||||
@IsString()
|
||||
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'stopwatch'])
|
||||
@IsIn([
|
||||
'clock',
|
||||
'search',
|
||||
'calendar',
|
||||
'note',
|
||||
'calculator',
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
])
|
||||
widgetType!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -58,6 +58,8 @@ vi.mock('@/components/dashboard/widgets/note-widget', () => ({ NoteWidget: () =>
|
||||
vi.mock('@/components/dashboard/widgets/calculator-widget', () => ({ CalculatorWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureFrameWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockStore.isEditMode = false;
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 } from '@/components/dashboard/widget-registry';
|
||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wirePictureFrameWidget, wireXframeWidget } 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';
|
||||
@@ -13,6 +13,8 @@ import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
||||
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
|
||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
|
||||
@@ -24,6 +26,8 @@ wireNoteWidget(NoteWidget);
|
||||
wireCalculatorWidget(CalculatorWidget);
|
||||
wireStopwatchWidget(StopwatchWidget);
|
||||
wireFavoritesWidget(FavoritesWidget);
|
||||
wirePictureFrameWidget(PictureFrameWidget);
|
||||
wireXframeWidget(XframeWidget);
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('widgets');
|
||||
|
||||
@@ -23,6 +23,10 @@ vi.mock('next-intl', () => ({
|
||||
'favorites.description': 'Schnellzugriff auf Favoriten',
|
||||
'stopwatch.name': 'Stoppuhr',
|
||||
'stopwatch.description': 'Zeit messen',
|
||||
'pictureFrame.name': 'Bilderrahmen',
|
||||
'pictureFrame.description': 'Bilder als Diashow',
|
||||
'xframe.name': 'XFrame',
|
||||
'xframe.description': 'Webseite einbetten',
|
||||
},
|
||||
common: {
|
||||
close: 'Schließen',
|
||||
|
||||
@@ -18,6 +18,8 @@ const WIDGET_TYPES: WidgetType[] = [
|
||||
'calculator',
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,10 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
||||
'calculator',
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
// Bilderrahmen (quick-260921-pi9)
|
||||
'picture-frame',
|
||||
// XFrame (quick-260921-qd3)
|
||||
'xframe',
|
||||
];
|
||||
|
||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
@@ -51,7 +55,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
||||
});
|
||||
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt, sieben Typen)', () => {
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu, neun Typen)', () => {
|
||||
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
|
||||
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
||||
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
|
||||
@@ -64,6 +68,8 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
||||
});
|
||||
|
||||
let counted = 0;
|
||||
@@ -73,6 +79,6 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
counted++;
|
||||
}
|
||||
}
|
||||
expect(counted).toBe(28);
|
||||
expect(counted).toBe(36);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { ComponentType } from 'react';
|
||||
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere
|
||||
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
|
||||
* decken den Fall ab).
|
||||
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||
*/
|
||||
export type WidgetType =
|
||||
| 'clock'
|
||||
@@ -14,7 +16,9 @@ export type WidgetType =
|
||||
| 'note'
|
||||
| 'calculator'
|
||||
| 'favorites'
|
||||
| 'stopwatch';
|
||||
| 'stopwatch'
|
||||
| 'picture-frame'
|
||||
| 'xframe';
|
||||
|
||||
/**
|
||||
* Props contract that every widget component must accept.
|
||||
@@ -53,6 +57,16 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
// quick-260921-pi9: ein Bild braucht Flaeche — 8x12 entspricht der
|
||||
// Kalender-Vorgabe (rund 530x330 px, Querformat wie ein Foto); 8x8 war im
|
||||
// Browser-Rundgang mit 216 px Hoehe zu flach. 4x4 ist die kleinste Kachel,
|
||||
// in der Bild und Unterschrift-Streifen noch erkennbar sind.
|
||||
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
||||
// quick-260921-qd3: eine Webseite braucht Breite UND Hoehe — 12x12 = halbe
|
||||
// Rasterbreite bei rund 330 px Hoehe (8 Zeilen waeren nur 216 px, zu flach
|
||||
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein
|
||||
// Rahmen noch Sinn hat.
|
||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -248,6 +262,51 @@ function StopwatchIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PictureFrameIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XframeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<circle cx="6.5" cy="6.5" r="0.5" />
|
||||
<circle cx="9.5" cy="6.5" r="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of all widget types. Used by the widget catalog modal
|
||||
* and the grid renderer to look up components and constraints.
|
||||
@@ -309,6 +368,22 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
...WIDGET_CONSTRAINTS.stopwatch,
|
||||
component: PlaceholderWidget, // Replaced via wireStopwatchWidget()
|
||||
},
|
||||
'picture-frame': {
|
||||
type: 'picture-frame',
|
||||
nameKey: 'pictureFrame.name',
|
||||
descriptionKey: 'pictureFrame.description',
|
||||
icon: PictureFrameIcon,
|
||||
...WIDGET_CONSTRAINTS['picture-frame'],
|
||||
component: PlaceholderWidget, // Replaced via wirePictureFrameWidget()
|
||||
},
|
||||
xframe: {
|
||||
type: 'xframe',
|
||||
nameKey: 'xframe.name',
|
||||
descriptionKey: 'xframe.description',
|
||||
icon: XframeIcon,
|
||||
...WIDGET_CONSTRAINTS.xframe,
|
||||
component: PlaceholderWidget, // Replaced via wireXframeWidget()
|
||||
},
|
||||
};
|
||||
|
||||
// Wire actual widget components lazily to avoid circular deps
|
||||
@@ -370,3 +445,19 @@ export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PICTURE_FRAME_CAPTION_MAX,
|
||||
PICTURE_FRAME_INTERVAL_DEFAULT,
|
||||
PICTURE_FRAME_INTERVAL_MAX,
|
||||
PICTURE_FRAME_INTERVAL_MIN,
|
||||
PICTURE_FRAME_INTERVAL_OPTIONS,
|
||||
PICTURE_FRAME_MAX_IMAGES,
|
||||
entryKey,
|
||||
isHttpsUrl,
|
||||
pickNextIndex,
|
||||
resolvePictureFrameConfig,
|
||||
} from './picture-frame-config';
|
||||
|
||||
/**
|
||||
* picture-frame-config.test — NEU (quick-260921-pi9, Bilderrahmen).
|
||||
*
|
||||
* Reine Helfer ohne React: die Aufloesung der Widget-Konfiguration (die API
|
||||
* prueft Config-Felder nicht — allein diese Funktion entscheidet, was zum
|
||||
* `<img src>` wird, T-PI9-07), die https-Pruefung, die Wahl des naechsten
|
||||
* Bildes (Zufall nie dasselbe zweimal) und die React-Schluessel.
|
||||
*/
|
||||
describe('resolvePictureFrameConfig (quick-260921-pi9)', () => {
|
||||
it('Test 1: leere Konfiguration -> Vorgaben (keine Bilder, contain, 30 s, Reihenfolge)', () => {
|
||||
expect(resolvePictureFrameConfig({})).toEqual({
|
||||
images: [],
|
||||
fit: 'contain',
|
||||
intervalSeconds: PICTURE_FRAME_INTERVAL_DEFAULT,
|
||||
order: 'sequence',
|
||||
});
|
||||
expect(PICTURE_FRAME_INTERVAL_DEFAULT).toBe(30);
|
||||
});
|
||||
|
||||
it('Test 2: Intervall wird geklemmt — 3 -> 5, 9999 -> 3600, 0 bleibt 0, "abc" -> 30', () => {
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 3 }).intervalSeconds).toBe(PICTURE_FRAME_INTERVAL_MIN);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 9999 }).intervalSeconds).toBe(PICTURE_FRAME_INTERVAL_MAX);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 0 }).intervalSeconds).toBe(0);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 'abc' }).intervalSeconds).toBe(30);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: Number.NaN }).intervalSeconds).toBe(30);
|
||||
expect(resolvePictureFrameConfig({ intervalSeconds: 7.6 }).intervalSeconds).toBe(8);
|
||||
expect(PICTURE_FRAME_INTERVAL_MIN).toBe(5);
|
||||
expect(PICTURE_FRAME_INTERVAL_MAX).toBe(3600);
|
||||
expect(PICTURE_FRAME_INTERVAL_OPTIONS).toEqual([0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]);
|
||||
});
|
||||
|
||||
it('Test 3: unbekannte fit/order-Werte fallen auf die Vorgabe zurueck, gueltige bleiben', () => {
|
||||
expect(resolvePictureFrameConfig({ fit: 'x' }).fit).toBe('contain');
|
||||
expect(resolvePictureFrameConfig({ fit: 'cover' }).fit).toBe('cover');
|
||||
expect(resolvePictureFrameConfig({ order: 'x' }).order).toBe('sequence');
|
||||
expect(resolvePictureFrameConfig({ order: 'random' }).order).toBe('random');
|
||||
});
|
||||
|
||||
it('Test 4: Eintraege ohne gueltiges kind, Upload ohne imageId-String und Nicht-Objekte werden weggelassen', () => {
|
||||
const { images } = resolvePictureFrameConfig({
|
||||
images: [
|
||||
{ kind: 'video', url: 'https://a.de/x.mp4' },
|
||||
{ kind: 'upload' },
|
||||
{ kind: 'upload', imageId: 42 },
|
||||
'text',
|
||||
null,
|
||||
{ kind: 'upload', imageId: 'ok-1' },
|
||||
],
|
||||
});
|
||||
expect(images).toEqual([{ kind: 'upload', imageId: 'ok-1' }]);
|
||||
expect(resolvePictureFrameConfig({ images: 'nicht-array' }).images).toEqual([]);
|
||||
});
|
||||
|
||||
it('Test 5: URL-Eintraege mit http://, javascript:, data: oder ohne Parser-Erfolg werden weggelassen (T-PI9-07)', () => {
|
||||
const { images } = resolvePictureFrameConfig({
|
||||
images: [
|
||||
{ kind: 'url', url: 'http://a.de/b.jpg' },
|
||||
{ kind: 'url', url: 'javascript:alert(1)' },
|
||||
{ kind: 'url', url: 'data:image/png;base64,AAAA' },
|
||||
{ kind: 'url', url: 'nicht-url' },
|
||||
{ kind: 'url', url: 'file:///etc/passwd' },
|
||||
{ kind: 'url', url: 42 },
|
||||
{ kind: 'url', url: 'https://a.de/b.jpg' },
|
||||
],
|
||||
});
|
||||
expect(images).toEqual([{ kind: 'url', url: 'https://a.de/b.jpg' }]);
|
||||
});
|
||||
|
||||
it('Test 6: beide Varianten in EINER Liste behalten ihre Reihenfolge (Invariante der Vereinigung)', () => {
|
||||
const { images } = resolvePictureFrameConfig({
|
||||
images: [
|
||||
{ kind: 'url', url: 'https://a.de/1.jpg' },
|
||||
{ kind: 'upload', imageId: 'u-2' },
|
||||
{ kind: 'url', url: 'https://a.de/3.jpg' },
|
||||
],
|
||||
});
|
||||
expect(images.map((e) => (e.kind === 'url' ? e.url : e.imageId))).toEqual([
|
||||
'https://a.de/1.jpg',
|
||||
'u-2',
|
||||
'https://a.de/3.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Test 7: caption nur als String, auf 200 Zeichen gekuerzt; leere Unterschrift wird weggelassen', () => {
|
||||
const { images } = resolvePictureFrameConfig({
|
||||
images: [
|
||||
{ kind: 'upload', imageId: 'a', caption: 'x'.repeat(300) },
|
||||
{ kind: 'upload', imageId: 'b', caption: 12 },
|
||||
{ kind: 'upload', imageId: 'c', caption: '' },
|
||||
{ kind: 'url', url: 'https://a.de/1.jpg', caption: 'Urlaub' },
|
||||
],
|
||||
});
|
||||
expect(images[0]).toEqual({ kind: 'upload', imageId: 'a', caption: 'x'.repeat(PICTURE_FRAME_CAPTION_MAX) });
|
||||
expect(images[1]).toEqual({ kind: 'upload', imageId: 'b' });
|
||||
expect(images[2]).toEqual({ kind: 'upload', imageId: 'c' });
|
||||
expect(images[3]).toEqual({ kind: 'url', url: 'https://a.de/1.jpg', caption: 'Urlaub' });
|
||||
expect(PICTURE_FRAME_CAPTION_MAX).toBe(200);
|
||||
expect(PICTURE_FRAME_MAX_IMAGES).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHttpsUrl (quick-260921-pi9)', () => {
|
||||
it('Test 8: https ja (auch grossgeschrieben), http/ftp/javascript/kein-URL nein', () => {
|
||||
expect(isHttpsUrl('https://a.de/b.jpg')).toBe(true);
|
||||
expect(isHttpsUrl('HTTPS://a.de/b.jpg')).toBe(true);
|
||||
expect(isHttpsUrl('http://a.de/b.jpg')).toBe(false);
|
||||
expect(isHttpsUrl('ftp://a.de/b.jpg')).toBe(false);
|
||||
expect(isHttpsUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isHttpsUrl('nicht-url')).toBe(false);
|
||||
expect(isHttpsUrl('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickNextIndex (quick-260921-pi9)', () => {
|
||||
it('Test 9: count <= 1 -> 0; sequence -> (current + 1) % count', () => {
|
||||
expect(pickNextIndex(0, 0, 'sequence', Math.random)).toBe(0);
|
||||
expect(pickNextIndex(5, 1, 'random', Math.random)).toBe(0);
|
||||
expect(pickNextIndex(0, 3, 'sequence', Math.random)).toBe(1);
|
||||
expect(pickNextIndex(2, 3, 'sequence', Math.random)).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 10: random liefert bei count = 3 ueber 200 Ziehungen nie den aktuellen Index, auch mit gestelltem Zufall', () => {
|
||||
let seed = 1;
|
||||
const random = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return (seed - 1) / 2147483646;
|
||||
};
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const current = i % 3;
|
||||
const next = pickNextIndex(current, 3, 'random', random);
|
||||
expect(next).not.toBe(current);
|
||||
expect(next).toBeGreaterThanOrEqual(0);
|
||||
expect(next).toBeLessThan(3);
|
||||
}
|
||||
// Randfaelle des Zufallsgenerators: 0 und knapp unter 1
|
||||
expect(pickNextIndex(0, 3, 'random', () => 0)).not.toBe(0);
|
||||
expect(pickNextIndex(2, 3, 'random', () => 0.999999)).not.toBe(2);
|
||||
expect(pickNextIndex(1, 2, 'random', () => 0.5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entryKey (quick-260921-pi9)', () => {
|
||||
it('Test 11: stabiler Schluessel je Variante', () => {
|
||||
expect(entryKey({ kind: 'upload', imageId: 'u-1' }, 3)).toBe('upload:u-1');
|
||||
expect(entryKey({ kind: 'url', url: 'https://a.de/1.jpg' }, 3)).toBe('url:https://a.de/1.jpg:3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* picture-frame-config — Typen, Grenzen und reine Helfer des Bilderrahmen-
|
||||
* Widgets (quick-260921-pi9). Ohne React-Import, damit der Test schlank
|
||||
* bleibt (Muster clock-font-size.ts, calendar-month.ts).
|
||||
*
|
||||
* Die Bildquelle ist EINE geordnete Liste `images` mit zwei Varianten
|
||||
* (`kind: 'upload'` mit Kennung eines hochgeladenen Bildes, `kind: 'url'`
|
||||
* mit https-Webadresse) — keine parallelen Listen, damit Reihenfolge,
|
||||
* Unterschrift und Wechsel fuer beide dieselbe Logik durchlaufen.
|
||||
*
|
||||
* T-PI9-07: Die API prueft Widget-Konfigurationen nicht inhaltlich
|
||||
* (`@IsObject()`, flache Zusammenfuehrung). Deshalb entscheidet ALLEIN
|
||||
* `resolvePictureFrameConfig`, was zum `<img src>` wird: nur eine Adresse,
|
||||
* die der echte `URL`-Parser als `https:` erkennt. `http:`, `data:`,
|
||||
* `javascript:`, `file:` und Unparsbares fallen still weg — ein von Hand
|
||||
* manipulierter Config-Wert wird auf dem eigenen Dashboard nicht einmal
|
||||
* gerendert. Das Formular prueft mit derselben Funktion, bevor es speichert.
|
||||
*/
|
||||
|
||||
export const PICTURE_FRAME_INTERVAL_MIN = 5;
|
||||
export const PICTURE_FRAME_INTERVAL_MAX = 3600;
|
||||
export const PICTURE_FRAME_INTERVAL_DEFAULT = 30;
|
||||
/** Auswahlwerte des Formulars in Sekunden; 0 = kein Wechsel. */
|
||||
export const PICTURE_FRAME_INTERVAL_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600];
|
||||
/** Gleich der Servergrenze DASHBOARD_IMAGE_MAX_COUNT. */
|
||||
export const PICTURE_FRAME_MAX_IMAGES = 30;
|
||||
export const PICTURE_FRAME_CAPTION_MAX = 200;
|
||||
|
||||
export type PictureFrameEntry =
|
||||
| { kind: 'upload'; imageId: string; caption?: string }
|
||||
| { kind: 'url'; url: string; caption?: string };
|
||||
|
||||
export type PictureFrameFit = 'contain' | 'cover';
|
||||
export type PictureFrameOrder = 'sequence' | 'random';
|
||||
|
||||
export interface PictureFrameConfig {
|
||||
images: PictureFrameEntry[];
|
||||
fit: PictureFrameFit;
|
||||
/** 0 = kein Wechsel, sonst MIN..MAX. */
|
||||
intervalSeconds: number;
|
||||
order: PictureFrameOrder;
|
||||
}
|
||||
|
||||
/** Echter URL-Parser, Protokoll kleingeschrieben verglichen. */
|
||||
export function isHttpsUrl(value: string): boolean {
|
||||
try {
|
||||
return new URL(value).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCaption(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string' || raw === '') return undefined;
|
||||
return raw.slice(0, PICTURE_FRAME_CAPTION_MAX);
|
||||
}
|
||||
|
||||
function resolveEntry(raw: unknown): PictureFrameEntry | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const caption = resolveCaption(record.caption);
|
||||
if (record.kind === 'upload') {
|
||||
if (typeof record.imageId !== 'string' || record.imageId === '') return null;
|
||||
return caption === undefined
|
||||
? { kind: 'upload', imageId: record.imageId }
|
||||
: { kind: 'upload', imageId: record.imageId, caption };
|
||||
}
|
||||
if (record.kind === 'url') {
|
||||
if (typeof record.url !== 'string' || !isHttpsUrl(record.url)) return null;
|
||||
return caption === undefined
|
||||
? { kind: 'url', url: record.url }
|
||||
: { kind: 'url', url: record.url, caption };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveInterval(raw: unknown): number {
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return PICTURE_FRAME_INTERVAL_DEFAULT;
|
||||
if (raw <= 0) return 0;
|
||||
const rounded = Math.round(raw);
|
||||
return Math.min(PICTURE_FRAME_INTERVAL_MAX, Math.max(PICTURE_FRAME_INTERVAL_MIN, rounded));
|
||||
}
|
||||
|
||||
export function resolvePictureFrameConfig(config: Record<string, unknown>): PictureFrameConfig {
|
||||
const rawImages = Array.isArray(config.images) ? config.images : [];
|
||||
const images: PictureFrameEntry[] = [];
|
||||
for (const raw of rawImages) {
|
||||
const entry = resolveEntry(raw);
|
||||
if (entry) images.push(entry);
|
||||
}
|
||||
return {
|
||||
images,
|
||||
fit: config.fit === 'cover' ? 'cover' : 'contain',
|
||||
intervalSeconds: resolveInterval(config.intervalSeconds),
|
||||
order: config.order === 'random' ? 'random' : 'sequence',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Naechster Index: Reihenfolge zyklisch; Zufall waehlt bei mehr als einem
|
||||
* Bild nie das aktuelle erneut (Ziehung aus count-1 Kandidaten, Verschiebung
|
||||
* ueber das aktuelle hinweg — kein Wiederholen bis es passt).
|
||||
*/
|
||||
export function pickNextIndex(
|
||||
current: number,
|
||||
count: number,
|
||||
order: PictureFrameOrder,
|
||||
random: () => number,
|
||||
): number {
|
||||
if (count <= 1) return 0;
|
||||
if (order === 'sequence') return (current + 1) % count;
|
||||
const draw = Math.min(count - 2, Math.max(0, Math.floor(random() * (count - 1))));
|
||||
return draw >= current ? draw + 1 : draw;
|
||||
}
|
||||
|
||||
/** Stabiler React-Schluessel je Eintrag. */
|
||||
export function entryKey(entry: PictureFrameEntry, index: number): string {
|
||||
return entry.kind === 'upload' ? `upload:${entry.imageId}` : `url:${entry.url}:${index}`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface PictureFrameLightboxProps {
|
||||
src: string;
|
||||
caption?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grossansicht des Bilderrahmen-Widgets (quick-260921-pi9).
|
||||
*
|
||||
* Aufbau wie widget-catalog-modal.tsx: Hintergrund als echte, benannte
|
||||
* Schaltflaeche (schliesst), Dialog `role="dialog" aria-modal` mit
|
||||
* `tabIndex={-1}` und Fokus beim Einhaengen, Escape-Listener mit Aufraeumung,
|
||||
* Schliessen-Knopf oben rechts. Die Fokus-RUECKGABE an den Bild-Knopf macht
|
||||
* das Widget selbst (es kennt den Ausloeser), nicht diese Komponente.
|
||||
*
|
||||
* `referrerPolicy="no-referrer"` auch hier (T-PI9-09): ein Fremdhost erfaehrt
|
||||
* die Tessera-Adresse nicht. Die Unterschrift ist ein React-Textknoten,
|
||||
* kein HTML (T-PI9-06).
|
||||
*
|
||||
* Gerendert per `createPortal` in `document.body` (wie der Kalender-Tooltip):
|
||||
* die Kachel liegt in einem `react-grid-item` mit CSS-`transform`, und ein
|
||||
* transformierter Vorfahr macht sich fuer `position: fixed` zum Bezugsrahmen —
|
||||
* ohne Portal waere die Grossansicht auf die Kachelflaeche beschraenkt
|
||||
* (im Browser-Rundgang am 21.09.2026 so gesehen: Dialog 531x216 statt Viewport).
|
||||
*/
|
||||
export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLightboxProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const tCommon = useTranslations('common');
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
dialogRef.current?.focus();
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('pictureFrame.close')}
|
||||
data-testid="picture-frame-lightbox-backdrop"
|
||||
className="fixed inset-0 bg-black/80"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={caption ?? t('pictureFrame.open')}
|
||||
className="relative z-50 flex max-h-[95vh] max-w-[95vw] flex-col items-center gap-2 p-4"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
data-testid="picture-frame-lightbox-close"
|
||||
className="absolute -right-1 -top-1 z-10 rounded-full bg-black/60 p-1.5 text-white transition-colors hover:bg-black/80"
|
||||
aria-label={tCommon('close')}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={src}
|
||||
alt={caption ?? ''}
|
||||
referrerPolicy="no-referrer"
|
||||
data-testid="picture-frame-lightbox-image"
|
||||
className="max-h-[85vh] max-w-[90vw] rounded object-contain shadow-2xl"
|
||||
/>
|
||||
{caption && (
|
||||
<p className="max-w-[90vw] truncate text-center text-sm text-white" data-testid="picture-frame-lightbox-caption">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// next-intl — Durchreiche t(key) => key (Muster stopwatch-widget.test.tsx)
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import { PictureFrameWidget } from './picture-frame-widget';
|
||||
|
||||
/**
|
||||
* picture-frame-widget.test — NEU (quick-260921-pi9).
|
||||
*
|
||||
* Kachel: Leerzustand, <img> je Variante (URL direkt mit no-referrer,
|
||||
* Upload ueber den Proxy-Pfad), Ausschnitt-Klassen, Unterschrift-Streifen,
|
||||
* Wechsel per Timer inkl. Raeumung beim Aushaengen, kein Wechsel bei 0,
|
||||
* kaputte Bilder verlassen den Umlauf, Grossansicht (Escape / Hintergrund /
|
||||
* Schliessen-Knopf, Fokus-Rueckgabe, Pause des Wechsels), kein Knopf im
|
||||
* Bearbeitungsmodus.
|
||||
*/
|
||||
const URL_A = 'https://bilder.example/a.jpg';
|
||||
const URL_B = 'https://bilder.example/b.jpg';
|
||||
|
||||
function renderWidget(config: Record<string, unknown>, isEditMode = false) {
|
||||
return render(<PictureFrameWidget instanceId="pf-1" config={config} isEditMode={isEditMode} />);
|
||||
}
|
||||
|
||||
function currentImg(): HTMLImageElement {
|
||||
return screen.getByTestId('picture-frame-image') as HTMLImageElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('PictureFrameWidget (quick-260921-pi9)', () => {
|
||||
it('Test 1: leere Konfiguration -> Leerhinweis, kein Bild', () => {
|
||||
renderWidget({});
|
||||
expect(screen.getByText('pictureFrame.empty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('picture-frame-image')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 2: URL-Eintrag -> <img> mit der Adresse, referrerPolicy no-referrer, object-contain, alt = Unterschrift', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A, caption: 'Urlaub' }] });
|
||||
const img = currentImg();
|
||||
expect(img.getAttribute('src')).toBe(URL_A);
|
||||
expect(img.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(img.className).toContain('object-contain');
|
||||
expect(img.getAttribute('alt')).toBe('Urlaub');
|
||||
expect(screen.getByTestId('picture-frame-caption')).toHaveTextContent('Urlaub');
|
||||
});
|
||||
|
||||
it('Test 3: fit cover -> object-cover; ohne Unterschrift alt="" und kein Streifen', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A }], fit: 'cover' });
|
||||
const img = currentImg();
|
||||
expect(img.className).toContain('object-cover');
|
||||
expect(img.getAttribute('alt')).toBe('');
|
||||
expect(screen.queryByTestId('picture-frame-caption')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 4: Upload-Eintrag -> src ueber den Proxy-Pfad /api-proxy/dashboard/images/<id>', () => {
|
||||
renderWidget({ images: [{ kind: 'upload', imageId: 'img 1' }] });
|
||||
expect(currentImg().getAttribute('src')).toBe('/api-proxy/dashboard/images/img%201');
|
||||
});
|
||||
|
||||
it('Test 5: Intervall 5 s mit zwei Bildern -> nach 5000 ms das zweite, nach 10000 ms wieder das erste; unmount raeumt den Timer', () => {
|
||||
const { unmount } = renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
],
|
||||
intervalSeconds: 5,
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_B);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 6: intervalSeconds 0 -> nie ein Wechsel, kein Timer; ein einzelnes Bild -> ebenfalls kein Timer', () => {
|
||||
renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
],
|
||||
intervalSeconds: 0,
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
cleanup();
|
||||
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A }], intervalSeconds: 5 });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 7: Zufall mit drei Bildern -> ueber 30 Wechsel nie dasselbe Bild zweimal hintereinander', () => {
|
||||
renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
{ kind: 'url', url: 'https://bilder.example/c.jpg' },
|
||||
],
|
||||
intervalSeconds: 5,
|
||||
order: 'random',
|
||||
});
|
||||
let previous = currentImg().getAttribute('src');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
const next = currentImg().getAttribute('src');
|
||||
expect(next).not.toBe(previous);
|
||||
previous = next;
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 8: onError nimmt den Eintrag aus dem Umlauf — bei zwei Eintraegen bleibt nur der andere; sind alle kaputt, steht pictureFrame.unavailable', () => {
|
||||
renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
],
|
||||
intervalSeconds: 5,
|
||||
});
|
||||
fireEvent.error(currentImg());
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_B);
|
||||
// nur noch ein Bild: kein Wechsel mehr
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_B);
|
||||
|
||||
fireEvent.error(currentImg());
|
||||
expect(screen.queryByTestId('picture-frame-image')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('pictureFrame.unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 9: Klick auf das Bild oeffnet die Grossansicht (role=dialog, grosses Bild, Unterschrift); Escape schliesst, Fokus zurueck am Bild-Knopf', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A, caption: 'Urlaub' }] });
|
||||
const openButton = screen.getByRole('button', { name: 'pictureFrame.open' });
|
||||
fireEvent.click(openButton);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(dialog);
|
||||
expect(screen.getByTestId('picture-frame-lightbox-image').getAttribute('src')).toBe(URL_A);
|
||||
expect(screen.getByTestId('picture-frame-lightbox-image').getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(screen.getByTestId('picture-frame-lightbox-caption')).toHaveTextContent('Urlaub');
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(openButton);
|
||||
});
|
||||
|
||||
it('Test 10: Hintergrund-Knopf und Schliessen-Knopf schliessen die Grossansicht', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A }] });
|
||||
const openButton = screen.getByRole('button', { name: 'pictureFrame.open' });
|
||||
|
||||
fireEvent.click(openButton);
|
||||
fireEvent.click(screen.getByTestId('picture-frame-lightbox-backdrop'));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(openButton);
|
||||
fireEvent.click(screen.getByTestId('picture-frame-lightbox-close'));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(openButton);
|
||||
});
|
||||
|
||||
it('Test 11: bei offener Grossansicht pausiert der Wechsel; nach dem Schliessen laeuft er weiter', () => {
|
||||
renderWidget({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'url', url: URL_B },
|
||||
],
|
||||
intervalSeconds: 5,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pictureFrame.open' }));
|
||||
// Kein Timer-Zaehler hier: nach einer Interaktion haelt React selbst
|
||||
// kurz einen Scheduler-Timer (gemessen: 1 nach dem Oeffnen, obwohl das
|
||||
// Intervall geraeumt ist). Belegt wird das Verhalten — vier Intervalle
|
||||
// vergehen, das Bild bleibt.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(20_000);
|
||||
});
|
||||
expect(screen.getByTestId('picture-frame-lightbox-image').getAttribute('src')).toBe(URL_A);
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_B);
|
||||
});
|
||||
|
||||
it('Test 12: Bearbeitungsmodus — kein Knopf im Widget, Klick auf das Bild oeffnet nichts', () => {
|
||||
renderWidget({ images: [{ kind: 'url', url: URL_A, caption: 'Urlaub' }] }, true);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
fireEvent.click(currentImg());
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
// Bild und Streifen werden trotzdem gezeigt
|
||||
expect(currentImg().getAttribute('src')).toBe(URL_A);
|
||||
expect(screen.getByTestId('picture-frame-caption')).toHaveTextContent('Urlaub');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
import { dashboardImageSrc } from '@/lib/dashboard-images-api';
|
||||
import {
|
||||
type PictureFrameEntry,
|
||||
entryKey,
|
||||
pickNextIndex,
|
||||
resolvePictureFrameConfig,
|
||||
} from './picture-frame-config';
|
||||
import { PictureFrameLightbox } from './picture-frame-lightbox';
|
||||
|
||||
/**
|
||||
* PictureFrameWidget — Diashow aus hochgeladenen Bildern und https-Adressen
|
||||
* (quick-260921-pi9).
|
||||
*
|
||||
* Woher die Bytes kommen: hochgeladene Bilder ueber den Next-Rewrite
|
||||
* `/api-proxy/dashboard/images/:id` (Cookie laeuft mit, eigener Origin);
|
||||
* Webadressen laedt AUSSCHLIESSLICH der Browser des Benutzers direkt per
|
||||
* `<img>` — der Server ruft nie eine Adresse ab (T-PI9-05: keine
|
||||
* SSRF-Flaeche, kein Proxy-Endpunkt, der eine URL entgegennimmt; interne
|
||||
* Hosts sieht damit nur, wer sie ohnehin erreicht). `referrerPolicy=
|
||||
* "no-referrer"` an jedem Bild, damit der Fremdhost die Tessera-Adresse
|
||||
* nicht erfaehrt (T-PI9-09).
|
||||
*
|
||||
* Was gerendert wird, entscheidet `resolvePictureFrameConfig` (T-PI9-07):
|
||||
* nur https-Adressen, nur Upload-Eintraege mit Kennung. Ein Bild, das nicht
|
||||
* laedt (`onError`), verlaesst den Umlauf; sind alle kaputt, steht
|
||||
* „Bild nicht verfuegbar“ (Entscheidung 7: leerer Zustand als zentrierter
|
||||
* grauer Text wie die anderen Widgets).
|
||||
*
|
||||
* Wechsel per Timer nur bei > 1 sichtbarem Bild, Intervall > 0 und
|
||||
* geschlossener Grossansicht; Zufall waehlt nie das aktuelle erneut
|
||||
* (`pickNextIndex`); der Timer wird beim Aushaengen und bei jeder Aenderung
|
||||
* der Eingaben geraeumt (Entscheidung 6).
|
||||
*
|
||||
* Klick auf das Bild NUR ausserhalb des Bearbeitungsmodus (Entscheidung 5):
|
||||
* dort liegt Bild + Streifen in einem `<button>` (Grossansicht, Fokus kehrt
|
||||
* nach dem Schliessen zum Knopf zurueck). Im Bearbeitungsmodus ist es ein
|
||||
* `<div>` ohne Handler — die ganze Karte bleibt der Ziehgriff
|
||||
* (widget-wrapper.tsx), ein Knopf wuerde ueber den cancel-Selektor des
|
||||
* Rasters das Ziehen an dieser Stelle verhindern.
|
||||
*/
|
||||
export function PictureFrameWidget({ config, isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const resolved = useMemo(() => resolvePictureFrameConfig(config), [config]);
|
||||
const { images, fit, intervalSeconds, order } = resolved;
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [brokenKeys, setBrokenKeys] = useState<string[]>([]);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const openButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
images
|
||||
.map((entry, i) => ({ entry, key: entryKey(entry, i) }))
|
||||
.filter(({ key }) => !brokenKeys.includes(key)),
|
||||
[images, brokenKeys],
|
||||
);
|
||||
const count = visible.length;
|
||||
const safeIndex = count === 0 ? 0 : index % count;
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalSeconds <= 0 || count <= 1 || lightboxOpen) return;
|
||||
const timer = setInterval(() => {
|
||||
setIndex((current) => pickNextIndex(current % count, count, order, Math.random));
|
||||
}, intervalSeconds * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [intervalSeconds, count, order, lightboxOpen]);
|
||||
|
||||
const markBroken = useCallback((key: string) => {
|
||||
setBrokenKeys((prev) => (prev.includes(key) ? prev : [...prev, key]));
|
||||
}, []);
|
||||
|
||||
const closeLightbox = useCallback(() => {
|
||||
setLightboxOpen(false);
|
||||
// Fokus-Rueckgabe an den Ausloeser (Muster: Dialog fokussiert beim
|
||||
// Oeffnen, der Ausloeser bekommt ihn nach dem Schliessen zurueck).
|
||||
openButtonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||
{t('pictureFrame.empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||
{t('pictureFrame.unavailable')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = visible[safeIndex];
|
||||
const src = entrySrc(current.entry);
|
||||
const caption = current.entry.caption;
|
||||
const imgClassName = fit === 'cover' ? 'h-full w-full object-cover' : 'h-full w-full object-contain';
|
||||
|
||||
const picture = (
|
||||
<>
|
||||
<img
|
||||
key={current.key}
|
||||
src={src}
|
||||
alt={caption ?? ''}
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
data-testid="picture-frame-image"
|
||||
className={imgClassName}
|
||||
onError={() => markBroken(current.key)}
|
||||
/>
|
||||
{caption && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-left text-xs text-white"
|
||||
data-testid="picture-frame-caption"
|
||||
>
|
||||
{caption}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{isEditMode ? (
|
||||
<div className="relative h-full w-full">{picture}</div>
|
||||
) : (
|
||||
<button
|
||||
ref={openButtonRef}
|
||||
type="button"
|
||||
aria-label={t('pictureFrame.open')}
|
||||
className="relative block h-full w-full cursor-zoom-in"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
>
|
||||
{picture}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{lightboxOpen && !isEditMode && (
|
||||
<PictureFrameLightbox src={src} caption={caption} onClose={closeLightbox} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function entrySrc(entry: PictureFrameEntry): string {
|
||||
return entry.kind === 'upload' ? dashboardImageSrc(entry.imageId) : entry.url;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
XFRAME_RELOAD_MAX,
|
||||
XFRAME_RELOAD_MIN,
|
||||
XFRAME_RELOAD_OPTIONS,
|
||||
XFRAME_SANDBOX,
|
||||
XFRAME_TITLE_MAX,
|
||||
isHttpsUrl,
|
||||
resolveXframeConfig,
|
||||
} from './xframe-config';
|
||||
|
||||
/**
|
||||
* xframe-config.test — NEU (quick-260921-qd3, XFrame).
|
||||
*
|
||||
* Reiner Resolver ohne React: die API prueft Config-Felder nicht — allein
|
||||
* diese Funktion entscheidet, was zum `<iframe src>` wird (T-QD3-03). Dazu
|
||||
* die Sandbox-Konstante (T-QD3-01: keine Top-Navigation, keine Modals) und
|
||||
* die Klemmung des Neulade-Intervalls (T-QD3-08).
|
||||
*/
|
||||
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 });
|
||||
});
|
||||
|
||||
it('Test 2: https-Adresse bleibt (getrimmt)', () => {
|
||||
expect(resolveXframeConfig({ url: 'https://intern.example/board' }).url).toBe('https://intern.example/board');
|
||||
expect(resolveXframeConfig({ url: ' https://a.de/x ' }).url).toBe('https://a.de/x');
|
||||
});
|
||||
|
||||
it('Test 3: http, javascript, data, ftp, Unparsbares, Zahl und leer -> null', () => {
|
||||
for (const url of ['http://a.de', 'javascript:alert(1)', 'data:text/html,x', 'ftp://a.de', 'kein-url', 42, '']) {
|
||||
expect(resolveXframeConfig({ url }).url, `url=${String(url)}`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 4: Titel getrimmt, auf 100 Zeichen gekuerzt, Nicht-String -> leer', () => {
|
||||
expect(resolveXframeConfig({ title: ' Board ' }).title).toBe('Board');
|
||||
expect(resolveXframeConfig({ title: 'x'.repeat(150) }).title).toHaveLength(XFRAME_TITLE_MAX);
|
||||
expect(XFRAME_TITLE_MAX).toBe(100);
|
||||
expect(resolveXframeConfig({ title: 7 }).title).toBe('');
|
||||
});
|
||||
|
||||
it('Test 5: reloadSeconds fehlt / "abc" / -5 / 30 -> 0 (unter der kleinsten Stufe heisst nie)', () => {
|
||||
expect(resolveXframeConfig({}).reloadSeconds).toBe(0);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 'abc' }).reloadSeconds).toBe(0);
|
||||
expect(resolveXframeConfig({ reloadSeconds: -5 }).reloadSeconds).toBe(0);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 30 }).reloadSeconds).toBe(0);
|
||||
expect(resolveXframeConfig({ reloadSeconds: Number.NaN }).reloadSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 6: reloadSeconds 60 -> 60, 90 -> 60, 599 -> 300, 600 -> 600 (groesste Stufe <= n)', () => {
|
||||
expect(resolveXframeConfig({ reloadSeconds: 60 }).reloadSeconds).toBe(60);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 90 }).reloadSeconds).toBe(60);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 599 }).reloadSeconds).toBe(300);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 600 }).reloadSeconds).toBe(600);
|
||||
});
|
||||
|
||||
it('Test 7: reloadSeconds 99999 -> 3600 (Obergrenze)', () => {
|
||||
expect(resolveXframeConfig({ reloadSeconds: 99999 }).reloadSeconds).toBe(XFRAME_RELOAD_MAX);
|
||||
expect(XFRAME_RELOAD_MAX).toBe(3600);
|
||||
expect(XFRAME_RELOAD_MIN).toBe(60);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('XFRAME_SANDBOX / XFRAME_RELOAD_OPTIONS / isHttpsUrl (quick-260921-qd3)', () => {
|
||||
it('Test 9: Sandbox enthaelt scripts, same-origin, forms, popups, popups-to-escape-sandbox', () => {
|
||||
const tokens = XFRAME_SANDBOX.split(' ');
|
||||
for (const token of [
|
||||
'allow-scripts',
|
||||
'allow-same-origin',
|
||||
'allow-forms',
|
||||
'allow-popups',
|
||||
'allow-popups-to-escape-sandbox',
|
||||
]) {
|
||||
expect(tokens).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 10: Sandbox enthaelt KEIN top-navigation und KEIN modals (T-QD3-01)', () => {
|
||||
expect(XFRAME_SANDBOX).not.toMatch(/top-navigation/);
|
||||
expect(XFRAME_SANDBOX).not.toMatch(/modals/);
|
||||
});
|
||||
|
||||
it('Test 11: Auswahlstufen des Neuladens sind 0, 60, 300, 600, 1800, 3600', () => {
|
||||
expect(XFRAME_RELOAD_OPTIONS).toEqual([0, 60, 300, 600, 1800, 3600]);
|
||||
});
|
||||
|
||||
it('Test 12: isHttpsUrl ist re-exportiert — HTTPS://A.DE true, http://a.de false', () => {
|
||||
expect(isHttpsUrl('HTTPS://A.DE')).toBe(true);
|
||||
expect(isHttpsUrl('http://a.de')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* xframe-config — Typen, Grenzen und reiner Resolver des XFrame-Widgets
|
||||
* (quick-260921-qd3). Ohne React-Import, damit der Test schlank bleibt
|
||||
* (Muster picture-frame-config.ts).
|
||||
*
|
||||
* T-QD3-03: Die API prueft Widget-Konfigurationen nicht inhaltlich
|
||||
* (`@IsObject()`, flache Zusammenfuehrung). Deshalb liegt die https-Pruefung
|
||||
* ALLEIN hier und im Formular: das Formular weist eine Nicht-https-Adresse
|
||||
* mit Meldung ab und speichert sie nicht; `resolveXframeConfig` laesst beim
|
||||
* Rendern jede Adresse auf `null` fallen, die der echte `URL`-Parser nicht
|
||||
* als `https:` erkennt (`http:`, `javascript:`, `data:`, `ftp:`, Unparsbares)
|
||||
* — ein von Hand manipulierter Config-Wert wird auf dem eigenen Dashboard
|
||||
* nicht einmal als `<iframe>` gerendert.
|
||||
*
|
||||
* T-QD3-01, Sandbox: `allow-same-origin` bleibt bewusst drin — ohne dieses
|
||||
* Token laeuft die Fremdseite in einem opaken Origin, ihre Cookies, ihr
|
||||
* `localStorage` und ihre Same-Origin-Aufrufe brechen, die meisten Seiten
|
||||
* sind dann unbenutzbar. Der Origin ist der der FREMDSEITE, nicht Tesseras;
|
||||
* die Sandbox hat hier allein die Aufgabe, die Navigation des obersten
|
||||
* Fensters und Modaldialoge zu unterbinden. Bewusst FEHLEN deshalb
|
||||
* `allow-top-navigation`, `allow-top-navigation-by-user-activation` (die
|
||||
* eingebettete Seite kann den Tessera-Tab nicht umlenken) und `allow-modals`
|
||||
* (keine `alert`/`confirm`/`prompt`, die als Tessera-Dialoge missdeutet
|
||||
* werden koennten). `allow-popups-to-escape-sandbox` betrifft nur NEUE
|
||||
* Fenster (z. B. Anmelde-Popups der Fremdseite), nie den Tessera-Tab.
|
||||
*/
|
||||
|
||||
import { isHttpsUrl } from './picture-frame-config';
|
||||
|
||||
/** Auswahlstufen des Formulars in Sekunden; 0 = nie. */
|
||||
export const XFRAME_RELOAD_OPTIONS: number[] = [0, 60, 300, 600, 1800, 3600];
|
||||
/** Unter der kleinsten Stufe heisst „nie“ — schuetzt die Fremdseite vor Sekundentakt (T-QD3-08). */
|
||||
export const XFRAME_RELOAD_MIN = 60;
|
||||
export const XFRAME_RELOAD_MAX = 3600;
|
||||
export const XFRAME_TITLE_MAX = 100;
|
||||
/** Exakter Sandbox-Wert des <iframe> — Begruendung im Dateikopf. */
|
||||
export const XFRAME_SANDBOX =
|
||||
'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
|
||||
|
||||
// Bewusst geteilt mit dem Bilderrahmen: EINE Regel fuer „https-Adresse“ im
|
||||
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
|
||||
export { isHttpsUrl };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function resolveUrl(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim();
|
||||
return isHttpsUrl(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveTitle(raw: unknown): string {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.trim().slice(0, XFRAME_TITLE_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groesste Auswahlstufe <= n, damit das <select> im Formular immer eine
|
||||
* passende Option zeigt; unter der kleinsten Stufe -> 0 (nie).
|
||||
*/
|
||||
function resolveReload(raw: unknown): number {
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return 0;
|
||||
if (raw < XFRAME_RELOAD_MIN) return 0;
|
||||
if (raw >= XFRAME_RELOAD_MAX) return XFRAME_RELOAD_MAX;
|
||||
let best = 0;
|
||||
for (const option of XFRAME_RELOAD_OPTIONS) {
|
||||
if (option <= raw && option > best) best = option;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
|
||||
return {
|
||||
url: resolveUrl(config.url),
|
||||
title: resolveTitle(config.title),
|
||||
reloadSeconds: resolveReload(config.reloadSeconds),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// next-intl — Durchreiche t(key) => key (Muster picture-frame-widget.test.tsx)
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import { XFRAME_SANDBOX } from './xframe-config';
|
||||
import { XframeWidget } from './xframe-widget';
|
||||
|
||||
/**
|
||||
* xframe-widget.test — NEU (quick-260921-qd3).
|
||||
*
|
||||
* Kachel: Leerzustand (auch bei http), genau ein <iframe> mit exakter
|
||||
* Sandbox (T-QD3-01), no-referrer (T-QD3-05), lazy, leerem allow
|
||||
* (T-QD3-06); Link „In neuem Tab öffnen“ als Ecksymbol oder in der
|
||||
* Kopfleiste; Titel wird escaped (T-QD3-09); Neuladen per Timer haengt den
|
||||
* Rahmen neu ein und raeumt beim Aushaengen (T-QD3-08); Bearbeitungs-Overlay
|
||||
* nach dem Rahmen im DOM (T-QD3-07).
|
||||
*
|
||||
* jsdom laedt keine Unterressourcen — ein <iframe src="https://…"> erzeugt
|
||||
* hier keinen Netzabruf.
|
||||
*/
|
||||
const URL_A = 'https://intern.example/board';
|
||||
|
||||
function renderWidget(config: Record<string, unknown>, isEditMode = false) {
|
||||
return render(<XframeWidget instanceId="xf-1" config={config} isEditMode={isEditMode} />);
|
||||
}
|
||||
|
||||
function frame(): HTMLIFrameElement {
|
||||
return screen.getByTestId('xframe-frame') as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('XframeWidget (quick-260921-qd3)', () => {
|
||||
it('Test 1: leere Konfiguration -> Leerhinweis, kein <iframe>, kein Link', () => {
|
||||
renderWidget({});
|
||||
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 2: http-Adresse -> ebenfalls Leerzustand (Resolver weist ab)', () => {
|
||||
renderWidget({ url: 'http://a.de' });
|
||||
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 3: https-Adresse -> genau ein <iframe> mit src, title = Adresse, exakter Sandbox, no-referrer, lazy, allow leer, Nonce 0', () => {
|
||||
const { container } = renderWidget({ url: URL_A });
|
||||
expect(container.querySelectorAll('iframe')).toHaveLength(1);
|
||||
const f = frame();
|
||||
expect(f.getAttribute('src')).toBe(URL_A);
|
||||
expect(f.getAttribute('title')).toBe(URL_A);
|
||||
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||
expect(f.getAttribute('sandbox')).not.toMatch(/top-navigation/);
|
||||
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(f.getAttribute('loading')).toBe('lazy');
|
||||
expect(f.getAttribute('allow')).toBe('');
|
||||
expect(f.getAttribute('data-reload-nonce')).toBe('0');
|
||||
});
|
||||
|
||||
it('Test 4: ohne Titel -> kein <h2>, Link als Ecksymbol (absolute) mit href, _blank, noopener noreferrer', () => {
|
||||
const { container } = renderWidget({ url: URL_A });
|
||||
expect(container.querySelector('h2')).toBeNull();
|
||||
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
|
||||
expect(link.getAttribute('href')).toBe(URL_A);
|
||||
expect(link.getAttribute('target')).toBe('_blank');
|
||||
expect(link.getAttribute('rel')).toContain('noopener');
|
||||
expect(link.getAttribute('rel')).toContain('noreferrer');
|
||||
expect(link.className).toContain('absolute');
|
||||
});
|
||||
|
||||
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre border-b), nicht absolute', () => {
|
||||
const { container } = renderWidget({ url: URL_A, title: 'Board' });
|
||||
expect(container.querySelector('h2')).toHaveTextContent('Board');
|
||||
expect(frame().getAttribute('title')).toBe('Board');
|
||||
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
|
||||
expect(link.closest('.border-b')).not.toBeNull();
|
||||
expect(link.className).not.toContain('absolute');
|
||||
});
|
||||
|
||||
it('Test 6: Titel <b>x</b> erscheint woertlich, kein <b>-Element (React-Escaping, T-QD3-09)', () => {
|
||||
const { container } = renderWidget({ url: URL_A, title: '<b>x</b>' });
|
||||
expect(screen.getByText('<b>x</b>')).toBeInTheDocument();
|
||||
expect(container.querySelector('b')).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 7: reloadSeconds 60 -> ein Timer; nach 60 s neues <iframe>-Objekt mit Nonce 1, nach 120 s Nonce 2; unmount raeumt', () => {
|
||||
const { unmount } = renderWidget({ url: URL_A, reloadSeconds: 60 });
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
const before = frame();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
const after = frame();
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.getAttribute('data-reload-nonce')).toBe('1');
|
||||
expect(after.getAttribute('src')).toBe(URL_A);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(frame().getAttribute('data-reload-nonce')).toBe('2');
|
||||
unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 8: reloadSeconds 0 -> kein Timer, Nonce bleibt 0', () => {
|
||||
renderWidget({ url: URL_A, reloadSeconds: 0 });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3_600_000);
|
||||
});
|
||||
expect(frame().getAttribute('data-reload-nonce')).toBe('0');
|
||||
});
|
||||
|
||||
it('Test 9: Bearbeitungsmodus + reloadSeconds 60 -> kein Timer (kein Neuladen beim Bearbeiten)', () => {
|
||||
renderWidget({ url: URL_A, reloadSeconds: 60 }, true);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 10: Bearbeitungsmodus -> Overlay aria-hidden, absolute inset-0, im DOM NACH dem <iframe>', () => {
|
||||
renderWidget({ url: URL_A }, true);
|
||||
const overlay = screen.getByTestId('xframe-edit-overlay');
|
||||
expect(overlay.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(overlay.className).toContain('absolute');
|
||||
expect(overlay.className).toContain('inset-0');
|
||||
// FOLLOWING = 4: das Overlay folgt dem Rahmen in Dokumentreihenfolge
|
||||
expect(frame().compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Test 11: Ansichtsmodus -> kein Overlay', () => {
|
||||
renderWidget({ url: URL_A }, false);
|
||||
expect(screen.queryByTestId('xframe-edit-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 12: Ecksymbol sitzt im Bearbeitungsmodus tiefer (top-6) als im Ansichtsmodus (top-1)', () => {
|
||||
renderWidget({ url: URL_A }, false);
|
||||
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-1');
|
||||
cleanup();
|
||||
renderWidget({ url: URL_A }, true);
|
||||
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
|
||||
/**
|
||||
* XframeWidget — eine Webseite per https-Adresse als eingebetteter Rahmen
|
||||
* in der Kachel (quick-260921-qd3).
|
||||
*
|
||||
* Sandbox: `XFRAME_SANDBOX` (Begruendung in xframe-config.ts, T-QD3-01) —
|
||||
* keine Freigabe der Navigation des obersten Fensters, keine Modaldialoge;
|
||||
* `allow=""` delegiert keine Berechtigungen (Kamera, Mikrofon, Standort,
|
||||
* T-QD3-06); `referrerPolicy="no-referrer"`, damit der Fremdhost die
|
||||
* Tessera-Adresse nicht erfaehrt (T-QD3-05).
|
||||
*
|
||||
* Woher die Seite kommt: AUSSCHLIESSLICH der Browser des Benutzers laedt
|
||||
* die Adresse — der Server ruft sie nie ab (T-QD3-04: keine SSRF-Flaeche,
|
||||
* kein Proxy-Endpunkt; interne Hosts sieht damit nur, wer sie ohnehin
|
||||
* erreicht). Was gerendert wird, entscheidet `resolveXframeConfig`
|
||||
* (T-QD3-03): nur eine https-Adresse, sonst der Leerzustand.
|
||||
*
|
||||
* Bearbeitungsmodus (T-QD3-07): ein <iframe> schluckt Mausereignisse und
|
||||
* bricht das Ziehen. Deshalb liegt bei `isEditMode` eine transparente
|
||||
* Flaeche NACH dem Rahmen im DOM ueber ihm, damit `mousedown` zur Karte
|
||||
* `.widget-drag-handle` aufsteigt (dashboard-grid.tsx: Griff = ganze Karte,
|
||||
* eine div startet das Ziehen). Im Ansichtsmodus gibt es die Flaeche nicht,
|
||||
* die Seite bleibt bedienbar.
|
||||
*
|
||||
* Neuladen (T-QD3-08): bei `reloadSeconds > 0` bumpt ein Intervall einen
|
||||
* Zaehler, der Teil des `key` des <iframe> ist — der Rahmen wird neu
|
||||
* eingehaengt. Aufraeumfunktion raeumt den Timer; im Bearbeitungsmodus
|
||||
* laeuft kein Timer.
|
||||
*
|
||||
* Verweigertes Einbetten (`X-Frame-Options`/`frame-ancestors` der
|
||||
* Fremdseite) ist cross-origin nicht zuverlaessig erkennbar — wird nicht
|
||||
* 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).
|
||||
*/
|
||||
export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config]);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (url === null || reloadSeconds === 0 || isEditMode) return;
|
||||
const timer = setInterval(() => setReloadNonce((n) => n + 1), reloadSeconds * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [url, reloadSeconds, isEditMode]);
|
||||
|
||||
if (url === null) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||
{t('xframe.empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const openLabel = t('xframe.openInNewTab');
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
{title !== '' && (
|
||||
// Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im
|
||||
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
|
||||
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen.
|
||||
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
|
||||
<NewTabLink
|
||||
url={url}
|
||||
label={openLabel}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<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"
|
||||
/>
|
||||
{isEditMode && (
|
||||
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />
|
||||
)}
|
||||
{title === '' && (
|
||||
<NewTabLink
|
||||
url={url}
|
||||
label={openLabel}
|
||||
className={`absolute right-1 z-10 rounded bg-card/80 p-1 text-muted-foreground shadow-sm hover:text-foreground ${
|
||||
isEditMode ? 'top-6' : 'top-1'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** „In neuem Tab öffnen“ — echter Link (Tastatur), ohne opener und ohne Referrer (T-QD3-05). */
|
||||
function NewTabLink({ url, label, className }: { url: string; label: string; className: string }) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={className}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
{/* Biome useAnchorContent zaehlt aria-label nicht als Inhalt — der
|
||||
sr-only-Text traegt denselben Namen fuer Vorleser. */}
|
||||
<span className="sr-only">{label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
|
||||
/**
|
||||
* picture-frame-config-form.test — NEU (quick-260921-pi9). Texte aus der
|
||||
* echten de.json (Muster widget-settings-panel.test.tsx): Auswahlfelder,
|
||||
* Eintragsliste mit Vorschau/Unterschrift/Pfeilen/Entfernen, Upload-Kette,
|
||||
* https-Pruefung, Grenze 30.
|
||||
*/
|
||||
vi.mock('next-intl', async (importOriginal) => {
|
||||
// Echter ICU-Uebersetzer von next-intl auf der echten de.json: die
|
||||
// Intervall-Beschriftung nutzt `{n, plural, one {…} other {…}}`, ein
|
||||
// handgeschriebener `{n}`-Ersatz wuerde das Muster nicht verstehen.
|
||||
const actual = await importOriginal<typeof import('next-intl')>();
|
||||
const messages = (await import('@/messages/de.json')).default;
|
||||
return {
|
||||
useTranslations: (ns?: string) => actual.createTranslator({ locale: 'de', messages, namespace: ns }),
|
||||
};
|
||||
});
|
||||
|
||||
const { uploadMock, deleteMock } = vi.hoisted(() => ({
|
||||
uploadMock: vi.fn(),
|
||||
deleteMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/dashboard-images-api', () => ({
|
||||
uploadDashboardImage: uploadMock,
|
||||
deleteDashboardImage: deleteMock,
|
||||
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${encodeURIComponent(id)}`,
|
||||
}));
|
||||
|
||||
import { PictureFrameConfigForm } from './picture-frame-config-form';
|
||||
|
||||
const texts = (de as { widgets: { pictureFrame: Record<string, string> } }).widgets.pictureFrame;
|
||||
const URL_A = 'https://bilder.example/a.jpg';
|
||||
|
||||
function renderForm(config: Record<string, unknown>) {
|
||||
const onChange = vi.fn();
|
||||
render(<PictureFrameConfigForm config={config} onChange={onChange} />);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
uploadMock.mockReset();
|
||||
deleteMock.mockReset();
|
||||
deleteMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('PictureFrameConfigForm (quick-260921-pi9)', () => {
|
||||
it('Test 1: drei Auswahlfelder mit Vorgaben; jede Aenderung ruft onChange mit genau dem einen Feld', () => {
|
||||
const { onChange } = renderForm({});
|
||||
const fit = screen.getByLabelText(texts.fitLabel) as HTMLSelectElement;
|
||||
const interval = screen.getByLabelText(texts.intervalLabel) as HTMLSelectElement;
|
||||
const order = screen.getByLabelText(texts.orderLabel) as HTMLSelectElement;
|
||||
expect(fit.value).toBe('contain');
|
||||
expect(interval.value).toBe('30');
|
||||
expect(order.value).toBe('sequence');
|
||||
expect(Array.from(interval.options).map((o) => o.value)).toEqual([
|
||||
'0', '5', '10', '15', '30', '60', '120', '300', '600', '1800', '3600',
|
||||
]);
|
||||
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('Kein Wechsel');
|
||||
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('5 Sekunden');
|
||||
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('30 Minuten');
|
||||
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('1 Minute');
|
||||
|
||||
fireEvent.change(fit, { target: { value: 'cover' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ fit: 'cover' });
|
||||
fireEvent.change(interval, { target: { value: '120' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ intervalSeconds: 120 });
|
||||
fireEvent.change(order, { target: { value: 'random' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ order: 'random' });
|
||||
expect(onChange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Test 2: Liste zeigt je Eintrag Vorschau (no-referrer, Upload ueber Proxy-Pfad); kaputte Vorschau wird zu "Bild nicht verfügbar"', () => {
|
||||
renderForm({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
],
|
||||
});
|
||||
const previews = screen.getAllByTestId('picture-frame-preview') as HTMLImageElement[];
|
||||
expect(previews).toHaveLength(2);
|
||||
expect(previews[0].getAttribute('src')).toBe(URL_A);
|
||||
expect(previews[0].getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(previews[1].getAttribute('src')).toBe('/api-proxy/dashboard/images/img-1');
|
||||
|
||||
fireEvent.error(previews[0]);
|
||||
expect(screen.getAllByTestId('picture-frame-preview')).toHaveLength(1);
|
||||
expect(screen.getByText(texts.unavailable)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 3: Unterschrift — Entwurf im Feld, Uebernahme bei Blur/Enter mit dem GANZEN images-Array; unveraendert -> kein Aufruf', () => {
|
||||
const { onChange } = renderForm({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A, caption: 'Alt' },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
],
|
||||
});
|
||||
const inputs = screen.getAllByLabelText(texts.captionPlaceholder) as HTMLInputElement[];
|
||||
expect(inputs[0].value).toBe('Alt');
|
||||
expect(inputs[1].value).toBe('');
|
||||
|
||||
fireEvent.change(inputs[1], { target: { value: 'Neu' } });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(inputs[1], { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A, caption: 'Alt' },
|
||||
{ kind: 'upload', imageId: 'img-1', caption: 'Neu' },
|
||||
],
|
||||
});
|
||||
|
||||
// Leer machen entfernt die Unterschrift, Blur uebernimmt
|
||||
fireEvent.change(inputs[0], { target: { value: ' ' } });
|
||||
fireEvent.blur(inputs[0]);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
],
|
||||
});
|
||||
|
||||
// Unveraendert: kein weiterer Aufruf
|
||||
const calls = onChange.mock.calls.length;
|
||||
fireEvent.blur(inputs[1]);
|
||||
expect(onChange).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
it('Test 4: Pfeile — oberster ohne "hoch", unterster ohne "runter"; Bewegen sendet das ganze Array in neuer Reihenfolge', () => {
|
||||
const { onChange } = renderForm({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
{ kind: 'upload', imageId: 'img-2' },
|
||||
],
|
||||
});
|
||||
const ups = screen.getAllByRole('button', { name: texts.moveUpButton }) as HTMLButtonElement[];
|
||||
const downs = screen.getAllByRole('button', { name: texts.moveDownButton }) as HTMLButtonElement[];
|
||||
expect(ups[0].disabled).toBe(true);
|
||||
expect(ups[1].disabled).toBe(false);
|
||||
expect(downs[2].disabled).toBe(true);
|
||||
expect(downs[1].disabled).toBe(false);
|
||||
|
||||
fireEvent.click(downs[0]);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
images: [
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-2' },
|
||||
],
|
||||
});
|
||||
fireEvent.click(ups[2]);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-2' },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 5: Entfernen eines Upload-Eintrags ruft deleteDashboardImage (Fehler verschluckt) UND onChange mit dem verkuerzten Array; URL-Eintrag ohne Serveraufruf', async () => {
|
||||
deleteMock.mockRejectedValue(new Error('weg'));
|
||||
const { onChange } = renderForm({
|
||||
images: [
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
{ kind: 'url', url: URL_A },
|
||||
],
|
||||
});
|
||||
const removes = screen.getAllByRole('button', { name: texts.removeButton });
|
||||
|
||||
fireEvent.click(removes[0]);
|
||||
expect(deleteMock).toHaveBeenCalledWith('img-1');
|
||||
expect(onChange).toHaveBeenLastCalledWith({ images: [{ kind: 'url', url: URL_A }] });
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
fireEvent.click(removes[1]);
|
||||
expect(deleteMock).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenLastCalledWith({ images: [{ kind: 'upload', imageId: 'img-1' }] });
|
||||
});
|
||||
|
||||
it('Test 6: Webadresse — http:// zeigt role=alert und ruft onChange nicht; https ruft onChange mit angehaengtem URL-Eintrag', () => {
|
||||
const { onChange } = renderForm({ images: [{ kind: 'upload', imageId: 'img-1' }] });
|
||||
const input = screen.getByLabelText(texts.urlAddButton) as HTMLInputElement;
|
||||
const button = screen.getByRole('button', { name: texts.urlAddButton });
|
||||
|
||||
fireEvent.change(input, { target: { value: 'http://bilder.example/a.jpg' } });
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(texts.urlInvalid);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(input, { target: { value: URL_A } });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
images: [
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
{ kind: 'url', url: URL_A },
|
||||
],
|
||||
});
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('Test 7: Datei waehlen ruft uploadDashboardImage(file) und danach onChange mit neuem Upload-Eintrag (Kennung aus der Antwort)', async () => {
|
||||
uploadMock.mockResolvedValue({ id: 'neu-42', originalName: 'a.png', mimeType: 'image/png', size: 3 });
|
||||
const { onChange } = renderForm({ images: [{ kind: 'url', url: URL_A }] });
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' });
|
||||
const input = screen.getByTestId('picture-frame-file-input') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
expect(uploadMock).toHaveBeenCalledWith(file);
|
||||
await waitFor(() =>
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'neu-42' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 8: wirft der Upload, erscheint seine Meldung als role=alert und onChange bleibt aus', async () => {
|
||||
uploadMock.mockRejectedValue(new Error('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'));
|
||||
const { onChange } = renderForm({});
|
||||
const input = screen.getByTestId('picture-frame-file-input') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [new File([1], 'x.png')] } });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.',
|
||||
),
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 9: bei 30 Eintraegen ist "Bild hochladen" deaktiviert, der Hinweis limitReached steht, Webadresse ebenfalls gesperrt', () => {
|
||||
const images = Array.from({ length: 30 }, (_, i) => ({ kind: 'upload', imageId: `img-${i}` }));
|
||||
renderForm({ images });
|
||||
const upload = screen.getByRole('button', { name: texts.uploadButton }) as HTMLButtonElement;
|
||||
expect(upload.disabled).toBe(true);
|
||||
expect(screen.getByTestId('picture-frame-limit')).toHaveTextContent(texts.limitReached);
|
||||
expect((screen.getByRole('button', { name: texts.urlAddButton }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getAllByTestId('picture-frame-entry')).toHaveLength(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
'use client';
|
||||
|
||||
import { type ChangeEvent, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
PICTURE_FRAME_INTERVAL_OPTIONS,
|
||||
PICTURE_FRAME_MAX_IMAGES,
|
||||
type PictureFrameEntry,
|
||||
entryKey,
|
||||
isHttpsUrl,
|
||||
resolvePictureFrameConfig,
|
||||
} from '@/components/dashboard/widgets/picture-frame-config';
|
||||
import {
|
||||
dashboardImageSrc,
|
||||
deleteDashboardImage,
|
||||
uploadDashboardImage,
|
||||
} from '@/lib/dashboard-images-api';
|
||||
|
||||
interface PictureFrameConfigFormProps {
|
||||
config: Record<string, unknown>;
|
||||
onChange: (partial: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildverwaltung des Bilderrahmen-Widgets im WidgetSettingsPanel
|
||||
* (quick-260921-pi9, Muster ClockConfig/FavoritesConfig).
|
||||
*
|
||||
* `onChange` bekommt bei Listenaenderungen IMMER das vollstaendige
|
||||
* `images`-Array: die API fuehrt Widget-Konfigurationen flach zusammen
|
||||
* (`{ ...alt, ...neu }`), ein Teil-Array wuerde die uebrigen Eintraege
|
||||
* ueberschreiben. Die drei Auswahlfelder senden dagegen nur ihr eines Feld.
|
||||
*
|
||||
* Kette Datei -> Server -> Config: Datei waehlen -> `uploadDashboardImage`
|
||||
* (POST /dashboard/images, Magic-Byte-Pruefung serverseitig) -> Antwort
|
||||
* `{ id }` -> `onChange({ images: [...alt, { kind: 'upload', imageId }] })`.
|
||||
* Entfernen eines Upload-Eintrags loescht das Bild auch auf dem Server
|
||||
* (best effort, Fehler verschluckt — der Eintrag verschwindet in jedem Fall
|
||||
* aus der Config).
|
||||
*
|
||||
* Webadressen: nur https (`isHttpsUrl`, echter URL-Parser) — dieselbe
|
||||
* Pruefung, die das Widget beim Rendern erneut anwendet (T-PI9-07). Die
|
||||
* Vorschau laedt der Browser direkt mit `referrerPolicy="no-referrer"`
|
||||
* (T-PI9-09); der Server ruft nie eine Adresse ab (T-PI9-05).
|
||||
*/
|
||||
export function PictureFrameConfigForm({ config, onChange }: PictureFrameConfigFormProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const { images, fit, intervalSeconds, order } = resolvePictureFrameConfig(config);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [urlDraft, setUrlDraft] = useState('');
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
// Entwuerfe der Unterschriften je Eintragsschluessel; Uebernahme bei
|
||||
// Blur/Enter (Muster commitFontSize in ClockConfig).
|
||||
const [captionDrafts, setCaptionDrafts] = useState<Record<string, string>>({});
|
||||
const [brokenPreviews, setBrokenPreviews] = useState<string[]>([]);
|
||||
|
||||
const limitReached = images.length >= PICTURE_FRAME_MAX_IMAGES;
|
||||
|
||||
function setImages(next: PictureFrameEntry[]) {
|
||||
// Entwuerfe verwerfen: nach einer Listenaenderung gelten die Schluessel
|
||||
// (Index-Anteil bei URL-Eintraegen) nicht mehr zwingend fuer dieselbe Zeile.
|
||||
setCaptionDrafts({});
|
||||
onChange({ images: next });
|
||||
}
|
||||
|
||||
async function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
// Auswahl zuruecksetzen, damit dieselbe Datei erneut waehlbar ist.
|
||||
e.target.value = '';
|
||||
if (!file || limitReached) return;
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const meta = await uploadDashboardImage(file);
|
||||
setImages([...images, { kind: 'upload', imageId: meta.id }]);
|
||||
} catch (error) {
|
||||
setUploadError(error instanceof Error ? error.message : t('pictureFrame.uploadFailed'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddUrl() {
|
||||
const url = urlDraft.trim();
|
||||
if (!isHttpsUrl(url)) {
|
||||
setUrlError(true);
|
||||
return;
|
||||
}
|
||||
setUrlError(false);
|
||||
setUrlDraft('');
|
||||
setImages([...images, { kind: 'url', url }]);
|
||||
}
|
||||
|
||||
function handleRemove(index: number) {
|
||||
const entry = images[index];
|
||||
if (entry.kind === 'upload') {
|
||||
// Best effort: das Bild gehoert dem Benutzer, ein Fehler hier aendert
|
||||
// nichts daran, dass der Eintrag aus der Config verschwindet.
|
||||
deleteDashboardImage(entry.imageId).catch(() => undefined);
|
||||
}
|
||||
setImages(images.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function handleMove(index: number, direction: 'up' | 'down') {
|
||||
const target = direction === 'up' ? index - 1 : index + 1;
|
||||
if (target < 0 || target >= images.length) return;
|
||||
const next = [...images];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setImages(next);
|
||||
}
|
||||
|
||||
function commitCaption(index: number, key: string) {
|
||||
const draft = captionDrafts[key];
|
||||
if (draft === undefined) return;
|
||||
const trimmed = draft.trim();
|
||||
const current = images[index].caption ?? '';
|
||||
if (trimmed === current) return;
|
||||
const next = images.map((entry, i) => {
|
||||
if (i !== index) return entry;
|
||||
const { caption: _dropped, ...rest } = entry;
|
||||
return trimmed === '' ? rest : { ...rest, caption: trimmed };
|
||||
});
|
||||
setImages(next);
|
||||
}
|
||||
|
||||
function intervalLabel(seconds: number): string {
|
||||
if (seconds === 0) return t('pictureFrame.intervalOff');
|
||||
if (seconds < 60) return t('pictureFrame.intervalSeconds', { n: seconds });
|
||||
return t('pictureFrame.intervalMinutes', { n: seconds / 60 });
|
||||
}
|
||||
|
||||
const fieldClassName =
|
||||
'h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||
const iconButtonClassName =
|
||||
'rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Bildausschnitt */}
|
||||
<div>
|
||||
<label htmlFor="picture-frame-fit" className="mb-1 block text-sm text-foreground">
|
||||
{t('pictureFrame.fitLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="picture-frame-fit"
|
||||
className={fieldClassName}
|
||||
value={fit}
|
||||
onChange={(e) => onChange({ fit: e.target.value })}
|
||||
>
|
||||
<option value="contain">{t('pictureFrame.fitContain')}</option>
|
||||
<option value="cover">{t('pictureFrame.fitCover')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Wechselintervall */}
|
||||
<div>
|
||||
<label htmlFor="picture-frame-interval" className="mb-1 block text-sm text-foreground">
|
||||
{t('pictureFrame.intervalLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="picture-frame-interval"
|
||||
className={fieldClassName}
|
||||
value={String(intervalSeconds)}
|
||||
onChange={(e) => onChange({ intervalSeconds: Number(e.target.value) })}
|
||||
>
|
||||
{PICTURE_FRAME_INTERVAL_OPTIONS.map((seconds) => (
|
||||
<option key={seconds} value={String(seconds)}>
|
||||
{intervalLabel(seconds)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reihenfolge */}
|
||||
<div>
|
||||
<label htmlFor="picture-frame-order" className="mb-1 block text-sm text-foreground">
|
||||
{t('pictureFrame.orderLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="picture-frame-order"
|
||||
className={fieldClassName}
|
||||
value={order}
|
||||
onChange={(e) => onChange({ order: e.target.value })}
|
||||
>
|
||||
<option value="sequence">{t('pictureFrame.orderSequence')}</option>
|
||||
<option value="random">{t('pictureFrame.orderRandom')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bilder */}
|
||||
<div>
|
||||
<p className="mb-1 block text-sm text-foreground">{t('pictureFrame.imagesLabel')}</p>
|
||||
{images.length > 0 && (
|
||||
<ul className="space-y-2" data-testid="picture-frame-list">
|
||||
{images.map((entry, index) => {
|
||||
const key = entryKey(entry, index);
|
||||
const src = entry.kind === 'upload' ? dashboardImageSrc(entry.imageId) : entry.url;
|
||||
const draft = captionDrafts[key] ?? entry.caption ?? '';
|
||||
return (
|
||||
<li key={key} className="flex items-center gap-2" data-testid="picture-frame-entry">
|
||||
{brokenPreviews.includes(key) ? (
|
||||
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded bg-muted text-center text-[10px] leading-tight text-muted-foreground">
|
||||
{t('pictureFrame.unavailable')}
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={48}
|
||||
height={48}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-12 w-12 flex-shrink-0 rounded bg-muted object-cover"
|
||||
data-testid="picture-frame-preview"
|
||||
onError={() =>
|
||||
setBrokenPreviews((prev) => (prev.includes(key) ? prev : [...prev, key]))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t('pictureFrame.captionPlaceholder')}
|
||||
placeholder={t('pictureFrame.captionPlaceholder')}
|
||||
value={draft}
|
||||
onChange={(e) => setCaptionDrafts((prev) => ({ ...prev, [key]: e.target.value }))}
|
||||
onBlur={() => commitCaption(index, key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitCaption(index, key);
|
||||
}
|
||||
}}
|
||||
className="h-9 min-w-0 flex-1 rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('pictureFrame.moveUpButton')}
|
||||
disabled={index === 0}
|
||||
onClick={() => handleMove(index, 'up')}
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<ArrowIcon direction="up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('pictureFrame.moveDownButton')}
|
||||
disabled={index === images.length - 1}
|
||||
onClick={() => handleMove(index, 'down')}
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<ArrowIcon direction="down" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('pictureFrame.removeButton')}
|
||||
onClick={() => handleRemove(index)}
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Hochladen */}
|
||||
<div className="mt-3 space-y-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
className="hidden"
|
||||
aria-label={t('pictureFrame.uploadButton')}
|
||||
data-testid="picture-frame-file-input"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={limitReached || uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="h-9 rounded border border-border bg-background px-3 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{t('pictureFrame.uploadButton')}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">{t('pictureFrame.uploadHint')}</p>
|
||||
{limitReached && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="picture-frame-limit">
|
||||
{t('pictureFrame.limitReached')}
|
||||
</p>
|
||||
)}
|
||||
{uploadError && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Webadresse */}
|
||||
<div className="mt-3 space-y-1">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
aria-label={t('pictureFrame.urlAddButton')}
|
||||
placeholder={t('pictureFrame.urlPlaceholder')}
|
||||
value={urlDraft}
|
||||
onChange={(e) => {
|
||||
setUrlDraft(e.target.value);
|
||||
setUrlError(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddUrl();
|
||||
}
|
||||
}}
|
||||
disabled={limitReached}
|
||||
aria-invalid={urlError || undefined}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={limitReached}
|
||||
onClick={handleAddUrl}
|
||||
className="h-9 whitespace-nowrap rounded border border-border bg-background px-3 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{t('pictureFrame.urlAddButton')}
|
||||
</button>
|
||||
</div>
|
||||
{urlError && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t('pictureFrame.urlInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowIcon({ direction }: { direction: 'up' | 'down' }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{direction === 'up' ? <polyline points="18 15 12 9 6 15" /> : <polyline points="6 9 12 15 18 9" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -234,3 +234,32 @@ describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)', () => {
|
||||
expect(screen.queryByText('Title')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const xframe = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
|
||||
|
||||
describe('WidgetSettingsPanel — XFrame (quick-260921-qd3)', () => {
|
||||
it('Test X1: der Zweig rendert das XFrame-Formular, Kopfzeile zeigt "— Titel", Blur der Adresse ruft updateWidgetConfig/onWidgetUpdate', async () => {
|
||||
const onWidgetUpdate = vi.fn();
|
||||
const widget = {
|
||||
id: 'x1',
|
||||
widgetType: 'xframe',
|
||||
config: { url: 'https://intern.example/board', title: 'Board', reloadSeconds: 300 },
|
||||
};
|
||||
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
|
||||
const header = screen.getByRole('button', { name: /XFrame #1/ });
|
||||
expect(header).toHaveTextContent('— Board');
|
||||
fireEvent.click(header);
|
||||
|
||||
const url = screen.getByLabelText(xframe.urlLabel) as HTMLInputElement;
|
||||
expect(url.value).toBe('https://intern.example/board');
|
||||
expect((screen.getByLabelText(xframe.titleLabel) as HTMLInputElement).value).toBe('Board');
|
||||
expect((screen.getByLabelText(xframe.reloadLabel) as HTMLSelectElement).value).toBe('300');
|
||||
expect(screen.getByText(xframe.embedHint)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(url, { target: { value: 'https://b.de/' } });
|
||||
fireEvent.blur(url);
|
||||
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('x1', { url: 'https://b.de/' }));
|
||||
expect(updateWidgetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(updateWidgetConfig).toHaveBeenCalledWith('x1', { url: 'https://b.de/' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useCallback, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form';
|
||||
import { SearchProviderForm } from '@/components/settings/search-provider-form';
|
||||
import { XframeConfigForm } from '@/components/settings/xframe-config-form';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
import {
|
||||
CLOCK_FONT_SIZE_MAX_PT,
|
||||
@@ -118,7 +120,8 @@ export function WidgetSettingsPanel({
|
||||
<span>
|
||||
{t(def.nameKey)} #{idx + 1}
|
||||
{(widget.widgetType === 'note' ||
|
||||
widget.widgetType === 'favorites') &&
|
||||
widget.widgetType === 'favorites' ||
|
||||
widget.widgetType === 'xframe') &&
|
||||
typeof widget.config.title === 'string' &&
|
||||
widget.config.title.trim() !== '' && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
@@ -196,6 +199,26 @@ export function WidgetSettingsPanel({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bilderrahmen (quick-260921-pi9) */}
|
||||
{widget.widgetType === 'picture-frame' && (
|
||||
<PictureFrameConfigForm
|
||||
config={widget.config}
|
||||
onChange={(cfg) =>
|
||||
handleConfigChange(widget.id, cfg)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* XFrame (quick-260921-qd3) */}
|
||||
{widget.widgetType === 'xframe' && (
|
||||
<XframeConfigForm
|
||||
config={widget.config}
|
||||
onChange={(cfg) =>
|
||||
handleConfigChange(widget.id, cfg)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
|
||||
/**
|
||||
* xframe-config-form.test — NEU (quick-260921-qd3). Texte aus der echten
|
||||
* de.json ueber den echten ICU-Uebersetzer (Muster
|
||||
* picture-frame-config-form.test.tsx): Vorbelegung, Adresse mit Uebernahme
|
||||
* bei Blur/Enter, http -> Meldung ohne Aufruf (T-QD3-03), Leeren erlaubt,
|
||||
* Titel, Intervall-Auswahl, dauerhafter Einbett-Hinweis.
|
||||
*/
|
||||
vi.mock('next-intl', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next-intl')>();
|
||||
const messages = (await import('@/messages/de.json')).default;
|
||||
return {
|
||||
useTranslations: (ns?: string) => actual.createTranslator({ locale: 'de', messages, namespace: ns }),
|
||||
};
|
||||
});
|
||||
|
||||
import { XframeConfigForm } from './xframe-config-form';
|
||||
|
||||
const texts = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
|
||||
const URL_A = 'https://intern.example/board';
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('XframeConfigForm (quick-260921-qd3)', () => {
|
||||
it('Test 1: Felder aus der Konfiguration vorbelegt, Hinweis dauerhaft sichtbar, Intervall-Optionen beschriftet', () => {
|
||||
const { url, title, reload } = renderForm({ url: URL_A, title: 'Board', reloadSeconds: 300 });
|
||||
expect(url.value).toBe(URL_A);
|
||||
expect(url.type).toBe('url');
|
||||
expect(url.placeholder).toBe(texts.urlPlaceholder);
|
||||
expect(title.value).toBe('Board');
|
||||
expect(title.maxLength).toBe(100);
|
||||
expect(reload.value).toBe('300');
|
||||
expect(Array.from(reload.options).map((o) => o.value)).toEqual(['0', '60', '300', '600', '1800', '3600']);
|
||||
expect(Array.from(reload.options).map((o) => o.textContent)).toEqual([
|
||||
'Nie',
|
||||
'Jede Minute',
|
||||
'Alle 5 Minuten',
|
||||
'Alle 10 Minuten',
|
||||
'Alle 30 Minuten',
|
||||
'Jede Stunde',
|
||||
]);
|
||||
expect(screen.getByText(texts.embedHint)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 2: leere Konfiguration -> Adresse leer, Titel leer, Intervall Nie', () => {
|
||||
const { url, title, reload } = renderForm({});
|
||||
expect(url.value).toBe('');
|
||||
expect(title.value).toBe('');
|
||||
expect(reload.value).toBe('0');
|
||||
});
|
||||
|
||||
it('Test 3: https-Adresse aendern + Blur -> onChange({ url }) genau einmal; Enter uebernimmt ebenfalls', () => {
|
||||
const { onChange, url } = renderForm({ url: URL_A });
|
||||
fireEvent.change(url, { target: { value: 'https://b.de/' } });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.blur(url);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ url: 'https://b.de/' });
|
||||
|
||||
fireEvent.change(url, { target: { value: ' https://c.de/ ' } });
|
||||
fireEvent.keyDown(url, { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(onChange).toHaveBeenLastCalledWith({ url: 'https://c.de/' });
|
||||
});
|
||||
|
||||
it('Test 4: http-Adresse + Enter -> role=alert mit urlInvalid, aria-invalid, KEIN Aufruf; https danach raeumt die Meldung', () => {
|
||||
const { onChange, url } = renderForm({ url: URL_A });
|
||||
fireEvent.change(url, { target: { value: 'http://b.de' } });
|
||||
fireEvent.keyDown(url, { key: 'Enter' });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(texts.urlInvalid);
|
||||
expect(url.getAttribute('aria-invalid')).toBe('true');
|
||||
|
||||
fireEvent.change(url, { target: { value: 'javascript:alert(1)' } });
|
||||
fireEvent.blur(url);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(url, { target: { value: 'https://b.de/' } });
|
||||
fireEvent.blur(url);
|
||||
expect(onChange).toHaveBeenCalledWith({ url: 'https://b.de/' });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 5: Feld leeren + Blur -> onChange({ url: "" }) (Adresse entfernen erlaubt); bei bereits leerer Adresse kein Aufruf', () => {
|
||||
const { onChange, url } = renderForm({ url: URL_A });
|
||||
fireEvent.change(url, { target: { value: '' } });
|
||||
fireEvent.blur(url);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ url: '' });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
const second = renderForm({});
|
||||
fireEvent.blur(second.url);
|
||||
expect(second.onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 6: gleiche Adresse erneut uebernehmen (Blur ohne Aenderung) -> kein Aufruf', () => {
|
||||
const { onChange, url } = renderForm({ url: URL_A });
|
||||
fireEvent.blur(url);
|
||||
fireEvent.keyDown(url, { key: 'Enter' });
|
||||
fireEvent.change(url, { target: { value: ` ${URL_A} ` } });
|
||||
fireEvent.blur(url);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 7: Titel aendern + Blur -> onChange({ title }) getrimmt; unveraendert -> kein Aufruf; Enter uebernimmt', () => {
|
||||
const { onChange, title } = renderForm({ url: URL_A, title: 'Board' });
|
||||
fireEvent.blur(title);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.change(title, { target: { value: ' Neu ' } });
|
||||
fireEvent.blur(title);
|
||||
expect(onChange).toHaveBeenCalledWith({ title: 'Neu' });
|
||||
fireEvent.change(title, { target: { value: '' } });
|
||||
fireEvent.keyDown(title, { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ title: '' });
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Test 8: Intervall-Auswahl 600 -> onChange({ reloadSeconds: 600 }) sofort', () => {
|
||||
const { onChange, reload } = renderForm({ url: URL_A });
|
||||
fireEvent.change(reload, { target: { value: '600' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ reloadSeconds: 600 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { type KeyboardEvent, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
XFRAME_RELOAD_OPTIONS,
|
||||
XFRAME_TITLE_MAX,
|
||||
isHttpsUrl,
|
||||
resolveXframeConfig,
|
||||
} from '@/components/dashboard/widgets/xframe-config';
|
||||
|
||||
interface XframeConfigFormProps {
|
||||
config: Record<string, unknown>;
|
||||
onChange: (partial: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Einstellungen des XFrame-Widgets im WidgetSettingsPanel
|
||||
* (quick-260921-qd3, Muster ClockConfig/PictureFrameConfigForm).
|
||||
*
|
||||
* Adresse und Titel sind Entwuerfe mit Uebernahme bei Blur/Enter (Muster
|
||||
* commitFontSize in ClockConfig); jedes Feld sendet nur sich selbst — die
|
||||
* API fuehrt Widget-Konfigurationen flach zusammen.
|
||||
*
|
||||
* https-Pruefung (T-QD3-03): `isHttpsUrl` (echter URL-Parser) — dieselbe
|
||||
* Funktion, die das Widget beim Rendern erneut anwendet. Eine
|
||||
* Nicht-https-Adresse wird mit `role="alert"` abgewiesen und NICHT
|
||||
* gespeichert; Leeren ist erlaubt (Adresse entfernen -> Leerzustand).
|
||||
*
|
||||
* 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“.
|
||||
*/
|
||||
export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const { url, title, reloadSeconds } = resolveXframeConfig(config);
|
||||
|
||||
const [urlDraft, setUrlDraft] = useState(url ?? '');
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(title);
|
||||
|
||||
const commitUrl = () => {
|
||||
const raw = urlDraft.trim();
|
||||
if (raw === '') {
|
||||
setUrlError(false);
|
||||
if (url !== null) onChange({ url: '' });
|
||||
return;
|
||||
}
|
||||
if (!isHttpsUrl(raw)) {
|
||||
setUrlError(true);
|
||||
return;
|
||||
}
|
||||
setUrlError(false);
|
||||
if (raw !== url) onChange({ url: raw });
|
||||
};
|
||||
|
||||
const commitTitle = () => {
|
||||
const next = titleDraft.trim().slice(0, XFRAME_TITLE_MAX);
|
||||
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');
|
||||
if (seconds === 3600) return t('xframe.reloadHour');
|
||||
return t('xframe.reloadMinutes', { n: seconds / 60 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Adresse (nur https) */}
|
||||
<div>
|
||||
<label htmlFor="xframe-url" className={LABEL_CLASS}>
|
||||
{t('xframe.urlLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="xframe-url"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder={t('xframe.urlPlaceholder')}
|
||||
value={urlDraft}
|
||||
onChange={(e) => setUrlDraft(e.target.value)}
|
||||
onBlur={commitUrl}
|
||||
onKeyDown={onEnter(commitUrl)}
|
||||
aria-invalid={urlError || undefined}
|
||||
aria-describedby="xframe-url-hint"
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
<p id="xframe-url-hint" className="mt-1 text-xs text-muted-foreground">
|
||||
{t('xframe.embedHint')}
|
||||
</p>
|
||||
{urlError && (
|
||||
<p role="alert" className="mt-1 text-xs text-destructive">
|
||||
{t('xframe.urlInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titel (optional, Kopfleiste der Kachel) */}
|
||||
<div>
|
||||
<label htmlFor="xframe-title" className={LABEL_CLASS}>
|
||||
{t('xframe.titleLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="xframe-title"
|
||||
type="text"
|
||||
maxLength={XFRAME_TITLE_MAX}
|
||||
placeholder={t('xframe.titlePlaceholder')}
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={onEnter(commitTitle)}
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Neuladen-Intervall */}
|
||||
<div>
|
||||
<label htmlFor="xframe-reload" className={LABEL_CLASS}>
|
||||
{t('xframe.reloadLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="xframe-reload"
|
||||
value={String(reloadSeconds)}
|
||||
onChange={(e) => onChange({ reloadSeconds: Number(e.target.value) })}
|
||||
className={FIELD_CLASS}
|
||||
>
|
||||
{XFRAME_RELOAD_OPTIONS.map((seconds) => (
|
||||
<option key={seconds} value={String(seconds)}>
|
||||
{reloadLabel(seconds)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
dashboardImageSrc,
|
||||
deleteDashboardImage,
|
||||
fetchDashboardImages,
|
||||
uploadDashboardImage,
|
||||
} from './dashboard-images-api';
|
||||
|
||||
/**
|
||||
* dashboard-images-api.test — NEU (quick-260921-pi9). Die vier Aufrufe des
|
||||
* Bilderrahmen-Widgets gegen /dashboard/images: FormData-Feld `image` ohne
|
||||
* eigenen Content-Type, Cookie immer mit, 413 und 400 mit deutschen
|
||||
* Meldungen, Proxy-Pfad fuer <img src>.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('dashboard-images-api (quick-260921-pi9)', () => {
|
||||
it('Test 1: uploadDashboardImage sendet POST mit FormData-Feld image, credentials include, ohne Content-Type von Hand', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'img-1', originalName: 'a.png', mimeType: 'image/png', size: 3 }), {
|
||||
status: 201,
|
||||
}),
|
||||
);
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' });
|
||||
const result = await uploadDashboardImage(file);
|
||||
expect(result.id).toBe('img-1');
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/dashboard/images')).toBe(true);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(init.headers).toBeUndefined();
|
||||
const body = init.body as FormData;
|
||||
expect(body.get('image')).toBeInstanceOf(File);
|
||||
expect((body.get('image') as File).name).toBe('a.png');
|
||||
});
|
||||
|
||||
it('Test 2: Status 413 -> deutsche Meldung "zu gross"', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{"message":"File too large"}', { status: 413 }));
|
||||
await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow(
|
||||
'Die Datei ist zu groß – erlaubt sind höchstens 5 MB.',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 3: Status 400 mit { message } -> genau diese Meldung; sonst allgemeine Meldung', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: 'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.' }), {
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow(
|
||||
'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.',
|
||||
);
|
||||
|
||||
mockFetch.mockResolvedValue(new Response('kaputt', { status: 500 }));
|
||||
await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow(
|
||||
'Das Bild konnte nicht hochgeladen werden.',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 4: fetchDashboardImages (GET) und deleteDashboardImage (DELETE, Kennung kodiert) mit Cookie', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('[]', { status: 200 }));
|
||||
await expect(fetchDashboardImages()).resolves.toEqual([]);
|
||||
const [listUrl, listInit] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(listUrl).endsWith('/dashboard/images')).toBe(true);
|
||||
expect(listInit.credentials).toBe('include');
|
||||
|
||||
mockFetch.mockResolvedValue(new Response('{"id":"a b"}', { status: 200 }));
|
||||
await deleteDashboardImage('a b');
|
||||
const [delUrl, delInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(String(delUrl).endsWith('/dashboard/images/a%20b')).toBe(true);
|
||||
expect(delInit.method).toBe('DELETE');
|
||||
expect(delInit.credentials).toBe('include');
|
||||
|
||||
mockFetch.mockResolvedValue(new Response('', { status: 404 }));
|
||||
await expect(deleteDashboardImage('weg')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('Test 5: dashboardImageSrc kodiert die Kennung im Proxy-Pfad', () => {
|
||||
expect(dashboardImageSrc('a b')).toBe('/api-proxy/dashboard/images/a%20b');
|
||||
expect(dashboardImageSrc('img-1')).toBe('/api-proxy/dashboard/images/img-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* dashboard-images-api — Aufrufe des Bilderrahmen-Widgets gegen
|
||||
* /dashboard/images (quick-260921-pi9). Muster favorites-api.ts: Cookie
|
||||
* immer mit (`credentials: 'include'`).
|
||||
*
|
||||
* `uploadDashboardImage` schickt `multipart/form-data` mit genau dem Feld
|
||||
* `image` und OHNE eigenen Content-Type-Header — die Multipart-Grenze setzt
|
||||
* der Browser (Muster bug-report-api.ts). Die deutsche Meldung fuer „zu
|
||||
* gross“ entsteht HIER aus dem Status 413 (multer meldet englisch), die
|
||||
* Meldungen fuer 400 kommen von der API (bereits deutsch).
|
||||
*
|
||||
* `dashboardImageSrc` liefert den Proxy-Pfad fuer `<img src>`: der
|
||||
* Next-Rewrite `/api-proxy/:path*` (next.config.ts) reicht Cookies mit,
|
||||
* die Bytes kommen aus Tesseras eigenem Origin (Muster FavoriteIcon).
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export interface DashboardImageMeta {
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function fetchDashboardImages(): Promise<DashboardImageMeta[]> {
|
||||
const res = await fetch(`${API_URL}/dashboard/images`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('Failed to fetch dashboard images');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function uploadDashboardImage(file: File): Promise<DashboardImageMeta> {
|
||||
const body = new FormData();
|
||||
body.append('image', file, file.name);
|
||||
const res = await fetch(`${API_URL}/dashboard/images`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body,
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
|
||||
if (res.status === 413) {
|
||||
throw new Error('Die Datei ist zu groß – erlaubt sind höchstens 5 MB.');
|
||||
}
|
||||
if (res.status === 400) {
|
||||
const message = await readMessage(res);
|
||||
if (message) throw new Error(message);
|
||||
}
|
||||
throw new Error('Das Bild konnte nicht hochgeladen werden.');
|
||||
}
|
||||
|
||||
export async function deleteDashboardImage(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/dashboard/images/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete dashboard image');
|
||||
}
|
||||
|
||||
export function dashboardImageSrc(id: string): string {
|
||||
return `/api-proxy/dashboard/images/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
async function readMessage(res: Response): Promise<string | null> {
|
||||
try {
|
||||
const data: unknown = await res.json();
|
||||
if (typeof data === 'object' && data !== null && 'message' in data) {
|
||||
const message = (data as { message: unknown }).message;
|
||||
if (typeof message === 'string' && message !== '') return message;
|
||||
}
|
||||
} catch {
|
||||
// kein JSON — allgemeine Meldung
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -333,6 +333,53 @@
|
||||
"stop": "Stop",
|
||||
"reset": "Reset",
|
||||
"lap": "Runde"
|
||||
},
|
||||
"pictureFrame": {
|
||||
"name": "Bilderrahmen",
|
||||
"description": "Bilder hochladen oder verlinken, als Diashow",
|
||||
"empty": "Noch keine Bilder — über die Einstellungen hinzufügen",
|
||||
"unavailable": "Bild nicht verfügbar",
|
||||
"open": "Bild groß anzeigen",
|
||||
"close": "Großansicht schließen",
|
||||
"fitLabel": "Bildausschnitt",
|
||||
"fitContain": "Ganz sichtbar",
|
||||
"fitCover": "Formatfüllend",
|
||||
"intervalLabel": "Wechselintervall",
|
||||
"intervalOff": "Kein Wechsel",
|
||||
"intervalSeconds": "{n} Sekunden",
|
||||
"intervalMinutes": "{n, plural, one {# Minute} other {# Minuten}}",
|
||||
"orderLabel": "Reihenfolge der Bilder",
|
||||
"orderSequence": "Reihenfolge",
|
||||
"orderRandom": "Zufall",
|
||||
"imagesLabel": "Bilder",
|
||||
"captionPlaceholder": "Bildunterschrift (optional)",
|
||||
"uploadButton": "Bild hochladen",
|
||||
"uploadHint": "PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder",
|
||||
"urlPlaceholder": "https://…",
|
||||
"urlAddButton": "Webadresse hinzufügen",
|
||||
"urlInvalid": "Bitte geben Sie eine vollständige https-Adresse ein.",
|
||||
"removeButton": "Bild entfernen",
|
||||
"moveUpButton": "Nach oben",
|
||||
"moveDownButton": "Nach unten",
|
||||
"limitReached": "Die Höchstzahl von 30 Bildern ist erreicht.",
|
||||
"uploadFailed": "Das Bild konnte nicht hochgeladen werden."
|
||||
},
|
||||
"xframe": {
|
||||
"name": "XFrame",
|
||||
"description": "Webseite einbetten",
|
||||
"empty": "Keine Adresse eingestellt — über die Einstellungen festlegen",
|
||||
"openInNewTab": "In neuem Tab öffnen",
|
||||
"urlLabel": "Adresse (https)",
|
||||
"urlPlaceholder": "https://…",
|
||||
"urlInvalid": "Bitte geben Sie eine vollständige https-Adresse ein.",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Titel (optional)",
|
||||
"reloadLabel": "Automatisch neu laden",
|
||||
"reloadOff": "Nie",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -333,6 +333,53 @@
|
||||
"stop": "Stop",
|
||||
"reset": "Reset",
|
||||
"lap": "Lap"
|
||||
},
|
||||
"pictureFrame": {
|
||||
"name": "Picture frame",
|
||||
"description": "Upload or link images as a slideshow",
|
||||
"empty": "No images yet — add some in the settings",
|
||||
"unavailable": "Image not available",
|
||||
"open": "Show image enlarged",
|
||||
"close": "Close enlarged view",
|
||||
"fitLabel": "Image fit",
|
||||
"fitContain": "Fully visible",
|
||||
"fitCover": "Fill the frame",
|
||||
"intervalLabel": "Change interval",
|
||||
"intervalOff": "No change",
|
||||
"intervalSeconds": "{n} seconds",
|
||||
"intervalMinutes": "{n, plural, one {# minute} other {# minutes}}",
|
||||
"orderLabel": "Image order",
|
||||
"orderSequence": "In order",
|
||||
"orderRandom": "Random",
|
||||
"imagesLabel": "Images",
|
||||
"captionPlaceholder": "Caption (optional)",
|
||||
"uploadButton": "Upload image",
|
||||
"uploadHint": "PNG, JPEG, GIF or WebP, at most 5 MB, up to 30 images",
|
||||
"urlPlaceholder": "https://…",
|
||||
"urlAddButton": "Add web address",
|
||||
"urlInvalid": "Please enter a complete https address.",
|
||||
"removeButton": "Remove image",
|
||||
"moveUpButton": "Move up",
|
||||
"moveDownButton": "Move down",
|
||||
"limitReached": "The maximum of 30 images has been reached.",
|
||||
"uploadFailed": "The image could not be uploaded."
|
||||
},
|
||||
"xframe": {
|
||||
"name": "XFrame",
|
||||
"description": "Embed a web page",
|
||||
"empty": "No address set — configure it in the settings",
|
||||
"openInNewTab": "Open in a new tab",
|
||||
"urlLabel": "Address (https)",
|
||||
"urlPlaceholder": "https://…",
|
||||
"urlInvalid": "Please enter a complete https address.",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Title (optional)",
|
||||
"reloadLabel": "Reload automatically",
|
||||
"reloadOff": "Never",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -131,6 +131,11 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'guest',
|
||||
'Passwort',
|
||||
'Adresse',
|
||||
// Bilderrahmen-Widget (quick-260921-pi9): korrektes Deutsch mit „ss“
|
||||
'Webadresse',
|
||||
'Bildausschnitt',
|
||||
// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen
|
||||
'neuem',
|
||||
'Absenderadresse',
|
||||
'muss',
|
||||
'musste',
|
||||
|
||||
@@ -79,8 +79,10 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
|
||||
| Taschenrechner | Grundrechenarten, auch per Tastatur bedienbar |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt |
|
||||
| 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 |
|
||||
|
||||
Für Uhr, Suchleiste, Kalender, Notizen und Favoriten gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||
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).
|
||||
|
||||
## Marktplatz
|
||||
|
||||
@@ -150,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.
|
||||
**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 > 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.
|
||||
|
||||
|
||||
@@ -168,16 +168,16 @@ 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 | 12 | 0 | **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 | 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) |
|
||||
| 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 | 3 | 0 | **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** | **179** | **5** | **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 |
|
||||
| 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 |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 72 Paare)
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare)
|
||||
|
||||
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
||||
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
||||
@@ -191,6 +191,16 @@ Die Zahl ist der Ausgabe der Pruefung in
|
||||
`apps/api/src/prisma/rls-access-inventory.spec.ts` entnommen, nicht
|
||||
geschaetzt.
|
||||
|
||||
**Nachtrag 260921-pi9:** 74 Paare — ein neues Paar
|
||||
`dashboard/dashboard-images.service.ts`/`dashboardImage`
|
||||
(muss-mandantengebunden, gebunden) fuer die hochgeladenen Bilder des
|
||||
Bilderrahmen-Widgets. Nachgezaehlt mit `grep -cE` ueber die Fundstellen-
|
||||
tabelle: vor diesem Eintrag standen dort bereits 73 Zeilen, nicht 72 — das
|
||||
Paar `bug-reports/bug-reports.service.ts`/`user` (260914-m97,
|
||||
muss-mandantengebunden) war in der Tabelle eingetragen, in dieser
|
||||
Verteilung aber nie mitgezaehlt. Beide Korrekturen (72→74, 35→37) sind
|
||||
gemessen, nicht geschaetzt.
|
||||
|
||||
**Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle
|
||||
hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben:
|
||||
`tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von
|
||||
@@ -319,11 +329,11 @@ entnommen (30 Zusicherungen, darunter der Wachhund
|
||||
|
||||
| Klasse | Anzahl Paare |
|
||||
|---|---|
|
||||
| muss-mandantengebunden | 35 |
|
||||
| muss-mandantengebunden | 37 |
|
||||
| keine-mandantengebundene-tabelle | 21 |
|
||||
| beides | 14 |
|
||||
| bewusst-uebergreifend | 2 |
|
||||
| **Summe** | **72** |
|
||||
| **Summe** | **74** |
|
||||
|
||||
## Der Hintergrunddienst als Falle — sechs Fälle
|
||||
|
||||
@@ -663,6 +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.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). |
|
||||
|
||||
Reference in New Issue
Block a user