diff --git a/app/(dashboard)/settings/privacy/page.tsx b/app/(dashboard)/settings/privacy/page.tsx
new file mode 100644
index 0000000..bd4d02d
--- /dev/null
+++ b/app/(dashboard)/settings/privacy/page.tsx
@@ -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 (
+
- To exercise any of the rights described above, contact us at{" "}
+ You can exercise the most common rights yourself, instantly, from{" "}
+ Settings → Privacy & Data in your dashboard:
+
+
+ -
+ Access & portability — download a complete,
+ machine-readable JSON export of your personal data and portfolio records.
+
+ -
+ Erasure — 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.
+
+ -
+ Rectification — correct your details at any time in{" "}
+ Settings → Profile.
+
+
+
+ For any other request, contact us at{" "}
{LEGAL.privacyEmail}. For
data-protection matters, you may also contact our data-protection team at{" "}
{LEGAL.dpoEmail}. You also have the right
diff --git a/app/actions/admin.ts b/app/actions/admin.ts
index 7e97850..93076c1 100644
--- a/app/actions/admin.ts
+++ b/app/actions/admin.ts
@@ -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")
diff --git a/app/actions/gdpr.ts b/app/actions/gdpr.ts
new file mode 100644
index 0000000..51240e5
--- /dev/null
+++ b/app/actions/gdpr.ts
@@ -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 {
+ 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 {
+ 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)
+}
diff --git a/app/api/cron/gdpr/route.ts b/app/api/cron/gdpr/route.ts
new file mode 100644
index 0000000..bf9f1f2
--- /dev/null
+++ b/app/api/cron/gdpr/route.ts
@@ -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 })
+}
diff --git a/app/api/gdpr/consent/route.ts b/app/api/gdpr/consent/route.ts
new file mode 100644
index 0000000..3146f3a
--- /dev/null
+++ b/app/api/gdpr/consent/route.ts
@@ -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 })
+}
diff --git a/app/api/gdpr/export/route.ts b/app/api/gdpr/export/route.ts
new file mode 100644
index 0000000..ed29812
--- /dev/null
+++ b/app/api/gdpr/export/route.ts
@@ -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",
+ },
+ })
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 45d326d..6e7b5fa 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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({
{children}
+