Files
property-management-network/components/shared/cookie-consent.tsx
T
Leon SerfatyandClaude Fable 5 5a555c715e 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>
2026-07-03 06:03:27 -04:00

138 lines
4.8 KiB
TypeScript

"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 &ldquo;Essential only&rdquo; 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>
)
}