syncova-policies/app/erklaerung/[slug]/page.tsx
Weapie 265ca8ac38
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
Improve accessibility and access controls; verify production and database recovery
2026-09-14 11:44:41 +02:00

182 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
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
import { prisma } from "@/lib/prisma"
// 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 id="inhalt" tabIndex={-1} className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm: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>
)
}