feat(sprint-6): Phase 2 — DSGVO consent management
- 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:
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user