All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
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>
|
||
)
|
||
}
|