From 61c4998a79529e0fd27c7b12e4f25cfbda8f9e5b Mon Sep 17 00:00:00 2001 From: Jerrit Fritzsche Date: Mon, 7 Sep 2026 20:35:05 +0200 Subject: [PATCH] =?UTF-8?q?Datenschutzerkl=C3=A4rungen=20ver=C3=B6ffentlic?= =?UTF-8?q?hungsreif=20machen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die öffentliche API gab bei ?admin=true alle Datensätze heraus, auch die nicht veröffentlichten: von 117 Erklärungen waren 112 Entwürfe, die jeder Besucher abrufen konnte. Der Parameter setzt jetzt eine Admin-Sitzung voraus, sonst kommt die öffentliche Liste. Der Einzelabruf behandelt Entwürfe wie nicht vorhanden, damit sich über die fortlaufende ID nicht abklopfen lässt, welche Datensätze existieren. Markdown wurde mit sanitize: false gerendert und per dangerouslySetInnerHTML eingesetzt. Das Standard-Schema lässt jetzt Überschriften, Listen, Tabellen und http(s)/mailto-Links durch und entfernt Skripte, Event-Attribute, eingebettete Rahmen und javascript:-Ziele. Der Titel in der Druckfassung war an drei Stellen unmaskiert. Standardtexte - Zehn Bausteine, die für alle Erklärungen gelten, werden einmal zentral gepflegt. In der Erklärung steht nur eine Abweichung; ein leeres Feld bedeutet "folgt dem Standard". Ein Wechsel in der Amtsleitung wirkt damit sofort auf alle Erklärungen, die nicht bewusst abweichen. - Die Migration übernimmt den häufigsten Bestandswert als Standard und setzt übereinstimmende Felder auf NULL. Von 117 Erklärungen folgen danach 116 dem Standard, eine weicht ab (Online-Verfahren mit ZIT-SH als zusätzlichem Verantwortlichen). Pflichtangaben nach Art. 13/14 DSGVO - Neue Felder für Drittlandübermittlung (Art. 13 Abs. 1 lit. f), automatisierte Entscheidungsfindung (Art. 13 Abs. 2 lit. f), Datenkategorien (Art. 14 Abs. 1 lit. d) und berechtigte Interessen (Art. 13 Abs. 1 lit. d). Auf die ersten beiden muss auch dann hingewiesen werden, wenn es sie nicht gibt, deshalb sind sie Standardtexte mit ausdrücklichem "findet nicht statt". - Der Hinweis auf das Widerspruchsrecht steht nach Art. 21 Abs. 4 in einem eigenen, abgesetzten Kasten vor allen Abschnitten, nicht als Zeile im Fließtext der Betroffenenrechte. - Zweck, Rechtsgrundlage und Speicherdauer sind Pflicht. Geprüft wird beim Veröffentlichen, nicht beim Speichern: Ein Entwurf darf unvollständig sein, eine öffentlich sichtbare Erklärung nicht. So bleiben Bestandsdaten bearbeitbar, ohne dass die Anwendung Angaben erfindet, die nur die Fachabteilung kennt. Die Admin-Übersicht markiert unvollständige Erklärungen und nennt die fehlenden Angaben. Öffentliche Seiten - Jede Erklärung hat eine dauerhafte Adresse /erklaerung/, serverseitig gerendert, zum Verlinken aus Antragsformularen. Der Bezeichner wird aus dem Titel abgeleitet und bleibt beim Bearbeiten erhalten, solange der Titel gleich bleibt. Entwürfe liefern 404. - Impressum, Datenschutzerklärung des Portals und Erklärung zur Barrierefreiheit werden im Admin als Markdown gepflegt und in der Fußzeile verlinkt. Die Fußzeile rendert serverseitig, weil das Impressum ohne JavaScript erreichbar sein muss. Ein leerer Text nimmt die Seite vom Netz, statt eine leere Seite auszuliefern. - Die Übersicht nutzt echte Links statt Klick-Handler und wird serverseitig vorbefüllt. APP_COMPANY_NAME ersetzt den Namen der Anwendung in Kopf- und Fußzeile, Seitentiteln, Anmeldeseite, Druckfassung und Startmeldung des Containers. Der Wert wird zur Laufzeit gelesen, ein Neubau des Images ist zum Umbenennen nicht nötig. Getestet gegen die 117 Bestandsdatensätze: Migrationen angewendet, der angezeigte Text blieb bei allen 117 x 7 Baustein-Feldern identisch. Alle 117 Bezeichner sind eindeutig und stimmen mit lib/slug.ts überein (SQLites LOWER() kennt nur ASCII, Großumlaute werden deshalb vorher ersetzt). ?admin=true liefert ohne Anmeldung 5 statt 117 Datensätze, Entwürfe 404. Über die API mit Anmeldung geprüft: unvollständig als Entwurf wird angelegt, dieselben Angaben mit isPublic abgelehnt, ebenso fehlende Datenkategorien bei angegebener Herkunft. Ohne APP_COMPANY_NAME erscheint "Syncova Policies", mit gesetztem Wert kein einziges Vorkommen mehr. Die Änderungen liegen in einem Commit, weil Schema, API-Routen und Formulare von allen Teilen berührt werden und eine thematische Aufteilung nicht lauffähige Zwischenstände ergäbe. Co-Authored-By: Claude Opus 5 --- .env.example | 4 + README.md | 79 ++++- app/admin/page.tsx | 114 ++++++- app/api/policy-defaults/route.ts | 64 ++++ app/api/privacy-policies/[id]/route.ts | 86 +++-- app/api/privacy-policies/route.ts | 111 +++--- app/api/site-pages/route.ts | 51 +++ app/auth/signin/page.tsx | 5 +- app/barrierefreiheit/page.tsx | 10 + app/datenschutz/page.tsx | 10 + app/erklaerung/[slug]/page.tsx | 182 ++++++++++ app/impressum/page.tsx | 10 + app/layout.tsx | 26 +- app/page.tsx | 221 ++---------- components/brand.tsx | 18 +- components/defaultable-field.tsx | 133 ++++++++ components/legal-page.tsx | 48 +++ components/objection-notice.tsx | 46 +++ components/policy-defaults-form.tsx | 247 ++++++++++++++ components/policy-detail.tsx | 9 + components/policy-form.tsx | 317 ++++++++++++------ components/policy-list.tsx | 164 +++++++++ components/print-policy.tsx | 45 ++- components/providers/app-config-provider.tsx | 33 ++ components/public-header.tsx | 38 +++ components/site-footer.tsx | 45 +++ components/site-pages-form.tsx | 243 ++++++++++++++ docker-compose.yml | 4 + docker-entrypoint.sh | 2 +- hooks/use-policy-defaults.ts | 40 +++ hooks/use-privacy-policies.ts | 19 +- hooks/use-site-pages.ts | 32 ++ lib/api.ts | 33 +- lib/app-config.ts | 14 + lib/markdown.ts | 19 +- lib/policy-completeness.ts | 133 ++++++++ lib/policy-defaults.server.ts | 53 +++ lib/policy-defaults.ts | 128 +++++++ lib/policy-slug.server.ts | 32 ++ lib/require-admin.ts | 21 +- lib/site-pages.server.ts | 20 ++ lib/site-pages.ts | 37 ++ lib/slug.ts | 36 ++ middleware.ts | 8 +- .../migration.sql | 75 +++++ .../20260907200000_policy_slug/migration.sql | 78 +++++ .../20260907210000_site_pages/migration.sql | 11 + .../migration.sql | 21 ++ prisma/schema.prisma | 59 +++- scripts/import-pdfs.js | 12 + scripts/import-policy.js | 63 +++- scripts/lib/policy-defaults.js | 73 ++++ scripts/lib/slug.js | 48 +++ types/privacy-policy.ts | 54 ++- 54 files changed, 3054 insertions(+), 430 deletions(-) create mode 100644 app/api/policy-defaults/route.ts create mode 100644 app/api/site-pages/route.ts create mode 100644 app/barrierefreiheit/page.tsx create mode 100644 app/datenschutz/page.tsx create mode 100644 app/erklaerung/[slug]/page.tsx create mode 100644 app/impressum/page.tsx create mode 100644 components/defaultable-field.tsx create mode 100644 components/legal-page.tsx create mode 100644 components/objection-notice.tsx create mode 100644 components/policy-defaults-form.tsx create mode 100644 components/policy-list.tsx create mode 100644 components/providers/app-config-provider.tsx create mode 100644 components/public-header.tsx create mode 100644 components/site-footer.tsx create mode 100644 components/site-pages-form.tsx create mode 100644 hooks/use-policy-defaults.ts create mode 100644 hooks/use-site-pages.ts create mode 100644 lib/app-config.ts create mode 100644 lib/policy-completeness.ts create mode 100644 lib/policy-defaults.server.ts create mode 100644 lib/policy-defaults.ts create mode 100644 lib/policy-slug.server.ts create mode 100644 lib/site-pages.server.ts create mode 100644 lib/site-pages.ts create mode 100644 lib/slug.ts create mode 100644 prisma/migrations/20260907190000_policy_defaults/migration.sql create mode 100644 prisma/migrations/20260907200000_policy_slug/migration.sql create mode 100644 prisma/migrations/20260907210000_site_pages/migration.sql create mode 100644 prisma/migrations/20260907220000_dsgvo_pflichtangaben/migration.sql create mode 100644 scripts/lib/policy-defaults.js create mode 100644 scripts/lib/slug.js 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)}