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:
@@ -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