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:
Leon Serfaty
2026-07-03 06:03:27 -04:00
co-authored by Claude Fable 5
parent 0d11018019
commit 5a555c715e
24 changed files with 5069 additions and 9 deletions
+1
View File
@@ -16,6 +16,7 @@ const pageTitles: Record<string, string> = {
"/expenses": "Expenses",
"/settings/profile": "Settings",
"/settings/billing": "Billing",
"/settings/privacy": "Privacy & Data",
"/settings/demo": "Demo Data",
"/ai": "AI Assistant",
"/reports": "Reports",
+210
View File
@@ -0,0 +1,210 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Download, Loader2, ShieldCheck, Trash2, TriangleAlert } from "lucide-react"
import { requestAccountDeletion, cancelAccountDeletion } from "@/app/actions/gdpr"
const inputClass =
"w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
function formatDate(value: string): string {
const d = new Date(value)
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })
}
function daysUntil(value: string): number {
return Math.max(0, Math.ceil((new Date(value).getTime() - Date.now()) / 86_400_000))
}
export function PrivacyManager({
accountEmail,
graceDays,
pendingDeletion,
}: {
accountEmail: string
graceDays: number
pendingDeletion: { scheduled_for: string; created_at: string } | null
}) {
const router = useRouter()
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmEmail, setConfirmEmail] = useState("")
const [reason, setReason] = useState("")
const [busy, setBusy] = useState(false)
async function handleRequestDeletion(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setBusy(true)
try {
await requestAccountDeletion({ confirmEmail, reason })
toast.success("Account deletion scheduled. Check your email for confirmation.")
setConfirmOpen(false)
setConfirmEmail("")
setReason("")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to schedule deletion")
} finally {
setBusy(false)
}
}
async function handleCancelDeletion() {
setBusy(true)
try {
await cancelAccountDeletion()
toast.success("Deletion cancelled — your account is safe.")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to cancel deletion")
} finally {
setBusy(false)
}
}
return (
<div className="space-y-6">
{/* ── Export ────────────────────────────────────────────────────────── */}
<div className="rounded-xl border border-white/10 bg-[#111118] p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-indigo-500/10 p-2">
<ShieldCheck className="h-5 w-5 text-indigo-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Export your data</p>
<p className="mt-0.5 text-xs text-white/40">
Download a machine-readable JSON file with everything we store about you and your
portfolio profile, properties, tenants, payments, documents metadata, activity, and
consent history. Passwords and connected-service credentials are never included.
</p>
<a
href="/api/gdpr/export"
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
>
<Download className="h-3.5 w-3.5" />
Download my data
</a>
</div>
</div>
</div>
{/* ── Delete account ───────────────────────────────────────────────── */}
<div className="rounded-xl border border-red-500/25 bg-red-500/[0.04] p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-red-500/10 p-2">
<Trash2 className="h-5 w-5 text-red-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Delete your account</p>
{pendingDeletion ? (
<>
<div className="mt-3 rounded-lg border border-red-500/25 bg-red-500/10 px-4 py-3">
<p className="flex items-center gap-2 text-sm font-semibold text-red-300">
<TriangleAlert className="h-4 w-4 shrink-0" />
Deletion scheduled for {formatDate(pendingDeletion.scheduled_for)}
</p>
<p className="mt-1 text-xs text-red-200/70">
{daysUntil(pendingDeletion.scheduled_for)} days left. Your account stays fully
usable until then. After that date, all data and files are permanently erased.
</p>
</div>
<button
type="button"
onClick={handleCancelDeletion}
disabled={busy}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-white/10 disabled:opacity-50"
>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Cancel deletion keep my account
</button>
</>
) : (
<>
<p className="mt-0.5 text-xs text-white/40">
Permanently deletes your account, all properties, tenants, payments, documents,
and uploaded files, and cancels any active subscription. There is a{" "}
{graceDays}-day grace period during which you can change your mind after that,
deletion is irreversible.
</p>
{!confirmOpen ? (
<button
type="button"
onClick={() => setConfirmOpen(true)}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-3.5 py-2 text-xs font-semibold text-red-300 transition hover:bg-red-500/20"
>
<Trash2 className="h-3.5 w-3.5" />
Delete my account
</button>
) : (
<form onSubmit={handleRequestDeletion} className="mt-4 space-y-3">
<div>
<label
htmlFor="confirm-email"
className="mb-1.5 block text-xs font-medium text-white/70"
>
Type your account email (<span className="text-white/40">{accountEmail}</span>)
to confirm
</label>
<input
id="confirm-email"
type="email"
required
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
placeholder={accountEmail}
className={inputClass}
autoComplete="off"
/>
</div>
<div>
<label
htmlFor="deletion-reason"
className="mb-1.5 block text-xs font-medium text-white/70"
>
Reason <span className="text-white/30">(optional helps us improve)</span>
</label>
<textarea
id="deletion-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={2}
maxLength={500}
className={inputClass}
/>
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy || confirmEmail.trim().toLowerCase() !== accountEmail.toLowerCase()}
className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-red-500 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Schedule permanent deletion
</button>
<button
type="button"
onClick={() => setConfirmOpen(false)}
className="rounded-lg px-3.5 py-2 text-xs font-medium text-white/40 transition hover:text-white/70"
>
Never mind
</button>
</div>
</form>
)}
</>
)}
</div>
</div>
</div>
</div>
)
}
+23 -1
View File
@@ -7,7 +7,7 @@ import {
LayoutDashboard, Building2, Users, CreditCard,
Wrench, FileText, Receipt, Settings, LogOut,
X, Menu, ChevronRight, Zap, Sparkles, Bot, BarChart3, Hammer, ClipboardList,
PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell, Palette, KeyRound, Plug, Webhook,
PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell, Palette, KeyRound, Plug, Webhook, ShieldCheck,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Logo, LogoMark } from "@/components/shared/logo"
@@ -245,6 +245,28 @@ function NavContent({
{!collapsed && "Webhooks"}
</Link>
<Link
href="/settings/privacy"
onClick={onClose}
title={collapsed ? "Privacy & Data" : undefined}
className={cn(
"group relative flex items-center rounded-xl transition-all duration-200",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
pathname === "/settings/privacy"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{pathname === "/settings/privacy" && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
<ShieldCheck className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/privacy" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Privacy & Data"}
</Link>
{(plan === "landlord" || plan === "lifetime") && (
<>
<Link
+137
View File
@@ -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 &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>
)
}