feat(quick-260921-qd3): XFrame-Widget - Webseite als Rahmen im Dashboard, Sandbox ohne Top-Navigation, Neuladen-Intervall

- xframe-config.ts: Resolver (https-Pruefung via isHttpsUrl des Bilderrahmens,
  Titel bis 100 Zeichen, Neuladen 0/60/300/600/1800/3600 s geklemmt),
  XFRAME_SANDBOX ohne allow-top-navigation und allow-modals; 12 Tests zuerst rot
- xframe-widget.tsx: genau ein <iframe> (sandbox, allow="", no-referrer, lazy),
  Kopfleiste mit Titel oder Ecksymbol "In neuem Tab oeffnen", Neuladen ueber
  key-Wechsel mit Timer-Raeumung, transparente Flaeche im Bearbeitungsmodus
  damit die Kachel Ziehgriff bleibt; 12 Tests zuerst rot
- xframe-config-form.tsx: Adresse/Titel mit Uebernahme bei Blur/Enter, http wird
  mit Meldung abgewiesen und nicht gespeichert, Intervall-Auswahl, dauerhafter
  Hinweis auf verweigertes Einbetten; 8 Tests
- Panel-Zweig samt "— Titel" in der Kopfzeile, Registry (12x12, Fenster-Symbol),
  Katalog, Seite, DTO @IsIn, de/en widgets.xframe (15 Schluessel), Umlaut-Allowlist
  "neuem"; der Server ruft die Adresse nie ab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:18:48 +02:00
parent 8bf3601a18
commit d63d9f5563
18 changed files with 904 additions and 7 deletions
@@ -0,0 +1,84 @@
/**
* xframe-config — Typen, Grenzen und reiner Resolver des XFrame-Widgets
* (quick-260921-qd3). Ohne React-Import, damit der Test schlank bleibt
* (Muster picture-frame-config.ts).
*
* T-QD3-03: Die API prueft Widget-Konfigurationen nicht inhaltlich
* (`@IsObject()`, flache Zusammenfuehrung). Deshalb liegt die https-Pruefung
* ALLEIN hier und im Formular: das Formular weist eine Nicht-https-Adresse
* mit Meldung ab und speichert sie nicht; `resolveXframeConfig` laesst beim
* Rendern jede Adresse auf `null` fallen, die der echte `URL`-Parser nicht
* als `https:` erkennt (`http:`, `javascript:`, `data:`, `ftp:`, Unparsbares)
* — ein von Hand manipulierter Config-Wert wird auf dem eigenen Dashboard
* nicht einmal als `<iframe>` gerendert.
*
* T-QD3-01, Sandbox: `allow-same-origin` bleibt bewusst drin — ohne dieses
* Token laeuft die Fremdseite in einem opaken Origin, ihre Cookies, ihr
* `localStorage` und ihre Same-Origin-Aufrufe brechen, die meisten Seiten
* sind dann unbenutzbar. Der Origin ist der der FREMDSEITE, nicht Tesseras;
* die Sandbox hat hier allein die Aufgabe, die Navigation des obersten
* Fensters und Modaldialoge zu unterbinden. Bewusst FEHLEN deshalb
* `allow-top-navigation`, `allow-top-navigation-by-user-activation` (die
* eingebettete Seite kann den Tessera-Tab nicht umlenken) und `allow-modals`
* (keine `alert`/`confirm`/`prompt`, die als Tessera-Dialoge missdeutet
* werden koennten). `allow-popups-to-escape-sandbox` betrifft nur NEUE
* Fenster (z. B. Anmelde-Popups der Fremdseite), nie den Tessera-Tab.
*/
import { isHttpsUrl } from './picture-frame-config';
/** Auswahlstufen des Formulars in Sekunden; 0 = nie. */
export const XFRAME_RELOAD_OPTIONS: number[] = [0, 60, 300, 600, 1800, 3600];
/** Unter der kleinsten Stufe heisst „nie“ — schuetzt die Fremdseite vor Sekundentakt (T-QD3-08). */
export const XFRAME_RELOAD_MIN = 60;
export const XFRAME_RELOAD_MAX = 3600;
export const XFRAME_TITLE_MAX = 100;
/** Exakter Sandbox-Wert des <iframe> — Begruendung im Dateikopf. */
export const XFRAME_SANDBOX =
'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
// Bewusst geteilt mit dem Bilderrahmen: EINE Regel fuer „https-Adresse“ im
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
export { isHttpsUrl };
export interface XframeConfig {
/** null = keine gueltige https-Adresse -> Leerzustand. */
url: string | null;
title: string;
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
reloadSeconds: number;
}
function resolveUrl(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return isHttpsUrl(trimmed) ? trimmed : null;
}
function resolveTitle(raw: unknown): string {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, XFRAME_TITLE_MAX);
}
/**
* Groesste Auswahlstufe <= n, damit das <select> im Formular immer eine
* passende Option zeigt; unter der kleinsten Stufe -> 0 (nie).
*/
function resolveReload(raw: unknown): number {
if (typeof raw !== 'number' || !Number.isFinite(raw)) return 0;
if (raw < XFRAME_RELOAD_MIN) return 0;
if (raw >= XFRAME_RELOAD_MAX) return XFRAME_RELOAD_MAX;
let best = 0;
for (const option of XFRAME_RELOAD_OPTIONS) {
if (option <= raw && option > best) best = option;
}
return best;
}
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
return {
url: resolveUrl(config.url),
title: resolveTitle(config.title),
reloadSeconds: resolveReload(config.reloadSeconds),
};
}