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,55 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { account_deletion_requests } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { LEGAL } from "@/lib/legal"
|
||||
import { PrivacyManager } from "@/components/dashboard/privacy-manager"
|
||||
|
||||
export const metadata = { title: "Privacy & Data" }
|
||||
|
||||
export default async function PrivacySettingsPage() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const pending = await db.query.account_deletion_requests.findFirst({
|
||||
where: and(
|
||||
eq(account_deletion_requests.user_id, user.id),
|
||||
eq(account_deletion_requests.status, "pending")
|
||||
),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Privacy & Data</h2>
|
||||
<p className="text-sm text-white/40">
|
||||
Exercise your data rights under the GDPR — export a copy of your data or delete your
|
||||
account. Details are in our{" "}
|
||||
<Link
|
||||
href="/gdpr"
|
||||
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
|
||||
>
|
||||
GDPR & Data Rights
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>{" "}
|
||||
pages.
|
||||
</p>
|
||||
</div>
|
||||
<PrivacyManager
|
||||
accountEmail={user.email}
|
||||
graceDays={LEGAL.dataDeletionDays}
|
||||
pendingDeletion={
|
||||
pending ? { scheduled_for: pending.scheduled_for, created_at: pending.created_at } : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -134,7 +134,27 @@ export default function GdprPage() {
|
||||
|
||||
<Section id="exercise" heading="8. How to exercise your rights">
|
||||
<p>
|
||||
To exercise any of the rights described above, contact us at{" "}
|
||||
You can exercise the most common rights yourself, instantly, from{" "}
|
||||
<strong>Settings → Privacy & Data</strong> in your dashboard:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Access & portability</strong> — download a complete,
|
||||
machine-readable JSON export of your personal data and portfolio records.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Erasure</strong> — delete your account. Deletion is scheduled{" "}
|
||||
{LEGAL.dataDeletionDays} days out (during which you can cancel), after which your
|
||||
account, data, and uploaded files are permanently erased and any active
|
||||
subscription is cancelled.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Rectification</strong> — correct your details at any time in{" "}
|
||||
<strong>Settings → Profile</strong>.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
For any other request, contact us at{" "}
|
||||
<a href={`mailto:${LEGAL.privacyEmail}`}>{LEGAL.privacyEmail}</a>. For
|
||||
data-protection matters, you may also contact our data-protection team at{" "}
|
||||
<a href={`mailto:${LEGAL.dpoEmail}`}>{LEGAL.dpoEmail}</a>. You also have the right
|
||||
|
||||
@@ -12,6 +12,7 @@ import { setAiProvider, type AiProvider } from "@/lib/ai/provider"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db"
|
||||
import { profiles, user as userTable } from "@/lib/db/schema"
|
||||
import { executeAccountDeletion } from "@/lib/gdpr/delete"
|
||||
|
||||
// ── gate ────────────────────────────────────────────────────────────────────
|
||||
// Every server action re-verifies the caller is an admin. NEVER skip — these
|
||||
@@ -109,15 +110,15 @@ export async function deleteUser(userId: string) {
|
||||
const a = await guard()
|
||||
if (userId === a.user.id) throw new Error("You cannot delete yourself")
|
||||
|
||||
await auth.api.removeUser({
|
||||
body: { userId },
|
||||
headers: await headers(),
|
||||
})
|
||||
// Full GDPR-grade purge: cancels Stripe billing, deletes stored files, and
|
||||
// removes the user row (FK cascade erases the whole portfolio + sessions).
|
||||
const outcome = await executeAccountDeletion(userId)
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "delete_user",
|
||||
targetUserId: userId,
|
||||
metadata: { ...outcome },
|
||||
})
|
||||
|
||||
redirect("/admin/users")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { account_deletion_requests } from "@/lib/db/schema"
|
||||
import { getSessionUser, isAdminUser } from "@/lib/session"
|
||||
import { LEGAL } from "@/lib/legal"
|
||||
import { sendEmail, accountDeletionRequestedHtml } from "@/lib/email/send"
|
||||
|
||||
// ============================================================================
|
||||
// GDPR self-service actions (Settings → Privacy & Data).
|
||||
//
|
||||
// Deletion is a two-step, grace-period flow: the request schedules a hard
|
||||
// delete LEGAL.dataDeletionDays out (the retention window promised on /gdpr);
|
||||
// the gdpr cron executes it. Until then the account stays usable and the user
|
||||
// can cancel. Data export is a GET route (/api/gdpr/export), not an action,
|
||||
// so the browser can download it as a file.
|
||||
// ============================================================================
|
||||
|
||||
const SETTINGS_PATH = "/settings/privacy"
|
||||
|
||||
export type DeletionRequestDTO = {
|
||||
id: string
|
||||
status: "pending" | "cancelled" | "completed"
|
||||
scheduled_for: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function requestAccountDeletion(input: {
|
||||
confirmEmail: string
|
||||
reason?: string
|
||||
}): Promise<DeletionRequestDTO> {
|
||||
const user = await getSessionUser()
|
||||
if (!user) throw new Error("Unauthorized")
|
||||
|
||||
// Admins manage the platform — deleting one from self-service risks locking
|
||||
// everyone out. They can be removed via the admin panel by another admin.
|
||||
if (isAdminUser(user as { id?: string; email?: string; role?: string | null })) {
|
||||
throw new Error("Admin accounts cannot be deleted from self-service. Contact another administrator.")
|
||||
}
|
||||
|
||||
const typed = (input.confirmEmail ?? "").trim().toLowerCase()
|
||||
if (!typed || typed !== user.email.toLowerCase()) {
|
||||
throw new Error("The email you typed doesn't match your account email.")
|
||||
}
|
||||
|
||||
const existing = await db.query.account_deletion_requests.findFirst({
|
||||
where: and(
|
||||
eq(account_deletion_requests.user_id, user.id),
|
||||
eq(account_deletion_requests.status, "pending")
|
||||
),
|
||||
})
|
||||
if (existing) throw new Error("Your account is already scheduled for deletion.")
|
||||
|
||||
const scheduledFor = new Date(Date.now() + LEGAL.dataDeletionDays * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
let row: typeof account_deletion_requests.$inferSelect
|
||||
try {
|
||||
;[row] = await db
|
||||
.insert(account_deletion_requests)
|
||||
.values({
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
reason: (input.reason ?? "").trim().slice(0, 500) || null,
|
||||
scheduled_for: scheduledFor,
|
||||
})
|
||||
.returning()
|
||||
} catch {
|
||||
// The partial unique index makes a double-submit race land here.
|
||||
throw new Error("Your account is already scheduled for deletion.")
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Your account deletion is scheduled",
|
||||
html: accountDeletionRequestedHtml({
|
||||
name: user.name || user.email,
|
||||
scheduledDate: new Date(scheduledFor).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
graceDays: LEGAL.dataDeletionDays,
|
||||
}),
|
||||
})
|
||||
|
||||
revalidatePath(SETTINGS_PATH)
|
||||
return { id: row.id, status: row.status, scheduled_for: row.scheduled_for, created_at: row.created_at }
|
||||
}
|
||||
|
||||
export async function cancelAccountDeletion(): Promise<void> {
|
||||
const user = await getSessionUser()
|
||||
if (!user) throw new Error("Unauthorized")
|
||||
|
||||
const [row] = await db
|
||||
.update(account_deletion_requests)
|
||||
.set({ status: "cancelled", cancelled_at: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(account_deletion_requests.user_id, user.id),
|
||||
eq(account_deletion_requests.status, "pending")
|
||||
)
|
||||
)
|
||||
.returning({ id: account_deletion_requests.id })
|
||||
if (!row) throw new Error("No pending deletion request found.")
|
||||
|
||||
revalidatePath(SETTINGS_PATH)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { isAuthorizedCron } from "@/lib/cron-auth"
|
||||
import { processDueDeletions } from "@/lib/gdpr/delete"
|
||||
|
||||
// GDPR deletion drain: hard-deletes accounts whose grace period
|
||||
// (LEGAL.dataDeletionDays after the request) has elapsed. Scheduled daily via
|
||||
// DigitalOcean Functions — see functions/project.yml.
|
||||
export async function GET(request: Request) {
|
||||
if (!isAuthorizedCron(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { processed, deleted } = await processDueDeletions(25)
|
||||
return NextResponse.json({ processed, deleted })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { headers } from "next/headers"
|
||||
import { db } from "@/lib/db"
|
||||
import { consent_log } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { LEGAL } from "@/lib/legal"
|
||||
|
||||
// Records a cookie-consent choice from the banner. Only signed-in users are
|
||||
// logged — anonymous visitors keep their choice in localStorage only, so this
|
||||
// endpoint can't be used to spam the consent table.
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return new NextResponse(null, { status: 204 })
|
||||
|
||||
let analytics = false
|
||||
try {
|
||||
const body = await request.json()
|
||||
analytics = body?.analytics === true
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const h = await headers()
|
||||
await db.insert(consent_log).values({
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
kind: "cookies",
|
||||
granted: analytics,
|
||||
policy_version: LEGAL.lastUpdated,
|
||||
source: "cookie-banner",
|
||||
ip_address: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip"),
|
||||
user_agent: h.get("user-agent"),
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { db } from "@/lib/db"
|
||||
import { usage_events } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { buildUserDataExport } from "@/lib/gdpr/export"
|
||||
|
||||
// GDPR data export (Articles 15/20) — downloads everything the platform stores
|
||||
// about the signed-in user as a single JSON file. Sensitive credentials are
|
||||
// excluded by the builder (see lib/gdpr/export.ts).
|
||||
export async function GET() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const data = await buildUserDataExport(user.id)
|
||||
|
||||
// DSAR evidence: record that the export was served.
|
||||
await db.insert(usage_events).values({ user_id: user.id, event_type: "gdpr_data_export" })
|
||||
|
||||
const filename = `pmn-data-export-${new Date().toISOString().slice(0, 10)}.json`
|
||||
return new NextResponse(JSON.stringify(data, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono } from "next/font/google"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { UmamiAnalytics } from "@/components/analytics/umami"
|
||||
import { CookieConsent } from "@/components/shared/cookie-consent"
|
||||
import "./globals.css"
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -70,6 +71,7 @@ export default function RootLayout({
|
||||
<body suppressHydrationWarning className="min-h-full flex flex-col bg-[#09090b] text-white">
|
||||
{children}
|
||||
<Toaster />
|
||||
<CookieConsent />
|
||||
<UmamiAnalytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user