- 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>
211 lines
9.1 KiB
TypeScript
211 lines
9.1 KiB
TypeScript
"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>
|
|
)
|
|
}
|