syncova-policies/components/site-pages-form.tsx
Jerrit Fritzsche 61c4998a79
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m23s
Datenschutzerklärungen veröffentlichungsreif machen
Die öffentliche API gab bei ?admin=true alle Datensätze heraus, auch die
nicht veröffentlichten: von 117 Erklärungen waren 112 Entwürfe, die jeder
Besucher abrufen konnte. Der Parameter setzt jetzt eine Admin-Sitzung
voraus, sonst kommt die öffentliche Liste. Der Einzelabruf behandelt
Entwürfe wie nicht vorhanden, damit sich über die fortlaufende ID nicht
abklopfen lässt, welche Datensätze existieren.

Markdown wurde mit sanitize: false gerendert und per
dangerouslySetInnerHTML eingesetzt. Das Standard-Schema lässt jetzt
Überschriften, Listen, Tabellen und http(s)/mailto-Links durch und
entfernt Skripte, Event-Attribute, eingebettete Rahmen und
javascript:-Ziele. Der Titel in der Druckfassung war an drei Stellen
unmaskiert.

Standardtexte
- Zehn Bausteine, die für alle Erklärungen gelten, werden einmal zentral
  gepflegt. In der Erklärung steht nur eine Abweichung; ein leeres Feld
  bedeutet "folgt dem Standard". Ein Wechsel in der Amtsleitung wirkt
  damit sofort auf alle Erklärungen, die nicht bewusst abweichen.
- Die Migration übernimmt den häufigsten Bestandswert als Standard und
  setzt übereinstimmende Felder auf NULL. Von 117 Erklärungen folgen
  danach 116 dem Standard, eine weicht ab (Online-Verfahren mit ZIT-SH
  als zusätzlichem Verantwortlichen).

Pflichtangaben nach Art. 13/14 DSGVO
- Neue Felder für Drittlandübermittlung (Art. 13 Abs. 1 lit. f),
  automatisierte Entscheidungsfindung (Art. 13 Abs. 2 lit. f),
  Datenkategorien (Art. 14 Abs. 1 lit. d) und berechtigte Interessen
  (Art. 13 Abs. 1 lit. d). Auf die ersten beiden muss auch dann
  hingewiesen werden, wenn es sie nicht gibt, deshalb sind sie
  Standardtexte mit ausdrücklichem "findet nicht statt".
- Der Hinweis auf das Widerspruchsrecht steht nach Art. 21 Abs. 4 in
  einem eigenen, abgesetzten Kasten vor allen Abschnitten, nicht als
  Zeile im Fließtext der Betroffenenrechte.
- Zweck, Rechtsgrundlage und Speicherdauer sind Pflicht. Geprüft wird
  beim Veröffentlichen, nicht beim Speichern: Ein Entwurf darf
  unvollständig sein, eine öffentlich sichtbare Erklärung nicht. So
  bleiben Bestandsdaten bearbeitbar, ohne dass die Anwendung Angaben
  erfindet, die nur die Fachabteilung kennt. Die Admin-Übersicht
  markiert unvollständige Erklärungen und nennt die fehlenden Angaben.

Öffentliche Seiten
- Jede Erklärung hat eine dauerhafte Adresse /erklaerung/<bezeichner>,
  serverseitig gerendert, zum Verlinken aus Antragsformularen. Der
  Bezeichner wird aus dem Titel abgeleitet und bleibt beim Bearbeiten
  erhalten, solange der Titel gleich bleibt. Entwürfe liefern 404.
- Impressum, Datenschutzerklärung des Portals und Erklärung zur
  Barrierefreiheit werden im Admin als Markdown gepflegt und in der
  Fußzeile verlinkt. Die Fußzeile rendert serverseitig, weil das
  Impressum ohne JavaScript erreichbar sein muss. Ein leerer Text nimmt
  die Seite vom Netz, statt eine leere Seite auszuliefern.
- Die Übersicht nutzt echte Links statt Klick-Handler und wird
  serverseitig vorbefüllt.

APP_COMPANY_NAME ersetzt den Namen der Anwendung in Kopf- und Fußzeile,
Seitentiteln, Anmeldeseite, Druckfassung und Startmeldung des
Containers. Der Wert wird zur Laufzeit gelesen, ein Neubau des Images
ist zum Umbenennen nicht nötig.

Getestet gegen die 117 Bestandsdatensätze: Migrationen angewendet, der
angezeigte Text blieb bei allen 117 x 7 Baustein-Feldern identisch. Alle
117 Bezeichner sind eindeutig und stimmen mit lib/slug.ts überein
(SQLites LOWER() kennt nur ASCII, Großumlaute werden deshalb vorher
ersetzt). ?admin=true liefert ohne Anmeldung 5 statt 117 Datensätze,
Entwürfe 404. Über die API mit Anmeldung geprüft: unvollständig als
Entwurf wird angelegt, dieselben Angaben mit isPublic abgelehnt, ebenso
fehlende Datenkategorien bei angegebener Herkunft. Ohne
APP_COMPANY_NAME erscheint "Syncova Policies", mit gesetztem Wert kein
einziges Vorkommen mehr.

Die Änderungen liegen in einem Commit, weil Schema, API-Routen und
Formulare von allen Teilen berührt werden und eine thematische Aufteilung
nicht lauffähige Zwischenstände ergäbe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:35:05 +02:00

244 lines
7.8 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 { 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`,
}
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>
Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit
des Portals selbst. Sie erscheinen in der Fußzeile der öffentlichen
Seiten – ein leer gelassener Text nimmt die 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>
{SITE_PAGES.map((key) => (
<TabsTrigger key={key} value={key}>
{key === "accessibility"
? "Barrierefreiheit"
: SITE_PAGE_META[key].title}
</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>
<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>
)
}