diff --git a/.env.example b/.env.example index 832beff..b18d6e1 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ +# Name, unter dem die Anwendung auftritt – in Kopfzeile, Fußzeile, Seitentitel +# und Druckfassung. Leer lassen für „Syncova Policies“. +APP_COMPANY_NAME="" + # Datenbank DATABASE_URL="file:./dev.db" diff --git a/README.md b/README.md index 8d9fc3b..629b459 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ unter `/admin`. | Variable | Pflicht | Bedeutung | |---|---|---| +| `APP_COMPANY_NAME` | nein | Name, unter dem die Anwendung auftritt – Kopfzeile, Fußzeile, Seitentitel und Druckfassung. Leer lassen für „Syncova Policies“. Wird zur Laufzeit gelesen, ein Neubau des Images ist nicht nötig. | | `NEXTAUTH_SECRET` | ja | Signiert die Session-Token. Erzeugen mit `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"` | | `NEXTAUTH_URL` | nein | Nur nötig, wenn ein Reverse-Proxy keine `X-Forwarded-*`-Header setzt. Sonst erkennt die Anwendung Host und Port selbst – ein fester Wert erzwingt Weiterleitungen auf genau diesen Port. | | `DATABASE_URL` | ja | SQLite-Pfad. Lokal `file:./dev.db`, im Container `file:/app/data/syncova.db` | @@ -75,6 +76,78 @@ docker exec -it syncova-policies node scripts/reset-admin.js --email admin@examp ## Datenpflege +### Standardtexte + +Sieben Bausteine sind in aller Regel für jede Erklärung gleich und werden +deshalb einmal zentral gepflegt – im Admin-Bereich unter **Standardtexte**: + +| Baustein | Artikel | Pflicht | +|---|---|---| +| Verantwortlicher im Sinne der DSGVO | Art. 13 Abs. 1 lit. a | ja | +| Kontaktdaten der/des Datenschutzbeauftragten | Art. 13 Abs. 1 lit. b | ja | +| Betroffenen-Rechte | Art. 13 Abs. 2 lit. b | ja | +| Beschwerderecht bei der Aufsichtsbehörde | Art. 13 Abs. 2 lit. d | ja | +| Widerrufsrecht bei Einwilligung | Art. 13 Abs. 2 lit. c | ja | +| Widerspruchsrecht | Art. 21 Abs. 4 | ja | +| Übermittlung in Drittländer | Art. 13 Abs. 1 lit. f | ja | +| Automatisierte Entscheidungsfindung | Art. 13 Abs. 2 lit. f | ja | +| Ihre Pflicht zur Bereitstellung der Daten | Art. 13 Abs. 2 lit. e | nein | +| Folgen, wenn Sie die Daten nicht angeben | Art. 13 Abs. 2 lit. e | nein | + +Auf Drittlandübermittlung und automatisierte Entscheidungen muss auch dann +hingewiesen werden, wenn es beides nicht gibt – ein ausdrückliches „findet nicht +statt“ ist die richtige Antwort, kein leeres Feld. Der Hinweis auf das +Widerspruchsrecht steht nach Art. 21 Abs. 4 in einem eigenen, abgesetzten +Kasten, getrennt von den übrigen Informationen. + +Im Formular einer einzelnen Erklärung erscheinen diese Felder schreibgeschützt +mit der Markierung **Standard**. Erst „Für diese Erklärung abweichen“ macht +einen Baustein für genau diesen Datensatz bearbeitbar; „Standard übernehmen“ +verwirft die Abweichung wieder. + +Technisch steht in der Erklärung nur die Abweichung: Ein leeres Feld bedeutet +„folgt dem Standard“. Eine Änderung an den Standardtexten – etwa ein Wechsel +in der Amtsleitung – wirkt damit sofort auf alle Erklärungen, die nicht bewusst +abweichen. Welche Felder abweichen, liefert die API im Feld `overrides`. + +### Rechtliche Seiten + +Impressum, Datenschutzerklärung des Portals und Erklärung zur Barrierefreiheit +werden im Admin unter **Rechtliche Seiten** als Markdown gepflegt und unter +`/impressum`, `/datenschutz` und `/barrierefreiheit` ausgeliefert. Sie erscheinen +in der Fußzeile aller öffentlichen Seiten. Ein leer gelassener Text nimmt die +betreffende Seite vom Netz – verlinkt wird nur, was auch gepflegt ist. Die +Eingabefelder enthalten Vorlagen mit den jeweils erforderlichen Angaben. + +### Erklärungen + +Jede veröffentlichte Erklärung hat eine eigene, dauerhafte Adresse unter +`/erklaerung/` – etwa zum Verlinken aus einem Antragsformular. Der +Bezeichner wird aus dem Titel abgeleitet und bleibt beim Bearbeiten erhalten, +solange der Titel unverändert ist. Nicht veröffentlichte Erklärungen sind unter +ihrer Adresse nicht erreichbar. + +#### Vollständigkeit + +Pflichtangaben aus Art. 13/14 DSGVO werden **beim Veröffentlichen** geprüft, +nicht beim Speichern: Ein Entwurf darf unvollständig sein, eine öffentlich +sichtbare Erklärung nicht. Bestehende Datensätze lassen sich damit weiter +bearbeiten, ohne dass die Anwendung Angaben erfindet, die nur die Fachabteilung +kennt. + +Zusätzlich zu den Bausteinen oben verlangt die Veröffentlichung: + +| Angabe | Artikel | +|---|---| +| Zweck der Datenerhebung | Art. 13 Abs. 1 lit. c | +| Rechtsgrundlage | Art. 13 Abs. 1 lit. c | +| Geplante Empfänger | Art. 13 Abs. 1 lit. e | +| Speicherdauer oder Kriterien | Art. 13 Abs. 2 lit. a | +| Kategorien der verarbeiteten Daten | Art. 14 Abs. 1 lit. d – nur, wenn eine Herkunft der Daten angegeben ist, die Daten also nicht bei der betroffenen Person selbst erhoben wurden | + +Die Übersicht im Admin markiert jede Erklärung mit fehlenden Pflichtangaben und +nennt sie im Tooltip; die Kopfzeile zählt sie. + Datenschutzerklärungen werden normalerweise im Admin-Bereich gepflegt. Für Massenimporte gibt es zwei Wege: @@ -90,6 +163,10 @@ Dokuments gegen ein Referenzdokument und meldet jede Abweichung, statt sie stillschweigend zu übernehmen. Importierte Datensätze werden am Titel erkannt und aktualisiert, nicht dupliziert. +Beide Importwege gleichen die sieben Bausteine gegen die Standardtexte ab: +Was dem Standard entspricht, wird nicht je Erklärung dupliziert, sondern als +„folgt dem Standard“ gespeichert. Nur echte Abweichungen landen im Datensatz. + --- ## Veröffentlichung des Images @@ -105,7 +182,7 @@ Voraussetzung: im Repository unter **Settings → Actions → Secrets** ein Secr ## Projektstruktur ``` -app/ Routen (öffentlich, /admin, /auth, /api) +app/ Routen (öffentlich, /erklaerung, /admin, /auth, /api) components/ UI-Komponenten; components/ui = shadcn/ui hooks/ SWR-Datenzugriff, Theme lib/ Prisma/Auth-Helfer, Markdown, Formatierung diff --git a/app/admin/page.tsx b/app/admin/page.tsx index ed61958..9a31740 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -7,11 +7,14 @@ import { Eye, EyeOff, FileText, + FileType2, + Scale, LogOut, MoreHorizontal, Pencil, Plus, Search, + TriangleAlert, Trash2, Users, X, @@ -53,10 +56,14 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Brand } from "@/components/brand" import { PolicyDetail } from "@/components/policy-detail" +import { PolicyDefaultsForm } from "@/components/policy-defaults-form" import { PolicyForm } from "@/components/policy-form" +import { SitePagesForm } from "@/components/site-pages-form" import { ThemeToggle } from "@/components/theme-toggle" import { UserManagement } from "@/components/user-management" import { deletePolicy, usePrivacyPolicies } from "@/hooks/use-privacy-policies" +import { usePolicyDefaults } from "@/hooks/use-policy-defaults" +import { describeMissing, missingRequiredFields } from "@/lib/policy-completeness" import { formatDateTime, initials } from "@/lib/format" import { PrivacyPolicy } from "@/types/privacy-policy" @@ -65,6 +72,7 @@ type SortKey = "title" | "createdAt" | "updatedAt" export default function AdminDashboard() { const { data: session, status } = useSession() const { policies, isLoading, error } = usePrivacyPolicies() + const { defaults } = usePolicyDefaults() const [searchTerm, setSearchTerm] = useState("") const [sortBy, setSortBy] = useState("createdAt") @@ -76,6 +84,8 @@ export default function AdminDashboard() { const [policyToDelete, setPolicyToDelete] = useState(null) const [isDeleting, setIsDeleting] = useState(false) const [isUserManagementOpen, setIsUserManagementOpen] = useState(false) + const [isDefaultsOpen, setIsDefaultsOpen] = useState(false) + const [isSitePagesOpen, setIsSitePagesOpen] = useState(false) const visiblePolicies = useMemo(() => { const term = searchTerm.trim().toLowerCase() @@ -140,6 +150,28 @@ export default function AdminDashboard() { const publicCount = policies.filter((policy) => policy.isPublic).length + /** + * Fehlende Pflichtangaben nach Art. 13/14 DSGVO je Erklärung. + * + * Die API liefert die Bausteine bereits mit eingesetztem Standardtext – was + * hier als fehlend gilt, fehlt also tatsächlich und nicht nur im Datensatz. + */ + const missingByPolicy = useMemo(() => { + const map = new Map() + + for (const policy of policies) { + const missing = missingRequiredFields( + policy as unknown as Record, + defaults + ) + if (missing.length > 0) map.set(policy.id, missing.map(describeMissing)) + } + + return map + }, [policies, defaults]) + + const incompleteCount = missingByPolicy.size + const SortableHead = ({ column, children, @@ -206,6 +238,20 @@ export default function AdminDashboard() { Öffentliche Ansicht + + + + + Standardtexte + + + +
+
+

