docs(quick-260921-qd3): Akte - XFrame-Widget gebaut, Browser-Rundgang ohne Befund; Stand nach beiden Widgets
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:
+240
@@ -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>
|
||||
+217
@@ -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.
|
||||
Reference in New Issue
Block a user