syncova-policies/components/policy-form.tsx
Weapie 265ca8ac38
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
Improve accessibility and access controls; verify production and database recovery
2026-09-14 11:44:41 +02:00

516 lines
20 KiB
TypeScript
Raw Permalink 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 { FormErrors } from "@/components/form-errors"
import { useEffect, useState } from "react"
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 { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { MarkdownInput } from "@/components/markdown-input"
import { DefaultableField } from "@/components/defaultable-field"
import { createPolicy, updatePolicy } from "@/hooks/use-privacy-policies"
import { usePolicyDefaults } from "@/hooks/use-policy-defaults"
import { ApiError } from "@/lib/api"
import {
DEFAULTED_FIELD_LABELS,
DefaultedField,
REQUIRED_DEFAULTED_FIELDS,
} from "@/lib/policy-defaults"
import { FormData, PrivacyPolicy } from "@/types/privacy-policy"
const initialFormData: FormData = {
title: "",
responsible: "",
contactDPO: "",
dataSubjectsRights: "",
complaintRight: "",
withdrawalRight: "",
objectionRight: "",
thirdCountryTransfer: "",
automatedDecision: "",
processingDescription: "",
processingPurposes: "",
legalBasis: "",
legitimateInterests: "",
recipients: "",
storageDuration: "",
dataSource: "",
dataCategories: "",
provisionObligation: "",
provisionConsequences: "",
isPublic: true,
}
type TabId = "base" | "processing" | "rights"
/**
* Felder, die diese Erklärung selbst füllen muss. Die Bausteine aus den
* Standardtexten stehen bewusst nicht hier – sie werden gegen den Standard
* geprüft, nicht gegen das Formular.
*/
const requiredFields: { field: keyof FormData; label: string; tab: TabId }[] = [
{ field: "title", label: "Titel", tab: "base" },
{ field: "processingDescription", label: "Verarbeitungstätigkeit", tab: "processing" },
{ field: "recipients", label: "Empfänger", tab: "processing" },
]
/**
* Angaben, die erst zur Veröffentlichung vorliegen müssen. Ein Entwurf darf sie
* offen lassen – die Prüfung greift nur, wenn „Öffentlich sichtbar“ gesetzt ist.
*/
const publicOnlyFields: { field: keyof FormData; label: string; article: string; tab: TabId }[] = [
{ field: "processingPurposes", label: "Zweck der Datenerhebung", article: "Art. 13 Abs. 1 lit. c", tab: "processing" },
{ field: "legalBasis", label: "Rechtsgrundlage", article: "Art. 13 Abs. 1 lit. c", tab: "processing" },
{ field: "storageDuration", label: "Speicherdauer", article: "Art. 13 Abs. 2 lit. a", tab: "processing" },
]
/** Reiter, auf dem der jeweilige Standard-Baustein steht. */
const defaultedFieldTab: Record<DefaultedField, TabId> = {
responsible: "base",
contactDPO: "base",
dataSubjectsRights: "rights",
complaintRight: "rights",
withdrawalRight: "rights",
objectionRight: "rights",
thirdCountryTransfer: "processing",
automatedDecision: "processing",
provisionObligation: "rights",
provisionConsequences: "rights",
}
const placeholders = {
processingDescription: `**Website-Betrieb und Nutzerinteraktion**
Verarbeitung von Nutzerdaten zum Betrieb der Website und zur Bereitstellung unserer Dienstleistungen.`,
processingPurposes: `1. **Website-Betrieb** – Bereitstellung der Funktionalität
2. **Kommunikation** – Beantwortung von Anfragen
3. **Verbesserung** – Analyse der Nutzung`,
legalBasis: `- **Art. 6 Abs. 1 lit. a** – *Einwilligung*
- **Art. 6 Abs. 1 lit. b** – *Vertragserfüllung*
- **Art. 6 Abs. 1 lit. f** – *Berechtigte Interessen*`,
recipients: `### Interne Empfänger
- Kundenservice, IT-Abteilung
### Externe Dienstleister
- **Hosting:** [Anbieter]
- **E-Mail:** [Service]`,
storageDuration: `- **Verträge:** 10 Jahre nach Vertragsende
- **Newsletter:** bis zum Widerruf
- **Website-Logs:** 30 Tage`,
legitimateInterests: `Nur auszufüllen, wenn die Verarbeitung auf
**Art. 6 Abs. 1 lit. f** gestützt wird – dann ist das berechtigte Interesse zu
benennen, z. B.:
Sicherstellung des ordnungsgemäßen Dienstbetriebs`,
dataCategories: `Welche Arten von Daten verarbeitet werden, z. B.:
- **Stammdaten:** Name, Anschrift, Geburtsdatum
- **Kontaktdaten:** Telefonnummer, E-Mail-Adresse
- **Verfahrensdaten:** Aktenzeichen, Bescheiddaten`,
dataSource: `- **Direkte Eingabe:** Kontaktformulare, Registrierung
- **Website-Nutzung:** Cookies, Logs
- **Öffentliche Quellen:** Handelsregister`,
}
interface PolicyFormProps {
open: boolean
onOpenChange: (open: boolean) => void
policy: PrivacyPolicy | null
}
export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
const { defaults } = usePolicyDefaults()
const [formData, setFormData] = useState<FormData>(initialFormData)
const [errors, setErrors] = useState<string[]>([])
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set())
const [isSubmitting, setIsSubmitting] = useState(false)
const [tab, setTab] = useState<TabId>("base")
// Zählt jedes Öffnen hoch und setzt darüber die Baustein-Felder zurück –
// sonst behielten sie den Standard/Abweichung-Modus der zuletzt geöffneten
// Erklärung bei.
const [formKey, setFormKey] = useState(0)
useEffect(() => {
if (!open) return
setErrors([])
setInvalidFields(new Set())
setTab("base")
setFormKey((previous) => previous + 1)
// Die API liefert die Bausteine bereits mit eingesetztem Standardtext.
// Ins Formular gehört aber nur ein tatsächlich abweichender Text – welche
// Felder das sind, steht in `overrides`.
const overrideOf = (field: DefaultedField) =>
policy?.overrides?.includes(field) ? (policy[field] ?? "") : ""
setFormData(
policy
? {
title: policy.title,
responsible: overrideOf("responsible"),
contactDPO: overrideOf("contactDPO"),
dataSubjectsRights: overrideOf("dataSubjectsRights"),
complaintRight: overrideOf("complaintRight"),
withdrawalRight: overrideOf("withdrawalRight"),
objectionRight: overrideOf("objectionRight"),
thirdCountryTransfer: overrideOf("thirdCountryTransfer"),
automatedDecision: overrideOf("automatedDecision"),
processingDescription: policy.processingDescription,
processingPurposes: policy.processingPurposes || "",
legalBasis: policy.legalBasis || "",
legitimateInterests: policy.legitimateInterests || "",
recipients: policy.recipients,
storageDuration: policy.storageDuration || "",
dataSource: policy.dataSource || "",
dataCategories: policy.dataCategories || "",
provisionObligation: overrideOf("provisionObligation"),
provisionConsequences: overrideOf("provisionConsequences"),
isPublic: policy.isPublic,
}
: initialFormData
)
}, [open, policy])
const set = <K extends keyof FormData>(key: K, value: FormData[K]) => {
setFormData((prev) => ({ ...prev, [key]: value }))
setInvalidFields((prev) => {
if (!prev.has(key as string)) return prev
const next = new Set(prev)
next.delete(key as string)
return next
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const gefuellt = (field: keyof FormData) =>
String(formData[field] ?? "").trim().length > 0
// Ohne diese Angaben lässt sich nicht einmal ein Entwurf speichern
const missing = requiredFields.filter(({ field }) => !gefuellt(field))
// Alles Weitere wird erst zur Veröffentlichung verlangt
const fehlendeMeldungen: string[] = missing.map(
({ label }) => `${label} ist erforderlich`
)
const fehlendeFelder: string[] = missing.map(({ field }) => field as string)
let ersterReiter: TabId | null = missing[0]?.tab ?? null
if (formData.isPublic) {
for (const { field, label, article, tab } of publicOnlyFields) {
if (gefuellt(field)) continue
fehlendeMeldungen.push(`${label} (${article}) – zum Veröffentlichen erforderlich`)
fehlendeFelder.push(field as string)
ersterReiter = ersterReiter ?? tab
}
// Art. 14 greift nur, wenn die Daten nicht bei der betroffenen Person
// selbst erhoben wurden – erkennbar an der angegebenen Herkunft.
if (gefuellt("dataSource") && !gefuellt("dataCategories")) {
fehlendeMeldungen.push(
"Kategorien der verarbeiteten Daten (Art. 14 Abs. 1 lit. d) – erforderlich, weil eine Herkunft der Daten angegeben ist"
)
fehlendeFelder.push("dataCategories")
ersterReiter = ersterReiter ?? "processing"
}
// Ein Baustein fehlt nur, wenn weder diese Erklärung noch der Standard
// einen Text dafür hat.
for (const field of REQUIRED_DEFAULTED_FIELDS) {
if (gefuellt(field) || defaults[field].trim()) continue
fehlendeMeldungen.push(
`${DEFAULTED_FIELD_LABELS[field]}: kein Standardtext hinterlegt – unter „Standardtexte“ pflegen oder hier abweichen`
)
fehlendeFelder.push(field)
ersterReiter = ersterReiter ?? defaultedFieldTab[field]
}
}
if (fehlendeMeldungen.length > 0) {
setErrors(fehlendeMeldungen)
setInvalidFields(new Set(fehlendeFelder))
setTab(ersterReiter ?? "base")
return
}
setErrors([])
setInvalidFields(new Set())
setIsSubmitting(true)
try {
if (policy) {
await updatePolicy(policy.id, formData)
toast.success("Datenschutzerklärung aktualisiert")
} else {
await createPolicy(formData)
toast.success("Datenschutzerklärung erstellt")
}
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)
}
}
const isInvalid = (field: keyof FormData) => invalidFields.has(field as string)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100dvh-2rem)] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>
{policy ? "Datenschutzerklärung bearbeiten" : "Neue Datenschutzerklärung"}
</DialogTitle>
<DialogDescription>
Alle Pflichtfelder (*) ausfüllen. Die Felder unterstützen Markdown.
Als „Standard“ markierte Bausteine kommen aus den zentralen
Standardtexten und lassen sich hier nur im Sonderfall abweichend
festlegen.
</DialogDescription>
</DialogHeader>
<form aria-label="Bearbeitungsformular" onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<Tabs
value={tab}
onValueChange={(value) => setTab(value as TabId)}
className="min-h-0 flex-1 gap-3"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<TabsList>
<TabsTrigger value="base">Stammdaten</TabsTrigger>
<TabsTrigger value="processing">Verarbeitung</TabsTrigger>
<TabsTrigger value="rights">Rechte</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<Label htmlFor="isPublic" className="cursor-pointer text-muted-foreground">
Öffentlich sichtbar
</Label>
<Switch
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => set("isPublic", checked)}
/>
</div>
</div>
<FormErrors errors={errors} />
<DialogBody className="pt-1">
<TabsContent value="base" className="space-y-5">
<div className="space-y-2">
<Label htmlFor="title" className="gap-1">
Titel der Datenschutzerklärung
<span className="text-destructive">*</span>
</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => set("title", e.target.value)}
placeholder="z. B. Datenschutzerklärung für die Website"
aria-invalid={isInvalid("title")}
aria-required="true"
aria-describedby={isInvalid("title") ? "title-error" : undefined}
/>
{isInvalid("title") && <p id="title-error" className="text-sm text-destructive">Bitte geben Sie einen Titel ein.</p>}
</div>
<div className="grid gap-5 md:grid-cols-2">
{(["responsible", "contactDPO"] as DefaultedField[]).map((field) => (
<DefaultableField
key={`${formKey}-${field}`}
id={field}
label={DEFAULTED_FIELD_LABELS[field]}
defaultValue={defaults[field]}
value={formData[field] ?? ""}
onChange={(value) => set(field, value)}
invalid={isInvalid(field)}
required
/>
))}
</div>
</TabsContent>
<TabsContent value="processing" className="grid gap-5 md:grid-cols-2">
<MarkdownInput
id="processingDescription"
label="Bezeichnung der Verarbeitungstätigkeit"
value={formData.processingDescription}
onChange={(value) => set("processingDescription", value)}
placeholder={placeholders.processingDescription}
invalid={isInvalid("processingDescription")}
required
/>
<MarkdownInput
id="processingPurposes"
label="Zweck der Datenerhebung"
value={formData.processingPurposes || ""}
onChange={(value) => set("processingPurposes", value)}
placeholder={placeholders.processingPurposes}
invalid={isInvalid("processingPurposes")}
required
/>
<MarkdownInput
id="legalBasis"
label="Rechtsgrundlage der Datenerhebung"
value={formData.legalBasis || ""}
onChange={(value) => set("legalBasis", value)}
placeholder={placeholders.legalBasis}
invalid={isInvalid("legalBasis")}
required
/>
<MarkdownInput
id="legitimateInterests"
label="Berechtigte Interessen (nur bei Art. 6 Abs. 1 lit. f)"
value={formData.legitimateInterests || ""}
onChange={(value) => set("legitimateInterests", value)}
placeholder={placeholders.legitimateInterests}
rows={4}
/>
<MarkdownInput
id="recipients"
label="Geplante Empfänger der Daten"
value={formData.recipients}
onChange={(value) => set("recipients", value)}
placeholder={placeholders.recipients}
invalid={isInvalid("recipients")}
required
/>
<MarkdownInput
id="storageDuration"
label="Speicherdauer"
value={formData.storageDuration || ""}
onChange={(value) => set("storageDuration", value)}
placeholder={placeholders.storageDuration}
invalid={isInvalid("storageDuration")}
required
/>
<MarkdownInput
id="dataSource"
label="Herkunft der Daten (optional)"
value={formData.dataSource || ""}
onChange={(value) => set("dataSource", value)}
placeholder={placeholders.dataSource}
/>
<MarkdownInput
id="dataCategories"
label={
// Art. 14 greift nur, wenn die Daten woanders herkommen.
formData.dataSource?.trim()
? "Kategorien der verarbeiteten Daten"
: "Kategorien der verarbeiteten Daten (optional)"
}
value={formData.dataCategories || ""}
onChange={(value) => set("dataCategories", value)}
placeholder={placeholders.dataCategories}
invalid={isInvalid("dataCategories")}
required={!!formData.dataSource?.trim()}
/>
<div className="grid gap-5 md:col-span-2 md:grid-cols-2">
<p className="text-sm text-muted-foreground md:col-span-2">
Auf Drittländer und automatisierte Entscheidungen muss
ausdrücklich hingewiesen werden – auch dann, wenn es beides
nicht gibt. Beide Bausteine kommen aus den Standardtexten.
</p>
{(["thirdCountryTransfer", "automatedDecision"] as DefaultedField[]).map(
(field) => (
<DefaultableField
key={`${formKey}-${field}`}
id={field}
label={DEFAULTED_FIELD_LABELS[field]}
defaultValue={defaults[field]}
value={formData[field] ?? ""}
onChange={(value) => set(field, value)}
invalid={isInvalid(field)}
rows={5}
required
/>
)
)}
</div>
</TabsContent>
<TabsContent value="rights" className="grid gap-5 md:grid-cols-2">
<DefaultableField
key={`${formKey}-dataSubjectsRights`}
id="dataSubjectsRights"
label={DEFAULTED_FIELD_LABELS.dataSubjectsRights}
defaultValue={defaults.dataSubjectsRights}
value={formData.dataSubjectsRights ?? ""}
onChange={(value) => set("dataSubjectsRights", value)}
invalid={isInvalid("dataSubjectsRights")}
rows={9}
required
/>
<div className="space-y-5">
{(
[
["complaintRight", 6, true],
["withdrawalRight", 6, true],
["objectionRight", 7, true],
["provisionObligation", 4, false],
["provisionConsequences", 4, false],
] as [DefaultedField, number, boolean][]
).map(([field, rows, required]) => (
<DefaultableField
key={`${formKey}-${field}`}
id={field}
label={DEFAULTED_FIELD_LABELS[field]}
defaultValue={defaults[field]}
value={formData[field] ?? ""}
onChange={(value) => set(field, value)}
invalid={isInvalid(field)}
rows={rows}
required={required}
/>
))}
</div>
</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}>
{isSubmitting
? "Speichern…"
: policy
? "Änderungen speichern"
: "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}