syncova-policies/components/policy-form.tsx
Weapie 328f042307 Syncova Policies: Portal für DSGVO-Datenschutzerklärungen
Next.js 15 App Router mit öffentlicher Übersicht und geschütztem
Admin-Bereich zur Pflege von Datenschutzerklärungen nach Art. 12ff. DSGVO.

- Oberfläche auf Basis von shadcn/ui, Inter lokal eingebunden
- Prisma/SQLite, Auth.js mit Credentials-Provider und Rollen
- Massenimport der Merkblätter des Amtes Leezen aus PDF
- Docker-Image (standalone) und Gitea-Workflow zur Veröffentlichung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:23:51 +02:00

406 lines
15 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 } from "lucide-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 { createPolicy, updatePolicy } from "@/hooks/use-privacy-policies"
import { ApiError } from "@/lib/api"
import { FormData, PrivacyPolicy } from "@/types/privacy-policy"
const initialFormData: FormData = {
title: "",
responsible: "",
contactDPO: "",
dataSubjectsRights: "",
complaintRight: "",
withdrawalRight: "",
processingDescription: "",
processingPurposes: "",
legalBasis: "",
recipients: "",
storageDuration: "",
dataSource: "",
provisionObligation: "",
provisionConsequences: "",
isPublic: true,
}
type TabId = "base" | "processing" | "rights"
const requiredFields: { field: keyof FormData; label: string; tab: TabId }[] = [
{ field: "title", label: "Titel", tab: "base" },
{ field: "responsible", label: "Verantwortlicher", tab: "base" },
{ field: "contactDPO", label: "Datenschutzbeauftragte/r", tab: "base" },
{ field: "processingDescription", label: "Verarbeitungstätigkeit", tab: "processing" },
{ field: "recipients", label: "Empfänger", tab: "processing" },
{ field: "dataSubjectsRights", label: "Betroffenen-Rechte", tab: "rights" },
{ field: "complaintRight", label: "Beschwerderecht", tab: "rights" },
{ field: "withdrawalRight", label: "Widerrufsrecht", tab: "rights" },
]
const placeholders = {
responsible: `**Firmenname**
Musterstraße 123
12345 Musterstadt
*Vertretungsberechtigt:* Max Mustermann`,
contactDPO: `**Datenschutzbeauftragte/r:** Max Mustermann
**E-Mail:** datenschutz@beispiel.de
**Telefon:** +49 123 456789`,
dataSubjectsRights: `- **Auskunftsrecht** (Art. 15 DSGVO)
- **Recht auf Berichtigung** (Art. 16 DSGVO)
- **Recht auf Löschung** (Art. 17 DSGVO)
- **Recht auf Einschränkung** (Art. 18 DSGVO)
- **Recht auf Widerspruch** (Art. 21 DSGVO)`,
complaintRight: `Sie haben das **Recht zur Beschwerde** bei einer Aufsichtsbehörde.
**Zuständige Aufsichtsbehörde:**
[Landesdatenschutzbehörde](https://www.datenschutz.de)`,
withdrawalRight: `Soweit die Verarbeitung auf einer *Einwilligung* beruht, können Sie diese **jederzeit widerrufen** – per E-Mail an datenschutz@beispiel.de.`,
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`,
provisionObligation: `Wer ist zur Bereitstellung der Daten verpflichtet, z. B.:
Eigentümer der Immobilie / Verbrauchsstelle`,
provisionConsequences: `Was passiert, wenn die Daten nicht angegeben werden, z. B.:
Verbrauchsschätzung`,
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 [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")
useEffect(() => {
if (!open) return
setErrors([])
setInvalidFields(new Set())
setTab("base")
setFormData(
policy
? {
title: policy.title,
responsible: policy.responsible,
contactDPO: policy.contactDPO,
dataSubjectsRights: policy.dataSubjectsRights,
complaintRight: policy.complaintRight,
withdrawalRight: policy.withdrawalRight,
processingDescription: policy.processingDescription,
processingPurposes: policy.processingPurposes || "",
legalBasis: policy.legalBasis || "",
recipients: policy.recipients,
storageDuration: policy.storageDuration || "",
dataSource: policy.dataSource || "",
provisionObligation: policy.provisionObligation || "",
provisionConsequences: policy.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 missing = requiredFields.filter(
({ field }) => !String(formData[field] ?? "").trim()
)
if (missing.length > 0) {
setErrors(missing.map(({ label }) => `${label} ist erforderlich`))
setInvalidFields(new Set(missing.map(({ field }) => field as string)))
setTab(missing[0].tab)
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-[88vh] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>
{policy ? "Datenschutzerklärung bearbeiten" : "Neue Datenschutzerklärung"}
</DialogTitle>
<DialogDescription>
Alle Pflichtfelder (*) ausfüllen. Die Felder unterstützen Markdown.
</DialogDescription>
</DialogHeader>
<form 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 items-center justify-between gap-4">
<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>
{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">
<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")}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<MarkdownInput
id="responsible"
label="Verantwortlicher im Sinne der DSGVO"
value={formData.responsible}
onChange={(value) => set("responsible", value)}
placeholder={placeholders.responsible}
invalid={isInvalid("responsible")}
required
/>
<MarkdownInput
id="contactDPO"
label="Kontaktdaten der/des Datenschutzbeauftragten"
value={formData.contactDPO}
onChange={(value) => set("contactDPO", value)}
placeholder={placeholders.contactDPO}
invalid={isInvalid("contactDPO")}
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 (optional)"
value={formData.processingPurposes || ""}
onChange={(value) => set("processingPurposes", value)}
placeholder={placeholders.processingPurposes}
/>
<MarkdownInput
id="legalBasis"
label="Rechtsgrundlage der Datenerhebung (optional)"
value={formData.legalBasis || ""}
onChange={(value) => set("legalBasis", value)}
placeholder={placeholders.legalBasis}
/>
<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 (optional)"
value={formData.storageDuration || ""}
onChange={(value) => set("storageDuration", value)}
placeholder={placeholders.storageDuration}
/>
<MarkdownInput
id="dataSource"
label="Herkunft der Daten (optional)"
value={formData.dataSource || ""}
onChange={(value) => set("dataSource", value)}
placeholder={placeholders.dataSource}
/>
</TabsContent>
<TabsContent value="rights" className="grid gap-5 md:grid-cols-2">
<MarkdownInput
id="dataSubjectsRights"
label="Betroffenen-Rechte"
value={formData.dataSubjectsRights}
onChange={(value) => set("dataSubjectsRights", value)}
placeholder={placeholders.dataSubjectsRights}
invalid={isInvalid("dataSubjectsRights")}
rows={9}
required
/>
<div className="space-y-5">
<MarkdownInput
id="complaintRight"
label="Beschwerderecht bei der Aufsichtsbehörde"
value={formData.complaintRight}
onChange={(value) => set("complaintRight", value)}
placeholder={placeholders.complaintRight}
invalid={isInvalid("complaintRight")}
required
/>
<MarkdownInput
id="withdrawalRight"
label="Widerrufsrecht bei Einwilligung"
value={formData.withdrawalRight}
onChange={(value) => set("withdrawalRight", value)}
placeholder={placeholders.withdrawalRight}
invalid={isInvalid("withdrawalRight")}
required
/>
<MarkdownInput
id="provisionObligation"
label="Ihre Pflicht zur Bereitstellung der Daten (optional)"
value={formData.provisionObligation || ""}
onChange={(value) => set("provisionObligation", value)}
placeholder={placeholders.provisionObligation}
rows={4}
/>
<MarkdownInput
id="provisionConsequences"
label="Folgen, wenn Sie die Daten nicht angeben (optional)"
value={formData.provisionConsequences || ""}
onChange={(value) => set("provisionConsequences", value)}
placeholder={placeholders.provisionConsequences}
rows={4}
/>
</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>
)
}