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>
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
"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>
|
||
)
|
||
}
|