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:
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
|
||||
/**
|
||||
* XframeWidget — eine Webseite per https-Adresse als eingebetteter Rahmen
|
||||
* in der Kachel (quick-260921-qd3).
|
||||
*
|
||||
* Sandbox: `XFRAME_SANDBOX` (Begruendung in xframe-config.ts, T-QD3-01) —
|
||||
* keine Freigabe der Navigation des obersten Fensters, keine Modaldialoge;
|
||||
* `allow=""` delegiert keine Berechtigungen (Kamera, Mikrofon, Standort,
|
||||
* T-QD3-06); `referrerPolicy="no-referrer"`, damit der Fremdhost die
|
||||
* Tessera-Adresse nicht erfaehrt (T-QD3-05).
|
||||
*
|
||||
* Woher die Seite kommt: AUSSCHLIESSLICH der Browser des Benutzers laedt
|
||||
* die Adresse — der Server ruft sie nie ab (T-QD3-04: keine SSRF-Flaeche,
|
||||
* kein Proxy-Endpunkt; interne Hosts sieht damit nur, wer sie ohnehin
|
||||
* erreicht). Was gerendert wird, entscheidet `resolveXframeConfig`
|
||||
* (T-QD3-03): nur eine https-Adresse, sonst der Leerzustand.
|
||||
*
|
||||
* Bearbeitungsmodus (T-QD3-07): ein <iframe> schluckt Mausereignisse und
|
||||
* bricht das Ziehen. Deshalb liegt bei `isEditMode` eine transparente
|
||||
* Flaeche NACH dem Rahmen im DOM ueber ihm, damit `mousedown` zur Karte
|
||||
* `.widget-drag-handle` aufsteigt (dashboard-grid.tsx: Griff = ganze Karte,
|
||||
* eine div startet das Ziehen). Im Ansichtsmodus gibt es die Flaeche nicht,
|
||||
* die Seite bleibt bedienbar.
|
||||
*
|
||||
* Neuladen (T-QD3-08): bei `reloadSeconds > 0` bumpt ein Intervall einen
|
||||
* Zaehler, der Teil des `key` des <iframe> ist — der Rahmen wird neu
|
||||
* eingehaengt. Aufraeumfunktion raeumt den Timer; im Bearbeitungsmodus
|
||||
* laeuft kein Timer.
|
||||
*
|
||||
* Verweigertes Einbetten (`X-Frame-Options`/`frame-ancestors` der
|
||||
* Fremdseite) ist cross-origin nicht zuverlaessig erkennbar — wird nicht
|
||||
* versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“
|
||||
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
|
||||
* Rasters und startet im Bearbeitungsmodus kein Ziehen).
|
||||
*/
|
||||
export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config]);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (url === null || reloadSeconds === 0 || isEditMode) return;
|
||||
const timer = setInterval(() => setReloadNonce((n) => n + 1), reloadSeconds * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [url, reloadSeconds, isEditMode]);
|
||||
|
||||
if (url === null) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||
{t('xframe.empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const openLabel = t('xframe.openInNewTab');
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
{title !== '' && (
|
||||
// Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im
|
||||
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
|
||||
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen.
|
||||
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
|
||||
<NewTabLink
|
||||
url={url}
|
||||
label={openLabel}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<iframe
|
||||
key={`${url}#${reloadNonce}`}
|
||||
src={url}
|
||||
title={title !== '' ? title : url}
|
||||
sandbox={XFRAME_SANDBOX}
|
||||
allow=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
data-testid="xframe-frame"
|
||||
data-reload-nonce={reloadNonce}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
/>
|
||||
{isEditMode && (
|
||||
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />
|
||||
)}
|
||||
{title === '' && (
|
||||
<NewTabLink
|
||||
url={url}
|
||||
label={openLabel}
|
||||
className={`absolute right-1 z-10 rounded bg-card/80 p-1 text-muted-foreground shadow-sm hover:text-foreground ${
|
||||
isEditMode ? 'top-6' : 'top-1'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** „In neuem Tab öffnen“ — echter Link (Tastatur), ohne opener und ohne Referrer (T-QD3-05). */
|
||||
function NewTabLink({ url, label, className }: { url: string; label: string; className: string }) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={className}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
{/* Biome useAnchorContent zaehlt aria-label nicht als Inhalt — der
|
||||
sr-only-Text traegt denselben Namen fuer Vorleser. */}
|
||||
<span className="sr-only">{label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user