Datenschutzerklärungen veröffentlichungsreif machen
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m23s
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:
parent
c357057b69
commit
61c4998a79
@ -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"
|
||||
|
||||
|
||||
79
README.md
79
README.md
@ -24,6 +24,7 @@ unter `/admin`.
|
||||
|
||||
| Variable | Pflicht | Bedeutung |
|
||||
|---|---|---|
|
||||
| `APP_COMPANY_NAME` | nein | Name, unter dem die Anwendung auftritt – Kopfzeile, Fußzeile, Seitentitel und Druckfassung. Leer lassen für „Syncova Policies“. Wird zur Laufzeit gelesen, ein Neubau des Images ist nicht nötig. |
|
||||
| `NEXTAUTH_SECRET` | ja | Signiert die Session-Token. Erzeugen mit `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"` |
|
||||
| `NEXTAUTH_URL` | nein | Nur nötig, wenn ein Reverse-Proxy keine `X-Forwarded-*`-Header setzt. Sonst erkennt die Anwendung Host und Port selbst – ein fester Wert erzwingt Weiterleitungen auf genau diesen Port. |
|
||||
| `DATABASE_URL` | ja | SQLite-Pfad. Lokal `file:./dev.db`, im Container `file:/app/data/syncova.db` |
|
||||
@ -75,6 +76,78 @@ docker exec -it syncova-policies node scripts/reset-admin.js --email admin@examp
|
||||
|
||||
## Datenpflege
|
||||
|
||||
### Standardtexte
|
||||
|
||||
Sieben Bausteine sind in aller Regel für jede Erklärung gleich und werden
|
||||
deshalb einmal zentral gepflegt – im Admin-Bereich unter **Standardtexte**:
|
||||
|
||||
| Baustein | Artikel | Pflicht |
|
||||
|---|---|---|
|
||||
| Verantwortlicher im Sinne der DSGVO | Art. 13 Abs. 1 lit. a | ja |
|
||||
| Kontaktdaten der/des Datenschutzbeauftragten | Art. 13 Abs. 1 lit. b | ja |
|
||||
| Betroffenen-Rechte | Art. 13 Abs. 2 lit. b | ja |
|
||||
| Beschwerderecht bei der Aufsichtsbehörde | Art. 13 Abs. 2 lit. d | ja |
|
||||
| Widerrufsrecht bei Einwilligung | Art. 13 Abs. 2 lit. c | ja |
|
||||
| Widerspruchsrecht | Art. 21 Abs. 4 | ja |
|
||||
| Übermittlung in Drittländer | Art. 13 Abs. 1 lit. f | ja |
|
||||
| Automatisierte Entscheidungsfindung | Art. 13 Abs. 2 lit. f | ja |
|
||||
| Ihre Pflicht zur Bereitstellung der Daten | Art. 13 Abs. 2 lit. e | nein |
|
||||
| Folgen, wenn Sie die Daten nicht angeben | Art. 13 Abs. 2 lit. e | nein |
|
||||
|
||||
Auf Drittlandübermittlung und automatisierte Entscheidungen muss auch dann
|
||||
hingewiesen werden, wenn es beides nicht gibt – ein ausdrückliches „findet nicht
|
||||
statt“ ist die richtige Antwort, kein leeres Feld. Der Hinweis auf das
|
||||
Widerspruchsrecht steht nach Art. 21 Abs. 4 in einem eigenen, abgesetzten
|
||||
Kasten, getrennt von den übrigen Informationen.
|
||||
|
||||
Im Formular einer einzelnen Erklärung erscheinen diese Felder schreibgeschützt
|
||||
mit der Markierung **Standard**. Erst „Für diese Erklärung abweichen“ macht
|
||||
einen Baustein für genau diesen Datensatz bearbeitbar; „Standard übernehmen“
|
||||
verwirft die Abweichung wieder.
|
||||
|
||||
Technisch steht in der Erklärung nur die Abweichung: Ein leeres Feld bedeutet
|
||||
„folgt dem Standard“. Eine Änderung an den Standardtexten – etwa ein Wechsel
|
||||
in der Amtsleitung – wirkt damit sofort auf alle Erklärungen, die nicht bewusst
|
||||
abweichen. Welche Felder abweichen, liefert die API im Feld `overrides`.
|
||||
|
||||
### Rechtliche Seiten
|
||||
|
||||
Impressum, Datenschutzerklärung des Portals und Erklärung zur Barrierefreiheit
|
||||
werden im Admin unter **Rechtliche Seiten** als Markdown gepflegt und unter
|
||||
`/impressum`, `/datenschutz` und `/barrierefreiheit` ausgeliefert. Sie erscheinen
|
||||
in der Fußzeile aller öffentlichen Seiten. Ein leer gelassener Text nimmt die
|
||||
betreffende Seite vom Netz – verlinkt wird nur, was auch gepflegt ist. Die
|
||||
Eingabefelder enthalten Vorlagen mit den jeweils erforderlichen Angaben.
|
||||
|
||||
### Erklärungen
|
||||
|
||||
Jede veröffentlichte Erklärung hat eine eigene, dauerhafte Adresse unter
|
||||
`/erklaerung/<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
|
||||
|
||||
@ -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,6 +430,7 @@ export default function AdminDashboard() {
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{policy.isPublic ? (
|
||||
<Badge variant="success">
|
||||
<Eye />
|
||||
@ -379,6 +442,31 @@ export default function AdminDashboard() {
|
||||
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)}
|
||||
|
||||
64
app/api/policy-defaults/route.ts
Normal file
64
app/api/policy-defaults/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,30 @@
|
||||
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')
|
||||
|
||||
@ -24,9 +33,26 @@ function validatePrivacyPolicyData(data: any) {
|
||||
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(
|
||||
|
||||
@ -1,21 +1,30 @@
|
||||
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')
|
||||
|
||||
@ -24,26 +33,54 @@ function validatePrivacyPolicyData(data: any) {
|
||||
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({
|
||||
const [policies, defaults] = await Promise.all([
|
||||
prisma.privacyPolicy.findMany({
|
||||
where: isAdmin ? {} : {
|
||||
isPublic: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
})
|
||||
}),
|
||||
readPolicyDefaults(prisma),
|
||||
])
|
||||
|
||||
return NextResponse.json(policies)
|
||||
// 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,9 +97,10 @@ 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 },
|
||||
@ -74,24 +112,21 @@ export async function POST(request: NextRequest) {
|
||||
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(
|
||||
|
||||
51
app/api/site-pages/route.ts
Normal file
51
app/api/site-pages/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
10
app/barrierefreiheit/page.tsx
Normal file
10
app/barrierefreiheit/page.tsx
Normal 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
10
app/datenschutz/page.tsx
Normal 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" />
|
||||
}
|
||||
182
app/erklaerung/[slug]/page.tsx
Normal file
182
app/erklaerung/[slug]/page.tsx
Normal 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
10
app/impressum/page.tsx
Normal 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" />
|
||||
}
|
||||
@ -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 = {
|
||||
// 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: "Syncova Policies",
|
||||
template: "%s · Syncova Policies",
|
||||
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}>
|
||||
<AppConfigProvider appName={appName()}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</AppConfigProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
221
app/page.tsx
221
app/page.tsx
@ -1,202 +1,37 @@
|
||||
"use client"
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { ChevronRight, FileText, Search, Settings2, X } from "lucide-react"
|
||||
import { PolicyList } from "@/components/policy-list"
|
||||
import { PublicHeader } from "@/components/public-header"
|
||||
import { SiteFooter } from "@/components/site-footer"
|
||||
import { readPolicyDefaults, toApiPolicy } from "@/lib/policy-defaults.server"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Brand } from "@/components/brand"
|
||||
import { PolicyDetail } from "@/components/policy-detail"
|
||||
import { PrintPolicy } from "@/components/print-policy"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { usePublicPrivacyPolicies } from "@/hooks/use-privacy-policies"
|
||||
import { PrivacyPolicy } from "@/types/privacy-policy"
|
||||
import { formatDate } from "@/lib/format"
|
||||
// Prisma client instance
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export default function PublicDashboard() {
|
||||
const { policies, isLoading, error } = usePublicPrivacyPolicies()
|
||||
const [selectedPolicy, setSelectedPolicy] = useState<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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">
|
||||
{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">
|
||||
|
||||
133
components/defaultable-field.tsx
Normal file
133
components/defaultable-field.tsx
Normal 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
48
components/legal-page.tsx
Normal 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 }
|
||||
}
|
||||
46
components/objection-notice.tsx
Normal file
46
components/objection-notice.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
247
components/policy-defaults-form.tsx
Normal file
247
components/policy-defaults-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
]
|
||||
|
||||
@ -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")}
|
||||
{(["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
164
components/policy-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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",
|
||||
|
||||
33
components/providers/app-config-provider.tsx
Normal file
33
components/providers/app-config-provider.tsx
Normal 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
|
||||
}
|
||||
38
components/public-header.tsx
Normal file
38
components/public-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
components/site-footer.tsx
Normal file
45
components/site-footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
243
components/site-pages-form.tsx
Normal file
243
components/site-pages-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 "$@"
|
||||
|
||||
40
hooks/use-policy-defaults.ts
Normal file
40
hooks/use-policy-defaults.ts
Normal 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
|
||||
}
|
||||
@ -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
32
hooks/use-site-pages.ts
Normal 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
|
||||
}
|
||||
33
lib/api.ts
33
lib/api.ts
@ -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
14
lib/app-config.ts
Normal 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
|
||||
}
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export async function markdownToHtml(markdown: string): Promise<string> {
|
||||
if (!markdown || typeof markdown !== 'string') {
|
||||
|
||||
133
lib/policy-completeness.ts
Normal file
133
lib/policy-completeness.ts
Normal 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}`
|
||||
}
|
||||
53
lib/policy-defaults.server.ts
Normal file
53
lib/policy-defaults.server.ts
Normal 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
128
lib/policy-defaults.ts
Normal 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
32
lib/policy-slug.server.ts
Normal 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)
|
||||
}
|
||||
@ -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
20
lib/site-pages.server.ts
Normal 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
37
lib/site-pages.ts
Normal 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
36
lib/slug.ts
Normal 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}`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
78
prisma/migrations/20260907200000_policy_slug/migration.sql
Normal file
78
prisma/migrations/20260907200000_policy_slug/migration.sql
Normal 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;
|
||||
11
prisma/migrations/20260907210000_site_pages/migration.sql
Normal file
11
prisma/migrations/20260907210000_site_pages/migration.sql
Normal 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);
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
73
scripts/lib/policy-defaults.js
Normal file
73
scripts/lib/policy-defaults.js
Normal 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
48
scripts/lib/slug.js
Normal 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 }
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user