feat(sprint-6): Phase 2 — DSGVO consent management
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

- V6 migration: consents table with audit columns
- Consent entity, repository, service (grant/revoke/check)
- ConsentController: GET/POST/DELETE consent endpoints
- DSGVO export (Art. 15): full personal data JSON download
- DSGVO deletion (Art. 17): anonymization + account deactivation
- Frontend: consent banner (modal, cannot dismiss), privacy settings page
- React Query hooks for consent + DSGVO operations
- Full i18n (de/en) for consent and DSGVO namespaces
This commit is contained in:
Patrick Plate
2026-06-12 22:22:48 +02:00
parent b38902a7ee
commit 3232d2f7fd
17 changed files with 2227 additions and 0 deletions
+27
View File
@@ -343,5 +343,32 @@
"notFound": "Ressource nicht gefunden.",
"quotaExceeded": "Kontingent überschritten.",
"serverError": "Serverfehler. Bitte versuche es später erneut."
},
"consent": {
"title": "Datenschutz-Einwilligung",
"dataProcessing": "Datenverarbeitung",
"dataProcessingDesc": "Ich willige ein, dass meine personenbezogenen Daten (Name, E-Mail, Geburtsdatum, Ausgabe-Historie) zum Zweck der Vereinsverwaltung verarbeitet werden. Rechtsgrundlage: Art. 6 Abs. 1 lit. a DSGVO.",
"marketing": "Marketing-Benachrichtigungen",
"marketingDesc": "Ich möchte über neue Funktionen und Angebote informiert werden.",
"accept": "Ich stimme zu",
"reject": "Ablehnen und Konto löschen",
"required": "Erforderlich",
"revoke": "Einwilligung widerrufen",
"revokeWarning": "Ohne Einwilligung zur Datenverarbeitung kann der Dienst nicht genutzt werden.",
"granted": "Erteilt am",
"revoked": "Widerrufen"
},
"dsgvo": {
"title": "Datenschutz",
"export": "Meine Daten exportieren",
"exportDesc": "Laden Sie alle über Sie gespeicherten Daten als JSON-Datei herunter (Art. 15 DSGVO).",
"exportButton": "Daten herunterladen",
"exporting": "Daten werden zusammengestellt...",
"exported": "Datenexport heruntergeladen.",
"delete": "Konto und Daten löschen",
"deleteDesc": "Alle personenbezogenen Daten werden unwiderruflich gelöscht oder anonymisiert (Art. 17 DSGVO). Ausgabe-Daten bleiben anonymisiert erhalten (gesetzliche Aufbewahrungspflicht).",
"deleteButton": "Konto endgültig löschen",
"deleteConfirm": "Bist du sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteSuccess": "Dein Konto wurde gelöscht. Du wirst jetzt abgemeldet."
}
}
+27
View File
@@ -343,5 +343,32 @@
"notFound": "Resource not found.",
"quotaExceeded": "Quota exceeded.",
"serverError": "Server error. Please try again later."
},
"consent": {
"title": "Privacy Consent",
"dataProcessing": "Data Processing",
"dataProcessingDesc": "I consent to the processing of my personal data (name, email, date of birth, distribution history) for the purpose of club management. Legal basis: Art. 6(1)(a) GDPR.",
"marketing": "Marketing Notifications",
"marketingDesc": "I would like to receive information about new features and offers.",
"accept": "I agree",
"reject": "Decline and delete account",
"required": "Required",
"revoke": "Revoke consent",
"revokeWarning": "Without consent for data processing, the service cannot be used.",
"granted": "Granted on",
"revoked": "Revoked"
},
"dsgvo": {
"title": "Privacy",
"export": "Export my data",
"exportDesc": "Download all data stored about you as a JSON file (Art. 15 GDPR).",
"exportButton": "Download data",
"exporting": "Compiling data...",
"exported": "Data export downloaded.",
"delete": "Delete account and data",
"deleteDesc": "All personal data will be irreversibly deleted or anonymized (Art. 17 GDPR). Distribution data will be retained in anonymized form (legal retention requirement).",
"deleteButton": "Permanently delete account",
"deleteConfirm": "Are you sure? This action cannot be undone.",
"deleteSuccess": "Your account has been deleted. You will now be logged out."
}
}
@@ -1,6 +1,7 @@
import { NextIntlClientProvider } from "next-intl"
import { getMessages } from "next-intl/server"
import { ConsentBanner } from "@/components/consent-banner"
import { Layout } from "@/components/layout"
export default async function DashboardLayout({
@@ -13,6 +14,7 @@ export default async function DashboardLayout({
return (
<NextIntlClientProvider messages={messages}>
<Layout>{children}</Layout>
<ConsentBanner />
</NextIntlClientProvider>
)
}
@@ -0,0 +1,203 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import {
useConsentsQuery,
useDeleteAccountMutation,
useExportDataMutation,
useGrantConsentMutation,
useRevokeConsentMutation,
} from "@/services/consent"
import { useTranslations } from "next-intl"
import { Download, Shield, ToggleLeft, ToggleRight, Trash2 } from "lucide-react"
export default function PrivacySettingsPage() {
const t = useTranslations("dsgvo")
const tc = useTranslations("consent")
const router = useRouter()
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const { data: consents, isLoading } = useConsentsQuery()
const grantMutation = useGrantConsentMutation()
const revokeMutation = useRevokeConsentMutation()
const exportMutation = useExportDataMutation()
const deleteMutation = useDeleteAccountMutation()
const consentTypes = [
{
type: "DATA_PROCESSING" as const,
label: tc("dataProcessing"),
description: tc("dataProcessingDesc"),
required: true,
},
{
type: "MARKETING" as const,
label: tc("marketing"),
description: tc("marketingDesc"),
required: false,
},
{
type: "ANALYTICS" as const,
label: "Analytics",
description: "Nutzungsanalysen zur Verbesserung des Dienstes.",
required: false,
},
]
const isGranted = (type: string) =>
consents?.find((c) => c.type === type)?.granted ?? false
const getGrantedDate = (type: string) =>
consents?.find((c) => c.type === type)?.grantedAt
const handleToggle = async (type: string, currentlyGranted: boolean) => {
if (currentlyGranted) {
if (type === "DATA_PROCESSING") {
setShowDeleteConfirm(true)
return
}
await revokeMutation.mutateAsync(type)
} else {
await grantMutation.mutateAsync({
type: type as "DATA_PROCESSING" | "MARKETING" | "ANALYTICS",
version: 1,
})
}
}
const handleExport = () => {
exportMutation.mutate()
}
const handleDelete = async () => {
await deleteMutation.mutateAsync()
router.push("/login")
}
return (
<div className="mx-auto max-w-2xl space-y-8 p-6">
<div className="flex items-center gap-3">
<Shield className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">{t("title")}</h1>
</div>
{/* Consent Toggles */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">{tc("title")}</h2>
{isLoading ? (
<div className="animate-pulse space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 rounded-lg bg-muted" />
))}
</div>
) : (
consentTypes.map((ct) => {
const granted = isGranted(ct.type)
const grantedDate = getGrantedDate(ct.type)
return (
<div
key={ct.type}
className="flex items-start justify-between rounded-lg border p-4"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium">{ct.label}</h3>
{ct.required && (
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{tc("required")}
</span>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{ct.description}
</p>
{granted && grantedDate && (
<p className="mt-1 text-xs text-muted-foreground">
{tc("granted")}{" "}
{new Date(grantedDate).toLocaleDateString("de-DE")}
</p>
)}
</div>
<button
onClick={() => handleToggle(ct.type, granted)}
disabled={grantMutation.isPending || revokeMutation.isPending}
className="ml-4 shrink-0"
aria-label={granted ? tc("revoke") : tc("accept")}
>
{granted ? (
<ToggleRight className="h-8 w-8 text-primary" />
) : (
<ToggleLeft className="h-8 w-8 text-muted-foreground" />
)}
</button>
</div>
)
})
)}
</section>
{/* Data Export */}
<section className="space-y-3 rounded-lg border p-4">
<div className="flex items-center gap-2">
<Download className="h-5 w-5" />
<h2 className="text-lg font-semibold">{t("export")}</h2>
</div>
<p className="text-sm text-muted-foreground">{t("exportDesc")}</p>
<button
onClick={handleExport}
disabled={exportMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-secondary px-4 py-2 text-sm font-medium transition-colors hover:bg-secondary/80 disabled:opacity-50"
>
<Download className="h-4 w-4" />
{exportMutation.isPending ? t("exporting") : t("exportButton")}
</button>
{exportMutation.isSuccess && (
<p className="text-sm text-green-600">{t("exported")}</p>
)}
</section>
{/* Account Deletion */}
<section className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<h2 className="text-lg font-semibold text-destructive">
{t("delete")}
</h2>
</div>
<p className="text-sm text-muted-foreground">{t("deleteDesc")}</p>
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
className="inline-flex items-center gap-2 rounded-lg bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
<Trash2 className="h-4 w-4" />
{t("deleteButton")}
</button>
) : (
<div className="space-y-3 rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<p className="text-sm font-medium text-destructive">
{t("deleteConfirm")}
</p>
<div className="flex gap-3">
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:opacity-50"
>
{deleteMutation.isPending ? "..." : t("deleteButton")}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="rounded-lg border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted"
>
Abbrechen
</button>
</div>
</div>
)}
</section>
</div>
)
}
@@ -0,0 +1,100 @@
"use client"
import { useState } from "react"
import {
useConsentCheckQuery,
useGrantConsentMutation,
} from "@/services/consent"
import { useTranslations } from "next-intl"
import { CheckCircle, Shield } from "lucide-react"
/**
* DSGVO Consent Banner — fullscreen modal overlay.
* Shows when a logged-in user has NOT yet granted DATA_PROCESSING consent.
* Cannot be dismissed without action.
*/
export function ConsentBanner() {
const t = useTranslations("consent")
const [marketingChecked, setMarketingChecked] = useState(false)
const { data: consentCheck, isLoading } = useConsentCheckQuery()
const grantMutation = useGrantConsentMutation()
// Don't show if still loading or consent already granted
if (isLoading || consentCheck?.hasDataProcessingConsent) {
return null
}
const handleAccept = async () => {
// Grant DATA_PROCESSING consent (required)
await grantMutation.mutateAsync({ type: "DATA_PROCESSING", version: 1 })
// Grant MARKETING if checked
if (marketingChecked) {
await grantMutation.mutateAsync({ type: "MARKETING", version: 1 })
}
}
const handleReject = () => {
// Redirect to deletion confirmation
window.location.href = "/settings/privacy?action=delete"
}
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="mx-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-card p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<Shield className="h-8 w-8 text-primary" />
<h2 className="text-xl font-semibold">{t("title")}</h2>
</div>
{/* Required: Data Processing */}
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5 p-4">
<h3 className="mb-1 font-medium">{t("dataProcessing")}</h3>
<p className="text-sm text-muted-foreground">
{t("dataProcessingDesc")}
</p>
<p className="mt-2 text-xs font-medium text-primary">
{t("required")}
</p>
</div>
{/* Optional: Marketing */}
<div className="mb-6 rounded-lg border p-4">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={marketingChecked}
onChange={(e) => setMarketingChecked(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300"
/>
<div>
<h3 className="font-medium">{t("marketing")}</h3>
<p className="text-sm text-muted-foreground">
{t("marketingDesc")}
</p>
</div>
</label>
</div>
{/* Actions */}
<div className="flex flex-col gap-3">
<button
onClick={handleAccept}
disabled={grantMutation.isPending}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<CheckCircle className="h-4 w-4" />
{grantMutation.isPending ? "..." : t("accept")}
</button>
<button
onClick={handleReject}
className="w-full text-center text-sm text-destructive underline transition-colors hover:text-destructive/80"
>
{t("reject")}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,102 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export interface ConsentRecord {
id: string
type: "DATA_PROCESSING" | "MARKETING" | "ANALYTICS"
granted: boolean
grantedAt: string | null
revokedAt: string | null
version: number
}
export interface ConsentCheckResponse {
hasDataProcessingConsent: boolean
}
export interface GrantConsentRequest {
type: "DATA_PROCESSING" | "MARKETING" | "ANALYTICS"
version?: number
}
export interface DsgvoExportData {
exportDate: string
legalBasis: string
personalData: Record<string, unknown>
memberProfile?: Record<string, unknown>
distributions?: Record<string, unknown>[]
consents?: Record<string, unknown>[]
}
// --- Query Hooks ---
export function useConsentsQuery() {
return useQuery({
queryKey: ["consent", "list"],
queryFn: () => apiClient<ConsentRecord[]>("/consent"),
})
}
export function useConsentCheckQuery() {
return useQuery({
queryKey: ["consent", "check"],
queryFn: () => apiClient<ConsentCheckResponse>("/consent/check"),
})
}
// --- Mutation Hooks ---
export function useGrantConsentMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: GrantConsentRequest) =>
apiClient<ConsentRecord>("/consent", { method: "POST", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["consent"] })
},
})
}
export function useRevokeConsentMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (type: string) =>
apiClient<void>(`/consent/${type}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["consent"] })
},
})
}
export function useExportDataMutation() {
return useMutation({
mutationFn: async () => {
const data = await apiClient<DsgvoExportData>("/dsgvo/export")
// Trigger download
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `meine-daten-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
return data
},
})
}
export function useDeleteAccountMutation() {
return useMutation({
mutationFn: () =>
apiClient<{ status: string; message: string }>("/dsgvo/delete", {
method: "DELETE",
}),
})
}