docs(quick-260921-qd3): Akte - XFrame-Widget gebaut, Browser-Rundgang ohne Befund; Stand nach beiden Widgets
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m9s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m46s

Plan, Zusammenfassung mit abgehakter Pruefliste und die Zeile in der
Quick-Tabelle von STATE.md; Stopp-Punkt: beide bestellten Widgets fertig,
nichts offen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:29:07 +02:00
parent 8686b1a673
commit 31ca115796
3 changed files with 466 additions and 8 deletions
+9 -8
View File
@@ -4,10 +4,10 @@ milestone: v1.2
current_phase: 18
current_phase_name: desktop-client-fertigstellen
status: verified
stopped_at: "Elf Quick-Vorgaenge am 2026-09-21, alle abgeschlossen und verifiziert. Der gesamte Fehler- und Lint-Rueckstand ist abgearbeitet: 2923 → 123 Diagnosen, 0 Fehlerstufe, Tests 447 → 1679. NICHTS OFFEN aus der Fehlerarbeit. NAECHSTER AUFTRAG: zwei neue Dashboard-Widgets. Das erste ist ein Bilderrahmen, die Produktfragen sind bereits geklaert — Bilder werden hochgeladen ODER per https-Webadresse eingebunden (der Browser laedt Fremdbilder direkt, kein Server-Abruf, damit keine SSRF-Flaeche); Einstellungen: Bildausschnitt (ganz sichtbar oder formatfuellend), Wechselintervall, Reihenfolge oder Zufall, Bildunterschrift, Klick zeigt gross; Stil wie die bestehenden Widgets; Bilder gehoeren dem hochladenden Benutzer, Groesse und Anzahl begrenzt. DAS ZWEITE WIDGET HAT DER NUTZER NOCH NICHT BENANNT — danach fragen. Offen beim Nutzer: alpha ziehen, Windows-Client pruefen, Freigabe 1.3.0 auf Zuruf."
last_updated: "2026-09-21T16:45:00.000Z"
stopped_at: "Zwei Dashboard-Widgets am 2026-09-21 abends gebaut, im Browser nachgewiesen und gepusht: Bilderrahmen (quick-260921-pi9, 4 Commits inkl. Portal-Korrektur der Grossansicht) und XFrame (quick-260921-qd3, 2 Commits). NICHTS OFFEN aus diesen Auftraegen. Offen beim Nutzer: alpha ziehen (beide Widgets kommen mit dem naechsten Beta-Pull), Windows-Client pruefen, Freigabe 1.3.0 auf Zuruf. Kein weiterer Auftrag benannt."
last_updated: "2026-09-21T17:40:00.000Z"
last_activity: 2026-09-21
last_activity_desc: Quick 260921-9ie, a1d, bi2, fi3 und gof — Lint-Tor scharf, Benutzerverwaltung meldet abgewiesene Aktionen, Lint-Rueckstand 2923 → 446, erzwungener Passwortwechsel an der API durchgesetzt (war eine tote Sperre), 21 Effekt-Abhaengigkeiten einzeln beurteilt; alle fuenf verifiziert, die letzten drei am laufenden System
last_activity_desc: Quick 260921-pi9 (Bilderrahmen) und 260921-qd3 (XFrame) — zwei neue Dashboard-Widgets, beide im Browser nachgewiesen, drei Rundgang-Befunde beim Bilderrahmen behoben (Grossansicht per Portal, ICU-Plural, Kachel 8x12); api 1175, web 603
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress:
total_phases: 18
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-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
Last activity: 2026-09-21 - Quick 260921-pi9 und 260921-qd3: die zwei bestellten Dashboard-Widgets „Bilderrahmen“ (Upload in der Datenbank oder https-Adresse, Diashow mit Grossansicht) und „XFrame“ (Webseite als Rahmen, Sandbox ohne Top-Navigation) gebaut und im Browser nachgewiesen; gepusht
Progress: [██████████] 99%
@@ -456,6 +456,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 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/) |
| 260921-qd3 | **Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel.** Zweites der zwei vom Nutzer bestellten Widgets, vom Nutzer so benannt. Config `url` (https-Pflicht ueber dieselbe `isHttpsUrl`-Regel wie der Bilderrahmen, web-seitig doppelt: Formular + Render-Resolver), `title` (max. 100 Zeichen, Kopfleiste), `reloadSeconds` (0/60/300/600/1800/3600; Timer haengt den Rahmen per `key` neu ein, nicht im Bearbeitungsmodus). `<iframe sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox">` — bewusst OHNE `allow-top-navigation*` (die eingebettete Seite kann den Tessera-Tab nicht umleiten) und OHNE `allow-modals`; `allow=""` (keine Kamera/Mikro/Standort-Delegation), `referrerPolicy="no-referrer"`, `loading="lazy"`. Die API ruft die Adresse nie ab (nur `'xframe'` im `@IsIn` des DTO; kein CSP/Frame-Header in apps/web noetig, per grep belegt). Im Bearbeitungsmodus liegt eine unsichtbare Flaeche ueber dem Rahmen, sonst schluckt der iframe die Zeigerereignisse und die Kachel liesse sich nicht ziehen. Ob eine Seite das Einbetten verweigert, entscheidet die fremde Seite (`X-Frame-Options`/`frame-ancestors`, cross-origin nicht erkennbar) — deshalb dauerhafter Hinweis im Formular und immer ein Link „In neuem Tab öffnen“ (`rel="noopener noreferrer"`, in der Kopfleiste oder als Ecksymbol). Formular als eigenes Modul `xframe-config-form.tsx` wie beim Bilderrahmen; Kachel-Vorgabe 12x12. Browser-Rundgang (Orchestrator, neun Punkte + Tests) ohne Befund: example.com im Rahmen, google.com verweigert mit `X-Frame-Options: sameorigin` und der Link fuehrt trotzdem hin, Neuladen nach 60 s mit genau einem zweiten Dokumentabruf, Ziehen und Groesse aendern ueber dem Rahmen, API-Log ohne Fremdabruf. **Zahlen:** web 569 → 603, api 1175 unveraendert, type-check 4/4, lint 5/5, Zaehler unveraendert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` 13). Biome `useAnchorContent` wertet `aria-label` nicht als Linkinhalt → `sr-only`-Text statt `biome-ignore`. | 2026-09-21 | d63d9f5,20a9eb2 | [260921-qd3-dashboard-widget-xframe-eine-webseite-pe](./quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/) |
## Deferred Items
@@ -497,8 +498,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity
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.
Last session: 2026-09-21T17:40:00Z
Resumed: 2026-09-21 (abends) — Sitzung ueber /gsd-resume-work fortgesetzt; danach die zwei bestellten Widgets gebaut.
Stopped at: Beide Widgets fertig, nachgewiesen, gepusht (main == origin/main). Naechster Auftrag nicht benannt. Offen beim Nutzer: alpha ziehen, Windows-Client pruefen, Freigabe 1.3.0 auf Zuruf.
Resume file: None
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
Last activity: 2026-09-21 - Quick 260921-pi9 und 260921-qd3: die zwei bestellten Dashboard-Widgets „Bilderrahmen“ und „XFrame“ gebaut, im Browser nachgewiesen, gepusht
@@ -0,0 +1,240 @@
---
phase: quick-260921-qd3
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260921-QD3]
files_modified:
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/web/src/components/dashboard/widgets/xframe-config.ts
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
- apps/web/src/components/settings/widget-settings-panel.tsx
- apps/web/src/components/settings/widget-settings-panel.test.tsx
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-registry.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.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
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 2
confidence: low
must_haves:
truths:
- "Im Widget-Katalog gibt es „XFrame“ (Beschreibung „Webseite einbetten“ / „Embed a web page“) mit Fenster-Symbol; eine frisch platzierte Kachel (12×8) zeigt zentriert grau „Keine Adresse eingestellt — über die Einstellungen festlegen“ und keinen Rahmen."
- "Unter Einstellungen → Dashboard → XFrame trägt der Benutzer eine https-Adresse (Übernahme bei Blur/Enter), optional einen Titel (höchstens 100 Zeichen) und ein Neuladen-Intervall (Nie / 1 / 5 / 10 / 30 Minuten / 1 Stunde) ein; eine http-, data- oder javascript-Adresse wird mit deutscher `role=\"alert\"`-Meldung abgewiesen und NICHT gespeichert; dauerhaft steht der Hinweis, dass manche Webseiten das Einbetten verweigern."
- "Die Kachel rendert genau ein `<iframe>` mit `src` = Adresse, `sandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"` (keine Freigabe der Navigation des obersten Fensters), `referrerPolicy=\"no-referrer\"`, `loading=\"lazy\"`, leerem `allow`; mit Titel als schmale Kopfleiste samt Knopf „In neuem Tab öffnen“, ohne Titel derselbe Knopf als kleines Symbol in der Ecke; der Knopf ist ein `<a target=\"_blank\" rel=\"noopener noreferrer\">` mit `aria-label` und per Tastatur erreichbar."
- "Bei Neuladen-Intervall > 0 wird der Rahmen im Takt neu eingehängt (`key` aus Adresse + Zähler, Zähler sichtbar als `data-reload-nonce`); der Timer wird beim Aushängen geräumt; im Bearbeitungsmodus läuft kein Timer."
- "Im Bearbeitungsmodus liegt eine transparente Fläche über dem Rahmen, damit die ganze Kachel Ziehgriff bleibt (der Rahmen schluckt sonst die Mausereignisse); im Ansichtsmodus gibt es diese Fläche nicht."
- "Der Server ruft die Adresse nie ab (kein Proxy, kein Fetch); die API lässt `xframe` als Widget-Typ zu und prüft die Konfiguration wie bisher nicht inhaltlich — die https-Prüfung läuft web-seitig zweifach (Formular UND `resolveXframeConfig` beim Rendern)."
- "Alle Tore bleiben grün: `pnpm type-check` 4/4, `pnpm lint` 5/5, API-Tests mindestens 1175 (Stand nach pi9), Web-Tests mindestens 590 (heute 569), Umlaut-Wächter grün; `as unknown as` api 27 / web 6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1, kein `!`, keine neue `any`."
artifacts:
- "apps/web/src/components/dashboard/widgets/xframe-config.ts — `XframeConfig`, `resolveXframeConfig`, `XFRAME_SANDBOX`, `XFRAME_RELOAD_OPTIONS`, `XFRAME_RELOAD_MIN/MAX`, `XFRAME_TITLE_MAX`, Re-Export `isHttpsUrl` aus picture-frame-config.ts; ohne React-Import"
- "apps/web/src/components/dashboard/widgets/xframe-widget.tsx — Kachel mit Kopfleiste/Ecksymbol, `<iframe>`, Neulade-Timer, Bearbeitungs-Overlay, Leerzustand"
- "apps/web/src/components/settings/widget-settings-panel.tsx — Zweig `widget.widgetType === 'xframe'` mit `XframeConfig`-Formular (Adresse, Titel, Intervall, Hinweis) und Titel in der Instanz-Kopfzeile"
- "apps/web/src/components/dashboard/widget-registry.tsx — `WidgetType` + `'xframe'`, `WIDGET_CONSTRAINTS.xframe = { minW: 4, minH: 4, defaultW: 12, defaultH: 8 }`, `XframeIcon`, Registry-Eintrag, `wireXframeWidget`"
- "apps/api/src/dashboard/dto/create-widget.dto.ts — `'xframe'` in `@IsIn([...])`"
- "apps/web/src/messages/de.json + en.json — Namensraum `widgets.xframe`, identischer Schlüsselsatz"
- "CHANGELOG.md — Stichpunkt unter „Unveröffentlicht → Neu“; docs/anleitung-anwender.md — Zeile in der Widget-Tabelle + Satz im Abschnitt Dashboard > Widgets"
key_links:
- "Katalog `WIDGET_TYPES` -> `addWidget('xframe')` -> `POST /dashboard/widgets` mit `widgetType: 'xframe'` -> `CreateWidgetDto @IsIn` (ohne den Eintrag 400) -> Kachel über `WIDGET_REGISTRY.xframe.component` (verdrahtet in `(portal)/page.tsx`)"
- "Formular `commitUrl` -> `isHttpsUrl` -> `onChange({ url })` -> `PATCH /dashboard/widgets/:id/config` (flache Zusammenführung, bestehend) -> Kachel `resolveXframeConfig(config)` -> `url` nur wenn https, sonst `null` -> Leerzustand"
- "`reloadSeconds > 0 && !isEditMode` -> `setInterval` -> `reloadNonce + 1` -> `key` wechselt -> `<iframe>` wird neu eingehängt; Aufräumfunktion `clearInterval`"
- "`isEditMode` -> `<div className=\"absolute inset-0\" aria-hidden>` NACH dem `<iframe>` im DOM -> Mausereignisse treffen die Fläche, nicht den Rahmen -> `mousedown` steigt zur Karte `.widget-drag-handle` auf (dashboard-grid.tsx: `handle` ganze Karte, `cancel` nur input/textarea/select/button/a/[data-no-drag]/.widgetNoDrag)"
---
# Quick-Aufgabe 260921-qd3: Dashboard-Widget „XFrame“
<objective>
Ein neues Dashboard-Widget „XFrame“ (Widget-Typ `xframe`, Übersetzungs-Namensraum `widgets.xframe`): eine Webseite wird per https-Adresse als eingebetteter Rahmen (`<iframe>`) in der Kachel angezeigt. Einstellungen: Adresse, Titel, optionales Neuladen-Intervall. Stil und Bedienmuster wie die bestehenden Widgets; die eingebettete Seite darf die Tessera-Seite nicht verlassen (Sandbox ohne Freigabe der Navigation des obersten Fensters); der Server ruft die Adresse nie ab; das Formular weist dauerhaft darauf hin, dass manche Seiten das Einbetten verweigern, und die Kachel bietet immer „In neuem Tab öffnen“.
Purpose: zweites der zwei vom Nutzer gewünschten neuen Widgets (STATE.md „NAECHSTER AUFTRAG“, das erste — Bilderrahmen, quick-260921-pi9 — ist in HEAD c3b4597); die Produktfragen sind geklärt, die technischen Entscheidungen hat der Orchestrator getroffen (Kasten unten) — dieser Plan setzt sie um, ohne sie neu zu öffnen.
Output: reiner Konfigurations-Resolver mit Tests (zuerst rot), Web-Widget + Formular im Einstellungs-Panel + Katalog/Registry/Seiten-Verdrahtung + Übersetzungen mit Tests, ein Wort im API-DTO, Changelog- und Handbuch-Eintrag; alle Tore grün.
</objective>
## Gebundene Entscheidungen (Orchestrator, nicht neu verhandeln)
1. **Typ und Name.** Widget-Typ `xframe`, Anzeigename „XFrame“ in beiden Sprachen (Wahl des Nutzers), Beschreibung de „Webseite einbetten“ / en „Embed a web page“. Neunter Widget-Typ — `picture-frame` ist bereits da (verifiziert in HEAD: Registry, Katalog, Seite, DTO, Panel tragen es); `xframe` wird überall **nach** `picture-frame` ergänzt.
2. **Konfiguration** im bestehenden Config-JSON: `url: string` (nur https), `title?: string` (höchstens 100 Zeichen), `reloadSeconds: number` (0 = nie; Auswahl 0/60/300/600/1800/3600; Voreinstellung 0). Resolver `resolveXframeConfig(raw)` mit Voreinstellungen und Klemmung, reines Modul ohne React-Import, zuerst rot getestet. **https-Prüfung:** `isHttpsUrl` ist in `picture-frame-config.ts` exportiert (HEAD, Zeile 45: echter `URL`-Parser, `protocol === 'https:'`) — wird importiert und re-exportiert, nicht dupliziert. **Befund am Code (wie pi9):** die API prüft Widget-Konfigurationen nicht inhaltlich (`UpdateWidgetConfigDto` nur `@IsObject()`, `updateWidgetConfig` führt flach zusammen) — deshalb läuft die https-Prüfung web-seitig zweifach: Formular (abweisen) UND Resolver (beim Rendern fällt jede Nicht-https-Adresse auf `null` → Leerzustand). Ein manipulierter Config-Wert schadet nur dem eigenen Dashboard und wird dort nicht einmal gerendert.
3. **Rendering.** `<iframe src={url} title={title || url} sandbox={XFRAME_SANDBOX} allow="" referrerPolicy="no-referrer" loading="lazy" className="h-full w-full border-0 bg-background">` mit `XFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox'`. Keine Freigabe der Navigation des obersten Fensters (kein `allow-top-navigation`, kein `allow-top-navigation-by-user-activation`) — die eingebettete Seite kann den Tessera-Tab nicht umlenken. Dateikopf-Kommentar erklärt, warum `allow-same-origin` trotzdem nötig ist: ohne dieses Token läuft 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, Navigation des obersten Fensters und Modaldialoge (`alert`/`confirm`/`prompt` sind ohne `allow-modals` gesperrt) zu unterbinden. `allow` bleibt leer (keine Delegation von Kamera/Mikrofon/Standort). **Kein CSP-Umbau nötig:** in apps/web ist nirgends eine Content-Security-Policy, `frame-src` oder `X-Frame-Options` gesetzt (verifiziert per grep über next.config, middleware, api main.ts) — Einbetten fremder https-Seiten braucht keine Header-Änderung. Neuladen: bei `reloadSeconds > 0` bumpt ein `setInterval` einen `reloadNonce`-Zustand, der Teil des `key` des `<iframe>` ist → Neueinhängen; Aufräumfunktion räumt den Timer; im Bearbeitungsmodus kein Timer.
4. **Bearbeitungsmodus.** Ein `<iframe>` schluckt Mausereignisse und bricht das Ziehen. Bei `isEditMode` liegt eine transparente Fläche `<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />` **nach** dem `<iframe>` im DOM über dem Rahmen (Standard-`pointer-events`), damit `mousedown` zur Karte `.widget-drag-handle` aufsteigt (dashboard-grid.tsx: Griff = ganze Karte, `cancel`-Selektor `input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag` — eine `div` startet das Ziehen). Im Ansichtsmodus keine Fläche. Test: im Bearbeitungsmodus existiert die Fläche, im Ansichtsmodus nicht.
5. **„In neuem Tab öffnen“.** `<a href={url} target="_blank" rel="noopener noreferrer" aria-label={t('xframe.openInNewTab')} title={…}>` mit Inline-SVG (externer Link), per Tastatur erreichbar (echter Link). Mit Titel sitzt er rechts in der Kopfleiste; ohne Titel als kleines Symbol in der rechten oberen Ecke (`absolute right-1 z-10`, `top-1` im Ansichtsmodus, `top-6` im Bearbeitungsmodus — die Griff-Kopfleiste der Karte ist 20 px hoch, `top-6` = 24 px liegt darunter). Der Link ist ein `a` und damit im `cancel`-Selektor: im Bearbeitungsmodus klickbar, startet kein Ziehen. Leerzustand (keine gültige Adresse): zentrierter grauer Text `xframe.empty`, kein Rahmen, kein Link. Verweigertes Einbetten (`X-Frame-Options`/`frame-ancestors` der Fremdseite) ist cross-origin nicht zuverlässig erkennbar — **nicht** versuchen; stattdessen zeigt das Formular dauerhaft den Hinweis `xframe.embedHint`, und die Kachel bietet den Link immer, sobald eine Adresse gesetzt ist.
6. **Einstellungsformular** `XframeConfig({ config, onChange })` als weitere Formularfunktion **in** `widget-settings-panel.tsx` (Muster `ClockConfig`/`FavoritesConfig`; das Formular ist klein — drei Felder plus Hinweis —, deshalb kein eigenes Modul wie beim Bilderrahmen): Adresse als Entwurf mit Übernahme bei Blur/Enter (`http://` → `role="alert"` `xframe.urlInvalid`, kein `onChange`); Titel als Entwurf mit Übernahme bei Blur/Enter; Intervall als `<select>`; gleiche Klassenketten wie die Nachbarformulare (`mb-1 block text-sm text-foreground`, `h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground`). Zusätzlich zeigt die Instanz-Kopfzeile des Panels „— Titel“ auch für `xframe` (Bedingung, die heute `note`/`favorites` abdeckt, erweitern).
7. **Constraints** `WIDGET_CONSTRAINTS.xframe = { minW: 4, minH: 4, defaultW: 12, defaultH: 8 }` (eine Webseite braucht Breite — halbe Rasterbreite). **Symbol** `XframeIcon`: Inline-SVG Browserfenster (`rect x=3 y=4 width=18 height=16 rx=2`, `line 3 9 → 21 9`, zwei kleine Kreise `cx=6.5`/`cx=9.5` bei `cy=6.5`, `r=0.5`), gleiche Attribute wie die Nachbarn (`aria-hidden`, `stroke="currentColor"`, `strokeWidth="2"`).
8. **Texte** Deutsch mit „Sie“ plus Englisch; CHANGELOG-Stichpunkt unter „Unveröffentlicht → Neu“ (kein Fließtext), Zeile in der Widget-Tabelle von `docs/anleitung-anwender.md` (nach „Bilderrahmen“) und ein Satz im Abschnitt „Dashboard > Widgets“ der persönlichen Einstellungen. **Umlaut-Wächter** (`apps/web/src/messages/umlaut-guard.spec.ts`): jedes Token mit `ae/oe/ue/ss` in de.json muss in `UMLAUT_ALLOWLIST` stehen; vorab gegen die Allowlist geprüft — einziger neuer Verdachts-Token ist **`neuem`** („In neuem Tab öffnen“, korrektes Deutsch wie das bereits gelistete `neuen`) → in `umlaut-dictionary.ts` in die Allowlist aufnehmen (Muster pi9: `Webadresse`, `Bildausschnitt`). `Adresse` und `lassen` sind bereits gelistet.
9. **API:** nur `'xframe'` in `CreateWidgetDto @IsIn` (sonst 400 beim Anlegen). Kein Prisma-Schema, keine Migration (Schema-Tor feuert nicht), kein neuer Endpunkt, **niemals** ein serverseitiger Abruf der Adresse (keine SSRF-Fläche). Keine neuen Pakete.
## Ausgangsmessung (21.09.2026, HEAD c3b4597 nach pi9)
| Größe | Wert |
|---|---:|
| API-Tests | 1175 |
| Web-Tests | 569 |
| `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) |
| `biome-ignore` in apps/api/src | 1 |
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widget-registry.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/picture-frame-config.ts
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/dashboard-grid.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.test.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/messages/umlaut-dictionary.ts
@/home/vicolab/projects/tessera-ctl/.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-SUMMARY.md
</context>
## Hinweise für den Executor
- **Ausgangspunkt ist HEAD (c3b4597).** pi9 ist vollständig committet; `git status` zeigt nur `.planning/`. Alle Stellen, an denen pi9 `picture-frame` eingetragen hat (`git diff 573d070 HEAD --stat`), bekommen `xframe` **direkt dahinter** in derselben Form: `WidgetType`, `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY`, `wire…Widget`, `WIDGET_TYPES` im Katalog, Import + `wire…` in `(portal)/page.tsx`, `vi.mock` in `page.test.tsx`, Übersetzungsattrappe im Katalog-Test, Typliste + Erwartungstabelle + Zähler im Registry-Test (32 → 36, „neun Typen“), `@IsIn` im DTO, Zweig im Panel.
- **Tore vor jedem Commit:** `pnpm type-check`, `pnpm lint`, die betroffenen Vitest-Dateien; am Ende (Aufgabe 2) `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollständig.
- **Rot-Nachweis:** `xframe-config.test.ts` und die Widget-Tests werden VOR der Umsetzung geschrieben und einmal rot gefahren (Ausgabe kurz im SUMMARY festhalten).
- **Kein `any`**, keine neue `as unknown as`, kein `!`. `sandbox`, `allow`, `referrerPolicy`, `loading` sind reguläre React-Props des `<iframe>` — keine Zusicherung nötig.
- **jsdom lädt keine Unterressourcen** — ein `<iframe src="https://…">` im Test erzeugt keinen Netzabruf; Attribute per `getAttribute` prüfen, Neueinhängen über Objektidentität (`before !== after`) und `data-reload-nonce`.
- **Commits:** je Aufgabe genau ein Commit, Stil `git log --oneline -15`, Scope `quick-260921-qd3`, deutsche Betreffzeile. Akte/STATE-Commit macht der Orchestrator.
- **Nie** auf den Testserver deployen; Browser-Rundgang macht der Orchestrator lokal (Prüfliste im SUMMARY).
<tasks>
<task type="tracer" tdd="true">
<name>Aufgabe 1: Resolver (rot → grün), Widget, Formular im Panel, Verdrahtung, Übersetzungen, API-DTO — Ende-zu-Ende „Adresse eintragen → Seite erscheint in der Kachel“</name>
<files>apps/web/src/components/dashboard/widgets/xframe-config.ts, apps/web/src/components/dashboard/widgets/xframe-config.test.ts, apps/web/src/components/dashboard/widgets/xframe-widget.tsx, apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/settings/widget-settings-panel.test.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.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, apps/api/src/dashboard/dto/create-widget.dto.ts</files>
<behavior>
- `resolveXframeConfig({})` → `{ url: null, title: '', reloadSeconds: 0 }`; `url: 'https://intern.example/board'` → unverändert (getrimmt); `url: ' https://a.de/x '` → `'https://a.de/x'`; `url: 'http://a.de'`, `'javascript:alert(1)'`, `'data:text/html,x'`, `'ftp://a.de'`, `'kein-url'`, `42`, `''` → `url: null`; `title: ' Board '` → `'Board'`; Titel mit 150 Zeichen → auf 100 gekürzt; `title: 7` → `''`; `reloadSeconds`: fehlt/`'abc'`/`-5`/`30` → `0` (unter dem kleinsten Intervall heißt „nie“ — schützt die Fremdseite vor Sekundentakt), `60` → `60`, `90` → `60` (größte Auswahlstufe ≤ n), `599` → `300`, `600` → `600`, `99999` → `3600`.
- `XFRAME_SANDBOX` enthält `allow-scripts`, `allow-same-origin`, `allow-forms`, `allow-popups`, `allow-popups-to-escape-sandbox` und kein Token, das `top-navigation` oder `modals` enthält (Regex-Prüfung auf den Konstantenwert). `XFRAME_RELOAD_OPTIONS` gleich `[0, 60, 300, 600, 1800, 3600]`. `isHttpsUrl` ist aus `xframe-config` importierbar und liefert für `'HTTPS://A.DE'` true, für `'http://a.de'` false.
- Widget, `{}` → Text `xframe.empty`, kein `<iframe>`, kein Link. `{ url: 'http://a.de' }` → ebenfalls Leerzustand (Resolver weist ab).
- Widget, `{ url: U }` → genau ein `<iframe>` (`data-testid="xframe-frame"`) mit `src === U`, `title === U`, `sandbox === XFRAME_SANDBOX`, `sandbox` enthält kein `top-navigation`, `referrerpolicy === 'no-referrer'`, `loading === 'lazy'`, `allow === ''`, `data-reload-nonce === '0'`; kein `<h2>`; ein Link `role="link"` mit Name `xframe.openInNewTab`, `href === U`, `target === '_blank'`, `rel` enthält `noopener` und `noreferrer`, Klasse enthält `absolute` (Ecksymbol).
- Widget, `{ url: U, title: 'Board' }` → `<h2>` mit „Board“, `<iframe title="Board">`, der Link steht in der Kopfleiste (Vorfahre mit Klasse `border-b`), Klasse ohne `absolute`.
- Widget, `{ url: U, title: '<b>x</b>' }` → der Text `<b>x</b>` erscheint wörtlich (`getByText`), `container.querySelector('b')` ist `null` (React-Escaping).
- Widget, `{ url: U, reloadSeconds: 60 }` mit Fake-Timern → `vi.getTimerCount()` 1; nach `advanceTimersByTime(60_000)` ist das `<iframe>`-Element ein **anderes** Objekt als vorher und `data-reload-nonce === '1'`, nach weiteren 60 000 ms `'2'`; `unmount()` → `vi.getTimerCount()` 0. `reloadSeconds: 0` → `getTimerCount()` 0. `isEditMode: true` + `reloadSeconds: 60` → `getTimerCount()` 0 (kein Neuladen beim Bearbeiten).
- Widget, `isEditMode: true` → `data-testid="xframe-edit-overlay"` existiert, hat `aria-hidden="true"`, Klassen `absolute` und `inset-0`, steht im DOM **nach** dem `<iframe>` (`compareDocumentPosition`); `isEditMode: false` → kein Overlay.
- Panel (`WidgetSettingsPanel` mit `{ id: 'x1', widgetType: 'xframe', config: { url: U, title: 'Board', reloadSeconds: 300 } }`, aufgeklappt): Feld `xframe-url` hat Wert U, Feld `xframe-title` Wert „Board“, Auswahl `xframe-reload` Wert `'300'`, der Hinweis `widgets.xframe.embedHint` (Text aus de.json) ist sichtbar, Instanz-Kopfzeile enthält „— Board“. Adresse auf `https://b.de/` ändern + Blur → `updateWidgetConfig('x1', { url: 'https://b.de/' })` genau einmal und `onWidgetUpdate` gleich. Adresse auf `http://b.de` + Enter → `role="alert"` mit `widgets.xframe.urlInvalid`, `aria-invalid` am Feld, **kein** Aufruf. Feld leeren + Blur → `{ url: '' }` (Adresse entfernen ist erlaubt → Leerzustand). Titel ändern + Blur → `{ title: 'Neu' }`; Auswahl `'600'` → `{ reloadSeconds: 600 }`. Gleiche Adresse erneut übernehmen (Blur ohne Änderung) → kein Aufruf.
- Registry: `WIDGET_CONSTRAINTS.xframe` gleich `{ minW: 4, minH: 4, defaultW: 12, defaultH: 8 }`, Gesamtzähler 36; Katalog zeigt einen Knopf mit Namen /XFrame/; `CreateWidgetDto` lässt `'xframe'` zu (Auszug aus der `@IsIn`-Liste im DTO-Kommentar: „nine supported types“).
</behavior>
<action>
**Reihenfolge: Resolver rot → grün, dann Widget (Tests zuerst), dann Formular im Panel, zuletzt Verdrahtung, Übersetzungen, DTO.**
1. **`xframe-config.ts`** (Muster `picture-frame-config.ts`, ohne React-Import). Dateikopf-Kommentar (Deutsch, wie die Nachbarn): Zweck; warum die https-Prüfung ALLEIN hier und im Formular liegt (API prüft Config nicht inhaltlich, Entscheidung 2, T-QD3-03); warum `allow-same-origin` in der Sandbox bleibt und welche Tokens bewusst fehlen (Entscheidung 3, T-QD3-01). Exporte: `XFRAME_RELOAD_OPTIONS = [0, 60, 300, 600, 1800, 3600] as const`-artig als `number[]`, `XFRAME_RELOAD_MIN = 60`, `XFRAME_RELOAD_MAX = 3600`, `XFRAME_TITLE_MAX = 100`, `XFRAME_SANDBOX` (Entscheidung 3, exakter String), `interface XframeConfig { url: string | null; title: string; reloadSeconds: number }`, `resolveXframeConfig(config: Record<string, unknown>): XframeConfig`, und `export { isHttpsUrl } from './picture-frame-config'` (Re-Export, damit Formular und Tests eine Quelle haben; Kommentar: bewusst geteilt mit dem Bilderrahmen, eine Regel für „https-Adresse“ im ganzen Dashboard). Regeln: `url` nur wenn String, getrimmt, `isHttpsUrl` true — sonst `null`; `title` nur wenn String, getrimmt, `slice(0, XFRAME_TITLE_MAX)` — sonst `''`; `reloadSeconds`: nicht endliche Zahl oder `< XFRAME_RELOAD_MIN` → 0, `≥ XFRAME_RELOAD_MAX` → 3600, sonst größter Wert aus `XFRAME_RELOAD_OPTIONS`, der `≤ n` ist (damit das `<select>` im Formular immer eine passende Option zeigt). **`xframe-config.test.ts`** mit allen Fällen aus `<behavior>` (mindestens 12 `it`), vor der Umsetzung rot.
2. **`xframe-widget.tsx`** (`'use client'`, `export function XframeWidget({ config, isEditMode }: WidgetProps)`; `instanceId` wird nicht gebraucht — Props-Muster wie `PictureFrameWidget`). Dateikopf-Kommentar: Sandbox-Begründung (verweist auf `XFRAME_SANDBOX`), warum der Server nie abruft (T-QD3-04), warum im Bearbeitungsmodus eine Fläche über dem Rahmen liegt (Entscheidung 4, T-QD3-07). Aufbau: `const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config])`; `const [reloadNonce, setReloadNonce] = useState(0)`; `useEffect` mit Abhängigkeiten `[url, reloadSeconds, isEditMode]`: wenn `url === null || reloadSeconds === 0 || isEditMode` → nichts; sonst `const timer = setInterval(() => setReloadNonce((n) => n + 1), reloadSeconds * 1000)` und Aufräumfunktion `clearInterval(timer)`. Leerzustand (`url === null`): `<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">{t('xframe.empty')}</div>` und sonst nichts. Andernfalls Wurzel `<div className="relative flex h-full w-full flex-col overflow-hidden">`: (a) bei Titel eine Kopfleiste `<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">` mit `<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>` (Muster Favoriten-Kopfzeile) und dem Link (Klasse `shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground`); (b) Rumpf `<div className="relative min-h-0 flex-1">` mit dem `<iframe>` (Entscheidung 3; `key={`${url}#${reloadNonce}`}`, `data-testid="xframe-frame"`, `data-reload-nonce={reloadNonce}`), danach bei `isEditMode` die Fläche aus Entscheidung 4, danach — nur ohne Titel — der Link als Ecksymbol (`absolute right-1 z-10 rounded bg-card/80 p-1 text-muted-foreground shadow-sm hover:text-foreground` plus `top-1`/`top-6` je Modus). Der Link (eine kleine Funktion `NewTabLink({ url, className })` in derselben Datei): `<a href={url} target="_blank" rel="noopener noreferrer" aria-label={t('xframe.openInNewTab')} title={t('xframe.openInNewTab')}>` mit Inline-SVG 16×16 (`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"`, `aria-hidden`). Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im Bearbeitungsmodus überdeckt die 20-px-Griffleiste der Karte den oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen. **`xframe-widget.test.tsx`** (Muster `picture-frame-widget.test.tsx`: `vi.mock('next-intl')` mit Durchreiche `t(key) => key`, Fake-Timer in `beforeEach`, `cleanup` + `useRealTimers` in `afterEach`), mindestens 10 `it` gemäß `<behavior>`, vor der Umsetzung rot.
3. **Formular im Panel** (`widget-settings-panel.tsx`): Import `XFRAME_RELOAD_OPTIONS, XFRAME_TITLE_MAX, isHttpsUrl, resolveXframeConfig` aus `@/components/dashboard/widgets/xframe-config`; Zweig `{widget.widgetType === 'xframe' && (<XframeConfig config={widget.config} onChange={(cfg) => handleConfigChange(widget.id, cfg)} />)}` nach dem Bilderrahmen-Zweig; die Kopfzeilen-Bedingung `(widget.widgetType === 'note' || widget.widgetType === 'favorites')` um `|| widget.widgetType === 'xframe'` erweitern. Funktion `XframeConfig` am Dateiende (Kommentar `// XFrame (quick-260921-qd3, Muster ClockConfig/FavoritesConfig)`): `const { url, title, reloadSeconds } = resolveXframeConfig(config)`; `urlDraft` (`useState(url ?? '')`), `urlError` (boolean), `titleDraft` (`useState(title)`). `commitUrl`: `const raw = urlDraft.trim()`; leer → `setUrlError(false)`, wenn `url !== null` → `onChange({ url: '' })`; nicht `isHttpsUrl(raw)` → `setUrlError(true)`, kein `onChange`; sonst `setUrlError(false)`, wenn `raw !== url` → `onChange({ url: raw })`. `commitTitle`: `const next = titleDraft.trim().slice(0, XFRAME_TITLE_MAX)`; wenn `next !== title` → `onChange({ title: next })`. Felder: Adresse `<input id="xframe-url" type="url" inputMode="url" placeholder={t('xframe.urlPlaceholder')} aria-invalid={urlError || undefined} aria-describedby="xframe-url-hint">` mit `onBlur={commitUrl}` und Enter-Handling wie `commitFontSize` in `ClockConfig`; darunter `<p id="xframe-url-hint" className="mt-1 text-xs text-muted-foreground">{t('xframe.embedHint')}</p>` (dauerhaft, Entscheidung 5) und bei Fehler `<p role="alert" className="mt-1 text-xs text-destructive">{t('xframe.urlInvalid')}</p>`; Titel `<input id="xframe-title" type="text" maxLength={XFRAME_TITLE_MAX} placeholder={t('xframe.titlePlaceholder')}>` mit Blur/Enter-Übernahme; Intervall `<select id="xframe-reload" value={String(reloadSeconds)} onChange={(e) => onChange({ reloadSeconds: Number(e.target.value) })}>` über `XFRAME_RELOAD_OPTIONS` mit Beschriftung `0 → t('xframe.reloadOff')`, `60 → t('xframe.reloadMinute')`, `3600 → t('xframe.reloadHour')`, sonst `t('xframe.reloadMinutes', { n: s / 60 })`. Labels `mb-1 block text-sm text-foreground`, Felder die Klassenkette aus Entscheidung 6, Abstände `space-y-4`. **`widget-settings-panel.test.tsx`**: neuer `describe('WidgetSettingsPanel — XFrame (quick-260921-qd3)')` nach dem vorhandenen Muster (Texte aus der echten de.json, `updateWidgetConfig`-Attrappe, Instanz aufklappen per Klick auf die Kopfzeile), mindestens 6 `it` gemäß `<behavior>`.
4. **Verdrahtung** (alles „nach `picture-frame`“): `widget-registry.tsx` — Kopfkommentar um `xframe: Webseite als Rahmen (quick-260921-qd3)` ergänzen, `WidgetType | 'xframe'`, `WIDGET_CONSTRAINTS.xframe` mit Kommentar (`// quick-260921-qd3: eine Webseite braucht Breite — 12x8 = halbe Rasterbreite; 4x4 kleinste Kachel, in der ein Rahmen noch Sinn hat`), `XframeIcon` (Entscheidung 7), Registry-Eintrag `xframe: { type: 'xframe', nameKey: 'xframe.name', descriptionKey: 'xframe.description', icon: XframeIcon, ...WIDGET_CONSTRAINTS.xframe, component: PlaceholderWidget }`, `wireXframeWidget` nach dem Muster `wirePictureFrameWidget`. `widget-registry.test.tsx` — `'xframe'` in `ALL_WIDGET_TYPES`, Zeile in der `toEqual`-Tabelle, `counted` 32 → 36, Testtitel „…; quick-260921-qd3: XFrame dazu, neun Typen“. `widget-catalog-modal.tsx` — `'xframe'` als letzter Eintrag in `WIDGET_TYPES`; `widget-catalog-modal.test.tsx` — Attrappe um `'xframe.name': 'XFrame'`, `'xframe.description': 'Webseite einbetten'`. `(portal)/page.tsx` — `wireXframeWidget` in die Import-Liste, `import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget'`, `wireXframeWidget(XframeWidget)` nach `wirePictureFrameWidget`; `page.test.tsx` — `vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }))`.
5. **Übersetzungen** `de.json`/`en.json`, Namensraum `widgets.xframe` direkt nach `pictureFrame`, exakt diese Schlüssel in beiden Dateien: `name` („XFrame“/„XFrame“), `description` („Webseite einbetten“/„Embed a web page“), `empty` („Keine Adresse eingestellt — über die Einstellungen festlegen“/„No address set — configure it in the settings“), `openInNewTab` („In neuem Tab öffnen“/„Open in a new tab“), `urlLabel` („Adresse (https)“/„Address (https)“), `urlPlaceholder` („https://…“ beide), `urlInvalid` („Bitte geben Sie eine vollständige https-Adresse ein.“/„Please enter a complete https address.“), `titleLabel` („Titel“/„Title“), `titlePlaceholder` („Titel (optional)“/„Title (optional)“), `reloadLabel` („Automatisch neu laden“/„Reload automatically“), `reloadOff` („Nie“/„Never“), `reloadMinute` („Jede Minute“/„Every minute“), `reloadMinutes` („Alle {n} Minuten“/„Every {n} minutes“), `reloadHour` („Jede Stunde“/„Every hour“), `embedHint` („Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem.“/„Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page.“). **`umlaut-dictionary.ts`**: `'neuem'` in `UMLAUT_ALLOWLIST` aufnehmen (Kommentar `// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen`). Danach `pnpm --filter @tessera/web exec vitest run src/messages` — der Wächter meldet jedes weitere vergessene Token mit Pfad; dann ebenfalls in die Allowlist (nur korrekte deutsche Wörter, keine Umschreibungen).
6. **API-DTO** `create-widget.dto.ts`: `'xframe'` als letzter Eintrag in `@IsIn([...])`, Kommentar „one of the nine supported types ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3)“. Sonst nichts an der API.
Commit: `feat(quick-260921-qd3): XFrame-Widget - Webseite als Rahmen im Dashboard, Sandbox ohne Top-Navigation, Neuladen-Intervall` (Wortlaut frei, Stil beachten).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/components/settings src/messages "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/web lint && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint && test "$(grep -rn 'as unknown as' apps/web/src --include=*.ts --include=*.tsx | wc -l)" -eq 6 && grep -q "'xframe'" apps/api/src/dashboard/dto/create-widget.dto.ts && node -e "const d=require('./apps/web/src/messages/de.json').widgets.xframe,e=require('./apps/web/src/messages/en.json').widgets.xframe;if(!d||!e){console.error('xframe-Namensraum fehlt');process.exit(1)}const m=Object.keys(d).filter(k=>!(k in e)).concat(Object.keys(e).filter(k=>!(k in d)));if(m.length){console.error('Schluessel ungleich:',m);process.exit(1)}"</automated>
</verify>
<done>`xframe-config.test.ts` ≥ 12, `xframe-widget.test.tsx` ≥ 10, neuer Panel-`describe` ≥ 6 Fälle — alle grün, Resolver- und Widget-Tests nachweislich zuerst rot (Rot-Lauf im SUMMARY). Registry-/Katalog-/Seiten-Tests grün mit dem neunten Typ (Zähler 36). Umlaut-Wächter grün (`neuem` gelistet). Beide Sprachdateien tragen denselben Schlüsselsatz unter `widgets.xframe`. Kette nachgewiesen (Tests): https-Adresse im Formular → `updateWidgetConfig(id, { url })`; http → Meldung, kein Aufruf; Kachel rendert `<iframe>` mit exakter Sandbox, `no-referrer`, `lazy`, leerem `allow`; Neuladen hängt neu ein und räumt den Timer; im Bearbeitungsmodus Overlay und kein Timer; Titel wird escaped. `as unknown as` web 6 / api 27, keine `any`, kein `!`. `pnpm --filter @tessera/web exec tsc --noEmit` und beide Linter ohne Befund.</done>
</task>
<task type="auto">
<name>Aufgabe 2: Changelog, Anwenderhandbuch, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
<files>CHANGELOG.md, docs/anleitung-anwender.md</files>
<action>
1. **`CHANGELOG.md`** unter „Unveröffentlicht → Neu“ als ERSTER Stichpunkt (vor dem Bilderrahmen; kein Fließtext, Tonlage der Nachbarzeilen): „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“.
2. **`docs/anleitung-anwender.md`**: (a) in der Widget-Tabelle (Kopf „| Widget | Zweck |“, Zeile ~73) nach der Zeile „Bilderrahmen“ eine Zeile „| 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 |“; (b) im Satz „Für Uhr, Suchleiste, Kalender, Notizen, Favoriten und Bilderrahmen gibt es zusätzliche Einstellungen (…)“ (Zeile ~84) „und XFrame“ sowie in der Klammer „Adresse, Titel und Neuladen des XFrame“ ergänzen; (c) im Absatz „**Dashboard > Widgets:**“ (Zeile ~154) am Ende einen Satz anfügen: „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.“ Siezen, Schreibweise der Nachbarzeilen (Anführungszeichen „…“, „Einstellungen > Dashboard“).
3. **Volle Tore**: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`; Zähler messen wie in pi9-SUMMARY (Tabelle „Endmessung“: `as unknown as` api/web per grep, `noNonNullAssertion`/`noExplicitAny` per `biome lint` in apps/api/src, `biome-ignore` per grep) und ins SUMMARY schreiben.
4. **Prüfliste** im SUMMARY für den Orchestrator (Browser, Playwright-MCP, lokal — NICHT Testserver), Punkt für Punkt abhakbar: (a) Dashboard → Bearbeiten → „Widget hinzufügen“ zeigt „XFrame“ mit Fenster-Symbol und Beschreibung „Webseite einbetten“; platzierte Kachel ist 12×8 und zeigt den Leerhinweis; (b) Einstellungen → Dashboard → „XFrame #1“ aufklappen: Hinweistext „Manche Webseiten lassen sich nicht einbetten …“ steht dauerhaft da; https-Adresse einer einbettbaren Seite (z. B. eine interne Tessera-Seite oder `https://example.com`) eintragen, Feld verlassen → Kachel zeigt die Seite; (c) `http://…` eintragen → rote Meldung, Netzwerk-Tab zeigt keinen PATCH; (d) Titel „Board“ → Kopfleiste mit Titel und Symbol „In neuem Tab öffnen“ rechts; Titel leeren → Symbol wandert in die rechte obere Ecke; (e) Klick auf das Symbol öffnet die Adresse in einem neuen Tab, der Tessera-Tab bleibt; Tab-Taste erreicht das Symbol; (f) Intervall „Jede Minute“ → nach 60 s wird der Rahmen neu geladen (Netzwerk-Tab: zweiter Dokumentabruf; im DOM springt `data-reload-nonce` auf 1); (g) Bearbeitungsmodus: Kachel lässt sich an einer Stelle **über dem Rahmen** anfassen und ziehen, Größe ändern funktioniert, im DOM liegt `xframe-edit-overlay`; Ansichtsmodus: Overlay weg, Seite bedienbar (Scrollen/Klicken im Rahmen); (h) Adresse einer Seite, die Einbetten verweigert (z. B. `https://www.google.com`) → Rahmen bleibt leer (Browser-Konsole meldet `X-Frame-Options`/`frame-ancestors`), „In neuem Tab öffnen“ funktioniert; (i) API-Log während (b)/(f): kein Abruf der Fremdadresse durch die API — nur der Browser lädt sie; (j) `pnpm --filter @tessera/api test` und `-web test` grün, Zähler wie Ausgangsmessung.
Commit: `docs(quick-260921-qd3): Changelog und Anwenderhandbuch - XFrame-Widget` (nur CHANGELOG.md + docs/anleitung-anwender.md; Akte/STATE macht der Orchestrator).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'XFrame' CHANGELOG.md && test "$(grep -c '| XFrame |' docs/anleitung-anwender.md)" -eq 1 && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
</verify>
<done>Changelog-Stichpunkt steht als erster unter „Unveröffentlicht → Neu“; Handbuch trägt die Tabellenzeile, den erweiterten Satz und den Absatz-Zusatz; `pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Befund der Stufe `error`; API ≥ 1175 Tests, Web ≥ 590 Tests, alle grün; Zähler unverändert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1); die zehnpunktige Prüfliste steht im SUMMARY; genau zwei Code/Doku-Commits mit Scope `quick-260921-qd3` (`git log --oneline -2`).</done>
</task>
</tasks>
<assumption_delta_decision>
Assumption-Delta-Detektor: Quick-Aufgabe ohne ROADMAP-Abschnitt → Abfrage liefert `phase_unresolved` (übersprungen, kein Verdikt). Gedanklich ausgeführt über die Aufgabenbeschreibung: **feuert nicht** — genau EINE Adresse, EIN Titel, EIN Intervall; keine zweite Variante, kein Pflichtfeld wird optional, kein abgeleiteter Wert wird gewählt. `url` bleibt einfacher String im Config-JSON, kein Vereinigungstyp nötig. Entscheidung: `no-change`.
API-Coverage-Detektor (`api-coverage.cjs --json` über die Aufgabenbeschreibung): `{"detected":false,"signals":[]}` — kein externer Dienst, keine SDK-Integration; der Browser rendert eine Fremdseite in einem Rahmen, die API kennt nur das Wort `xframe` in einer Zulassungsliste. Keine COVERAGE.md nötig.
Schema-Tor: kein Prisma-, Migrations- oder Schema-Pfad im Umfang → feuert nicht.
</assumption_delta_decision>
<threat_model>
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
## Vertrauensgrenzen
| Grenze | Beschreibung |
|---|---|
| Config-JSON → Browser | `url`/`title`/`reloadSeconds` stammen aus dem vom Benutzer selbst beschreibbaren Widget-Config (API prüft nicht inhaltlich) und landen in `<iframe src>`, `title`, Text und Timer |
| Tessera-Seite ↔ eingebettete Fremdseite | Die Fremdseite läuft im eigenen Origin innerhalb der Tessera-Kachel; sie sieht die Anfrage (und ohne Gegenmaßnahme den Referrer) und könnte versuchen, das oberste Fenster zu navigieren oder Berechtigungen zu nutzen |
| Browser → Fremdhost | Nur der Browser des Benutzers ruft die Adresse ab; der Server nie |
| Widget → Grid (Ziehen) | Ein `<iframe>` schluckt Mausereignisse; die Bedienbarkeit des Bearbeitungsmodus hängt am Overlay |
## STRIDE-Register
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|---|---|---|---|---|---|
| T-QD3-01 | Spoofing / Elevation (Navigation des obersten Fensters, Phishing) | `<iframe sandbox>` in `xframe-widget.tsx` | high | mitigate | `sandbox={XFRAME_SANDBOX}` OHNE `allow-top-navigation` und OHNE `allow-top-navigation-by-user-activation` — die Fremdseite kann `window.top` nicht umlenken, der Tessera-Tab bleibt Tessera. Ohne `allow-modals`: keine `alert`/`confirm`/`prompt`-Dialoge, die als Tessera-Dialoge missdeutet werden könnten. `allow-popups-to-escape-sandbox` betrifft nur NEUE Fenster (Popups verlassen die Sandbox, damit z. B. Anmelde-Popups der Fremdseite funktionieren), nie den Tessera-Tab. Getestet: Konstante und gerendertes Attribut enthalten kein `top-navigation`/`modals`. |
| T-QD3-02 | Tampering (Clickjacking, Richtung) | Tessera als Einbettender | low | accept | Hier bettet Tessera FREMDE Seiten ein — die klassische Clickjacking-Richtung (jemand bettet Tessera ein) ist unverändert und außerhalb des Umfangs: Tesseras eigene `frame-ancestors`/`X-Frame-Options`-Lage wird durch dieses Widget nicht berührt (in apps/web ist heute kein solcher Header gesetzt; das war vor dem Widget so und bleibt so). Umgekehrt kann die eingebettete Seite Tessera-Elemente nicht überlagern: sie lebt in ihrer eigenen Kachel, Tessera legt nichts Interaktives über sie außer dem Bearbeitungs-Overlay. |
| T-QD3-03 | Tampering (gefährliche Schemata: `javascript:`, `data:`, `http:` Mixed Content) | `url` im Config-JSON | high | mitigate | `isHttpsUrl` (echter `URL`-Parser, `protocol === 'https:'`) im Formular (Abweisung mit Meldung, kein Speichern) UND in `resolveXframeConfig` beim Rendern (`url` wird `null` → Leerzustand, `<iframe>` wird nicht einmal gerendert). Serverseitig nicht prüfbar (API kennt keine Config-Inhalte) — Risiko bleibt auf das eigene Dashboard beschränkt. Getestet: `http:`, `javascript:`, `data:`, `ftp:`, Unparsbares → `null`. |
| T-QD3-04 | Server-Side Request Forgery | API | high | mitigate | Die API ruft NIE die Adresse ab: kein Proxy-Endpunkt, kein Fetch, `url` ist für die API ein undurchsichtiger JSON-Wert; einzige API-Änderung ist das Wort `'xframe'` in `@IsIn`. Der Browser des Benutzers lädt die Seite — interne Hosts sieht damit nur, wer sie ohnehin erreicht. Nachweis im Rundgang (i). |
| T-QD3-05 | Information Disclosure (Referrer) | `<iframe>` auf Fremdhost, Link „In neuem Tab öffnen“ | low | mitigate | `referrerPolicy="no-referrer"` am `<iframe>`, `rel="noopener noreferrer"` am Link — der Fremdhost erfährt die Tessera-Adresse nicht, das neue Fenster hat keinen `window.opener`. |
| T-QD3-06 | Elevation of Privilege (Berechtigungs-Delegation) | `allow`-Attribut | medium | mitigate | `allow=""` — keine Delegation von Kamera, Mikrofon, Standort, Zahlung o. ä. an die Fremdseite; getestet (`getAttribute('allow') === ''`). |
| T-QD3-07 | Denial of Service (Bedienbarkeit: Ziehen/Größe im Bearbeitungsmodus) | Overlay in `xframe-widget.tsx` | medium | mitigate | Transparente Fläche über dem Rahmen nur bei `isEditMode` (getestet: vorhanden/nicht vorhanden, Position im DOM nach dem `<iframe>`), damit `mousedown` die Karte erreicht; im Ansichtsmodus bleibt die Seite bedienbar. Rundgang (g). |
| T-QD3-08 | Denial of Service (Neulade-Takt gegen Fremdhost / eigenen Browser) | Timer | low | mitigate | Resolver klemmt: alles unter 60 s wird „nie“, Obergrenze 3600 s; kein Timer im Bearbeitungsmodus; Aufräumfunktion beim Aushängen (getestet über `vi.getTimerCount()`). |
| T-QD3-09 | Tampering (XSS über Titel) | `title` in Kopfleiste und `<iframe title>` | medium | mitigate | Nur React-Textknoten bzw. Attributwert (React escaped), keine HTML-Einfügung; Titel auf 100 Zeichen gekürzt. Getestet: `<b>x</b>` erscheint wörtlich, kein `<b>`-Element. |
| T-QD3-10 | Repudiation | Änderungen an Adresse/Titel | low | accept | Kein Audit-Log — persönliche Kachel ohne Fremdwirkung; für ASVS 1 ausreichend. |
| T-QD3-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — `<iframe>` ist HTML, Sandbox ein Attribut. Sollte der Executor dennoch ein Paket installieren wollen: Stopp, Rückfrage an den Orchestrator. |
</threat_model>
<verification>
Automatisch (Executor, je Aufgabe im `<verify>`): Web-Tests der neuen und angefassten Dateien inklusive `src/messages` (Umlaut-Wächter + de/en-Schlüsselgleichheit), `tsc --noEmit` beider Apps, Biome beider Apps, Zähler `as unknown as`, `'xframe'` im DTO, Schlüsselparität `widgets.xframe`.
Am Ende (Aufgabe 2): `pnpm type-check` 4/4, `pnpm lint` 5/5, volle Testläufe beider Apps, Disziplin-Zähler wie in der Ausgangsmessung, Handbuch-Zeile genau einmal.
Manuell (Orchestrator, Prüfliste aus Aufgabe 2 Punkt 4, lokal im Browser): Katalog, Leerzustand, https → Seite erscheint, http → Meldung, Kopfleiste/Ecksymbol, neuer Tab ohne Verlassen des Tessera-Tabs, Neuladen nach 60 s, Ziehen über dem Rahmen im Bearbeitungsmodus, verweigertes Einbetten bleibt leer + Link funktioniert, kein Server-Abruf.
</verification>
<success_criteria>
- [ ] Alle sieben `must_haves.truths` erfüllt und je mit Test oder Rundgangspunkt belegt
- [ ] Sandbox exakt `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`, `allow=""`, `referrerPolicy="no-referrer"`, `loading="lazy"` — per Test am gerenderten Element
- [ ] http/javascript/data-Adressen: Formular weist ab (Meldung, kein Speichern), Resolver rendert nichts
- [ ] Neuladen: Neueinhängen im Takt, Timer geräumt, kein Timer im Bearbeitungsmodus
- [ ] Bearbeitungsmodus: Overlay vorhanden und nach dem Rahmen im DOM; Ansichtsmodus ohne Overlay
- [ ] Katalog/Registry/Seite/DTO tragen `xframe` als neunten Typ, Registry-Zähler 36
- [ ] Beide Sprachdateien vollständig, Texte siezen, Umlaut-Wächter grün (`neuem` gelistet)
- [ ] Changelog-Stichpunkt und Handbuch-Zeile/-Sätze vorhanden
- [ ] Tore grün, Zähler unverändert, keine neue `any`, zwei Commits mit Scope `quick-260921-qd3`
</success_criteria>
<output>
Nach Abschluss `.planning/quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/260921-qd3-SUMMARY.md` anlegen (Muster `260921-pi9-SUMMARY.md`): Rot-Nachweis der Resolver-/Widget-Tests, Zahlen der Endmessung, die zehnpunktige Browser-Prüfliste für den Orchestrator, offene Punkte.
</output>
@@ -0,0 +1,217 @@
---
phase: quick-260921-qd3
plan: 01
subsystem: apps/web/src/components/dashboard/widgets, apps/web/src/components/settings, apps/api/src/dashboard/dto
tags: [dashboard, widget, xframe, iframe, sandbox, tdd, i18n]
status: complete
requires:
- "STATE.md „NAECHSTER AUFTRAG“: zweites der zwei neuen Dashboard-Widgets, Produktfragen geklaert"
- "quick-260921-pi9 (Bilderrahmen): isHttpsUrl in picture-frame-config.ts, Muster fuer Widget/Formular/Verdrahtung"
provides:
- "Widget-Typ xframe: Webseite per https-Adresse als <iframe> in der Kachel, Sandbox ohne Top-Navigation und ohne Modals"
- "Einstellungen (Adresse, Titel, Neuladen-Intervall) im WidgetSettingsPanel, Formular als eigenes Modul"
- "Uebersetzungs-Namensraum widgets.xframe (15 Schluessel de/en)"
affects:
- "apps/web/src/components/dashboard/widget-registry.tsx (neunter Typ, Zaehler 36)"
- "apps/api/src/dashboard/dto/create-widget.dto.ts (@IsIn)"
- "apps/web/src/messages/umlaut-dictionary.ts (Allowlist: neuem)"
tech-stack:
added: []
patterns:
- "Fremdseite laedt nur der Browser (<iframe referrerPolicy=no-referrer sandbox=…>), die API kennt keinen Proxy und ruft die Adresse nie ab"
- "https-Pruefung zweifach web-seitig (Formular weist ab, Resolver rendert nichts), weil die API Config-Inhalte nicht prueft"
- "Neuladen ueber key-Wechsel des <iframe> (Adresse + Zaehler), Zaehler als data-reload-nonce sichtbar"
- "Transparente Flaeche NACH dem <iframe> im DOM nur im Bearbeitungsmodus, damit die Karte Ziehgriff bleibt"
key-files:
created:
- apps/web/src/components/dashboard/widgets/xframe-config.ts
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
- apps/web/src/components/settings/xframe-config-form.tsx
- apps/web/src/components/settings/xframe-config-form.test.tsx
modified:
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/web/src/components/settings/widget-settings-panel.tsx
- apps/web/src/components/settings/widget-settings-panel.test.tsx
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-registry.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.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:
- "Formular als eigenes Modul xframe-config-form.tsx (Orchestrator-Aenderung 1, statt Funktion im Panel wie der Plan vorsah) — gleiches Muster wie der Bilderrahmen, eigener Test mit dem echten ICU-Uebersetzer"
- "Constraints 12x12 statt 12x8 (Orchestrator-Aenderung 2): 8 Zeilen sind nur 216 px, zu flach fuer eine Webseite; Bilderrahmen wurde in 8bf3601 aus demselben Grund auf 8x12 gehoben"
- "Link „In neuem Tab öffnen“ traegt zusaetzlich zum aria-label einen sr-only-Text: Biome useAnchorContent zaehlt aria-label nicht als Inhalt, ein biome-ignore ist verboten — der sichtbare Name bleibt identisch"
- "Re-Export von isHttpsUrl aus picture-frame-config.ts, keine Kopie: eine Regel fuer „https-Adresse“ im ganzen Dashboard"
metrics:
duration: "ca. 8 min (19:12 bis 19:20 Uhr, 21.09.2026)"
completed: 2026-09-21
actuals:
tokens: 14400
tasks: 2
commits: 2
plan_head_before: 8bf3601
---
# Quick-Aufgabe 260921-qd3: Dashboard-Widget „XFrame“ Summary
Ein neues Dashboard-Widget zeigt eine Webseite per https-Adresse als
eingebetteten Rahmen in der Kachel. Die Sandbox laesst der Fremdseite
Skripte, Formulare, Popups und ihren eigenen Origin, verbietet aber die
Navigation des obersten Fensters und Modaldialoge — der Tessera-Tab bleibt
Tessera. Adresse, optionaler Titel (Kopfleiste) und Neuladen-Intervall
(nie / 1 / 5 / 10 / 30 Minuten / 1 Stunde) stehen unter Einstellungen ->
Dashboard; „In neuem Tab öffnen“ ist immer da, sobald eine Adresse gesetzt
ist. Der Server ruft die Adresse nie ab. Alle Tore sind gruen.
## Was gebaut wurde
**Resolver (`xframe-config.ts`).** Reines Modul ohne React: `url` nur wenn
String, getrimmt und vom echten `URL`-Parser als `https:` erkannt (Re-Export
`isHttpsUrl` aus dem Bilderrahmen, nicht dupliziert) — sonst `null`; `title`
getrimmt und auf 100 Zeichen gekuerzt; `reloadSeconds` nicht endlich oder
unter 60 -> 0 (nie), ab 3600 -> 3600, sonst die groesste Auswahlstufe <= n,
damit das `<select>` immer eine passende Option zeigt. `XFRAME_SANDBOX =
'allow-scripts allow-same-origin allow-forms allow-popups
allow-popups-to-escape-sandbox'`; der Dateikopf erklaert, warum
`allow-same-origin` bleibt (Origin der Fremdseite, nicht Tesseras; ohne das
Token brechen Cookies/localStorage der meisten Seiten) und welche Tokens
bewusst fehlen (Top-Navigation, Modals).
**Widget (`xframe-widget.tsx`).** Leerzustand als zentrierter grauer Text
(auch bei http/javascript/data — der Resolver liefert `null`). Sonst genau
ein `<iframe>` mit `sandbox`, `allow=""`, `referrerPolicy="no-referrer"`,
`loading="lazy"`, `title` = Titel oder Adresse, `key` aus Adresse + Zaehler,
Zaehler als `data-reload-nonce`. Mit Titel eine schmale Kopfleiste (`h2` +
Link rechts, Muster Favoriten), ohne Titel der Link als Ecksymbol
(`absolute right-1`, `top-1` im Ansichts-, `top-6` im Bearbeitungsmodus
unter der 20-px-Griffleiste). Der Link ist ein echtes `<a target="_blank"
rel="noopener noreferrer">` mit `aria-label`, `title` und sr-only-Text.
Timer nur bei `url !== null && reloadSeconds > 0 && !isEditMode`,
Aufraeumfunktion `clearInterval`. Im Bearbeitungsmodus liegt eine
transparente `div` (`absolute inset-0`, `aria-hidden`) NACH dem Rahmen im
DOM, damit `mousedown` zur Karte `.widget-drag-handle` aufsteigt.
**Formular (`xframe-config-form.tsx`, eigenes Modul).** Adresse und Titel
als Entwuerfe mit Uebernahme bei Blur/Enter; leere Adresse -> `{ url: '' }`
(Entfernen erlaubt), Nicht-https -> `role="alert"` + `aria-invalid`, kein
`onChange`; unveraenderter Wert -> kein Aufruf; Intervall als `<select>`
mit Beschriftungen Nie / Jede Minute / Alle {n} Minuten / Jede Stunde; der
Einbett-Hinweis steht dauerhaft unter dem Adressfeld. Im Panel nur der
Zweig `widgetType === 'xframe'` und die Kopfzeilen-Bedingung „— Titel“.
**Verdrahtung.** `WidgetType | 'xframe'`, `WIDGET_CONSTRAINTS.xframe =
{ minW 4, minH 4, defaultW 12, defaultH 12 }`, `XframeIcon`
(Browserfenster), Registry-Eintrag, `wireXframeWidget`, Katalog-Liste,
Import + Verdrahtung in `(portal)/page.tsx`, `'xframe'` in
`CreateWidgetDto @IsIn` (Kommentar: neun Typen). de/en: 15 Schluessel unter
`widgets.xframe`, identischer Satz; `neuem` auf der Umlaut-Allowlist.
**Doku (Commit 20a9eb2).** Changelog-Stichpunkt als erster unter
„Unveroeffentlicht -> Neu“, Zeile in der Widget-Tabelle, erweiterter Satz
und Absatz-Zusatz unter „Dashboard > Widgets“ im Anwenderhandbuch.
## Die Tests, und der Beleg dass sie rot waren
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|---|---:|---|
| `xframe-config.test.ts` | 12 | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/xframe-config.test.ts src/components/dashboard/widgets/xframe-widget.test.tsx` -> `Error: Failed to resolve import "./xframe-config" from "src/components/dashboard/widgets/xframe-config.test.ts"`, Test Files 2 failed (2), Tests no tests |
| `xframe-widget.test.tsx` | 12 | derselbe Lauf -> `Failed to resolve import "./xframe-config" from "src/components/dashboard/widgets/xframe-widget.test.tsx"`; nach der Umsetzung erster Lauf 12/12 |
| `xframe-config-form.test.tsx` | 8 | nach dem Resolver, vor dem Formular geschrieben; erster Lauf nach der Umsetzung 8/8 |
| `widget-settings-panel.test.tsx` (Test X1) | 1 | Zweig rendert das Formular, Kopfzeile „— Board“, Blur -> `updateWidgetConfig('x1', { url })` genau einmal |
Zusammen 33 neue Faelle; Registry-/Katalog-/Seiten-Tests laufen mit dem
neunten Typ (Erwartungstabelle um `xframe`, `counted` 32 -> 36, Attrappen um
`xframe.*` und das neue Widget-Modul ergaenzt). Web-Tests 569 -> 603
(+34: 33 neue Faelle + 1 durch `it.each` in der Registry).
## Messungen (Endstand, HEAD 20a9eb2)
| Groesse | Ausgang (8bf3601) | Jetzt |
|---|---:|---:|
| `pnpm type-check` | 4/4 | 4/4 |
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web 53, keine Stufe `error`) |
| API-Tests | 1175 | **1175** (75 Dateien) |
| Web-Tests | 569 | **603** (80 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` / `!` / `any` in den neuen Dateien | – | 0 / 0 / 0 |
| de/en-Schluesselgleichheit `widgets.xframe` | – | 15 = 15 |
Keine neue `any`, kein `!`, kein neues Paket, kein Prisma-/Schema-Pfad.
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP — NICHT Testserver)
- [x] (a) Dashboard -> Bearbeiten -> „Widget hinzufügen“ zeigt „XFrame“ mit Fenster-Symbol und Beschreibung „Webseite einbetten“; die platzierte Kachel ist 12x12 und zeigt „Keine Adresse eingestellt — über die Einstellungen festlegen“
- [x] (b) Einstellungen -> Dashboard -> „XFrame #1“ aufklappen: Hinweistext „Manche Webseiten lassen sich nicht einbetten …“ steht dauerhaft da; https-Adresse einer einbettbaren Seite (z. B. eine interne Tessera-Seite oder `https://example.com`) eintragen, Feld verlassen -> Kachel zeigt die Seite
- [x] (c) `http://…` eintragen -> rote Meldung „Bitte geben Sie eine vollständige https-Adresse ein.“, Netzwerk-Tab zeigt keinen PATCH
- [x] (d) Titel „Board“ -> Kopfleiste mit Titel und Symbol „In neuem Tab öffnen“ rechts; Titel leeren -> Symbol wandert in die rechte obere Ecke
- [x] (e) Klick auf das Symbol oeffnet die Adresse in einem neuen Tab, der Tessera-Tab bleibt; Tab-Taste erreicht das Symbol
- [x] (f) Intervall „Jede Minute“ -> nach 60 s wird der Rahmen neu geladen (Netzwerk-Tab: zweiter Dokumentabruf; im DOM springt `data-reload-nonce` auf 1)
- [x] (g) Bearbeitungsmodus: Kachel laesst sich an einer Stelle ueber dem Rahmen anfassen und ziehen, Groesse aendern funktioniert, im DOM liegt `xframe-edit-overlay`; Ansichtsmodus: Overlay weg, Seite bedienbar (Scrollen/Klicken im Rahmen)
- [x] (h) Adresse einer Seite, die Einbetten verweigert (z. B. `https://www.google.com`) -> Rahmen bleibt leer (Browser-Konsole meldet `X-Frame-Options`/`frame-ancestors`), „In neuem Tab öffnen“ funktioniert
- [x] (i) API-Log waehrend (b)/(f): kein Abruf der Fremdadresse durch die API — nur der Browser laedt sie
- [x] (j) `pnpm --filter @tessera/api test` (1175) und `pnpm --filter @tessera/web test` (603) gruen, Zaehler wie Ausgangsmessung (siehe Tabelle oben)
**Rundgang durch den Orchestrator am 21.09.2026 (lokaler Stack, Abbilder aus 20a9eb2, Playwright-MCP):** alle Punkte bestanden. Belege: (a) Katalogeintrag „XFrame — Webseite einbetten“ mit Symbol, Kachel 801x328 px (12x12), Leerhinweis; (b) Hinweistext dauerhaft unter dem Adressfeld, `https://example.com/` → Kachel zeigt „Example Domain“ im Rahmen (`sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"`, `referrerpolicy=no-referrer`, `allow=""`, `loading=lazy`); (c) `http://example.com` → Meldung, `aria-invalid`, kein PATCH; https danach genau ein PATCH; (d) Titel „Board“ → Kopfleiste mit `<h2>` und Link in der Leiste (`border-b`-Vorfahr); (e) Klick oeffnet `https://example.com/` in einem neuen Tab, Tessera-Tab bleibt auf `/`, Tab-Taste erreicht den Link; (f) „Jede Minute“: `data-reload-nonce` 0 → 1 nach 62 s, genau ein zweiter Dokumentabruf von example.com; (g) Bearbeitungsmodus: Overlay `aria-hidden` NACH dem iframe, Ziehen ueber den Rahmen verschiebt die Kachel (`translate` 8 → 278 px), Groesse aendern 801x328 → 935x636, nach dem Speichern kein Overlay; (h) `https://www.google.com/` → Konsole „Refused to display … 'X-Frame-Options' to 'sameorigin'“, Rahmen leer, Link fuehrt zur Seite; (i) API-Log ohne Treffer auf `example.com`/`google.com`. Keine Befunde, keine Korrektur noetig.
## Deviations from Plan
1. **Orchestrator-Aenderung 1 umgesetzt:** das Einstellungsformular liegt
als `XframeConfigForm` in `apps/web/src/components/settings/xframe-config-form.tsx`
mit eigenem Test (8 Faelle, `createTranslator`-Attrappe wie beim
Bilderrahmen) statt als Funktion im Panel; das Panel traegt nur den
Zweig, die Kopfzeilen-Bedingung und einen Testfall (X1). Aufgabe 1,
Commit d63d9f5.
2. **Orchestrator-Aenderung 2 umgesetzt:** Constraints `12x12` statt `12x8`
(Registry, Registry-Test, Pruefpunkt (a)). Aufgabe 1, Commit d63d9f5.
3. **[Rule 3 - Blocking] Biome `useAnchorContent`** meldete den Link
„In neuem Tab öffnen“ (nur `aria-hidden`-SVG als Kind, `aria-label`
zaehlt fuer die Regel nicht als Inhalt) als neue Warnung (web 53 -> 54).
Kein `biome-ignore` erlaubt — der Link traegt zusaetzlich einen
`sr-only`-Text mit demselben Namen; `aria-label` und `title` bleiben wie
im Plan. Web-Lint wieder 53. Aufgabe 1, Commit d63d9f5.
4. **Widget-Test 7** prueft `vi.getTimerCount()` direkt nach dem Rendern
(ohne vorherige Interaktion haelt React keinen Scheduler-Timer, gemessen 1
= nur unser Intervall) — der pi9-Vorbehalt (Test 11 dort) trat hier nicht
auf.
5. **Zwei zusaetzliche Widget-Faelle** ueber die geforderten 10 hinaus
(Test 11 Ansichtsmodus ohne Overlay, Test 12 `top-1`/`top-6`); Resolver
genau 12 wie gefordert.
Nicht geaendert: `STATE.md`, `ROADMAP.md`, keine neue Abhaengigkeit, kein
Deploy, kein Zugriff auf den Testserver.
## Known Stubs
Keine. Kette verdrahtet: Katalog -> `POST /dashboard/widgets` (`@IsIn`) ->
Kachel ueber `WIDGET_REGISTRY.xframe.component`; Formular -> `isHttpsUrl`
-> `PATCH /dashboard/widgets/:id/config` -> `resolveXframeConfig` ->
`<iframe>`; Neuladen -> `key`-Wechsel; Bearbeitungsmodus -> Overlay.
## Threat Flags
Keine neue Flaeche ausserhalb des `<threat_model>` des Plans: T-QD3-01
(Sandbox ohne Top-Navigation/Modals), -03 (https zweifach), -04 (kein
Server-Abruf), -05 (no-referrer, noopener noreferrer), -06 (`allow=""`),
-07 (Overlay), -08 (Klemmung 60..3600, kein Timer beim Bearbeiten), -09
(Titel nur als Textknoten) sind je per Test belegt; -04 zusaetzlich
Rundgangspunkt (i).
## Self-Check: PASSED
Alle 6 neu angelegten Dateien liegen auf der Platte, die zwei Commits
d63d9f5 und 20a9eb2 sind in `git log` auffindbar
(`git rev-list --count 8bf3601..HEAD` = 2). Die Zahlen der Tabelle stammen
aus tatsaechlich gelaufenen Befehlen.