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 ( +
+
+

Privacy & Data

+

+ Exercise your data rights under the GDPR — export a copy of your data or delete your + account. Details are in our{" "} + + GDPR & Data Rights + {" "} + and{" "} + + Privacy Policy + {" "} + pages. +

+
+ +
+ ) +} diff --git a/app/(marketing)/gdpr/page.tsx b/app/(marketing)/gdpr/page.tsx index 5ce7e7e..cc74991 100644 --- a/app/(marketing)/gdpr/page.tsx +++ b/app/(marketing)/gdpr/page.tsx @@ -134,7 +134,27 @@ export default function GdprPage() {

- 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: +

+ +

+ 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} + diff --git a/components/dashboard/header.tsx b/components/dashboard/header.tsx index d52c90a..7503904 100644 --- a/components/dashboard/header.tsx +++ b/components/dashboard/header.tsx @@ -16,6 +16,7 @@ const pageTitles: Record = { "/expenses": "Expenses", "/settings/profile": "Settings", "/settings/billing": "Billing", + "/settings/privacy": "Privacy & Data", "/settings/demo": "Demo Data", "/ai": "AI Assistant", "/reports": "Reports", diff --git a/components/dashboard/privacy-manager.tsx b/components/dashboard/privacy-manager.tsx new file mode 100644 index 0000000..af21bc7 --- /dev/null +++ b/components/dashboard/privacy-manager.tsx @@ -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) { + 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 ( +

+ {/* ── Export ────────────────────────────────────────────────────────── */} +
+
+
+ +
+
+

Export your data

+

+ 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. +

+ + + Download my data + +
+
+
+ + {/* ── Delete account ───────────────────────────────────────────────── */} +
+
+
+ +
+
+

Delete your account

+ + {pendingDeletion ? ( + <> +
+

+ + Deletion scheduled for {formatDate(pendingDeletion.scheduled_for)} +

+

+ {daysUntil(pendingDeletion.scheduled_for)} days left. Your account stays fully + usable until then. After that date, all data and files are permanently erased. +

+
+ + + ) : ( + <> +

+ 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. +

+ + {!confirmOpen ? ( + + ) : ( +
+
+ + setConfirmEmail(e.target.value)} + placeholder={accountEmail} + className={inputClass} + autoComplete="off" + /> +
+
+ +