Datenschutzerklärungen veröffentlichungsreif machen
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m23s

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/<bezeichner>,
  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 <noreply@anthropic.com>
This commit is contained in:
Jerrit Fritzsche 2026-09-07 20:35:05 +02:00
parent c357057b69
commit 61c4998a79
54 changed files with 3054 additions and 430 deletions

View File

@ -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"

View File

@ -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/<bezeichner>` – 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

View File

@ -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<SortKey>("createdAt")
@ -76,6 +84,8 @@ export default function AdminDashboard() {
const [policyToDelete, setPolicyToDelete] = useState<PrivacyPolicy | null>(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<number, string[]>()
for (const policy of policies) {
const missing = missingRequiredFields(
policy as unknown as Record<string, unknown>,
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() {
<TooltipContent>Öffentliche Ansicht</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsDefaultsOpen(true)}
>
<FileType2 />
<span className="sr-only">Standardtexte</span>
</Button>
</TooltipTrigger>
<TooltipContent>Standardtexte</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -242,6 +288,14 @@ export default function AdminDashboard() {
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setIsDefaultsOpen(true)}>
<FileType2 />
Standardtexte pflegen
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsSitePagesOpen(true)}>
<Scale />
Rechtliche Seiten
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsUserManagementOpen(true)}>
<Users />
Benutzer verwalten
@ -269,6 +323,14 @@ export default function AdminDashboard() {
<p className="text-sm text-muted-foreground">
{policies.length} gesamt · {publicCount} öffentlich ·{" "}
{policies.length - publicCount} privat
{incompleteCount > 0 && (
<>
{" · "}
<span className="text-amber-600 dark:text-amber-500">
{incompleteCount} unvollständig
</span>
</>
)}
</p>
</div>
@ -368,17 +430,43 @@ export default function AdminDashboard() {
</span>
</TableCell>
<TableCell>
{policy.isPublic ? (
<Badge variant="success">
<Eye />
Öffentlich
</Badge>
) : (
<Badge variant="outline">
<EyeOff />
Privat
</Badge>
)}
<div className="flex flex-wrap items-center gap-1.5">
{policy.isPublic ? (
<Badge variant="success">
<Eye />
Öffentlich
</Badge>
) : (
<Badge variant="outline">
<EyeOff />
Privat
</Badge>
)}
{missingByPolicy.has(policy.id) && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="cursor-help border-amber-500/40 text-amber-600 dark:text-amber-500"
>
<TriangleAlert />
{missingByPolicy.get(policy.id)!.length}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="mb-1 font-medium">
Fehlende Pflichtangaben
</p>
<ul className="space-y-0.5">
{missingByPolicy.get(policy.id)!.map((text) => (
<li key={text}>· {text}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
<TableCell className="hidden whitespace-nowrap text-muted-foreground lg:table-cell">
{formatDateTime(policy.createdAt)}
@ -434,6 +522,10 @@ export default function AdminDashboard() {
onOpenChange={setIsPreviewOpen}
/>
<PolicyDefaultsForm open={isDefaultsOpen} onOpenChange={setIsDefaultsOpen} />
<SitePagesForm open={isSitePagesOpen} onOpenChange={setIsSitePagesOpen} />
<UserManagement
isOpen={isUserManagementOpen}
onClose={() => setIsUserManagementOpen(false)}

View File

@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { requireAdmin } from '@/lib/require-admin'
import { DEFAULTED_FIELDS, DEFAULTED_FIELD_LABELS, REQUIRED_DEFAULTED_FIELDS } from '@/lib/policy-defaults'
import { readPolicyDefaults } from '@/lib/policy-defaults.server'
// Prisma client instance
const prisma = new PrismaClient()
// GET /api/policy-defaults - liefert die zentralen Standardtexte
export async function GET() {
try {
const defaults = await readPolicyDefaults(prisma)
return NextResponse.json(defaults)
} catch (error) {
console.error('Error fetching policy defaults:', error)
return NextResponse.json(
{ error: 'Fehler beim Abrufen der Standardtexte' },
{ status: 500 }
)
}
}
// PUT /api/policy-defaults - speichert die zentralen Standardtexte (Admin)
export async function PUT(request: NextRequest) {
try {
const denied = await requireAdmin()
if (denied) return denied
const data = await request.json()
// Die Pflicht-Bausteine dürfen im Standard nicht leer bleiben – sonst
// fehlen sie in jeder Erklärung, die keinen eigenen Text hinterlegt hat.
const errors = REQUIRED_DEFAULTED_FIELDS.filter(
(field) => !String(data[field] ?? '').trim()
).map((field) => `${DEFAULTED_FIELD_LABELS[field]} ist erforderlich`)
if (errors.length > 0) {
return NextResponse.json(
{ error: 'Validierungsfehler', details: errors },
{ status: 400 }
)
}
// Nur die bekannten Felder übernehmen, jeweils getrimmt.
const values = Object.fromEntries(
DEFAULTED_FIELDS.map((field) => [field, String(data[field] ?? '').trim()])
)
const saved = await prisma.policyDefaults.upsert({
where: { id: 1 },
update: values,
create: { id: 1, ...values },
})
return NextResponse.json(saved)
} catch (error) {
console.error('Error updating policy defaults:', error)
return NextResponse.json(
{ error: 'Fehler beim Speichern der Standardtexte' },
{ status: 500 }
)
}
}

View File

@ -1,32 +1,58 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { requireAdmin } from '@/lib/require-admin'
import { isAdminSession, requireAdmin } from '@/lib/require-admin'
import {
DEFAULTED_FIELDS,
} from '@/lib/policy-defaults'
import { readPolicyDefaults, toApiPolicy } from '@/lib/policy-defaults.server'
import { describeMissing, missingRequiredFields } from '@/lib/policy-completeness'
import { resolveSlug } from '@/lib/policy-slug.server'
import { PolicyDefaults } from '@/types/privacy-policy'
// Prisma client instance
const prisma = new PrismaClient()
// Validation function for privacy policy data
function validatePrivacyPolicyData(data: any) {
/**
* Prüft die Angaben einer Erklärung.
*
* Zwei Stufen: Ohne Titel, Verarbeitungstätigkeit und Empfänger lässt sich
* nichts speichern. Die übrigen Pflichtangaben aus Art. 13/14 DSGVO werden
* erst beim Veröffentlichen verlangt – ein Entwurf darf unvollständig sein,
* eine öffentlich sichtbare Erklärung nicht.
*/
function validatePrivacyPolicyData(data: any, defaults: PolicyDefaults) {
const errors: string[] = []
// Required fields validation
if (!data.title?.trim()) errors.push('Title ist erforderlich')
if (!data.responsible?.trim()) errors.push('Verantwortlicher ist erforderlich')
if (!data.contactDPO?.trim()) errors.push('Kontaktdaten des Datenschutzbeauftragten sind erforderlich')
if (!data.dataSubjectsRights?.trim()) errors.push('Betroffenen-Rechte sind erforderlich')
if (!data.complaintRight?.trim()) errors.push('Beschwerderecht ist erforderlich')
if (!data.withdrawalRight?.trim()) errors.push('Widerrufsrecht ist erforderlich')
// Ohne diese Angaben ist der Datensatz nicht einmal als Entwurf brauchbar
if (!data.title?.trim()) errors.push('Titel ist erforderlich')
if (!data.processingDescription?.trim()) errors.push('Verarbeitungstätigkeit ist erforderlich')
if (!data.recipients?.trim()) errors.push('Empfänger sind erforderlich')
// Optional boolean validation
if (data.isPublic !== undefined && typeof data.isPublic !== 'boolean') {
errors.push('isPublic muss ein Boolean-Wert sein')
}
// Vollständigkeit nur verlangen, wenn die Erklärung öffentlich sein soll
if (data.isPublic !== false && errors.length === 0) {
for (const entry of missingRequiredFields(data, defaults)) {
errors.push(`${describeMissing(entry)} – zum Veröffentlichen erforderlich`)
}
}
return errors
}
/**
* Übernimmt die Baustein-Felder aus der Anfrage: leer wird zu `null` und
* bedeutet damit „Standardtext verwenden“.
*/
function defaultedFieldsFrom(data: any) {
return Object.fromEntries(
DEFAULTED_FIELDS.map((field) => [field, data[field]?.trim() || null])
) as Record<(typeof DEFAULTED_FIELDS)[number], string | null>
}
// GET /api/privacy-policies/[id] - returns a single privacy policy
export async function GET(
request: NextRequest,
@ -45,18 +71,23 @@ export async function GET(
}
// Find privacy policy by ID
const policy = await prisma.privacyPolicy.findUnique({
where: { id }
})
const [policy, defaults] = await Promise.all([
prisma.privacyPolicy.findUnique({ where: { id } }),
readPolicyDefaults(prisma),
])
if (!policy) {
// Nicht veröffentlichte Erklärungen sind Entwürfe: Ohne Admin-Sitzung
// verhalten sie sich, als gäbe es sie nicht – sonst ließe sich über die
// fortlaufende ID ermitteln, welche Datensätze existieren.
if (!policy || (!policy.isPublic && !(await isAdminSession()))) {
return NextResponse.json(
{ error: 'Datenschutzerklärung nicht gefunden' },
{ status: 404 }
)
}
return NextResponse.json(policy)
// Standardtexte einsetzen, damit Konsumenten den geltenden Text sehen
return NextResponse.json(toApiPolicy(policy, defaults))
} catch (error) {
console.error('Error fetching privacy policy:', error)
return NextResponse.json(
@ -87,8 +118,10 @@ export async function PUT(
)
}
const defaults = await readPolicyDefaults(prisma)
// Validate required fields
const validationErrors = validatePrivacyPolicyData(data)
const validationErrors = validatePrivacyPolicyData(data, defaults)
if (validationErrors.length > 0) {
return NextResponse.json(
{ error: 'Validierungsfehler', details: validationErrors },
@ -113,24 +146,21 @@ export async function PUT(
where: { id },
data: {
title: data.title.trim(),
responsible: data.responsible.trim(),
contactDPO: data.contactDPO.trim(),
dataSubjectsRights: data.dataSubjectsRights.trim(),
complaintRight: data.complaintRight.trim(),
withdrawalRight: data.withdrawalRight.trim(),
slug: await resolveSlug(prisma, data.title, existingPolicy),
...defaultedFieldsFrom(data),
processingDescription: data.processingDescription.trim(),
processingPurposes: data.processingPurposes?.trim() || null,
legalBasis: data.legalBasis?.trim() || null,
legitimateInterests: data.legitimateInterests?.trim() || null,
recipients: data.recipients.trim(),
storageDuration: data.storageDuration?.trim() || null,
dataSource: data.dataSource?.trim() || null,
provisionObligation: data.provisionObligation?.trim() || null,
provisionConsequences: data.provisionConsequences?.trim() || null,
dataCategories: data.dataCategories?.trim() || null,
isPublic: data.isPublic !== undefined ? data.isPublic : existingPolicy.isPublic
}
})
return NextResponse.json(updatedPolicy)
return NextResponse.json(toApiPolicy(updatedPolicy, defaults))
} catch (error) {
console.error('Error updating privacy policy:', error)
return NextResponse.json(
@ -188,4 +218,4 @@ export async function DELETE(
{ status: 500 }
)
}
}
}

View File

@ -1,49 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { requireAdmin } from '@/lib/require-admin'
import { isAdminSession, requireAdmin } from '@/lib/require-admin'
import {
DEFAULTED_FIELDS,
} from '@/lib/policy-defaults'
import { readPolicyDefaults, toApiPolicy } from '@/lib/policy-defaults.server'
import { describeMissing, missingRequiredFields } from '@/lib/policy-completeness'
import { resolveSlug } from '@/lib/policy-slug.server'
import { PolicyDefaults } from '@/types/privacy-policy'
// Prisma client instance
const prisma = new PrismaClient()
// Validation function for privacy policy data
function validatePrivacyPolicyData(data: any) {
/**
* Prüft die Angaben einer Erklärung.
*
* Zwei Stufen: Ohne Titel, Verarbeitungstätigkeit und Empfänger lässt sich
* nichts speichern. Die übrigen Pflichtangaben aus Art. 13/14 DSGVO werden
* erst beim Veröffentlichen verlangt – ein Entwurf darf unvollständig sein,
* eine öffentlich sichtbare Erklärung nicht.
*/
function validatePrivacyPolicyData(data: any, defaults: PolicyDefaults) {
const errors: string[] = []
// Required fields validation
if (!data.title?.trim()) errors.push('Title ist erforderlich')
if (!data.responsible?.trim()) errors.push('Verantwortlicher ist erforderlich')
if (!data.contactDPO?.trim()) errors.push('Kontaktdaten des Datenschutzbeauftragten sind erforderlich')
if (!data.dataSubjectsRights?.trim()) errors.push('Betroffenen-Rechte sind erforderlich')
if (!data.complaintRight?.trim()) errors.push('Beschwerderecht ist erforderlich')
if (!data.withdrawalRight?.trim()) errors.push('Widerrufsrecht ist erforderlich')
// Ohne diese Angaben ist der Datensatz nicht einmal als Entwurf brauchbar
if (!data.title?.trim()) errors.push('Titel ist erforderlich')
if (!data.processingDescription?.trim()) errors.push('Verarbeitungstätigkeit ist erforderlich')
if (!data.recipients?.trim()) errors.push('Empfänger sind erforderlich')
// Optional boolean validation
if (data.isPublic !== undefined && typeof data.isPublic !== 'boolean') {
errors.push('isPublic muss ein Boolean-Wert sein')
}
// Vollständigkeit nur verlangen, wenn die Erklärung öffentlich sein soll
if (data.isPublic !== false && errors.length === 0) {
for (const entry of missingRequiredFields(data, defaults)) {
errors.push(`${describeMissing(entry)} – zum Veröffentlichen erforderlich`)
}
}
return errors
}
/**
* Übernimmt die Baustein-Felder aus der Anfrage: leer wird zu `null` und
* bedeutet damit „Standardtext verwenden“.
*/
function defaultedFieldsFrom(data: any) {
return Object.fromEntries(
DEFAULTED_FIELDS.map((field) => [field, data[field]?.trim() || null])
) as Record<(typeof DEFAULTED_FIELDS)[number], string | null>
}
// GET /api/privacy-policies - returns privacy policies based on admin parameter
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const isAdmin = searchParams.get('admin') === 'true'
// `?admin=true` schließt die nicht veröffentlichten Erklärungen ein und
// setzt deshalb eine angemeldete Admin-Sitzung voraus. Ohne sie wird ganz
// normal die öffentliche Liste ausgeliefert, statt einen Fehler zu werfen.
const isAdmin =
searchParams.get('admin') === 'true' && (await isAdminSession())
// Get policies based on admin flag
const policies = await prisma.privacyPolicy.findMany({
where: isAdmin ? {} : {
isPublic: true
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json(policies)
const [policies, defaults] = await Promise.all([
prisma.privacyPolicy.findMany({
where: isAdmin ? {} : {
isPublic: true
},
orderBy: {
createdAt: 'desc'
}
}),
readPolicyDefaults(prisma),
])
// Standardtexte einsetzen, damit Konsumenten immer den geltenden Text sehen
return NextResponse.json(
policies.map((policy) => toApiPolicy(policy, defaults))
)
} catch (error) {
console.error('Error fetching privacy policies:', error)
return NextResponse.json(
@ -60,38 +97,36 @@ export async function POST(request: NextRequest) {
if (denied) return denied
const data = await request.json()
const defaults = await readPolicyDefaults(prisma)
// Validate required fields
const validationErrors = validatePrivacyPolicyData(data)
const validationErrors = validatePrivacyPolicyData(data, defaults)
if (validationErrors.length > 0) {
return NextResponse.json(
{ error: 'Validierungsfehler', details: validationErrors },
{ status: 400 }
)
}
// Create new privacy policy
const newPolicy = await prisma.privacyPolicy.create({
data: {
title: data.title.trim(),
responsible: data.responsible.trim(),
contactDPO: data.contactDPO.trim(),
dataSubjectsRights: data.dataSubjectsRights.trim(),
complaintRight: data.complaintRight.trim(),
withdrawalRight: data.withdrawalRight.trim(),
slug: await resolveSlug(prisma, data.title),
...defaultedFieldsFrom(data),
processingDescription: data.processingDescription.trim(),
processingPurposes: data.processingPurposes?.trim() || null,
legalBasis: data.legalBasis?.trim() || null,
legitimateInterests: data.legitimateInterests?.trim() || null,
recipients: data.recipients.trim(),
storageDuration: data.storageDuration?.trim() || null,
dataSource: data.dataSource?.trim() || null,
provisionObligation: data.provisionObligation?.trim() || null,
provisionConsequences: data.provisionConsequences?.trim() || null,
dataCategories: data.dataCategories?.trim() || null,
isPublic: data.isPublic !== undefined ? data.isPublic : true
}
})
return NextResponse.json(newPolicy, { status: 201 })
return NextResponse.json(toApiPolicy(newPolicy, defaults), { status: 201 })
} catch (error) {
console.error('Error creating privacy policy:', error)
return NextResponse.json(
@ -99,4 +134,4 @@ export async function POST(request: NextRequest) {
{ status: 500 }
)
}
}
}

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { requireAdmin } from '@/lib/require-admin'
import { SITE_PAGES } from '@/lib/site-pages'
import { readSitePages } from '@/lib/site-pages.server'
// Prisma client instance
const prisma = new PrismaClient()
// GET /api/site-pages - liefert die Inhalte der rechtlichen Seiten
export async function GET() {
try {
return NextResponse.json(await readSitePages())
} catch (error) {
console.error('Error fetching site pages:', error)
return NextResponse.json(
{ error: 'Fehler beim Abrufen der Seiteninhalte' },
{ status: 500 }
)
}
}
// PUT /api/site-pages - speichert die Inhalte der rechtlichen Seiten (Admin)
export async function PUT(request: NextRequest) {
try {
const denied = await requireAdmin()
if (denied) return denied
const data = await request.json()
// Nur die bekannten Seiten übernehmen, jeweils getrimmt. Ein leerer Text
// ist zulässig und bedeutet: Seite nicht veröffentlichen.
const values = Object.fromEntries(
SITE_PAGES.map((key) => [key, String(data[key] ?? '').trim()])
)
const saved = await prisma.sitePages.upsert({
where: { id: 1 },
update: values,
create: { id: 1, ...values },
})
return NextResponse.json(saved)
} catch (error) {
console.error('Error updating site pages:', error)
return NextResponse.json(
{ error: 'Fehler beim Speichern der Seiteninhalte' },
{ status: 500 }
)
}
}

View File

@ -9,8 +9,11 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ThemeToggle } from "@/components/theme-toggle"
import { useAppName } from "@/components/providers/app-config-provider"
export default function SignInPage() {
const appName = useAppName()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
@ -60,7 +63,7 @@ export default function SignInPage() {
</div>
<div className="space-y-1.5">
<h1 className="text-xl leading-none font-semibold tracking-tight">
Syncova Policies
{appName}
</h1>
<p className="text-sm text-muted-foreground">
Anmeldung zum Admin-Portal

View File

@ -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("accessibility")
export default function Page() {
return <LegalPage page="accessibility" />
}

10
app/datenschutz/page.tsx Normal file
View File

@ -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("privacy")
export default function Page() {
return <LegalPage page="privacy" />
}

View File

@ -0,0 +1,182 @@
import type { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { PrismaClient } from "@prisma/client"
import { ArrowLeft, Calendar } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ObjectionNotice } from "@/components/objection-notice"
import { PublicHeader } from "@/components/public-header"
import { SiteFooter } from "@/components/site-footer"
import { PrintPolicy } from "@/components/print-policy"
import { readPolicyDefaults, toApiPolicy } from "@/lib/policy-defaults.server"
import { markdownToHtml } from "@/lib/markdown"
import { formatDate } from "@/lib/format"
import { PrivacyPolicy } from "@/types/privacy-policy"
// Prisma client instance
const prisma = new PrismaClient()
// Inhalt kommt aus der Datenbank und ändert sich mit jeder Pflege im Admin.
export const dynamic = "force-dynamic"
/**
* Lädt eine veröffentlichte Erklärung samt Standardtexten.
*
* Entwürfe (`isPublic = false`) verhalten sich hier wie nicht vorhanden – die
* Seite ist ohne Anmeldung erreichbar und darf sie nicht preisgeben.
*/
async function loadPolicy(slug: string) {
const [policy, defaults] = await Promise.all([
prisma.privacyPolicy.findUnique({ where: { slug } }),
readPolicyDefaults(prisma),
])
if (!policy || !policy.isPublic) return null
return toApiPolicy(policy, defaults)
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const policy = await loadPolicy(slug)
if (!policy) return { title: "Nicht gefunden" }
return {
title: policy.title,
description: `Datenschutzerklärung nach DSGVO: ${policy.title}`,
}
}
/** Ein Abschnitt der Erklärung; leere Abschnitte entfallen ganz. */
async function Section({
title,
content,
}: {
title: string
content?: string | null
}) {
if (!content?.trim()) return null
const html = await markdownToHtml(content)
return (
<section className="space-y-2">
<h3 className="text-sm font-semibold">{title}</h3>
<div
className="markdown-content rounded-lg border bg-muted/30 p-4"
dangerouslySetInnerHTML={{ __html: html }}
/>
</section>
)
}
/** Die Abschnitte in der Reihenfolge, in der sie gelesen werden sollen. */
function sectionsOf(policy: PrivacyPolicy) {
return [
{
heading: "Verantwortlich für die Verarbeitung",
items: [
["Verantwortlicher im Sinne der DSGVO", policy.responsible],
["Kontaktdaten der/des Datenschutzbeauftragten", policy.contactDPO],
] as const,
},
{
heading: "Die Verarbeitung",
items: [
["Bezeichnung der Verarbeitungstätigkeit", policy.processingDescription],
["Zweck der Datenerhebung", policy.processingPurposes],
["Rechtsgrundlage der Verarbeitung", policy.legalBasis],
["Berechtigte Interessen", policy.legitimateInterests],
["Kategorien der verarbeiteten Daten", policy.dataCategories],
["Geplante Empfänger der Daten", policy.recipients],
["Übermittlung in Drittländer", policy.thirdCountryTransfer],
["Speicherdauer", policy.storageDuration],
["Herkunft der Daten", policy.dataSource],
["Automatisierte Entscheidungsfindung", policy.automatedDecision],
] as const,
},
{
heading: "Ihre Rechte",
items: [
["Betroffenen-Rechte", policy.dataSubjectsRights],
["Beschwerderecht bei der Aufsichtsbehörde", policy.complaintRight],
["Widerrufsrecht bei Einwilligung", policy.withdrawalRight],
["Ihre Pflicht zur Bereitstellung der Daten", policy.provisionObligation],
["Folgen, wenn Sie die Daten nicht angeben", policy.provisionConsequences],
] as const,
},
]
}
export default async function PolicyPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const policy = await loadPolicy(slug)
if (!policy) notFound()
return (
<div className="flex min-h-screen flex-col bg-background">
<PublicHeader subtitle="Datenschutz" />
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-8">
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-4 text-muted-foreground">
<Link href="/">
<ArrowLeft />
Alle Datenschutzerklärungen
</Link>
</Button>
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{policy.title}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<Calendar className="size-4" />
Erstellt am {formatDate(policy.createdAt)}
</span>
{policy.updatedAt !== policy.createdAt && (
<span>Aktualisiert am {formatDate(policy.updatedAt)}</span>
)}
<Badge variant="brand">DSGVO</Badge>
</div>
</div>
<PrintPolicy policy={policy} size="sm" />
</div>
{/* Art. 21 Abs. 4 verlangt den Hinweis getrennt von den übrigen
Informationen – deshalb steht er vor den Abschnitten in einem
eigenen, abgesetzten Kasten. */}
<ObjectionNotice content={policy.objectionRight} className="mb-10" />
<div className="space-y-10">
{sectionsOf(policy).map((group) => (
<section key={group.heading} className="space-y-4">
<h2 className="border-b pb-2 text-lg font-semibold tracking-tight">
{group.heading}
</h2>
<div className="space-y-5">
{group.items.map(([title, content]) => (
<Section key={title} title={title} content={content} />
))}
</div>
</section>
))}
</div>
</main>
<SiteFooter />
</div>
)
}

10
app/impressum/page.tsx Normal file
View File

@ -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 <LegalPage page="imprint" />
}

View File

@ -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<Metadata> {
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({
/>
</head>
<body className={inter.className}>
<AuthProvider>{children}</AuthProvider>
<AppConfigProvider appName={appName()}>
<AuthProvider>{children}</AuthProvider>
</AppConfigProvider>
<Toaster />
</body>
</html>

View File

@ -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<PrivacyPolicy | null>(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 (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4 px-6">
<Brand subtitle="Datenschutz" />
<div className="flex items-center gap-1">
<ThemeToggle />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => window.open("/admin", "_blank")}
>
<Settings2 />
<span className="sr-only">Admin-Bereich</span>
</Button>
</TooltipTrigger>
<TooltipContent>Admin-Bereich</TooltipContent>
</Tooltip>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight">
Datenschutzerklärungen
</h1>
<p className="text-sm text-muted-foreground">
Transparente Informationen zu unserer Datenverarbeitung nach DSGVO
</p>
</div>
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Suchen…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{searchTerm && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setSearchTerm("")}
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
>
<X />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
</div>
</div>
{error ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
Fehler beim Laden der Daten: {error.message}
</div>
) : isLoading ? (
<div className="divide-y rounded-lg border">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex items-center gap-4 p-4">
<Skeleton className="size-9 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3.5 w-1/2" />
</div>
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
) : filteredPolicies.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<FileText className="size-6 text-muted-foreground" />
</div>
<p className="font-medium">
{searchTerm
? "Keine Treffer"
: "Keine Datenschutzerklärungen verfügbar"}
</p>
<p className="max-w-sm text-sm text-muted-foreground">
{searchTerm
? "Versuchen Sie einen anderen Suchbegriff."
: "Derzeit sind keine öffentlichen Datenschutzerklärungen hinterlegt."}
</p>
{searchTerm && (
<Button variant="outline" onClick={() => setSearchTerm("")}>
Suche zurücksetzen
</Button>
)}
</div>
) : (
<div className="divide-y overflow-hidden rounded-lg border bg-card">
{filteredPolicies.map((policy) => (
<div
key={policy.id}
className="group flex items-center gap-3 px-3 transition-colors hover:bg-muted/40"
>
<button
type="button"
onClick={() => handleViewDetails(policy)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-md py-4 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand/10 text-brand">
<FileText className="size-4.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{policy.title}
</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
{policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]}
</span>
</span>
</button>
<div className="flex shrink-0 items-center gap-2 pr-1">
<span className="hidden text-sm text-muted-foreground sm:inline">
{formatDate(policy.updatedAt)}
</span>
<Separator orientation="vertical" className="hidden h-5 sm:block" />
<PrintPolicy policy={policy} size="icon-sm" variant="ghost" />
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleViewDetails(policy)}
className="text-muted-foreground group-hover:text-foreground"
>
<ChevronRight />
<span className="sr-only">Details anzeigen</span>
</Button>
</div>
</div>
))}
</div>
)}
{!isLoading && !error && filteredPolicies.length > 0 && (
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredPolicies.length}{" "}
{filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"}
{searchTerm && ` von ${policies.length}`}
</span>
<Badge variant="outline">DSGVO-konform</Badge>
</div>
)}
</main>
<PolicyDetail
policy={selectedPolicy}
open={isDetailOpen}
onOpenChange={setIsDetailOpen}
/>
<div className="flex min-h-screen flex-col bg-background">
<PublicHeader subtitle="Datenschutz" />
<PolicyList initial={policies.map((policy) => toApiPolicy(policy, defaults))} />
<SiteFooter />
</div>
)
}

View File

@ -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 (
<div className={cn("flex items-center gap-2.5", className)}>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand text-brand-foreground">
@ -15,8 +21,16 @@ export function Brand({ className, subtitle }: BrandProps) {
</div>
<div className="flex items-baseline gap-2">
<span className="text-base leading-none font-semibold tracking-tight">
Syncova
<span className="font-normal text-muted-foreground"> Policies</span>
{name === DEFAULT_APP_NAME ? (
// Der Vorgabename wird zweifarbig gesetzt; ein konfigurierter
// Firmenname bleibt unangetastet.
<>
Syncova
<span className="font-normal text-muted-foreground"> Policies</span>
</>
) : (
name
)}
</span>
{subtitle && (
<span className="hidden text-sm leading-none text-muted-foreground sm:inline">

View File

@ -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 (
<div className="space-y-2">
<MarkdownInput
id={id}
label={label}
value={value}
onChange={onChange}
rows={rows}
required={required}
invalid={invalid}
/>
<div className="flex items-center justify-between gap-2">
<Badge variant="outline" className="text-amber-600 dark:text-amber-500">
Weicht vom Standard ab
</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetToDefault}
className="text-muted-foreground"
>
<Undo2 />
Standard übernehmen
</Button>
</div>
</div>
)
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={id} className="gap-1">
{label}
{required && <span className="text-destructive">*</span>}
</Label>
<Badge variant="secondary">Standard</Badge>
</div>
<div
id={id}
className="rounded-md border border-dashed bg-muted/30 p-4"
style={{ minHeight: `${rows * 1.5 + 1}rem` }}
>
{defaultValue.trim() ? (
<MarkdownRenderer content={defaultValue} />
) : required ? (
<p className="flex gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
Noch kein Standardtext hinterlegt – im Admin unter „Standardtexte“
pflegen oder hier abweichen.
</p>
) : (
<p className="text-sm text-muted-foreground italic">
Kein Standardtext hinterlegt – dieser Abschnitt bleibt leer.
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={startOverride}
className="text-muted-foreground"
>
<PencilLine />
Für diese Erklärung abweichen
</Button>
</div>
)
}

48
components/legal-page.tsx Normal file
View File

@ -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 (
<div className="flex min-h-screen flex-col bg-background">
<PublicHeader subtitle={meta.title} />
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-8">
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
{meta.title}
</h1>
<div
className="markdown-content"
dangerouslySetInnerHTML={{ __html: html }}
/>
</main>
<SiteFooter />
</div>
)
}
/** Seitentitel für den Browser-Tab. */
export function legalPageMetadata(page: SitePageKey) {
return { title: SITE_PAGE_META[page].title }
}

View File

@ -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 (
<aside
aria-labelledby="widerspruchsrecht"
className={cn(
"rounded-lg border-2 border-brand/40 bg-brand/5 p-5",
className
)}
>
<h2
id="widerspruchsrecht"
className="mb-3 flex items-center gap-2 text-base font-semibold"
>
<OctagonAlert className="size-5 shrink-0 text-brand" />
Ihr Widerspruchsrecht nach Art. 21 DSGVO
</h2>
<div
className="markdown-content"
dangerouslySetInnerHTML={{ __html: html }}
/>
</aside>
)
}

View File

@ -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<PolicyDefaults, "updatedAt">
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<TabId, DefaultedField[]> = {
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<Record<DefaultedField, number>> = {
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<DefaultsFormData>(emptyForm)
const [errors, setErrors] = useState<string[]>([])
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set())
const [isSubmitting, setIsSubmitting] = useState(false)
const [tab, setTab] = useState<TabId>("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) => (
<MarkdownInput
key={name}
id={`default-${name}`}
label={DEFAULTED_FIELD_LABELS[name]}
value={formData[name]}
onChange={(value) => set(name, value)}
rows={fieldRows[name] ?? 6}
required={REQUIRED_DEFAULTED_FIELDS.includes(name)}
invalid={invalidFields.has(name)}
/>
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Standardtexte</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<Tabs
value={tab}
onValueChange={(value) => setTab(value as TabId)}
className="min-h-0 flex-1 gap-3"
>
<TabsList>
<TabsTrigger value="base">Stammdaten</TabsTrigger>
<TabsTrigger value="processing">Verarbeitung</TabsTrigger>
<TabsTrigger value="rights">Ihre Rechte</TabsTrigger>
</TabsList>
{errors.length > 0 && (
<div className="flex gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<ul className="space-y-1">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
<DialogBody className="pt-1">
<TabsContent value="base" className="grid gap-5 md:grid-cols-2">
{tabFields.base.map(field)}
</TabsContent>
<TabsContent value="processing" className="space-y-4">
<p className="text-sm text-muted-foreground">
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.
</p>
<div className="grid gap-5 md:grid-cols-2">
{tabFields.processing.map(field)}
</div>
</TabsContent>
<TabsContent value="rights" className="grid gap-5 md:grid-cols-2">
<div className="space-y-5">{tabFields.rights.slice(0, 2).map(field)}</div>
<div className="space-y-5">{tabFields.rights.slice(2).map(field)}</div>
</TabsContent>
</DialogBody>
</Tabs>
<DialogFooter className="border-t pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting || isLoading}>
{isSubmitting ? "Speichern…" : "Standardtexte speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -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 },
]

View File

@ -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<DefaultedField, TabId> = {
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<FormData>(initialFormData)
const [errors, setErrors] = useState<string[]>([])
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set())
const [isSubmitting, setIsSubmitting] = useState(false)
const [tab, setTab] = useState<TabId>("base")
// Zählt jedes Öffnen hoch und setzt darüber die Baustein-Felder zurück –
// sonst behielten sie den Standard/Abweichung-Modus der zuletzt geöffneten
// Erklärung bei.
const [formKey, setFormKey] = useState(0)
useEffect(() => {
if (!open) return
@ -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) {
</DialogTitle>
<DialogDescription>
Alle Pflichtfelder (*) ausfüllen. Die Felder unterstützen Markdown.
Als „Standard“ markierte Bausteine kommen aus den zentralen
Standardtexten und lassen sich hier nur im Sonderfall abweichend
festlegen.
</DialogDescription>
</DialogHeader>
@ -260,24 +342,18 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
</div>
<div className="grid gap-5 md:grid-cols-2">
<MarkdownInput
id="responsible"
label="Verantwortlicher im Sinne der DSGVO"
value={formData.responsible}
onChange={(value) => set("responsible", value)}
placeholder={placeholders.responsible}
invalid={isInvalid("responsible")}
required
/>
<MarkdownInput
id="contactDPO"
label="Kontaktdaten der/des Datenschutzbeauftragten"
value={formData.contactDPO}
onChange={(value) => set("contactDPO", value)}
placeholder={placeholders.contactDPO}
invalid={isInvalid("contactDPO")}
required
/>
{(["responsible", "contactDPO"] as DefaultedField[]).map((field) => (
<DefaultableField
key={`${formKey}-${field}`}
id={field}
label={DEFAULTED_FIELD_LABELS[field]}
defaultValue={defaults[field]}
value={formData[field] ?? ""}
onChange={(value) => set(field, value)}
invalid={isInvalid(field)}
required
/>
))}
</div>
</TabsContent>
@ -293,17 +369,29 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
/>
<MarkdownInput
id="processingPurposes"
label="Zweck der Datenerhebung (optional)"
label="Zweck der Datenerhebung"
value={formData.processingPurposes || ""}
onChange={(value) => set("processingPurposes", value)}
placeholder={placeholders.processingPurposes}
invalid={isInvalid("processingPurposes")}
required
/>
<MarkdownInput
id="legalBasis"
label="Rechtsgrundlage der Datenerhebung (optional)"
label="Rechtsgrundlage der Datenerhebung"
value={formData.legalBasis || ""}
onChange={(value) => set("legalBasis", value)}
placeholder={placeholders.legalBasis}
invalid={isInvalid("legalBasis")}
required
/>
<MarkdownInput
id="legitimateInterests"
label="Berechtigte Interessen (nur bei Art. 6 Abs. 1 lit. f)"
value={formData.legitimateInterests || ""}
onChange={(value) => set("legitimateInterests", value)}
placeholder={placeholders.legitimateInterests}
rows={4}
/>
<MarkdownInput
id="recipients"
@ -316,10 +404,12 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
/>
<MarkdownInput
id="storageDuration"
label="Speicherdauer (optional)"
label="Speicherdauer"
value={formData.storageDuration || ""}
onChange={(value) => set("storageDuration", value)}
placeholder={placeholders.storageDuration}
invalid={isInvalid("storageDuration")}
required
/>
<MarkdownInput
id="dataSource"
@ -328,54 +418,79 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
onChange={(value) => set("dataSource", value)}
placeholder={placeholders.dataSource}
/>
<MarkdownInput
id="dataCategories"
label={
// Art. 14 greift nur, wenn die Daten woanders herkommen.
formData.dataSource?.trim()
? "Kategorien der verarbeiteten Daten"
: "Kategorien der verarbeiteten Daten (optional)"
}
value={formData.dataCategories || ""}
onChange={(value) => set("dataCategories", value)}
placeholder={placeholders.dataCategories}
invalid={isInvalid("dataCategories")}
required={!!formData.dataSource?.trim()}
/>
<div className="grid gap-5 md:col-span-2 md:grid-cols-2">
<p className="text-sm text-muted-foreground md:col-span-2">
Auf Drittländer und automatisierte Entscheidungen muss
ausdrücklich hingewiesen werden – auch dann, wenn es beides
nicht gibt. Beide Bausteine kommen aus den Standardtexten.
</p>
{(["thirdCountryTransfer", "automatedDecision"] as DefaultedField[]).map(
(field) => (
<DefaultableField
key={`${formKey}-${field}`}
id={field}
label={DEFAULTED_FIELD_LABELS[field]}
defaultValue={defaults[field]}
value={formData[field] ?? ""}
onChange={(value) => set(field, value)}
invalid={isInvalid(field)}
rows={5}
required
/>
)
)}
</div>
</TabsContent>
<TabsContent value="rights" className="grid gap-5 md:grid-cols-2">
<MarkdownInput
<DefaultableField
key={`${formKey}-dataSubjectsRights`}
id="dataSubjectsRights"
label="Betroffenen-Rechte"
value={formData.dataSubjectsRights}
label={DEFAULTED_FIELD_LABELS.dataSubjectsRights}
defaultValue={defaults.dataSubjectsRights}
value={formData.dataSubjectsRights ?? ""}
onChange={(value) => set("dataSubjectsRights", value)}
placeholder={placeholders.dataSubjectsRights}
invalid={isInvalid("dataSubjectsRights")}
rows={9}
required
/>
<div className="space-y-5">
<MarkdownInput
id="complaintRight"
label="Beschwerderecht bei der Aufsichtsbehörde"
value={formData.complaintRight}
onChange={(value) => set("complaintRight", value)}
placeholder={placeholders.complaintRight}
invalid={isInvalid("complaintRight")}
required
/>
<MarkdownInput
id="withdrawalRight"
label="Widerrufsrecht bei Einwilligung"
value={formData.withdrawalRight}
onChange={(value) => set("withdrawalRight", value)}
placeholder={placeholders.withdrawalRight}
invalid={isInvalid("withdrawalRight")}
required
/>
<MarkdownInput
id="provisionObligation"
label="Ihre Pflicht zur Bereitstellung der Daten (optional)"
value={formData.provisionObligation || ""}
onChange={(value) => set("provisionObligation", value)}
placeholder={placeholders.provisionObligation}
rows={4}
/>
<MarkdownInput
id="provisionConsequences"
label="Folgen, wenn Sie die Daten nicht angeben (optional)"
value={formData.provisionConsequences || ""}
onChange={(value) => set("provisionConsequences", value)}
placeholder={placeholders.provisionConsequences}
rows={4}
/>
{(
[
["complaintRight", 6, true],
["withdrawalRight", 6, true],
["objectionRight", 7, true],
["provisionObligation", 4, false],
["provisionConsequences", 4, false],
] as [DefaultedField, number, boolean][]
).map(([field, rows, required]) => (
<DefaultableField
key={`${formKey}-${field}`}
id={field}
label={DEFAULTED_FIELD_LABELS[field]}
defaultValue={defaults[field]}
value={formData[field] ?? ""}
onChange={(value) => set(field, value)}
invalid={isInvalid(field)}
rows={rows}
required={required}
/>
))}
</div>
</TabsContent>
</DialogBody>

164
components/policy-list.tsx Normal file
View File

@ -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 (
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight">
Datenschutzerklärungen
</h1>
<p className="text-sm text-muted-foreground">
Transparente Informationen zu unserer Datenverarbeitung nach DSGVO
</p>
</div>
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Suchen…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{searchTerm && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setSearchTerm("")}
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
>
<X />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
</div>
</div>
{error ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
Fehler beim Laden der Daten: {error.message}
</div>
) : isLoading ? (
<div className="divide-y rounded-lg border">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex items-center gap-4 p-4">
<Skeleton className="size-9 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3.5 w-1/2" />
</div>
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
) : filteredPolicies.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<FileText className="size-6 text-muted-foreground" />
</div>
<p className="font-medium">
{searchTerm
? "Keine Treffer"
: "Keine Datenschutzerklärungen verfügbar"}
</p>
<p className="max-w-sm text-sm text-muted-foreground">
{searchTerm
? "Versuchen Sie einen anderen Suchbegriff."
: "Derzeit sind keine öffentlichen Datenschutzerklärungen hinterlegt."}
</p>
{searchTerm && (
<Button variant="outline" onClick={() => setSearchTerm("")}>
Suche zurücksetzen
</Button>
)}
</div>
) : (
<div className="divide-y overflow-hidden rounded-lg border bg-card">
{filteredPolicies.map((policy) => (
<div
key={policy.id}
className="group flex items-center gap-3 px-3 transition-colors hover:bg-muted/40"
>
{/* Echter Link: teilbar, in neuem Tab zu öffnen, ohne JavaScript nutzbar */}
<Link
href={`/erklaerung/${policy.slug}`}
className="flex min-w-0 flex-1 items-center gap-3 rounded-md py-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand/10 text-brand">
<FileText className="size-4.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{policy.title}
</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
{policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]}
</span>
</span>
</Link>
<div className="flex shrink-0 items-center gap-2 pr-1">
<span className="hidden text-sm text-muted-foreground sm:inline">
{formatDate(policy.updatedAt)}
</span>
<Separator orientation="vertical" className="hidden h-5 sm:block" />
<PrintPolicy policy={policy} size="icon-sm" variant="ghost" />
<Button
variant="ghost"
size="icon-sm"
asChild
className="text-muted-foreground group-hover:text-foreground"
>
<Link href={`/erklaerung/${policy.slug}`}>
<ChevronRight />
<span className="sr-only">{policy.title} öffnen</span>
</Link>
</Button>
</div>
</div>
))}
</div>
)}
{!isLoading && !error && filteredPolicies.length > 0 && (
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredPolicies.length}{" "}
{filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"}
{searchTerm && ` von ${policies.length}`}
</span>
<Badge variant="outline">DSGVO-konform</Badge>
</div>
)}
</main>
)
}

View File

@ -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
? `<section class="objection">
<h2 class="objection-title">Ihr Widerspruchsrecht nach Art. 21 DSGVO</h2>
<div class="section-content">${cleanMarkdown(content)}</div>
</section>`
: ""
const section = (title: string, content?: string | null) =>
content
? `<section class="section">
@ -75,7 +88,7 @@ function generatePrintContent(policy: PrivacyPolicy): string {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${policy.title}</title>
<title>${escapeHtml(policy.title)}</title>
<style>
@page { margin: 2cm 1.8cm; size: A4; }
* { box-sizing: border-box; }
@ -108,6 +121,19 @@ function generatePrintContent(policy: PrivacyPolicy): string {
}
.meta-info { font-size: 8pt; color: #71717a; margin: 0; }
.section { margin-bottom: 14px; page-break-inside: avoid; orphans: 3; widows: 3; }
.objection {
margin: 16px 0;
padding: 10px 12px;
border: 1.5pt solid #52525b;
border-radius: 3px;
page-break-inside: avoid;
}
.objection-title {
font-size: 10pt;
font-weight: 700;
margin: 0 0 5px 0;
page-break-after: avoid;
}
.section-title {
font-size: 9pt;
font-weight: 600;
@ -151,8 +177,8 @@ function generatePrintContent(policy: PrivacyPolicy): string {
</head>
<body>
<header class="header">
<p class="eyebrow">Syncova Policies · Datenschutzerklärung</p>
<h1>${policy.title}</h1>
<p class="eyebrow">${escapeHtml(appName)} · Datenschutzerklärung</p>
<h1>${escapeHtml(policy.title)}</h1>
<p class="meta-info">
Erstellt am ${formatDate(policy.createdAt)}${
policy.updatedAt !== policy.createdAt
@ -167,17 +193,22 @@ function generatePrintContent(policy: PrivacyPolicy): string {
${section("Bezeichnung der Verarbeitungstätigkeit", policy.processingDescription)}
${section("Zweck der Datenerhebung", policy.processingPurposes)}
${section("Rechtsgrundlage der Datenerhebung", policy.legalBasis)}
${section("Berechtigte Interessen", policy.legitimateInterests)}
${section("Kategorien der verarbeiteten Daten", policy.dataCategories)}
${section("Geplante Empfänger der Daten", policy.recipients)}
${section("Übermittlung in Drittländer", policy.thirdCountryTransfer)}
${section("Speicherdauer der Daten", policy.storageDuration)}
${section("Herkunft der Daten", policy.dataSource)}
${section("Automatisierte Entscheidungsfindung", policy.automatedDecision)}
${section("Betroffenen-Rechte", policy.dataSubjectsRights)}
${section("Beschwerderecht bei der Aufsichtsbehörde", policy.complaintRight)}
${section("Widerrufsrecht bei Einwilligung", policy.withdrawalRight)}
${objectionNotice(policy.objectionRight)}
${section("Ihre Pflicht zur Bereitstellung der Daten", policy.provisionObligation)}
${section("Folgen, wenn Sie die Daten nicht angeben", policy.provisionConsequences)}
<footer class="footer">
<span>${policy.title}</span>
<span>${escapeHtml(policy.title)}</span>
<span>Gedruckt am ${new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",

View File

@ -0,0 +1,33 @@
"use client"
import { createContext, useContext } from "react"
import { DEFAULT_APP_NAME } from "@/lib/app-config"
/**
* Reicht den serverseitig gelesenen Anzeigenamen an Client-Komponenten weiter.
* `APP_COMPANY_NAME` wird erst zur Laufzeit ausgewertet und steht deshalb nicht
* über `process.env` im Browser zur Verfügung.
*/
const AppConfigContext = createContext<{ appName: string }>({
appName: DEFAULT_APP_NAME,
})
export function AppConfigProvider({
appName,
children,
}: {
appName: string
children: React.ReactNode
}) {
return (
<AppConfigContext.Provider value={{ appName }}>
{children}
</AppConfigContext.Provider>
)
}
/** Der konfigurierte Anzeigename – oder „Syncova Policies“ als Vorgabe. */
export function useAppName() {
return useContext(AppConfigContext).appName
}

View File

@ -0,0 +1,38 @@
"use client"
import Link from "next/link"
import { Settings2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Brand } from "@/components/brand"
import { ThemeToggle } from "@/components/theme-toggle"
/** Kopfzeile der öffentlichen Seiten. */
export function PublicHeader({ subtitle }: { subtitle?: string }) {
return (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4 px-6">
<Link href="/" className="rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<Brand subtitle={subtitle} />
<span className="sr-only">Zur Übersicht</span>
</Link>
<div className="flex items-center gap-1">
<ThemeToggle />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<Link href="/admin" target="_blank">
<Settings2 />
<span className="sr-only">Admin-Bereich</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Admin-Bereich</TooltipContent>
</Tooltip>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,45 @@
import Link from "next/link"
import { appName } from "@/lib/app-config"
import { SITE_PAGES, SITE_PAGE_META } from "@/lib/site-pages"
import { readSitePages } from "@/lib/site-pages.server"
/**
* Fußzeile der öffentlichen Seiten mit den rechtlichen Pflichtangaben.
*
* Serverseitig gerendert: Das Impressum muss unmittelbar erreichbar sein, also
* auch ohne JavaScript im Dokument stehen. Verlinkt wird nur, was im Admin
* gepflegt ist – ein Link auf eine leere Impressumsseite wäre schlechter als
* gar keiner.
*/
export async function SiteFooter() {
const pages = await readSitePages()
const links = SITE_PAGES.filter((key) => pages[key].trim())
return (
<footer className="mt-12 border-t">
<div className="mx-auto flex max-w-5xl flex-col gap-3 px-6 py-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>
© {new Date().getFullYear()} {appName()}
</span>
{links.length > 0 && (
<nav aria-label="Rechtliche Hinweise">
<ul className="flex flex-wrap items-center gap-x-5 gap-y-2">
{links.map((key) => (
<li key={key}>
<Link
href={SITE_PAGE_META[key].path}
className="underline-offset-4 hover:text-foreground hover:underline"
>
{SITE_PAGE_META[key].title}
</Link>
</li>
))}
</ul>
</nav>
)}
</div>
</footer>
)
}

View File

@ -0,0 +1,243 @@
"use client"
import { useEffect, useState } from "react"
import { AlertCircle, ExternalLink } from "lucide-react"
import Link from "next/link"
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 { updateSitePages, useSitePages } from "@/hooks/use-site-pages"
import { ApiError } from "@/lib/api"
import {
EMPTY_SITE_PAGES,
SITE_PAGES,
SITE_PAGE_META,
SitePageKey,
SitePagesContent,
} from "@/lib/site-pages"
/**
* Vorlagen als Platzhalter. Sie werden nicht gespeichert, sondern zeigen nur,
* welche Angaben die jeweilige Seite enthalten muss.
*/
const placeholders: Record<SitePageKey, string> = {
imprint: `## Angaben gemäß § 5 DDG
**Amt Musterstadt**
Musterstraße 1
12345 Musterstadt
**Vertreten durch:** Amtsvorsteher/in Vorname Nachname
**Kontakt**
Telefon: 01234 5678-0
E-Mail: info@amt-musterstadt.de
**Zuständige Aufsichtsbehörde**
Name und Anschrift der Rechtsaufsicht
**Verantwortlich für den Inhalt:** Vorname Nachname, Anschrift`,
privacy: `## Datenschutzerklärung für dieses Portal
Diese Erklärung betrifft die Verarbeitung Ihrer Daten beim **Besuch dieses
Portals**. Die Merkblätter zu den einzelnen Verwaltungsverfahren finden Sie in
der Übersicht.
### Verantwortlicher
Name, Anschrift, Kontakt
### Datenschutzbeauftragte/r
Name, Anschrift, E-Mail
### Server-Protokolldaten
Beim Aufruf werden IP-Adresse, Zeitpunkt, aufgerufene Seite, übertragene
Datenmenge und Browserkennung protokolliert.
*Zweck:* technischer Betrieb und Abwehr von Angriffen
*Rechtsgrundlage:* Art. 6 Abs. 1 lit. e DSGVO i. V. m. Landesrecht
*Speicherdauer:* z. B. 7 Tage
### Cookies
Ohne Anmeldung setzt das Portal keine Cookies. Im Admin-Bereich wird ein
technisch notwendiges Sitzungs-Cookie verwendet.
### Ihre Rechte
Auskunft (Art. 15), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung
(Art. 18), Widerspruch (Art. 21) sowie Beschwerde bei der Aufsichtsbehörde
(Art. 77 DSGVO).`,
accessibility: `## Erklärung zur Barrierefreiheit
Diese Erklärung gilt für die Website **datenschutz.beispiel.de**.
### Stand der Vereinbarkeit mit den Anforderungen
Diese Website ist mit der BITV 2.0 *[vollständig / teilweise / nicht]*
vereinbar.
### Nicht barrierefreie Inhalte
Hier die bekannten Einschränkungen und deren Begründung auflisten.
### Erstellung dieser Erklärung
Diese Erklärung wurde am TT.MM.JJJJ erstellt.
Grundlage war eine *[Selbstbewertung / Prüfung durch Dritte]*.
Letzte Überprüfung: TT.MM.JJJJ
### Barrieren melden – Kontakt
Sie können uns Mängel bei der Barrierefreiheit mitteilen und Informationen in
barrierefreier Form anfordern:
Name, E-Mail, Telefon
### Schlichtungsverfahren
Wenn wir auf Ihre Meldung nicht zufriedenstellend reagieren, können Sie die
Schlichtungsstelle nach § 16 BGG anrufen:
Anschrift und Kontakt der zuständigen Schlichtungsstelle`,
}
interface SitePagesFormProps {
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* Pflegt Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit.
* Ein leer gelassener Text nimmt die betreffende Seite vom Netz.
*/
export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
const { pages, isLoading } = useSitePages()
const [formData, setFormData] = useState<SitePagesContent>(EMPTY_SITE_PAGES)
const [errors, setErrors] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [tab, setTab] = useState<SitePageKey>("imprint")
// Beim Öffnen den gespeicherten Stand laden
useEffect(() => {
if (!open || isLoading) return
setErrors([])
setTab("imprint")
setFormData({ ...pages })
// `pages` 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])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setErrors([])
setIsSubmitting(true)
try {
await updateSitePages(formData)
toast.success("Seiteninhalte 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)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Rechtliche Seiten</DialogTitle>
<DialogDescription>
Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit
des Portals selbst. Sie erscheinen in der Fußzeile der öffentlichen
Seiten – ein leer gelassener Text nimmt die Seite vom Netz.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<Tabs
value={tab}
onValueChange={(value) => setTab(value as SitePageKey)}
className="min-h-0 flex-1 gap-3"
>
<TabsList>
{SITE_PAGES.map((key) => (
<TabsTrigger key={key} value={key}>
{key === "accessibility"
? "Barrierefreiheit"
: SITE_PAGE_META[key].title}
</TabsTrigger>
))}
</TabsList>
{errors.length > 0 && (
<div className="flex gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<ul className="space-y-1">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
<DialogBody className="pt-1">
{SITE_PAGES.map((key) => (
<TabsContent key={key} value={key} className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{SITE_PAGE_META[key].hint}</span>
{formData[key].trim() && (
<Button variant="ghost" size="sm" asChild>
<Link href={SITE_PAGE_META[key].path} target="_blank">
<ExternalLink />
{SITE_PAGE_META[key].path}
</Link>
</Button>
)}
</div>
<MarkdownInput
id={`site-page-${key}`}
label={SITE_PAGE_META[key].title}
value={formData[key]}
onChange={(value) =>
setFormData((prev) => ({ ...prev, [key]: value }))
}
placeholder={placeholders[key]}
rows={20}
/>
</TabsContent>
))}
</DialogBody>
</Tabs>
<DialogFooter className="border-t pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting || isLoading}>
{isSubmitting ? "Speichern…" : "Seiteninhalte speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -14,6 +14,10 @@ services:
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET muss gesetzt sein}
DATABASE_URL: file:/app/data/syncova.db
# Name, unter dem die Anwendung auftritt (Kopfzeile, Fußzeile,
# Seitentitel, Druckfassung). Leer lassen für „Syncova Policies“.
APP_COMPANY_NAME: ${APP_COMPANY_NAME:-}
# NEXTAUTH_URL bewusst NICHT vorbelegt: Die Anwendung erkennt Host und
# Port selbst aus der Anfrage (trustHost). Ein fester Wert würde
# Weiterleitungen auf genau diesen Port zwingen. Nur setzen, wenn ein

View File

@ -21,5 +21,5 @@ if ! node scripts/seed-admin.js; then
echo "Manuell nachholen mit: docker exec -it <container> node scripts/seed-admin.js" >&2
fi
echo "Starte Syncova Policies…"
echo "Starte ${APP_COMPANY_NAME:-Syncova Policies}…"
exec "$@"

View File

@ -0,0 +1,40 @@
import useSWR, { mutate } from 'swr'
import { apiClient } from '@/lib/api'
import { EMPTY_POLICY_DEFAULTS } from '@/lib/policy-defaults'
import { PolicyDefaults } from '@/types/privacy-policy'
const DEFAULTS_KEY = '/api/policy-defaults'
/** Lädt die zentral gepflegten Standardtexte. */
export function usePolicyDefaults() {
const { data, error, isLoading } = useSWR<PolicyDefaults>(
DEFAULTS_KEY,
() => apiClient.getPolicyDefaults(),
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
}
)
return {
defaults: data ?? EMPTY_POLICY_DEFAULTS,
isLoading,
error,
mutate: () => mutate(DEFAULTS_KEY),
}
}
/**
* Speichert die Standardtexte und lädt anschließend auch die Erklärungen neu –
* deren angezeigte Texte hängen daran.
*/
export async function updatePolicyDefaults(data: Omit<PolicyDefaults, 'updatedAt'>) {
const saved = await apiClient.updatePolicyDefaults(data)
mutate(DEFAULTS_KEY)
mutate('/api/privacy-policies?admin=true')
mutate('/api/privacy-policies')
return saved
}

View File

@ -1,6 +1,6 @@
import useSWR, { mutate } from 'swr'
import { apiClient } from '@/lib/api'
import { PrivacyPolicy } from '@/types/privacy-policy'
import { FormData, PrivacyPolicy } from '@/types/privacy-policy'
const POLICIES_KEY = '/api/privacy-policies'
@ -25,11 +25,17 @@ export function usePrivacyPolicies() {
}
}
export function usePublicPrivacyPolicies() {
/**
* Öffentliche Erklärungen. `initial` sind die serverseitig gerenderten Daten:
* Damit steht die Liste schon im ausgelieferten HTML und wird anschließend im
* Hintergrund aktualisiert.
*/
export function usePublicPrivacyPolicies(initial?: PrivacyPolicy[]) {
const { data, error, isLoading } = useSWR<PrivacyPolicy[]>(
POLICIES_KEY,
() => apiClient.getAllPolicies(),
{
fallbackData: initial,
refreshInterval: 10000, // Refresh every 10 seconds for public view
revalidateOnFocus: true,
revalidateOnReconnect: true,
@ -40,13 +46,16 @@ export function usePublicPrivacyPolicies() {
return {
policies: publicPolicies,
isLoading,
// Liegen Startdaten vom Server vor, gibt es nichts zu laden. SWR meldet
// den ersten Hintergrund-Abgleich sonst als Ladezustand und die fertige
// Liste würde durch Platzhalter ersetzt.
isLoading: isLoading && !initial,
error,
mutate: () => mutate(POLICIES_KEY),
}
}
export async function createPolicy(data: Omit<PrivacyPolicy, 'id' | 'createdAt' | 'updatedAt'>) {
export async function createPolicy(data: FormData) {
try {
const newPolicy = await apiClient.createPolicy(data)
// Trigger revalidation of both admin and public policies lists
@ -58,7 +67,7 @@ export async function createPolicy(data: Omit<PrivacyPolicy, 'id' | 'createdAt'
}
}
export async function updatePolicy(id: number, data: Omit<PrivacyPolicy, 'id' | 'createdAt' | 'updatedAt'>) {
export async function updatePolicy(id: number, data: FormData) {
try {
const updatedPolicy = await apiClient.updatePolicy(id, data)
// Trigger revalidation of both admin and public policies lists

32
hooks/use-site-pages.ts Normal file
View File

@ -0,0 +1,32 @@
import useSWR, { mutate } from 'swr'
import { apiClient } from '@/lib/api'
import { EMPTY_SITE_PAGES, SitePagesContent } from '@/lib/site-pages'
const SITE_PAGES_KEY = '/api/site-pages'
/** Lädt die Inhalte der rechtlichen Seiten (Impressum, Datenschutz, Barrierefreiheit). */
export function useSitePages() {
const { data, error, isLoading } = useSWR<SitePagesContent>(
SITE_PAGES_KEY,
() => apiClient.getSitePages(),
{
revalidateOnFocus: false,
revalidateOnReconnect: true,
}
)
return {
pages: data ?? EMPTY_SITE_PAGES,
isLoading,
error,
mutate: () => mutate(SITE_PAGES_KEY),
}
}
/** Speichert die Inhalte der rechtlichen Seiten. */
export async function updateSitePages(data: SitePagesContent) {
const saved = await apiClient.updateSitePages(data)
mutate(SITE_PAGES_KEY)
return saved
}

View File

@ -1,4 +1,5 @@
import { PrivacyPolicy } from '@/types/privacy-policy'
import { SitePagesContent } from '@/lib/site-pages'
import { FormData, PolicyDefaults, PrivacyPolicy } from '@/types/privacy-policy'
const API_BASE_URL = '/api'
@ -43,14 +44,14 @@ export class ApiClient {
return this.request(`/privacy-policies/${id}`)
}
async createPolicy(data: Omit<PrivacyPolicy, 'id' | 'createdAt' | 'updatedAt'>): Promise<PrivacyPolicy> {
async createPolicy(data: FormData): Promise<PrivacyPolicy> {
return this.request('/privacy-policies', {
method: 'POST',
body: JSON.stringify(data),
})
}
async updatePolicy(id: number, data: Omit<PrivacyPolicy, 'id' | 'createdAt' | 'updatedAt'>): Promise<PrivacyPolicy> {
async updatePolicy(id: number, data: FormData): Promise<PrivacyPolicy> {
return this.request(`/privacy-policies/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
@ -62,6 +63,32 @@ export class ApiClient {
method: 'DELETE',
})
}
/** Liest die zentral gepflegten Standardtexte. */
async getPolicyDefaults(): Promise<PolicyDefaults> {
return this.request('/policy-defaults')
}
/** Speichert die zentral gepflegten Standardtexte (nur Admin). */
async updatePolicyDefaults(data: Omit<PolicyDefaults, 'updatedAt'>): Promise<PolicyDefaults> {
return this.request('/policy-defaults', {
method: 'PUT',
body: JSON.stringify(data),
})
}
/** Liest die Inhalte von Impressum, Datenschutzerklärung und Barrierefreiheit. */
async getSitePages(): Promise<SitePagesContent> {
return this.request('/site-pages')
}
/** Speichert die Inhalte der rechtlichen Seiten (nur Admin). */
async updateSitePages(data: SitePagesContent): Promise<SitePagesContent> {
return this.request('/site-pages', {
method: 'PUT',
body: JSON.stringify(data),
})
}
}
export const apiClient = new ApiClient()

14
lib/app-config.ts Normal file
View File

@ -0,0 +1,14 @@
/** Name, unter dem die Anwendung auftritt, wenn nichts konfiguriert ist. */
export const DEFAULT_APP_NAME = "Syncova Policies"
/**
* Liest den Anzeigenamen aus `APP_COMPANY_NAME`.
*
* Nur auf dem Server verwendbar – die Variable trägt bewusst kein
* `NEXT_PUBLIC_`-Präfix, damit sie im Container zur Laufzeit gesetzt werden
* kann und nicht beim Bauen des Images festgeschrieben wird. An Client-
* Komponenten kommt der Wert über den `AppConfigProvider` im Root-Layout.
*/
export function appName() {
return process.env.APP_COMPANY_NAME?.trim() || DEFAULT_APP_NAME
}

View File

@ -5,7 +5,24 @@ import remarkGfm from 'remark-gfm'
// Simple markdown processor with clean output
const processor = remark()
.use(remarkGfm) // Support for GitHub Flavored Markdown (tables, strikethrough, etc.)
.use(remarkHtml, { sanitize: false }) // Convert to HTML without sanitization
// Das Ergebnis geht über dangerouslySetInnerHTML in die öffentliche Seite.
// Das Standard-Schema lässt Überschriften, Listen, Tabellen und http(s)- bzw.
// mailto-Links durch und entfernt Skripte, Event-Attribute, eingebettete
// Rahmen und javascript:-Ziele.
.use(remarkHtml, { sanitize: true })
/**
* Maskiert Text für die Ausgabe in HTML. Für Werte, die nicht durch den
* Markdown-Prozessor laufen – etwa Titel in der Druckfassung.
*/
export function escapeHtml(value: string): string {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
}
export async function markdownToHtml(markdown: string): Promise<string> {
if (!markdown || typeof markdown !== 'string') {

133
lib/policy-completeness.ts Normal file
View File

@ -0,0 +1,133 @@
import {
DEFAULTED_FIELD_LABELS,
DefaultedField,
REQUIRED_DEFAULTED_FIELDS,
} from "@/lib/policy-defaults"
import { PolicyDefaults } from "@/types/privacy-policy"
/**
* Prüft, ob eine Erklärung die Pflichtangaben aus Art. 13/14 DSGVO enthält.
*
* Die Prüfung greift beim Veröffentlichen, nicht beim Speichern: Ein Entwurf
* darf unvollständig sein, eine veröffentlichte Erklärung nicht. So lassen sich
* bestehende Datensätze weiter bearbeiten, ohne dass die Anwendung Angaben
* erfindet, die nur die Fachabteilung kennt.
*/
/** Eine Angabe, die zur Veröffentlichung vorliegen muss. */
interface RequiredField {
field: string
label: string
article: string
}
/**
* Angaben, die die Erklärung selbst mitbringen muss – sie fallen je
* Verarbeitungstätigkeit unterschiedlich aus und haben deshalb keinen Standard.
*/
export const REQUIRED_OWN_FIELDS: RequiredField[] = [
{ field: "title", label: "Titel", article: "" },
{
field: "processingDescription",
label: "Bezeichnung der Verarbeitungstätigkeit",
article: "",
},
{
field: "processingPurposes",
label: "Zweck der Datenerhebung",
article: "Art. 13 Abs. 1 lit. c",
},
{
field: "legalBasis",
label: "Rechtsgrundlage der Verarbeitung",
article: "Art. 13 Abs. 1 lit. c",
},
{ field: "recipients", label: "Geplante Empfänger der Daten", article: "Art. 13 Abs. 1 lit. e" },
{
field: "storageDuration",
label: "Speicherdauer",
article: "Art. 13 Abs. 2 lit. a",
},
]
/** Artikelangabe je Standard-Baustein, für verständliche Fehlermeldungen. */
export const DEFAULTED_FIELD_ARTICLES: Partial<Record<DefaultedField, string>> = {
responsible: "Art. 13 Abs. 1 lit. a",
contactDPO: "Art. 13 Abs. 1 lit. b",
dataSubjectsRights: "Art. 13 Abs. 2 lit. b",
complaintRight: "Art. 13 Abs. 2 lit. d",
withdrawalRight: "Art. 13 Abs. 2 lit. c",
objectionRight: "Art. 21 Abs. 4",
thirdCountryTransfer: "Art. 13 Abs. 1 lit. f",
automatedDecision: "Art. 13 Abs. 2 lit. f",
}
/** Eine fehlende Pflichtangabe. */
export interface MissingField {
field: string
label: string
article: string
/** Bei einem Baustein: Der Standardtext ist ebenfalls leer. */
fromDefaults: boolean
}
/** Gibt den getrimmten Text zurück oder `null`. */
function textOrNull(value: unknown) {
const trimmed = typeof value === "string" ? value.trim() : ""
return trimmed ? trimmed : null
}
/**
* Ermittelt, welche Pflichtangaben einer Erklärung zur Veröffentlichung fehlen.
*
* `policy` enthält die Werte der Erklärung selbst – bei den Bausteinen also nur
* eine etwaige Abweichung. Fehlt dort ein Wert, zählt der Standardtext.
*/
export function missingRequiredFields(
policy: Record<string, unknown>,
defaults: PolicyDefaults
): MissingField[] {
const missing: MissingField[] = []
// Angaben, die nur die Erklärung selbst liefern kann
for (const { field, label, article } of REQUIRED_OWN_FIELDS) {
if (!textOrNull(policy[field])) {
missing.push({ field, label, article, fromDefaults: false })
}
}
// Bausteine: eigener Text oder Standardtext muss vorhanden sein
for (const field of REQUIRED_DEFAULTED_FIELDS) {
if (!textOrNull(policy[field]) && !textOrNull(defaults[field])) {
missing.push({
field,
label: DEFAULTED_FIELD_LABELS[field],
article: DEFAULTED_FIELD_ARTICLES[field] ?? "",
fromDefaults: true,
})
}
}
// Art. 14 greift nur, wenn die Daten nicht bei der betroffenen Person selbst
// erhoben wurden – erkennbar daran, dass eine Herkunft angegeben ist.
if (textOrNull(policy.dataSource) && !textOrNull(policy.dataCategories)) {
missing.push({
field: "dataCategories",
label: "Kategorien der verarbeiteten Daten",
article: "Art. 14 Abs. 1 lit. d",
fromDefaults: false,
})
}
return missing
}
/** Fehlende Angabe als Zeile für eine Fehlermeldung. */
export function describeMissing(entry: MissingField) {
const artikel = entry.article ? ` (${entry.article})` : ""
const hinweis = entry.fromDefaults
? " – weder hier noch als Standardtext hinterlegt"
: ""
return `${entry.label}${artikel} fehlt${hinweis}`
}

View File

@ -0,0 +1,53 @@
import type { PrismaClient, PrivacyPolicy as StoredPolicy } from '@prisma/client'
import {
DEFAULTED_FIELDS,
EMPTY_POLICY_DEFAULTS,
REQUIRED_DEFAULTED_FIELDS,
applyPolicyDefaults,
} from '@/lib/policy-defaults'
import { PolicyDefaults, PrivacyPolicy } from '@/types/privacy-policy'
/**
* Liest den einzigen Standarddatensatz (id = 1). Existiert er noch nicht –
* etwa direkt nach der Migration in einer leeren Datenbank – kommen leere
* Texte zurück, statt dass der Aufruf scheitert.
*/
export async function readPolicyDefaults(
prisma: PrismaClient
): Promise<PolicyDefaults> {
const row = await prisma.policyDefaults.findUnique({ where: { id: 1 } })
if (!row) return EMPTY_POLICY_DEFAULTS
return {
...(Object.fromEntries(
DEFAULTED_FIELDS.map((field) => [field, row[field] ?? ''])
) as Omit<PolicyDefaults, 'updatedAt'>),
updatedAt: row.updatedAt.toISOString(),
}
}
/**
* Bringt einen gespeicherten Datensatz in die Form, die API und Seiten
* verwenden: Standardtexte eingesetzt, Zeitstempel als ISO-Zeichenketten.
*
* Ein Pflicht-Baustein, für den weder die Erklärung noch der Standard einen
* Text hat, wird zur leeren Zeichenkette – der zugehörige Abschnitt entfällt
* dann in der Anzeige, statt „null“ auszugeben.
*/
export function toApiPolicy(
policy: StoredPolicy,
defaults: PolicyDefaults
): PrivacyPolicy {
const merged = applyPolicyDefaults(policy, defaults)
return {
...merged,
...(Object.fromEntries(
REQUIRED_DEFAULTED_FIELDS.map((field) => [field, merged[field] ?? ''])
) as Pick<PrivacyPolicy, (typeof REQUIRED_DEFAULTED_FIELDS)[number]>),
createdAt: policy.createdAt.toISOString(),
updatedAt: policy.updatedAt.toISOString(),
}
}

128
lib/policy-defaults.ts Normal file
View File

@ -0,0 +1,128 @@
import { PolicyDefaults, PrivacyPolicy } from '@/types/privacy-policy'
/**
* Felder, deren Text normalerweise für alle Erklärungen gleich ist und daher
* zentral unter „Standardtexte“ gepflegt wird.
*
* In einer einzelnen Erklärung bedeutet `null`/leer: der Standard gilt. Steht
* dort ein Wert, weicht genau diese Erklärung bewusst ab (Sonderfall).
*/
export const DEFAULTED_FIELDS = [
'responsible',
'contactDPO',
'dataSubjectsRights',
'complaintRight',
'withdrawalRight',
'objectionRight',
'thirdCountryTransfer',
'automatedDecision',
'provisionObligation',
'provisionConsequences',
] as const
/** Name eines Feldes, das aus den Standardtexten gespeist werden kann. */
export type DefaultedField = (typeof DEFAULTED_FIELDS)[number]
/**
* Bausteine, ohne die eine Erklärung nach Art. 13 DSGVO nicht auskommt. Sie
* müssen entweder im Standard oder in der Erklärung selbst gefüllt sein.
*/
export const REQUIRED_DEFAULTED_FIELDS: DefaultedField[] = [
'responsible',
'contactDPO',
'dataSubjectsRights',
'complaintRight',
'withdrawalRight',
// Auf Drittländer und automatisierte Entscheidungen muss auch dann
// hingewiesen werden, wenn es beides nicht gibt – dann eben mit einem
// ausdrücklichen „findet nicht statt“.
'objectionRight',
'thirdCountryTransfer',
'automatedDecision',
]
/** Beschriftungen der Standard-Bausteine – identisch in Formular und Ansicht. */
export const DEFAULTED_FIELD_LABELS: Record<DefaultedField, string> = {
responsible: 'Verantwortlicher im Sinne der DSGVO',
contactDPO: 'Kontaktdaten der/des Datenschutzbeauftragten',
dataSubjectsRights: 'Betroffenen-Rechte',
complaintRight: 'Beschwerderecht bei der Aufsichtsbehörde',
withdrawalRight: 'Widerrufsrecht bei Einwilligung',
objectionRight: 'Widerspruchsrecht',
thirdCountryTransfer: 'Übermittlung in Drittländer',
automatedDecision: 'Automatisierte Entscheidungsfindung',
provisionObligation: 'Ihre Pflicht zur Bereitstellung der Daten',
provisionConsequences: 'Folgen, wenn Sie die Daten nicht angeben',
}
/** Leerer Standarddatensatz – Fallback, solange nichts gepflegt wurde. */
export const EMPTY_POLICY_DEFAULTS: PolicyDefaults = {
responsible: '',
contactDPO: '',
dataSubjectsRights: '',
complaintRight: '',
withdrawalRight: '',
objectionRight: '',
thirdCountryTransfer: '',
automatedDecision: '',
provisionObligation: '',
provisionConsequences: '',
updatedAt: '',
}
/** Gibt den getrimmten Text zurück oder `null`, wenn nichts Sinnvolles drinsteht. */
function textOrNull(value?: string | null) {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
/**
* Setzt in eine gespeicherte Erklärung die Standardtexte ein, wo sie keinen
* eigenen Wert hat, und vermerkt in `overrides`, welche Felder abweichen.
*
* Die zurückgegebenen Felder tragen immer den tatsächlich anzuzeigenden Text –
* öffentliche Ansicht, Druckfassung und API-Konsumenten brauchen die
* Standardtexte deshalb nicht selbst zu kennen.
*/
export function applyPolicyDefaults<T extends Record<string, unknown>>(
policy: T,
defaults: PolicyDefaults
): T & { overrides: DefaultedField[] } {
const merged = { ...policy } as Record<string, unknown>
const overrides: DefaultedField[] = []
for (const field of DEFAULTED_FIELDS) {
const own = textOrNull(policy[field] as string | null | undefined)
if (own) {
overrides.push(field)
merged[field] = own
} else {
merged[field] = textOrNull(defaults[field])
}
}
return { ...(merged as T), overrides }
}
/**
* Prüft, ob alle Pflicht-Bausteine gefüllt sind – entweder in der Erklärung
* selbst oder im Standard. Gibt die Namen der fehlenden Felder zurück.
*/
export function missingDefaultedFields(
policy: Partial<Record<DefaultedField, string | null | undefined>>,
defaults: PolicyDefaults
): DefaultedField[] {
return REQUIRED_DEFAULTED_FIELDS.filter(
(field) => !textOrNull(policy[field]) && !textOrNull(defaults[field])
)
}
/** Der Text, der für ein Feld tatsächlich gilt – eigener Wert vor Standard. */
export function effectiveValue(
policy: Pick<PrivacyPolicy, DefaultedField> | Partial<Record<DefaultedField, string | null>>,
defaults: PolicyDefaults,
field: DefaultedField
) {
return textOrNull(policy[field]) ?? textOrNull(defaults[field])
}

32
lib/policy-slug.server.ts Normal file
View File

@ -0,0 +1,32 @@
import type { PrismaClient } from '@prisma/client'
import { uniqueSlug } from '@/lib/slug'
/**
* Bestimmt den Bezeichner für die öffentliche URL einer Erklärung.
*
* Er wird aus dem Titel abgeleitet und gegen die bereits vergebenen Bezeichner
* eindeutig gemacht. Beim Bearbeiten bleibt der bestehende Bezeichner erhalten,
* solange der Titel unverändert ist – veröffentlichte Links sollen nicht
* unbemerkt ins Leere laufen.
*/
export async function resolveSlug(
prisma: PrismaClient,
title: string,
existing?: { id: number; title: string; slug: string }
) {
if (existing && existing.title.trim() === title.trim()) {
return existing.slug
}
const vergeben = new Set(
(
await prisma.privacyPolicy.findMany({
where: existing ? { NOT: { id: existing.id } } : undefined,
select: { slug: true },
})
).map((row) => row.slug)
)
return uniqueSlug(title, vergeben)
}

View File

@ -2,17 +2,26 @@ import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
/**
* Sagt, ob die aktuelle Anfrage von einer angemeldeten Admin-Sitzung kommt.
* Für Routen, die je nach Anmeldung unterschiedlich viel ausliefern, statt
* die Anfrage abzuweisen.
*/
export async function isAdminSession() {
const session = await auth()
return (
!!session &&
(session.user.role === 'ADMIN' || session.user.role === 'SUPER_ADMIN')
)
}
/**
* Prüft, ob eine angemeldete Sitzung mit Admin-Rolle vorliegt.
* Gibt im Fehlerfall die fertige 401-Antwort zurück, sonst `null`.
*/
export async function requireAdmin() {
const session = await auth()
if (
!session ||
(session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')
) {
if (!(await isAdminSession())) {
return NextResponse.json(
{ error: 'Nicht autorisiert' },
{ status: 401 }

20
lib/site-pages.server.ts Normal file
View File

@ -0,0 +1,20 @@
import { PrismaClient } from '@prisma/client'
import { EMPTY_SITE_PAGES, SITE_PAGES, SitePagesContent } from '@/lib/site-pages'
// Prisma client instance
const prisma = new PrismaClient()
/**
* Liest die Inhalte der rechtlichen Seiten (id = 1). Fehlt der Datensatz,
* kommen leere Texte zurück – die Seiten liefern dann 404.
*/
export async function readSitePages(): Promise<SitePagesContent> {
const row = await prisma.sitePages.findUnique({ where: { id: 1 } })
if (!row) return { ...EMPTY_SITE_PAGES }
return Object.fromEntries(
SITE_PAGES.map((key) => [key, row[key] ?? ''])
) as SitePagesContent
}

37
lib/site-pages.ts Normal file
View File

@ -0,0 +1,37 @@
/** Die drei rechtlichen Seiten des Portals. */
export const SITE_PAGES = ["imprint", "privacy", "accessibility"] as const
/** Bezeichner einer rechtlichen Seite. */
export type SitePageKey = (typeof SITE_PAGES)[number]
/** Überschrift und URL je Seite – für Fußzeile, Admin und die Seiten selbst. */
export const SITE_PAGE_META: Record<
SitePageKey,
{ title: string; path: string; hint: string }
> = {
imprint: {
title: "Impressum",
path: "/impressum",
hint: "Anbieterkennzeichnung nach § 5 DDG",
},
privacy: {
title: "Datenschutzerklärung",
path: "/datenschutz",
hint: "Datenverarbeitung durch dieses Portal selbst",
},
accessibility: {
title: "Erklärung zur Barrierefreiheit",
path: "/barrierefreiheit",
hint: "Pflichtangabe öffentlicher Stellen nach BITV 2.0",
},
}
/** Inhalte der rechtlichen Seiten, wie sie API und Admin austauschen. */
export type SitePagesContent = Record<SitePageKey, string>
/** Leere Ausgangsinhalte – solange im Admin nichts gepflegt wurde. */
export const EMPTY_SITE_PAGES: SitePagesContent = {
imprint: "",
privacy: "",
accessibility: "",
}

36
lib/slug.ts Normal file
View File

@ -0,0 +1,36 @@
/**
* Erzeugt aus einem Titel einen URL-tauglichen Bezeichner.
*
* Deutsche Umlaute werden umschrieben, alles Übrige auf Kleinbuchstaben,
* Ziffern und Bindestriche reduziert. Gleiche Regel wie in den Importskripten,
* damit Dateinamen unter `data/policies/` und URLs übereinstimmen.
*/
export function slugify(value: string): string {
return value
// Zusammengesetzte Zeichen vereinheitlichen: Aus PDFs kommt "ä" oft als
// "a" plus Trema-Zeichen, das die Ersetzungen unten sonst nicht treffen.
.normalize("NFC")
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60)
}
/**
* Macht einen Bezeichner eindeutig, indem bei Kollision `-2`, `-3`, … angehängt
* wird. `vergeben` enthält die bereits belegten Bezeichner.
*/
export function uniqueSlug(value: string, vergeben: Set<string>): string {
const basis = slugify(value) || "erklaerung"
if (!vergeben.has(basis)) return basis
let zaehler = 2
while (vergeben.has(`${basis}-${zaehler}`)) zaehler++
return `${basis}-${zaehler}`
}

View File

@ -54,9 +54,15 @@ export default auth((req) => {
// Öffentlich erreichbare Bereiche
if (
pathname === "/" ||
pathname.startsWith("/erklaerung/") ||
pathname === "/impressum" ||
pathname === "/datenschutz" ||
pathname === "/barrierefreiheit" ||
pathname.startsWith("/auth/") ||
pathname.startsWith("/api/auth/") ||
pathname.startsWith("/api/privacy-policies")
pathname.startsWith("/api/privacy-policies") ||
pathname.startsWith("/api/policy-defaults") ||
pathname.startsWith("/api/site-pages")
) {
return
}

View File

@ -0,0 +1,75 @@
-- CreateTable
CREATE TABLE "PolicyDefaults" (
"id" INTEGER NOT NULL PRIMARY KEY DEFAULT 1,
"responsible" TEXT NOT NULL DEFAULT '',
"contactDPO" TEXT NOT NULL DEFAULT '',
"dataSubjectsRights" TEXT NOT NULL DEFAULT '',
"complaintRight" TEXT NOT NULL DEFAULT '',
"withdrawalRight" TEXT NOT NULL DEFAULT '',
"provisionObligation" TEXT NOT NULL DEFAULT '',
"provisionConsequences" TEXT NOT NULL DEFAULT '',
"updatedAt" DATETIME NOT NULL
);
-- Standarddatensatz anlegen. Die Texte übernimmt der häufigste Bestandswert
-- unten; ohne Bestand bleiben sie leer und werden im Admin gepflegt.
INSERT INTO "PolicyDefaults" ("id", "updatedAt") VALUES (1, CURRENT_TIMESTAMP);
UPDATE "PolicyDefaults" SET
"responsible" = COALESCE((SELECT "responsible" FROM "PrivacyPolicy" WHERE "responsible" <> '' GROUP BY "responsible" ORDER BY COUNT(*) DESC, MIN("id") LIMIT 1), ''),
"contactDPO" = COALESCE((SELECT "contactDPO" FROM "PrivacyPolicy" WHERE "contactDPO" <> '' GROUP BY "contactDPO" ORDER BY COUNT(*) DESC, MIN("id") LIMIT 1), ''),
"dataSubjectsRights" = COALESCE((SELECT "dataSubjectsRights" FROM "PrivacyPolicy" WHERE "dataSubjectsRights" <> '' GROUP BY "dataSubjectsRights" ORDER BY COUNT(*) DESC, MIN("id") LIMIT 1), ''),
"complaintRight" = COALESCE((SELECT "complaintRight" FROM "PrivacyPolicy" WHERE "complaintRight" <> '' GROUP BY "complaintRight" ORDER BY COUNT(*) DESC, MIN("id") LIMIT 1), ''),
"withdrawalRight" = COALESCE((SELECT "withdrawalRight" FROM "PrivacyPolicy" WHERE "withdrawalRight" <> '' GROUP BY "withdrawalRight" ORDER BY COUNT(*) DESC, MIN("id") LIMIT 1), '')
WHERE "id" = 1;
-- RedefineTables: die fünf Baustein-Felder werden nullable (NULL = Standard gilt)
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_PrivacyPolicy" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"responsible" TEXT,
"contactDPO" TEXT,
"dataSubjectsRights" TEXT,
"complaintRight" TEXT,
"withdrawalRight" TEXT,
"processingDescription" TEXT NOT NULL,
"processingPurposes" TEXT,
"legalBasis" TEXT,
"recipients" TEXT NOT NULL,
"storageDuration" TEXT,
"dataSource" TEXT,
"provisionObligation" TEXT,
"provisionConsequences" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"isPublic" BOOLEAN NOT NULL DEFAULT true
);
-- Werte, die dem übernommenen Standard entsprechen, werden auf NULL gesetzt:
-- die Erklärung folgt damit dem Standard, ihr angezeigter Text bleibt derselbe.
INSERT INTO "new_PrivacyPolicy" ("id", "title", "responsible", "contactDPO", "dataSubjectsRights", "complaintRight", "withdrawalRight", "processingDescription", "processingPurposes", "legalBasis", "recipients", "storageDuration", "dataSource", "provisionObligation", "provisionConsequences", "createdAt", "updatedAt", "isPublic")
SELECT
p."id",
p."title",
NULLIF(p."responsible", d."responsible"),
NULLIF(p."contactDPO", d."contactDPO"),
NULLIF(p."dataSubjectsRights", d."dataSubjectsRights"),
NULLIF(p."complaintRight", d."complaintRight"),
NULLIF(p."withdrawalRight", d."withdrawalRight"),
p."processingDescription",
p."processingPurposes",
p."legalBasis",
p."recipients",
p."storageDuration",
p."dataSource",
p."provisionObligation",
p."provisionConsequences",
p."createdAt",
p."updatedAt",
p."isPublic"
FROM "PrivacyPolicy" p CROSS JOIN "PolicyDefaults" d WHERE d."id" = 1;
DROP TABLE "PrivacyPolicy";
ALTER TABLE "new_PrivacyPolicy" RENAME TO "PrivacyPolicy";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -0,0 +1,78 @@
-- Bezeichner für die öffentliche Einzel-URL /erklaerung/<slug>
ALTER TABLE "PrivacyPolicy" ADD COLUMN "slug" TEXT;
-- Der Bezeichner wird aus dem Titel abgeleitet – dieselbe Regel wie in
-- lib/slug.ts. SQLite kennt kein REGEXP, deshalb Schritt für Schritt.
UPDATE "PrivacyPolicy" SET "slug" = "title";
-- Umlaute umschreiben. LOWER() in SQLite kennt nur ASCII und würde ein "Ä"
-- unverändert stehen lassen, deshalb kommen die Großbuchstaben zuerst dran.
UPDATE "PrivacyPolicy" SET "slug" =
REPLACE(REPLACE(REPLACE(REPLACE("slug", 'Ä', 'ae'), 'Ö', 'oe'), 'Ü', 'ue'), 'ẞ', 'ss');
UPDATE "PrivacyPolicy" SET "slug" = LOWER("slug");
UPDATE "PrivacyPolicy" SET "slug" =
REPLACE(REPLACE(REPLACE(REPLACE("slug", 'ä', 'ae'), 'ö', 'oe'), 'ü', 'ue'), 'ß', 'ss');
-- Alles Übrige, was kein Buchstabe und keine Ziffer ist, wird zum Bindestrich.
UPDATE "PrivacyPolicy" SET "slug" =
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE("slug",
' ', '-'), '/', '-'), '.', '-'), ',', '-'), ':', '-'), ';', '-'),
'(', '-'), ')', '-'), '[', '-'), ']', '-'), '&', '-'), '+', '-'),
'§', '-'), '%', '-'), '!', '-'), '?', '-'), '#', '-'), '*', '-'),
'"', '-'), '''', '-'), '„', '-'), '“', '-'), '–', '-'), '_', '-');
-- Mehrfache Bindestriche zusammenfassen (deckt bis zu acht in Folge ab)
UPDATE "PrivacyPolicy" SET "slug" = REPLACE("slug", '--', '-');
UPDATE "PrivacyPolicy" SET "slug" = REPLACE("slug", '--', '-');
UPDATE "PrivacyPolicy" SET "slug" = REPLACE("slug", '--', '-');
-- Auf dieselbe Länge wie in lib/slug.ts kürzen, dann die Ränder säubern
UPDATE "PrivacyPolicy" SET "slug" = SUBSTR("slug", 1, 60);
UPDATE "PrivacyPolicy" SET "slug" = LTRIM("slug", '-');
UPDATE "PrivacyPolicy" SET "slug" = RTRIM("slug", '-');
-- Was jetzt noch ein anderes Zeichen enthält – etwa ein Buchstabe mit Akzent,
-- der oben nicht aufgeführt ist – bekommt einen Bezeichner aus der ID.
-- Lieber unschön als nicht adressierbar.
UPDATE "PrivacyPolicy" SET "slug" = 'erklaerung-' || "id"
WHERE "slug" IS NULL OR "slug" = '' OR "slug" GLOB '*[^a-z0-9-]*';
-- Kollisionen über die ID eindeutig machen; der älteste Datensatz behält den
-- kurzen Bezeichner.
UPDATE "PrivacyPolicy" SET "slug" = "slug" || '-' || "id"
WHERE "id" NOT IN (SELECT MIN("id") FROM "PrivacyPolicy" GROUP BY "slug");
-- RedefineTables: slug als NOT NULL mit eindeutigem Index
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_PrivacyPolicy" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"responsible" TEXT,
"contactDPO" TEXT,
"dataSubjectsRights" TEXT,
"complaintRight" TEXT,
"withdrawalRight" TEXT,
"processingDescription" TEXT NOT NULL,
"processingPurposes" TEXT,
"legalBasis" TEXT,
"recipients" TEXT NOT NULL,
"storageDuration" TEXT,
"dataSource" TEXT,
"provisionObligation" TEXT,
"provisionConsequences" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"isPublic" BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO "new_PrivacyPolicy" ("id", "slug", "title", "responsible", "contactDPO", "dataSubjectsRights", "complaintRight", "withdrawalRight", "processingDescription", "processingPurposes", "legalBasis", "recipients", "storageDuration", "dataSource", "provisionObligation", "provisionConsequences", "createdAt", "updatedAt", "isPublic")
SELECT "id", "slug", "title", "responsible", "contactDPO", "dataSubjectsRights", "complaintRight", "withdrawalRight", "processingDescription", "processingPurposes", "legalBasis", "recipients", "storageDuration", "dataSource", "provisionObligation", "provisionConsequences", "createdAt", "updatedAt", "isPublic"
FROM "PrivacyPolicy";
DROP TABLE "PrivacyPolicy";
ALTER TABLE "new_PrivacyPolicy" RENAME TO "PrivacyPolicy";
CREATE UNIQUE INDEX "PrivacyPolicy_slug_key" ON "PrivacyPolicy"("slug");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "SitePages" (
"id" INTEGER NOT NULL PRIMARY KEY DEFAULT 1,
"imprint" TEXT NOT NULL DEFAULT '',
"privacy" TEXT NOT NULL DEFAULT '',
"accessibility" TEXT NOT NULL DEFAULT '',
"updatedAt" DATETIME NOT NULL
);
-- Leerer Ausgangsdatensatz; die Texte werden im Admin gepflegt.
INSERT INTO "SitePages" ("id", "updatedAt") VALUES (1, CURRENT_TIMESTAMP);

View File

@ -0,0 +1,21 @@
-- Pflichtangaben nach Art. 13/14 DSGVO, für die es bisher kein Feld gab.
--
-- Alle neuen Spalten sind nullable und bleiben zunächst leer. Bestehende
-- Erklärungen behalten damit unverändert ihren Inhalt; die Vollständigkeit
-- wird beim Veröffentlichen geprüft, nicht durch die Migration erzwungen.
-- Drei weitere Bausteine in den zentralen Standardtexten. Bei einer Behörde
-- lauten sie in aller Regel für jede Erklärung gleich.
ALTER TABLE "PolicyDefaults" ADD COLUMN "objectionRight" TEXT NOT NULL DEFAULT '';
ALTER TABLE "PolicyDefaults" ADD COLUMN "thirdCountryTransfer" TEXT NOT NULL DEFAULT '';
ALTER TABLE "PolicyDefaults" ADD COLUMN "automatedDecision" TEXT NOT NULL DEFAULT '';
-- Dieselben Bausteine je Erklärung: NULL bedeutet, der Standard gilt.
ALTER TABLE "PrivacyPolicy" ADD COLUMN "objectionRight" TEXT;
ALTER TABLE "PrivacyPolicy" ADD COLUMN "thirdCountryTransfer" TEXT;
ALTER TABLE "PrivacyPolicy" ADD COLUMN "automatedDecision" TEXT;
-- Zwei Angaben, die je Verarbeitung unterschiedlich ausfallen und deshalb
-- keinen Standard haben.
ALTER TABLE "PrivacyPolicy" ADD COLUMN "legitimateInterests" TEXT;
ALTER TABLE "PrivacyPolicy" ADD COLUMN "dataCategories" TEXT;

View File

@ -12,23 +12,62 @@ datasource db {
url = env("DATABASE_URL")
}
/// Standardtexte, die für alle Erklärungen gelten.
///
/// Genau ein Datensatz (id = 1). Die gleichnamigen Felder in `PrivacyPolicy`
/// sind Ausnahmen: Steht dort NULL, gilt der Standard von hier; steht dort ein
/// Wert, weicht diese eine Erklärung bewusst ab.
model PolicyDefaults {
id Int @id @default(1) // Singleton – immer 1
responsible String @default("") // Verantwortlicher im Sinne der DSGVO
contactDPO String @default("") // Kontaktdaten der/des Datenschutzbeauftragten
dataSubjectsRights String @default("") // Betroffenen-Rechte
complaintRight String @default("") // Beschwerderecht bei der Aufsichtsbehörde
withdrawalRight String @default("") // Widerrufsrecht bei Einwilligung
objectionRight String @default("") // Widerspruchsrecht (Art. 21 DSGVO)
thirdCountryTransfer String @default("") // Übermittlung in Drittländer (Art. 13 Abs. 1 lit. f)
automatedDecision String @default("") // Automatisierte Entscheidungsfindung (Art. 13 Abs. 2 lit. f)
provisionObligation String @default("") // Pflicht zur Bereitstellung der Daten
provisionConsequences String @default("") // Folgen, wenn die Daten nicht angegeben werden
updatedAt DateTime @updatedAt // Automatisch bei Update gesetzt
}
/// Inhalte der rechtlichen Seiten des Portals selbst.
///
/// Genau ein Datensatz (id = 1), gepflegt im Admin. Ein leerer Text bedeutet:
/// Die Seite wird nicht ausgeliefert und nicht in der Fußzeile verlinkt.
model SitePages {
id Int @id @default(1) // Singleton – immer 1
imprint String @default("") // Impressum (§ 5 DDG)
privacy String @default("") // Datenschutzerklärung für dieses Portal
accessibility String @default("") // Erklärung zur Barrierefreiheit (BITV 2.0)
updatedAt DateTime @updatedAt // Automatisch bei Update gesetzt
}
// Datenschutzerklärung model for structured privacy policies
model PrivacyPolicy {
id Int @id @default(autoincrement()) // Auto-Increment Primary Key
slug String @unique // Bezeichner in der öffentlichen URL /erklaerung/<slug>
title String // Pflichtfeld - Title of privacy policy
responsible String // Verantwortlicher im Sinne der DSGVO
contactDPO String // Kontaktdaten des Datenschutzbeauftragten
dataSubjectsRights String // Betroffenen-Rechte
complaintRight String // Beschwerderecht bei der Aufsichtsbehörde
withdrawalRight String // Widerrufsrecht bei Einwilligung
// NULL = Standard aus PolicyDefaults gilt, Wert = bewusste Abweichung
responsible String? // Verantwortlicher im Sinne der DSGVO
contactDPO String? // Kontaktdaten des Datenschutzbeauftragten
dataSubjectsRights String? // Betroffenen-Rechte
complaintRight String? // Beschwerderecht bei der Aufsichtsbehörde
withdrawalRight String? // Widerrufsrecht bei Einwilligung
objectionRight String? // Widerspruchsrecht (Art. 21 DSGVO)
thirdCountryTransfer String? // Übermittlung in Drittländer (Art. 13 Abs. 1 lit. f)
automatedDecision String? // Automatisierte Entscheidungsfindung (Art. 13 Abs. 2 lit. f)
processingDescription String // Bezeichnung der Verarbeitungstätigkeit
processingPurposes String? // Optional - Ihre Daten werden zu folgendem Zweck erhoben
legalBasis String? // Optional - Ihre Daten wurden aufgrund folgender Rechtsgrundlage erhoben
processingPurposes String? // Zweck der Verarbeitung (Art. 13 Abs. 1 lit. c) - Pflicht zur Veröffentlichung
legalBasis String? // Rechtsgrundlage (Art. 13 Abs. 1 lit. c) - Pflicht zur Veröffentlichung
legitimateInterests String? // Berechtigte Interessen, nur bei Art. 6 Abs. 1 lit. f (Art. 13 Abs. 1 lit. d)
recipients String // Wir beabsichtigen, Ihre Daten an folgende Empfänger weiterzuleiten
storageDuration String? // Optional - Dauer der Speicherung der personenbezogenen Daten
storageDuration String? // Speicherdauer oder Kriterien (Art. 13 Abs. 2 lit. a) - Pflicht zur Veröffentlichung
dataSource String? // Optional - Wir haben Ihre Daten aus folgender Quelle erhalten
provisionObligation String? // Optional - Ihre Pflicht zur Bereitstellung der Daten (Art. 13 Abs. 2 lit. e DSGVO)
provisionConsequences String? // Optional - Folgen, wenn Sie die Daten nicht angeben
dataCategories String? // Kategorien der Daten (Art. 14 Abs. 1 lit. d) - Pflicht, wenn dataSource gesetzt ist
provisionObligation String? // Abweichung vom Standard - Pflicht zur Bereitstellung (Art. 13 Abs. 2 lit. e DSGVO)
provisionConsequences String? // Abweichung vom Standard - Folgen, wenn die Daten nicht angegeben werden
createdAt DateTime @default(now()) // Automatisch bei Erstellung gesetzt
updatedAt DateTime @updatedAt // Automatisch bei Update gesetzt
isPublic Boolean @default(true) // Default true, bestimmt ob die Datenschutzerklärung öffentlich sichtbar ist

View File

@ -13,6 +13,8 @@
const fs = require("fs")
const path = require("path")
const { PrismaClient } = require("@prisma/client")
const { readDefaults, foldIntoDefaults } = require("./lib/policy-defaults")
const { resolveSlug } = require("./lib/slug")
const {
BOILERPLATE_HEADINGS,
@ -216,12 +218,18 @@ async function main() {
}
fs.mkdirSync(JSON_DIR, { recursive: true })
// Standardtexte einmal lesen: Bausteine, die ihnen entsprechen, werden nicht
// je Erklärung dupliziert, sondern als „folgt dem Standard“ gespeichert.
const defaults = await readDefaults(prisma)
let created = 0
let updated = 0
let withOverrides = 0
for (const record of records) {
const { _quelle, ...data } = record
if (foldIntoDefaults(data, defaults).length > 0) withOverrides += 1
fs.writeFileSync(
path.join(JSON_DIR, `${slugify(record.title)}.json`),
JSON.stringify(record, null, 2),
@ -230,8 +238,12 @@ async function main() {
const existing = await prisma.privacyPolicy.findFirst({
where: { title: data.title },
select: { id: true, title: true, slug: true, isPublic: true },
})
// Bezeichner für die öffentliche URL; bleibt bei unverändertem Titel gleich
data.slug = await resolveSlug(prisma, data.title, existing)
if (existing) {
// Sichtbarkeit bereits vorhandener Datensätze nicht überschreiben
await prisma.privacyPolicy.update({

View File

@ -11,23 +11,33 @@
const fs = require("fs")
const path = require("path")
const { PrismaClient } = require("@prisma/client")
const {
REQUIRED_DEFAULTED_FIELDS,
readDefaults,
foldIntoDefaults,
} = require("./lib/policy-defaults")
const { resolveSlug } = require("./lib/slug")
const prisma = new PrismaClient()
// Felder, die die Erklärung selbst mitbringen muss
const requiredFields = [
"title",
"responsible",
"contactDPO",
"dataSubjectsRights",
"complaintRight",
"withdrawalRight",
"processingDescription",
"processingPurposes",
"legalBasis",
"recipients",
]
const optionalFields = ["storageDuration", "dataSource"]
// Felder ohne Standard-Rückfall; leer bedeutet: Abschnitt entfällt.
// storageDuration ist nach Art. 13 Abs. 2 lit. a zur Veröffentlichung Pflicht –
// das Skript importiert trotzdem, warnt aber (siehe unten).
const optionalFields = [
"storageDuration",
"dataSource",
"dataCategories",
"legitimateInterests",
]
async function importPolicy() {
const file = process.argv.find((arg) => arg.endsWith(".json"))
@ -56,6 +66,20 @@ async function importPolicy() {
return
}
// Bausteine, die weder in der Datei noch im Standard stehen, fehlen wirklich
const defaults = await readDefaults(prisma)
const missingBoilerplate = REQUIRED_DEFAULTED_FIELDS.filter(
(field) =>
!String(raw[field] ?? "").trim() && !String(defaults[field] ?? "").trim()
)
if (missingBoilerplate.length > 0) {
console.error(
`Weder in der Datei noch als Standardtext vorhanden: ${missingBoilerplate.join(", ")}`
)
process.exitCode = 1
return
}
const data = {}
for (const field of requiredFields) {
data[field] = String(raw[field]).trim()
@ -66,8 +90,15 @@ async function importPolicy() {
}
data.isPublic = raw.isPublic !== undefined ? Boolean(raw.isPublic) : true
// Bausteine übernehmen; was dem Standard entspricht, wird zu null
for (const field of Object.keys(defaults)) {
data[field] = raw[field]
}
const overrides = foldIntoDefaults(data, defaults)
const existing = await prisma.privacyPolicy.findFirst({
where: { title: data.title },
select: { id: true, title: true, slug: true },
})
if (existing && !allowUpdate) {
@ -79,6 +110,9 @@ async function importPolicy() {
return
}
// Bezeichner für die öffentliche URL; bleibt bei unverändertem Titel gleich
data.slug = await resolveSlug(prisma, data.title, existing)
const policy = existing
? await prisma.privacyPolicy.update({ where: { id: existing.id }, data })
: await prisma.privacyPolicy.create({ data })
@ -88,6 +122,23 @@ async function importPolicy() {
policy.isPublic ? "öffentlich" : "privat"
})`
)
console.log(`Öffentliche Adresse: /erklaerung/${policy.slug}`)
console.log(
overrides.length > 0
? `Weicht vom Standard ab: ${overrides.join(", ")}`
: "Folgt durchgehend den Standardtexten"
)
// Pflichtangaben, die zur Veröffentlichung fehlen. Der Import wird deshalb
// nicht abgebrochen – die Angaben kennt nur die Fachabteilung.
const luecken = []
if (!data.storageDuration) luecken.push("Speicherdauer (Art. 13 Abs. 2 lit. a)")
if (data.dataSource && !data.dataCategories) {
luecken.push("Kategorien der Daten (Art. 14 Abs. 1 lit. d)")
}
if (luecken.length > 0) {
console.warn(`WARNUNG: fehlt zur Veröffentlichung – ${luecken.join(", ")}`)
}
if (raw._quelle) console.log(`Quelle: ${raw._quelle}`)
}

View File

@ -0,0 +1,73 @@
/**
* Gemeinsame Logik der Importskripte für die zentral gepflegten Standardtexte.
*
* Entspricht der Text einer Erklärung dem Standard, wird `null` gespeichert:
* Die Erklärung folgt damit dem Standard und ändert sich automatisch mit, wenn
* er im Admin angepasst wird. Nur echte Abweichungen bleiben im Datensatz.
*/
/** Felder, die aus den Standardtexten kommen können. */
const DEFAULTED_FIELDS = [
"responsible",
"contactDPO",
"dataSubjectsRights",
"complaintRight",
"withdrawalRight",
"objectionRight",
"thirdCountryTransfer",
"automatedDecision",
"provisionObligation",
"provisionConsequences",
]
/** Bausteine, die im Standard oder in der Erklärung vorhanden sein müssen. */
const REQUIRED_DEFAULTED_FIELDS = [
"responsible",
"contactDPO",
"dataSubjectsRights",
"complaintRight",
"withdrawalRight",
"objectionRight",
"thirdCountryTransfer",
"automatedDecision",
]
/** Leerer Standarddatensatz – solange im Admin nichts gepflegt wurde. */
const EMPTY_DEFAULTS = Object.fromEntries(DEFAULTED_FIELDS.map((f) => [f, ""]))
/** Liest die Standardtexte (id = 1); fehlt der Datensatz, kommen leere zurück. */
async function readDefaults(prisma) {
const row = await prisma.policyDefaults.findUnique({ where: { id: 1 } })
if (!row) return { ...EMPTY_DEFAULTS }
return Object.fromEntries(DEFAULTED_FIELDS.map((f) => [f, row[f] ?? ""]))
}
/**
* Ersetzt in einem Import-Datensatz jeden Baustein, der dem Standard
* entspricht, durch `null`. Gibt zusätzlich die Felder zurück, die abweichen.
*/
function foldIntoDefaults(data, defaults) {
const overrides = []
for (const field of DEFAULTED_FIELDS) {
const own = String(data[field] ?? "").trim()
if (!own || own === String(defaults[field] ?? "").trim()) {
data[field] = null
} else {
data[field] = own
overrides.push(field)
}
}
return overrides
}
module.exports = {
DEFAULTED_FIELDS,
REQUIRED_DEFAULTED_FIELDS,
EMPTY_DEFAULTS,
readDefaults,
foldIntoDefaults,
}

48
scripts/lib/slug.js Normal file
View File

@ -0,0 +1,48 @@
/**
* Bezeichner für die öffentliche URL /erklaerung/<slug>.
*
* Gleiche Regel wie in `lib/slug.ts`, damit Dateinamen unter data/policies/,
* Importe und URLs übereinstimmen.
*/
function slugify(value) {
return String(value)
// Zusammengesetzte Zeichen vereinheitlichen: Aus PDFs kommt "ä" oft als
// "a" plus Trema-Zeichen, das die Ersetzungen unten sonst nicht treffen.
.normalize("NFC")
.toLowerCase()
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60)
}
/** Hängt bei Kollision `-2`, `-3`, … an, bis der Bezeichner frei ist. */
function uniqueSlug(value, vergeben) {
const basis = slugify(value) || "erklaerung"
if (!vergeben.has(basis)) return basis
let zaehler = 2
while (vergeben.has(`${basis}-${zaehler}`)) zaehler++
return `${basis}-${zaehler}`
}
/**
* Bestimmt den Bezeichner beim Import. Ein bestehender Datensatz behält seinen,
* solange der Titel unverändert ist – veröffentlichte Links bleiben gültig.
*/
async function resolveSlug(prisma, title, existing) {
if (existing && existing.title.trim() === String(title).trim()) {
return existing.slug
}
const rows = await prisma.privacyPolicy.findMany({ select: { id: true, slug: true } })
const vergeben = new Set(
rows.filter((r) => !existing || r.id !== existing.id).map((r) => r.slug)
)
return uniqueSlug(title, vergeben)
}
module.exports = { slugify, uniqueSlug, resolveSlug }

View File

@ -1,37 +1,75 @@
export interface PrivacyPolicy {
id: number
title: string
/** Zentral gepflegte Standardtexte, die für alle Erklärungen gelten. */
export interface PolicyDefaults {
responsible: string
contactDPO: string
dataSubjectsRights: string
complaintRight: string
withdrawalRight: string
objectionRight: string
thirdCountryTransfer: string
automatedDecision: string
provisionObligation: string
provisionConsequences: string
updatedAt: string
}
export interface PrivacyPolicy {
id: number
/** Bezeichner in der öffentlichen URL /erklaerung/<slug> */
slug: string
title: string
/**
* Die folgenden fünf Felder liefert die API immer ausgefüllt: entweder mit
* dem eigenen Text der Erklärung oder mit dem Standardtext. Welche davon
* bewusst abweichen, steht in `overrides`.
*/
responsible: string
contactDPO: string
dataSubjectsRights: string
complaintRight: string
withdrawalRight: string
objectionRight: string
thirdCountryTransfer: string
automatedDecision: string
processingDescription: string
processingPurposes?: string | null
legalBasis?: string | null
legitimateInterests?: string | null
recipients: string
storageDuration?: string | null
dataSource?: string | null
dataCategories?: string | null
provisionObligation?: string | null
provisionConsequences?: string | null
createdAt: string
updatedAt: string
isPublic: boolean
/** Felder, die vom Standard abweichen – vom Server berechnet. */
overrides?: string[]
}
export interface FormData {
title: string
responsible: string
contactDPO: string
dataSubjectsRights: string
complaintRight: string
withdrawalRight: string
/**
* Leer bedeutet: Standardtext verwenden. Ein Wert ist eine bewusste
* Abweichung für genau diese Erklärung.
*/
responsible?: string
contactDPO?: string
dataSubjectsRights?: string
complaintRight?: string
withdrawalRight?: string
objectionRight?: string
thirdCountryTransfer?: string
automatedDecision?: string
processingDescription: string
processingPurposes?: string
legalBasis?: string
legitimateInterests?: string
recipients: string
storageDuration?: string
dataSource?: string
dataCategories?: string
provisionObligation?: string
provisionConsequences?: string
isPublic: boolean