{policy.title}

+
+ + + Erstellt am {formatDate(policy.createdAt)} + + {policy.updatedAt !== policy.createdAt && ( + Aktualisiert am {formatDate(policy.updatedAt)} + )} + DSGVO +
+
+ + +
+ + {/* Art. 21 Abs. 4 verlangt den Hinweis getrennt von den übrigen + Informationen – deshalb steht er vor den Abschnitten in einem + eigenen, abgesetzten Kasten. */} + + +
+ {sectionsOf(policy).map((group) => ( +
+

+ {group.heading} +

+
+ {group.items.map(([title, content]) => ( +
+ ))} +
+
+ ))} +
+ + + + + ) +} diff --git a/app/impressum/page.tsx b/app/impressum/page.tsx new file mode 100644 index 0000000..dc57175 --- /dev/null +++ b/app/impressum/page.tsx @@ -0,0 +1,10 @@ +import { LegalPage, legalPageMetadata } from "@/components/legal-page" + +// Inhalt kommt aus der Datenbank und wird im Admin gepflegt. +export const dynamic = "force-dynamic" + +export const metadata = legalPageMetadata("imprint") + +export default function Page() { + return +} diff --git a/app/layout.tsx b/app/layout.tsx index 31762ee..6896050 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next" import localFont from "next/font/local" +import { AppConfigProvider } from "@/components/providers/app-config-provider" import { AuthProvider } from "@/components/providers/session-provider" +import { appName } from "@/lib/app-config" import { Toaster } from "@/components/ui/sonner" import "./globals.css" @@ -22,12 +24,20 @@ const inter = localFont({ fallback: ["system-ui", "Segoe UI", "Arial", "sans-serif"], }) -export const metadata: Metadata = { - title: { - default: "Syncova Policies", - template: "%s · Syncova Policies", - }, - description: "Datenschutzerklärungen nach DSGVO – transparent und zentral verwaltet", +// APP_COMPANY_NAME wird erst zur Laufzeit gelesen. Ohne dynamisches Rendern +// würde beim Bauen des Images der Vorgabename festgeschrieben. +export const dynamic = "force-dynamic" + +export async function generateMetadata(): Promise { + const name = appName() + + return { + title: { + default: name, + template: `%s · ${name}`, + }, + description: "Datenschutzerklärungen nach DSGVO – transparent und zentral verwaltet", + } } export default function RootLayout({ @@ -46,7 +56,9 @@ export default function RootLayout({ /> - {children} + + {children} + diff --git a/app/page.tsx b/app/page.tsx index 7b10443..07ca58c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,202 +1,37 @@ -"use client" +import { PrismaClient } from "@prisma/client" -import { useMemo, useState } from "react" -import { ChevronRight, FileText, Search, Settings2, X } from "lucide-react" +import { PolicyList } from "@/components/policy-list" +import { PublicHeader } from "@/components/public-header" +import { SiteFooter } from "@/components/site-footer" +import { readPolicyDefaults, toApiPolicy } from "@/lib/policy-defaults.server" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { Skeleton } from "@/components/ui/skeleton" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { Brand } from "@/components/brand" -import { PolicyDetail } from "@/components/policy-detail" -import { PrintPolicy } from "@/components/print-policy" -import { ThemeToggle } from "@/components/theme-toggle" -import { usePublicPrivacyPolicies } from "@/hooks/use-privacy-policies" -import { PrivacyPolicy } from "@/types/privacy-policy" -import { formatDate } from "@/lib/format" +// Prisma client instance +const prisma = new PrismaClient() -export default function PublicDashboard() { - const { policies, isLoading, error } = usePublicPrivacyPolicies() - const [selectedPolicy, setSelectedPolicy] = useState(null) - const [isDetailOpen, setIsDetailOpen] = useState(false) - const [searchTerm, setSearchTerm] = useState("") +// Liste und Fußzeile kommen aus der Datenbank. +export const dynamic = "force-dynamic" - const filteredPolicies = useMemo(() => { - const term = searchTerm.trim().toLowerCase() - if (!term) return policies - - return policies.filter((policy) => - [policy.title, policy.responsible, policy.processingDescription].some((field) => - field.toLowerCase().includes(term) - ) - ) - }, [policies, searchTerm]) - - const handleViewDetails = (policy: PrivacyPolicy) => { - setSelectedPolicy(policy) - setIsDetailOpen(true) - } +/** + * Öffentliche Übersicht. + * + * Die Liste wird serverseitig geladen und als Startwert an die Suchkomponente + * gegeben: So stehen alle Erklärungen samt Adresse bereits im ausgelieferten + * HTML und sind auch ohne JavaScript erreichbar. + */ +export default async function PublicDashboard() { + const [policies, defaults] = await Promise.all([ + prisma.privacyPolicy.findMany({ + where: { isPublic: true }, + orderBy: { createdAt: "desc" }, + }), + readPolicyDefaults(prisma), + ]) return ( -
-
-
- -
- - - - - - Admin-Bereich - -
-
-
- -
-
-
-

