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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user