Build GDPR compliance system: data export, account deletion, consent
- Data export (Art. 15/20): GET /api/gdpr/export serves a full JSON export of the user's data (credentials/tokens excluded, exclusions declared) - Right to erasure (Art. 17): self-service deletion with 30-day grace period (Settings -> Privacy & Data), cancellable; daily /api/cron/gdpr drain cancels Stripe billing, purges Spaces files, cascade-deletes the account, anonymizes consent rows, and writes audit evidence - Migration 0011: account_deletion_requests (partial unique index = one pending per user) + FK-less consent_log (survives erasure) - Consent: terms/privacy acceptance logged at signup (email + Google); cookie banner with analytics opt-out (umami.disabled), choices logged server-side for signed-in users via POST /api/gdpr/consent - Admin deleteUser upgraded to the same full purge (was leaving Spaces files and Stripe subscriptions orphaned) - /gdpr legal page now points at the self-service tools - scripts/verify-gdpr.ts: end-to-end verification vs live dev DB (22/22) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0d11018019
commit
5a555c715e
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
import Link from "next/link"
|
||||
import { Cookie } from "lucide-react"
|
||||
|
||||
// Cookie/privacy consent banner.
|
||||
//
|
||||
// The platform only sets strictly-necessary cookies (auth session, CSRF) and
|
||||
// uses cookieless Umami analytics — so this banner is disclosure plus an
|
||||
// analytics opt-out, not a tracking gate. "Essential only" sets the
|
||||
// `umami.disabled` localStorage flag, which the Umami script honors, so the
|
||||
// choice takes effect without a reload for subsequent page views.
|
||||
//
|
||||
// The choice is stored locally for everyone; signed-in users also get a row in
|
||||
// consent_log via /api/gdpr/consent (anonymous visitors are a 204 no-op).
|
||||
|
||||
const STORAGE_KEY = "pmn-cookie-consent"
|
||||
const CONSENT_VERSION = 1
|
||||
|
||||
type StoredConsent = { v: number; analytics: boolean; ts: string }
|
||||
|
||||
// ── localStorage as an external store (SSR-safe, lint-clean) ────────────────
|
||||
let listeners: Array<() => void> = []
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.push(listener)
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener)
|
||||
}
|
||||
}
|
||||
|
||||
function notify() {
|
||||
for (const l of listeners) l()
|
||||
}
|
||||
|
||||
function readStored(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// Storage unavailable (private mode) — treat as "answered" so the banner
|
||||
// doesn't nag on every render; the choice just can't persist.
|
||||
return "unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
function hasValidConsent(raw: string | null): boolean {
|
||||
if (raw === null) return false
|
||||
if (raw === "unavailable") return true
|
||||
try {
|
||||
return (JSON.parse(raw) as StoredConsent).v === CONSENT_VERSION
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function applyAnalyticsChoice(analytics: boolean) {
|
||||
try {
|
||||
if (analytics) localStorage.removeItem("umami.disabled")
|
||||
else localStorage.setItem("umami.disabled", "1")
|
||||
} catch {
|
||||
// Storage unavailable — nothing to apply.
|
||||
}
|
||||
}
|
||||
|
||||
export function CookieConsent() {
|
||||
// Server snapshot says "answered" so nothing renders during SSR/hydration.
|
||||
const raw = useSyncExternalStore(subscribe, readStored, () => "unavailable")
|
||||
const visible = !hasValidConsent(raw)
|
||||
|
||||
// Re-apply a returning visitor's analytics opt-out (external system only).
|
||||
useEffect(() => {
|
||||
if (raw && raw !== "unavailable") {
|
||||
try {
|
||||
applyAnalyticsChoice((JSON.parse(raw) as StoredConsent).analytics)
|
||||
} catch {
|
||||
// Corrupt value — banner is showing anyway.
|
||||
}
|
||||
}
|
||||
}, [raw])
|
||||
|
||||
function choose(analytics: boolean) {
|
||||
const stored: StoredConsent = { v: CONSENT_VERSION, analytics, ts: new Date().toISOString() }
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
|
||||
} catch {
|
||||
// Private mode — still honor the choice for this page view.
|
||||
}
|
||||
applyAnalyticsChoice(analytics)
|
||||
// Record the choice server-side for signed-in users (fire-and-forget).
|
||||
fetch("/api/gdpr/consent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ analytics }),
|
||||
}).catch(() => {})
|
||||
notify()
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 p-4 sm:p-6" role="dialog" aria-label="Cookie consent">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-4 rounded-2xl border border-white/10 bg-[#111118]/95 p-5 shadow-2xl shadow-black/50 backdrop-blur sm:flex-row sm:items-center">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-indigo-500/10 p-2">
|
||||
<Cookie className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-white/60">
|
||||
We only use strictly-necessary cookies (sign-in and security) plus cookieless,
|
||||
privacy-friendly analytics. Choose “Essential only” to opt out of analytics.
|
||||
Details in our{" "}
|
||||
<Link href="/cookie-policy" className="text-indigo-400 underline underline-offset-2 hover:text-indigo-300">
|
||||
Cookie Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 sm:flex-col md:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose(true)}
|
||||
className="flex-1 whitespace-nowrap rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98] sm:w-full"
|
||||
>
|
||||
Accept all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose(false)}
|
||||
className="flex-1 whitespace-nowrap rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-xs font-semibold text-white/70 transition hover:bg-white/10 sm:w-full"
|
||||
>
|
||||
Essential only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user