- Datenschutzerklärungen -

-

- Transparente Informationen zu unserer Datenverarbeitung nach DSGVO -

-
- -
- - setSearchTerm(e.target.value)} - className="px-9" - /> - {searchTerm && ( - - )} -
-
- - {error ? ( -
- Fehler beim Laden der Daten: {error.message} -
- ) : isLoading ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( -
- -
- - -
- -
- ))} -
- ) : filteredPolicies.length === 0 ? ( -
-
- -
-

- {searchTerm - ? "Keine Treffer" - : "Keine Datenschutzerklärungen verfügbar"} -

-

- {searchTerm - ? "Versuchen Sie einen anderen Suchbegriff." - : "Derzeit sind keine öffentlichen Datenschutzerklärungen hinterlegt."} -

- {searchTerm && ( - - )} -
- ) : ( -
- {filteredPolicies.map((policy) => ( -
- - -
- - {formatDate(policy.updatedAt)} - - - - -
-
- ))} -
- )} - - {!isLoading && !error && filteredPolicies.length > 0 && ( -
- - {filteredPolicies.length}{" "} - {filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"} - {searchTerm && ` von ${policies.length}`} - - DSGVO-konform -
- )} -
- - +
+ + toApiPolicy(policy, defaults))} /> +
) } diff --git a/components/brand.tsx b/components/brand.tsx index 47ae148..fb96dcc 100644 --- a/components/brand.tsx +++ b/components/brand.tsx @@ -1,5 +1,9 @@ +"use client" + import { ShieldCheck } from "lucide-react" +import { useAppName } from "@/components/providers/app-config-provider" +import { DEFAULT_APP_NAME } from "@/lib/app-config" import { cn } from "@/lib/utils" interface BrandProps { @@ -8,6 +12,8 @@ interface BrandProps { } export function Brand({ className, subtitle }: BrandProps) { + const name = useAppName() + return (
@@ -15,8 +21,16 @@ export function Brand({ className, subtitle }: BrandProps) {
- Syncova - Policies + {name === DEFAULT_APP_NAME ? ( + // Der Vorgabename wird zweifarbig gesetzt; ein konfigurierter + // Firmenname bleibt unangetastet. + <> + Syncova + Policies + + ) : ( + name + )} {subtitle && ( diff --git a/components/defaultable-field.tsx b/components/defaultable-field.tsx new file mode 100644 index 0000000..26a0cdd --- /dev/null +++ b/components/defaultable-field.tsx @@ -0,0 +1,133 @@ +"use client" + +import { useState } from "react" +import { AlertCircle, PencilLine, Undo2 } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { MarkdownInput } from "@/components/markdown-input" +import { MarkdownRenderer } from "@/components/markdown-renderer" + +interface DefaultableFieldProps { + id: string + label: string + /** Der zentral gepflegte Standardtext für dieses Feld. */ + defaultValue: string + /** Abweichender Text dieser Erklärung; leer bedeutet „Standard gilt“. */ + value: string + onChange: (value: string) => void + /** Ohne Standardtext ist ein Pflichtfeld unvollständig – dann Warnung zeigen. */ + required?: boolean + rows?: number + invalid?: boolean +} + +/** + * Ein Baustein, der normalerweise aus den zentralen Standardtexten kommt. + * + * Im Regelfall zeigt das Feld den Standardtext nur an. Erst „Abweichen“ macht + * ihn für genau diese Erklärung bearbeitbar; „Standard übernehmen“ verwirft die + * Abweichung wieder. Gespeichert wird ausschließlich der abweichende Text – + * ein leerer Wert heißt, dass die Erklärung dem Standard folgt. + */ +export function DefaultableField({ + id, + label, + defaultValue, + value, + onChange, + required = false, + rows = 6, + invalid = false, +}: DefaultableFieldProps) { + // Beim Mounten entscheidet der gespeicherte Wert über den Modus; danach + // steuert ihn der Benutzer über die beiden Schaltflächen. + const [isOverride, setIsOverride] = useState(() => value.trim().length > 0) + + /** Wechselt in den Sonderfall und übernimmt den Standard als Ausgangstext. */ + const startOverride = () => { + setIsOverride(true) + if (!value.trim()) onChange(defaultValue) + } + + /** Kehrt zum Standard zurück und verwirft den abweichenden Text. */ + const resetToDefault = () => { + setIsOverride(false) + onChange("") + } + + if (isOverride) { + return ( +
+ +
+ + Weicht vom Standard ab + + +
+
+ ) + } + + return ( +
+
+ + Standard +
+ +
+ {defaultValue.trim() ? ( + + ) : required ? ( +

+ + Noch kein Standardtext hinterlegt – im Admin unter „Standardtexte“ + pflegen oder hier abweichen. +

+ ) : ( +

+ Kein Standardtext hinterlegt – dieser Abschnitt bleibt leer. +

+ )} +
+ + +
+ ) +} diff --git a/components/legal-page.tsx b/components/legal-page.tsx new file mode 100644 index 0000000..85852b5 --- /dev/null +++ b/components/legal-page.tsx @@ -0,0 +1,48 @@ +import { notFound } from "next/navigation" + +import { PublicHeader } from "@/components/public-header" +import { SiteFooter } from "@/components/site-footer" +import { markdownToHtml } from "@/lib/markdown" +import { SITE_PAGE_META, SitePageKey } from "@/lib/site-pages" +import { readSitePages } from "@/lib/site-pages.server" + +/** + * Rendert eine der rechtlichen Seiten aus dem im Admin gepflegten Markdown. + * + * Serverseitig gerendert: Der Text steht ohne JavaScript im Dokument und ist + * damit auch für Screenreader, Suchmaschinen und Archivierung erreichbar. + * Ist nichts gepflegt, gibt es die Seite nicht – ein leeres Impressum wäre + * irreführender als ein 404. + */ +export async function LegalPage({ page }: { page: SitePageKey }) { + const pages = await readSitePages() + const content = pages[page].trim() + + if (!content) notFound() + + const html = await markdownToHtml(content) + const meta = SITE_PAGE_META[page] + + return ( +
+ + +
+

+ {meta.title} +

+
+
+ + +
+ ) +} + +/** Seitentitel für den Browser-Tab. */ +export function legalPageMetadata(page: SitePageKey) { + return { title: SITE_PAGE_META[page].title } +} diff --git a/components/objection-notice.tsx b/components/objection-notice.tsx new file mode 100644 index 0000000..27b725c --- /dev/null +++ b/components/objection-notice.tsx @@ -0,0 +1,46 @@ +import { OctagonAlert } from "lucide-react" + +import { markdownToHtml } from "@/lib/markdown" +import { cn } from "@/lib/utils" + +/** + * Hinweis auf das Widerspruchsrecht nach Art. 21 DSGVO. + * + * Art. 21 Abs. 4 verlangt, dass darauf „ausdrücklich“ und „in einer + * verständlichen und von anderen Informationen getrennten Form“ hingewiesen + * wird. Deshalb ein eigener, farblich abgesetzter Kasten oberhalb der übrigen + * Abschnitte – und nicht eine Zeile unter vielen. + */ +export async function ObjectionNotice({ + content, + className, +}: { + content?: string | null + className?: string +}) { + if (!content?.trim()) return null + + const html = await markdownToHtml(content) + + return ( + + ) +} diff --git a/components/policy-defaults-form.tsx b/components/policy-defaults-form.tsx new file mode 100644 index 0000000..a37d5cc --- /dev/null +++ b/components/policy-defaults-form.tsx @@ -0,0 +1,247 @@ +"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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { MarkdownInput } from "@/components/markdown-input" +import { updatePolicyDefaults, usePolicyDefaults } from "@/hooks/use-policy-defaults" +import { ApiError } from "@/lib/api" +import { + DEFAULTED_FIELDS, + DEFAULTED_FIELD_LABELS, + DefaultedField, + REQUIRED_DEFAULTED_FIELDS, +} from "@/lib/policy-defaults" +import { PolicyDefaults } from "@/types/privacy-policy" + +/** Eingabewerte des Formulars – wie PolicyDefaults, aber ohne `updatedAt`. */ +type DefaultsFormData = Omit + +const emptyForm: DefaultsFormData = { + responsible: "", + contactDPO: "", + dataSubjectsRights: "", + complaintRight: "", + withdrawalRight: "", + objectionRight: "", + thirdCountryTransfer: "", + automatedDecision: "", + provisionObligation: "", + provisionConsequences: "", +} + +type TabId = "base" | "rights" | "processing" + +/** Aufteilung der Bausteine auf die Reiter des Dialogs. */ +const tabFields: Record = { + base: ["responsible", "contactDPO"], + processing: ["thirdCountryTransfer", "automatedDecision"], + rights: [ + "dataSubjectsRights", + "complaintRight", + "withdrawalRight", + "objectionRight", + "provisionObligation", + "provisionConsequences", + ], +} + +/** Zeilenhöhe der Textfelder – die Rechtetexte sind deutlich länger. */ +const fieldRows: Partial> = { + dataSubjectsRights: 12, + complaintRight: 8, + withdrawalRight: 6, + objectionRight: 8, + thirdCountryTransfer: 6, + automatedDecision: 6, + provisionObligation: 4, + provisionConsequences: 4, +} + +interface PolicyDefaultsFormProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * Pflegt die Standardtexte, die für alle Erklärungen gelten. Eine einzelne + * Erklärung kann jeden dieser Bausteine im Sonderfall überschreiben. + */ +export function PolicyDefaultsForm({ open, onOpenChange }: PolicyDefaultsFormProps) { + const { defaults, isLoading } = usePolicyDefaults() + + const [formData, setFormData] = useState(emptyForm) + const [errors, setErrors] = useState([]) + const [invalidFields, setInvalidFields] = useState>(new Set()) + const [isSubmitting, setIsSubmitting] = useState(false) + const [tab, setTab] = useState("base") + + // Beim Öffnen den gespeicherten Stand laden + useEffect(() => { + if (!open || isLoading) return + + setErrors([]) + setInvalidFields(new Set()) + setTab("base") + // Über die Feldliste statt Feld für Feld: Ein neuer Baustein im Schema + // landet damit automatisch auch im Formular. + setFormData( + Object.fromEntries( + DEFAULTED_FIELDS.map((key) => [key, defaults[key]]) + ) as DefaultsFormData + ) + // `defaults` 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]) + + /** Setzt ein Feld und entfernt dessen Fehlermarkierung. */ + const set = (key: DefaultedField, value: string) => { + setFormData((prev) => ({ ...prev, [key]: value })) + setInvalidFields((prev) => { + if (!prev.has(key)) return prev + const next = new Set(prev) + next.delete(key) + return next + }) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + // Pflicht-Bausteine dürfen zentral nicht leer sein + const missing = REQUIRED_DEFAULTED_FIELDS.filter( + (field) => !formData[field].trim() + ) + + if (missing.length > 0) { + setErrors(missing.map((field) => `${DEFAULTED_FIELD_LABELS[field]} ist erforderlich`)) + setInvalidFields(new Set(missing)) + const reiter = (Object.keys(tabFields) as TabId[]).find((id) => + tabFields[id].includes(missing[0]) + ) + setTab(reiter ?? "base") + return + } + + setErrors([]) + setInvalidFields(new Set()) + setIsSubmitting(true) + + try { + await updatePolicyDefaults(formData) + toast.success("Standardtexte 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) + } + } + + /** Rendert ein Baustein-Feld mit Beschriftung und Markdown-Vorschau. */ + const field = (name: DefaultedField) => ( + set(name, value)} + rows={fieldRows[name] ?? 6} + required={REQUIRED_DEFAULTED_FIELDS.includes(name)} + invalid={invalidFields.has(name)} + /> + ) + + return ( + + + + Standardtexte + + Diese Texte gelten für alle Datenschutzerklärungen, die keinen + eigenen Text hinterlegt haben. Eine Änderung wirkt sich sofort auf + alle diese Erklärungen aus. + + + +
+ setTab(value as TabId)} + className="min-h-0 flex-1 gap-3" + > + + Stammdaten + Verarbeitung + Ihre Rechte + + + {errors.length > 0 && ( +
+ +
    + {errors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ )} + + + + {tabFields.base.map(field)} + + + +

+ Auf beides muss ausdrücklich hingewiesen werden – auch dann, + wenn es nicht stattfindet. Ein klares „findet nicht statt“ ist + die richtige Antwort, kein leeres Feld. +

+
+ {tabFields.processing.map(field)} +
+
+ + +
{tabFields.rights.slice(0, 2).map(field)}
+
{tabFields.rights.slice(2).map(field)}
+
+
+
+ + + + + +
+
+
+ ) +} diff --git a/components/policy-detail.tsx b/components/policy-detail.tsx index b8236fa..41e4134 100644 --- a/components/policy-detail.tsx +++ b/components/policy-detail.tsx @@ -7,7 +7,11 @@ import { Contact, Database, ClipboardCheck, + Cpu, Gavel, + Globe, + Layers, + Scale, OctagonAlert, RotateCcw, ShieldCheck, @@ -73,9 +77,13 @@ export function PolicyDetail({ policy, open, onOpenChange }: PolicyDetailProps) { icon: Briefcase, label: "Bezeichnung der Verarbeitungstätigkeit", content: policy.processingDescription }, { icon: Target, label: "Zweck der Datenerhebung", content: policy.processingPurposes }, { icon: Gavel, label: "Rechtsgrundlage der Verarbeitung", content: policy.legalBasis }, + { icon: Scale, label: "Berechtigte Interessen", content: policy.legitimateInterests }, + { icon: Layers, label: "Kategorien der verarbeiteten Daten", content: policy.dataCategories }, { icon: Users, label: "Geplante Empfänger der Daten", content: policy.recipients }, + { icon: Globe, label: "Übermittlung in Drittländer", content: policy.thirdCountryTransfer }, { icon: Timer, label: "Speicherdauer", content: policy.storageDuration }, { icon: Database, label: "Herkunft der Daten", content: policy.dataSource }, + { icon: Cpu, label: "Automatisierte Entscheidungsfindung", content: policy.automatedDecision }, ] : [] @@ -84,6 +92,7 @@ export function PolicyDetail({ policy, open, onOpenChange }: PolicyDetailProps) { icon: UserCheck, label: "Betroffenen-Rechte", content: policy.dataSubjectsRights }, { icon: AlertTriangle, label: "Beschwerderecht bei der Aufsichtsbehörde", content: policy.complaintRight }, { icon: RotateCcw, label: "Widerrufsrecht bei Einwilligung", content: policy.withdrawalRight }, + { icon: OctagonAlert, label: "Widerspruchsrecht (Art. 21 DSGVO)", content: policy.objectionRight }, { icon: ClipboardCheck, label: "Ihre Pflicht zur Bereitstellung der Daten", content: policy.provisionObligation }, { icon: OctagonAlert, label: "Folgen, wenn Sie die Daten nicht angeben", content: policy.provisionConsequences }, ] diff --git a/components/policy-form.tsx b/components/policy-form.tsx index dc5c102..413b675 100644 --- a/components/policy-form.tsx +++ b/components/policy-form.tsx @@ -19,8 +19,15 @@ 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 = { @@ -30,12 +37,17 @@ const initialFormData: FormData = { dataSubjectsRights: "", complaintRight: "", withdrawalRight: "", + objectionRight: "", + thirdCountryTransfer: "", + automatedDecision: "", processingDescription: "", processingPurposes: "", legalBasis: "", + legitimateInterests: "", recipients: "", storageDuration: "", dataSource: "", + dataCategories: "", provisionObligation: "", provisionConsequences: "", isPublic: true, @@ -43,37 +55,42 @@ const initialFormData: FormData = { 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: "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" }, ] +/** + * 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 = { + responsible: "base", + contactDPO: "base", + dataSubjectsRights: "rights", + complaintRight: "rights", + withdrawalRight: "rights", + objectionRight: "rights", + thirdCountryTransfer: "processing", + automatedDecision: "processing", + provisionObligation: "rights", + provisionConsequences: "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.`, @@ -92,12 +109,16 @@ Verarbeitung von Nutzerdaten zum Betrieb der Website und zur Bereitstellung unse 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.: + 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.: -Eigentümer der Immobilie / Verbrauchsstelle`, - provisionConsequences: `Was passiert, wenn die Daten nicht angegeben werden, z. B.: +Sicherstellung des ordnungsgemäßen Dienstbetriebs`, + dataCategories: `Welche Arten von Daten verarbeitet werden, z. B.: -Verbrauchsschätzung`, +- **Stammdaten:** Name, Anschrift, Geburtsdatum +- **Kontaktdaten:** Telefonnummer, E-Mail-Adresse +- **Verfahrensdaten:** Aktenzeichen, Bescheiddaten`, dataSource: `- **Direkte Eingabe:** Kontaktformulare, Registrierung - **Website-Nutzung:** Cookies, Logs - **Öffentliche Quellen:** Handelsregister`, @@ -110,11 +131,17 @@ interface PolicyFormProps { } export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) { + const { defaults } = usePolicyDefaults() + const [formData, setFormData] = useState(initialFormData) const [errors, setErrors] = useState([]) const [invalidFields, setInvalidFields] = useState>(new Set()) const [isSubmitting, setIsSubmitting] = useState(false) const [tab, setTab] = useState("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 @@ -122,23 +149,36 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) { 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: policy.responsible, - contactDPO: policy.contactDPO, - dataSubjectsRights: policy.dataSubjectsRights, - complaintRight: policy.complaintRight, - withdrawalRight: policy.withdrawalRight, + 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 || "", - provisionObligation: policy.provisionObligation || "", - provisionConsequences: policy.provisionConsequences || "", + dataCategories: policy.dataCategories || "", + provisionObligation: overrideOf("provisionObligation"), + provisionConsequences: overrideOf("provisionConsequences"), isPublic: policy.isPublic, } : initialFormData @@ -158,14 +198,53 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - const missing = requiredFields.filter( - ({ field }) => !String(formData[field] ?? "").trim() - ) + const gefuellt = (field: keyof FormData) => + String(formData[field] ?? "").trim().length > 0 - if (missing.length > 0) { - setErrors(missing.map(({ label }) => `${label} ist erforderlich`)) - setInvalidFields(new Set(missing.map(({ field }) => field as string))) - setTab(missing[0].tab) + // 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 } @@ -204,6 +283,9 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) { 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. @@ -260,24 +342,18 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
- set("responsible", value)} - placeholder={placeholders.responsible} - invalid={isInvalid("responsible")} - required - /> - set("contactDPO", value)} - placeholder={placeholders.contactDPO} - invalid={isInvalid("contactDPO")} - required - /> + {(["responsible", "contactDPO"] as DefaultedField[]).map((field) => ( + set(field, value)} + invalid={isInvalid(field)} + required + /> + ))}
@@ -293,17 +369,29 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) { /> set("processingPurposes", value)} placeholder={placeholders.processingPurposes} + invalid={isInvalid("processingPurposes")} + required /> set("legalBasis", value)} placeholder={placeholders.legalBasis} + invalid={isInvalid("legalBasis")} + required + /> + set("legitimateInterests", value)} + placeholder={placeholders.legitimateInterests} + rows={4} /> set("storageDuration", value)} placeholder={placeholders.storageDuration} + invalid={isInvalid("storageDuration")} + required /> set("dataSource", value)} placeholder={placeholders.dataSource} /> + set("dataCategories", value)} + placeholder={placeholders.dataCategories} + invalid={isInvalid("dataCategories")} + required={!!formData.dataSource?.trim()} + /> + +
+

+ Auf Drittländer und automatisierte Entscheidungen muss + ausdrücklich hingewiesen werden – auch dann, wenn es beides + nicht gibt. Beide Bausteine kommen aus den Standardtexten. +

+ {(["thirdCountryTransfer", "automatedDecision"] as DefaultedField[]).map( + (field) => ( + set(field, value)} + invalid={isInvalid(field)} + rows={5} + required + /> + ) + )} +
- set("dataSubjectsRights", value)} - placeholder={placeholders.dataSubjectsRights} invalid={isInvalid("dataSubjectsRights")} rows={9} required />
- set("complaintRight", value)} - placeholder={placeholders.complaintRight} - invalid={isInvalid("complaintRight")} - required - /> - set("withdrawalRight", value)} - placeholder={placeholders.withdrawalRight} - invalid={isInvalid("withdrawalRight")} - required - /> - set("provisionObligation", value)} - placeholder={placeholders.provisionObligation} - rows={4} - /> - set("provisionConsequences", value)} - placeholder={placeholders.provisionConsequences} - rows={4} - /> + {( + [ + ["complaintRight", 6, true], + ["withdrawalRight", 6, true], + ["objectionRight", 7, true], + ["provisionObligation", 4, false], + ["provisionConsequences", 4, false], + ] as [DefaultedField, number, boolean][] + ).map(([field, rows, required]) => ( + set(field, value)} + invalid={isInvalid(field)} + rows={rows} + required={required} + /> + ))}
diff --git a/components/policy-list.tsx b/components/policy-list.tsx new file mode 100644 index 0000000..1623397 --- /dev/null +++ b/components/policy-list.tsx @@ -0,0 +1,164 @@ +"use client" + +import { useMemo, useState } from "react" +import Link from "next/link" +import { ChevronRight, FileText, Search, X } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { Skeleton } from "@/components/ui/skeleton" +import { PrintPolicy } from "@/components/print-policy" +import { usePublicPrivacyPolicies } from "@/hooks/use-privacy-policies" +import { formatDate } from "@/lib/format" +import { PrivacyPolicy } from "@/types/privacy-policy" + +export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) { + const { policies, isLoading, error } = usePublicPrivacyPolicies(initial) + const [searchTerm, setSearchTerm] = useState("") + + const filteredPolicies = useMemo(() => { + const term = searchTerm.trim().toLowerCase() + if (!term) return policies + + return policies.filter((policy) => + [policy.title, policy.responsible, policy.processingDescription].some((field) => + field.toLowerCase().includes(term) + ) + ) + }, [policies, searchTerm]) + + return ( +
+
+
+

+ Datenschutzerklärungen +

+

+ Transparente Informationen zu unserer Datenverarbeitung nach DSGVO +

+
+ +
+ + setSearchTerm(e.target.value)} + className="px-9" + /> + {searchTerm && ( + + )} +
+
+ + {error ? ( +
+ Fehler beim Laden der Daten: {error.message} +
+ ) : isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : filteredPolicies.length === 0 ? ( +
+
+ +
+

+ {searchTerm + ? "Keine Treffer" + : "Keine Datenschutzerklärungen verfügbar"} +

+

+ {searchTerm + ? "Versuchen Sie einen anderen Suchbegriff." + : "Derzeit sind keine öffentlichen Datenschutzerklärungen hinterlegt."} +

+ {searchTerm && ( + + )} +
+ ) : ( +
+ {filteredPolicies.map((policy) => ( +
+ {/* Echter Link: teilbar, in neuem Tab zu öffnen, ohne JavaScript nutzbar */} + + + + + + + {policy.title} + + + {policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]} + + + + +
+ + {formatDate(policy.updatedAt)} + + + + +
+
+ ))} +
+ )} + + {!isLoading && !error && filteredPolicies.length > 0 && ( +
+ + {filteredPolicies.length}{" "} + {filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"} + {searchTerm && ` von ${policies.length}`} + + DSGVO-konform +
+ )} +
+ ) +} diff --git a/components/print-policy.tsx b/components/print-policy.tsx index f516bd5..3dc89c7 100644 --- a/components/print-policy.tsx +++ b/components/print-policy.tsx @@ -4,8 +4,9 @@ import { Printer } from "lucide-react" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { useAppName } from "@/components/providers/app-config-provider" import { PrivacyPolicy } from "@/types/privacy-policy" -import { markdownToHtmlSync } from "@/lib/markdown" +import { escapeHtml, markdownToHtmlSync } from "@/lib/markdown" interface PrintPolicyProps { policy: PrivacyPolicy @@ -24,11 +25,13 @@ export function PrintPolicy({ label = "Drucken", className, }: PrintPolicyProps) { + const appName = useAppName() + const handlePrint = () => { const printWindow = window.open("", "_blank") if (!printWindow) return - printWindow.document.write(generatePrintContent(policy)) + printWindow.document.write(generatePrintContent(policy, appName)) printWindow.document.close() } @@ -51,7 +54,7 @@ export function PrintPolicy({ ) } -function generatePrintContent(policy: PrivacyPolicy): string { +function generatePrintContent(policy: PrivacyPolicy, appName: string): string { const formatDate = (dateString: string) => new Date(dateString).toLocaleDateString("de-DE", { year: "numeric", @@ -61,6 +64,16 @@ function generatePrintContent(policy: PrivacyPolicy): string { const cleanMarkdown = (content: string) => markdownToHtmlSync(content) + // Art. 21 Abs. 4 verlangt den Hinweis auf das Widerspruchsrecht „von anderen + // Informationen getrennt“ – in der Druckfassung als eigener, gerahmter Block. + const objectionNotice = (content?: string | null) => + content + ? `
+

Ihr Widerspruchsrecht nach Art. 21 DSGVO

+
${cleanMarkdown(content)}
+
` + : "" + const section = (title: string, content?: string | null) => content ? `
@@ -75,7 +88,7 @@ function generatePrintContent(policy: PrivacyPolicy): string { - ${policy.title} + ${escapeHtml(policy.title)}