syncova-policies/components/site-pages-form.tsx
Weapie 0134dd6db4
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m33s
alangu.de fuer die Gebaerdensprach-Einbindung freigeben
Der Player wird als iframe eingebunden, aber nicht als rohes HTML aus dem
Textfeld: Die Anwendung setzt die Attribute, im Admin steht nur die
Adresse. Damit laesst sich ueber das Feld nichts anderes einschleusen.

Freigegeben ist alangu.de samt Unterdomains, sonst nichts. Die Pruefung
haengt am Hostnamen, nicht an einem Textvergleich - getestet mit
boese-alangu.de.angreifer.example und evil.example/?a=alangu.de, beide
werden abgewiesen, ebenso http statt https.

Vom uebergebenen Einbettungscode weggelassen: accelerometer und
gyroscope. Ein Videoplayer braucht keine Bewegungssensoren.

Ausserdem: Die Gebaerdensprach-Seite erscheint jetzt auch ohne Text,
wenn eine Videoadresse hinterlegt ist - dort ist das Video der Inhalt.
Fusszeile und Seite entscheiden das ueber dieselbe Funktion, sonst gaebe
es eine erreichbare Seite ohne Link.

Geprueft am echten Player: laedt mit HTTP 200 samt CSS, JS und hls.js,
axe-core meldet in hell und dunkel keine Verstoesse, der Rahmen ist per
Tastatur erreichbar, und die Fusszeile verlinkt die Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:28:41 +02:00

