Files
property-management-network/components/dashboard/privacy-manager.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

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>
)
}