All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
516 lines
20 KiB
TypeScript
516 lines
20 KiB
TypeScript
"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>
|
||
)
|
||
}
|