344 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useState } from "react"
import { AlertCircle, ExternalLink } from "lucide-react"
import Link from "next/link"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { MarkdownInput } from "@/components/markdown-input"
import { updateSitePages, useSitePages } from "@/hooks/use-site-pages"
import { ApiError } from "@/lib/api"
import {
EMPTY_SITE_PAGES,
SITE_PAGES,
SITE_PAGE_META,
SitePageKey,
SitePagesContent,
} from "@/lib/site-pages"
/**
* Vorlagen als Platzhalter. Sie werden nicht gespeichert, sondern zeigen nur,
* welche Angaben die jeweilige Seite enthalten muss.
*/
const placeholders: Record<SitePageKey, string> = {
imprint: `## Angaben gemäß § 5 DDG
**Amt Musterstadt**
Musterstraße 1
12345 Musterstadt
**Vertreten durch:** Amtsvorsteher/in Vorname Nachname
**Kontakt**
Telefon: 01234 5678-0
E-Mail: info@amt-musterstadt.de
**Zuständige Aufsichtsbehörde**
Name und Anschrift der Rechtsaufsicht
**Verantwortlich für den Inhalt:** Vorname Nachname, Anschrift`,
privacy: `## Datenschutzerklärung für dieses Portal
Diese Erklärung betrifft die Verarbeitung Ihrer Daten beim **Besuch dieses
Portals**. Die Merkblätter zu den einzelnen Verwaltungsverfahren finden Sie in
der Übersicht.
### Verantwortlicher
Name, Anschrift, Kontakt
### Datenschutzbeauftragte/r
Name, Anschrift, E-Mail
### Server-Protokolldaten
Beim Aufruf werden IP-Adresse, Zeitpunkt, aufgerufene Seite, übertragene
Datenmenge und Browserkennung protokolliert.
*Zweck:* technischer Betrieb und Abwehr von Angriffen
*Rechtsgrundlage:* Art. 6 Abs. 1 lit. e DSGVO i. V. m. Landesrecht
*Speicherdauer:* z. B. 7 Tage
### Cookies
Ohne Anmeldung setzt das Portal keine Cookies. Im Admin-Bereich wird ein
technisch notwendiges Sitzungs-Cookie verwendet.
### Ihre Rechte
Auskunft (Art. 15), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung
(Art. 18), Widerspruch (Art. 21) sowie Beschwerde bei der Aufsichtsbehörde
(Art. 77 DSGVO).`,
accessibility: `## Erklärung zur Barrierefreiheit
Diese Erklärung gilt für die Website **datenschutz.beispiel.de**.
### Stand der Vereinbarkeit mit den Anforderungen
Diese Website ist mit der BITV 2.0 *[vollständig / teilweise / nicht]*
vereinbar.
### Nicht barrierefreie Inhalte
Hier die bekannten Einschränkungen und deren Begründung auflisten.
### Erstellung dieser Erklärung
Diese Erklärung wurde am TT.MM.JJJJ erstellt.
Grundlage war eine *[Selbstbewertung / Prüfung durch Dritte]*.
Letzte Überprüfung: TT.MM.JJJJ
### Barrieren melden – Kontakt
Sie können uns Mängel bei der Barrierefreiheit mitteilen und Informationen in
barrierefreier Form anfordern:
Name, E-Mail, Telefon
### Schlichtungsverfahren
Wenn wir auf Ihre Meldung nicht zufriedenstellend reagieren, können Sie die
Schlichtungsstelle nach § 16 BGG anrufen:
Anschrift und Kontakt der zuständigen Schlichtungsstelle`,
easyLanguage: `## Willkommen
Das hier ist die Internet-Seite vom Amt Musterstadt.
Hier steht: Was macht das Amt mit Ihren Daten?
## Was finden Sie auf dieser Seite?
Sie finden hier eine Liste.
In der Liste stehen viele Themen.
Zum Beispiel: Bau-Antrag. Oder: Wohn-Geld.
Zu jedem Thema gibt es einen Text.
In dem Text steht: Welche Daten braucht das Amt?
Und: Wie lange speichert das Amt die Daten?
## Wie finden Sie ein Thema?
Oben auf der Seite ist ein Such-Feld.
Dort können Sie ein Wort eintippen.
Dann zeigt die Seite nur noch passende Themen.
Sie können auch auf ein Thema in der Liste klicken.
## Barriere-Freiheit
Barriere-frei heißt: Alle Menschen können die Seite benutzen.
Wir arbeiten daran, dass die Seite barriere-frei wird.
Manche Sachen sind noch nicht barriere-frei.
Sie können uns das melden.
Schreiben Sie an: *[E-Mail-Adresse eintragen]*
*Dieser Text muss vor der Veröffentlichung von einer Prüf-Gruppe
gegengelesen werden. Das ist bei Leichter Sprache üblich.*`,
signLanguage: `## Informationen in Deutscher Gebärdensprache
Das Video oben erklärt:
- die wesentlichen Inhalte dieses Portals
- wie die Navigation funktioniert
- die wesentlichen Inhalte der Erklärung zur Barrierefreiheit
Die Adresse des Videos tragen Sie oben in das Feld ein; es erscheint dann
über diesem Text.
### Kontakt
Fragen zur Barrierefreiheit richten Sie bitte an:
*[Kontaktdaten eintragen]*`,
}
interface SitePagesFormProps {
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* Pflegt Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit.
* Ein leer gelassener Text nimmt die betreffende Seite vom Netz.
*/
export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
const { pages, isLoading } = useSitePages()
const [formData, setFormData] = useState<SitePagesContent>(EMPTY_SITE_PAGES)
const [errors, setErrors] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [tab, setTab] = useState<SitePageKey>("imprint")
// Beim Öffnen den gespeicherten Stand laden
useEffect(() => {
if (!open || isLoading) return
setErrors([])
setTab("imprint")
setFormData({ ...pages })
// `pages` bewusst nicht als Abhängigkeit: sonst würden Eingaben beim
// Hintergrund-Refresh von SWR überschrieben.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isLoading])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setErrors([])
setIsSubmitting(true)
try {
await updateSitePages(formData)
toast.success("Seiteninhalte gespeichert")
onOpenChange(false)
} catch (error) {
const details =
error instanceof ApiError && error.details.length > 0
? error.details
: [error instanceof Error ? error.message : "Fehler beim Speichern"]
setErrors(details)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Rechtliche Seiten</DialogTitle>
<DialogDescription>
Die eigenständigen Seiten des Portals: drei rechtliche Pflichtseiten
sowie Leichte Sprache und Gebärdensprache nach § 4 BITV 2.0. Sie
erscheinen in der Fußzeile der öffentlichen Seiten – ein leer
gelassener Text nimmt die betreffende Seite vom Netz.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<Tabs
value={tab}
onValueChange={(value) => setTab(value as SitePageKey)}
className="min-h-0 flex-1 gap-3"
>
<TabsList className="h-auto flex-wrap">
{SITE_PAGES.map((key) => (
<TabsTrigger key={key} value={key}>
{SITE_PAGE_META[key].short}
</TabsTrigger>
))}
</TabsList>
{errors.length > 0 && (
<div className="flex gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<ul className="space-y-1">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
<DialogBody className="pt-1">
{SITE_PAGES.map((key) => (
<TabsContent key={key} value={key} className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{SITE_PAGE_META[key].hint}</span>
{formData[key].trim() && (
<Button variant="ghost" size="sm" asChild>
<Link href={SITE_PAGE_META[key].path} target="_blank">
<ExternalLink />
{SITE_PAGE_META[key].path}
</Link>
</Button>
)}
</div>
{/* Das Video steht in eigenen Feldern, nicht im Markdown:
Der Markdown-Prozessor filtert HTML weg. */}
{key === "signLanguage" && (
<div className="grid gap-3 rounded-lg border bg-muted/30 p-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="signLanguageVideo" className="gap-1">
Adresse des Videos
</Label>
<Input
id="signLanguageVideo"
value={formData.signLanguageVideo}
onChange={(e) =>
setFormData((prev) => ({
...prev,
signLanguageVideo: e.target.value,
}))
}
placeholder="/videos/gebaerdensprache.mp4"
/>
<p className="text-xs text-muted-foreground">
Videodatei auf dem eigenen Server (beginnt mit /) oder
Player-Adresse eines freigegebenen Anbieters. Derzeit
freigegeben: alangu.de. Andere Videoportale sind
bewusst gesperrt – sie übertragen Besucherdaten an
Dritte.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="signLanguageCaptions" className="gap-1">
Untertiteldatei (optional)
</Label>
<Input
id="signLanguageCaptions"
value={formData.signLanguageCaptions}
onChange={(e) =>
setFormData((prev) => ({
...prev,
signLanguageCaptions: e.target.value,
}))
}
placeholder="/videos/gebaerdensprache.vtt"
/>
<p className="text-xs text-muted-foreground">
WebVTT-Datei, nur bei einer eigenen Videodatei nötig
(WCAG 1.2.2). Ein Player-Anbieter bringt seine
Untertitel selbst mit.
</p>
</div>
</div>
)}
<MarkdownInput
id={`site-page-${key}`}
label={SITE_PAGE_META[key].title}
value={formData[key]}
onChange={(value) =>
setFormData((prev) => ({ ...prev, [key]: value }))
}
placeholder={placeholders[key]}
rows={20}
/>
</TabsContent>
))}
</DialogBody>
</Tabs>
<DialogFooter className="border-t pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting || isLoading}>
{isSubmitting ? "Speichern…" : "Seiteninhalte speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}