4 Commits
Author SHA1 Message Date
Leon SerfatyandClaude Opus 4.8 595a5e3e04 feat(auth): hide Google sign-in until OAuth is configured
Google social login has no credentials in production, so the "Continue with
Google" button (and its divider) errored on click. Gate the button + divider on
a new isGoogleConfigured() helper (requires GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET)
on both the login and signup pages, and guard the signInWithGoogle action as
defense in depth. The button reappears automatically once both env vars are set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:26:08 -04:00
Leon SerfatyandClaude Opus 4.8 e5e987eb4c fix(csp): whitelist Umami analytics origin so tracking works
The CSP script-src/connect-src didn't include the Umami host
(fickanalytics.phluit.net), so the browser blocked both loading script.js and
the event beacons (POST /api/send) — analytics recorded 0 visits despite the
site being live. Add a umamiOrigin() helper (derived from NEXT_PUBLIC_UMAMI_SRC,
defaulting to the shared phluit instance) and include it in script-src and
connect-src.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:37:35 -04:00
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
Leon SerfatyandClaude Opus 4.8 0d11018019 fix(csp): allow inline scripts so the app hydrates on Turbopack builds
Next.js 16 builds with Turbopack, which does NOT stamp the middleware CSP
nonce onto its inline hydration scripts (self.__next_f.push). The nonce-based
`script-src 'self' 'nonce-…'` therefore blocked those inline scripts, React
never hydrated, and the marketing/app pages rendered as a blank/black shell
(header + framer-motion sections stuck at opacity:0).

Switch `script-src` to 'self' 'unsafe-inline' (Turbopack-compatible) and drop
the now-unused nonce plumbing. All other CSP directives stay strict
(object-src 'none', frame-ancestors 'none', locked connect-src/frame-src).
Verified in a local production container: served script-src is correct and
the page hydrates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 05:30:39 -04:00
28 changed files with 5166 additions and 71 deletions
+23 -18
View File
@@ -3,6 +3,7 @@ import Link from "next/link"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signIn, signInWithGoogle } from "@/app/actions/auth" import { signIn, signInWithGoogle } from "@/app/actions/auth"
import { isGoogleConfigured } from "@/lib/auth"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sign in", title: "Sign in",
@@ -28,25 +29,29 @@ export default async function LoginPage({
</div> </div>
<div className="rounded-xl border border-white/10 bg-[#111118] p-8"> <div className="rounded-xl border border-white/10 bg-[#111118] p-8">
{/* Google OAuth */} {isGoogleConfigured() && (
<form action={signInWithGoogle}> <>
<button {/* Google OAuth */}
type="submit" <form action={signInWithGoogle}>
className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10" <button
> type="submit"
<GoogleIcon /> className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10"
Continue with Google >
</button> <GoogleIcon />
</form> Continue with Google
</button>
</form>
<div className="relative my-6"> <div className="relative my-6">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" /> <div className="w-full border-t border-white/10" />
</div> </div>
<div className="relative flex justify-center text-xs"> <div className="relative flex justify-center text-xs">
<span className="bg-[#111118] px-3 text-white/40">or continue with email</span> <span className="bg-[#111118] px-3 text-white/40">or continue with email</span>
</div> </div>
</div> </div>
</>
)}
{/* Error / Success messages */} {/* Error / Success messages */}
{error && ( {error && (
+23 -18
View File
@@ -2,6 +2,7 @@ import Link from "next/link"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signUp, signInWithGoogle } from "@/app/actions/auth" import { signUp, signInWithGoogle } from "@/app/actions/auth"
import { isGoogleConfigured } from "@/lib/auth"
export default async function SignupPage({ export default async function SignupPage({
searchParams, searchParams,
@@ -47,25 +48,29 @@ export default async function SignupPage({
</div> </div>
<div className="rounded-xl border border-white/10 bg-[#111118] p-8"> <div className="rounded-xl border border-white/10 bg-[#111118] p-8">
{/* Google OAuth */} {isGoogleConfigured() && (
<form action={signInWithGoogle}> <>
<button {/* Google OAuth */}
type="submit" <form action={signInWithGoogle}>
className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10" <button
> type="submit"
<GoogleIcon /> className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10"
Continue with Google >
</button> <GoogleIcon />
</form> Continue with Google
</button>
</form>
<div className="relative my-6"> <div className="relative my-6">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" /> <div className="w-full border-t border-white/10" />
</div> </div>
<div className="relative flex justify-center text-xs"> <div className="relative flex justify-center text-xs">
<span className="bg-[#111118] px-3 text-white/40">or sign up with email</span> <span className="bg-[#111118] px-3 text-white/40">or sign up with email</span>
</div> </div>
</div> </div>
</>
)}
{error && ( {error && (
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400"> <div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
+55
View File
@@ -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 &amp; 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 &amp; 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>
)
}
+21 -1
View File
@@ -134,7 +134,27 @@ export default function GdprPage() {
<Section id="exercise" heading="8. How to exercise your rights"> <Section id="exercise" heading="8. How to exercise your rights">
<p> <p>
To exercise any of the rights described above, contact us at{" "} You can exercise the most common rights yourself, instantly, from{" "}
<strong>Settings &rarr; Privacy &amp; Data</strong> in your dashboard:
</p>
<ul>
<li>
<strong>Access &amp; portability</strong> &mdash; download a complete,
machine-readable JSON export of your personal data and portfolio records.
</li>
<li>
<strong>Erasure</strong> &mdash; 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> &mdash; correct your details at any time in{" "}
<strong>Settings &rarr; Profile</strong>.
</li>
</ul>
<p>
For any other request, contact us at{" "}
<a href={`mailto:${LEGAL.privacyEmail}`}>{LEGAL.privacyEmail}</a>. For <a href={`mailto:${LEGAL.privacyEmail}`}>{LEGAL.privacyEmail}</a>. For
data-protection matters, you may also contact our data-protection team at{" "} 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 <a href={`mailto:${LEGAL.dpoEmail}`}>{LEGAL.dpoEmail}</a>. You also have the right
+5 -4
View File
@@ -12,6 +12,7 @@ import { setAiProvider, type AiProvider } from "@/lib/ai/provider"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { profiles, user as userTable } from "@/lib/db/schema" import { profiles, user as userTable } from "@/lib/db/schema"
import { executeAccountDeletion } from "@/lib/gdpr/delete"
// ── gate ──────────────────────────────────────────────────────────────────── // ── gate ────────────────────────────────────────────────────────────────────
// Every server action re-verifies the caller is an admin. NEVER skip — these // 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() const a = await guard()
if (userId === a.user.id) throw new Error("You cannot delete yourself") if (userId === a.user.id) throw new Error("You cannot delete yourself")
await auth.api.removeUser({ // Full GDPR-grade purge: cancels Stripe billing, deletes stored files, and
body: { userId }, // removes the user row (FK cascade erases the whole portfolio + sessions).
headers: await headers(), const outcome = await executeAccountDeletion(userId)
})
await logAdminAction({ await logAdminAction({
adminId: a.user.id, adminId: a.user.id,
action: "delete_user", action: "delete_user",
targetUserId: userId, targetUserId: userId,
metadata: { ...outcome },
}) })
redirect("/admin/users") redirect("/admin/users")
+6 -1
View File
@@ -3,7 +3,7 @@
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { headers } from "next/headers" import { headers } from "next/headers"
import { APIError } from "better-auth/api" import { APIError } from "better-auth/api"
import { auth } from "@/lib/auth" import { auth, isGoogleConfigured } from "@/lib/auth"
import { verifyTurnstile } from "@/lib/turnstile" import { verifyTurnstile } from "@/lib/turnstile"
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
@@ -77,6 +77,11 @@ export async function signIn(formData: FormData) {
} }
export async function signInWithGoogle() { export async function signInWithGoogle() {
// Defense in depth: the auth pages hide the Google button when it isn't
// configured, but guard the action too in case it's POSTed directly.
if (!isGoogleConfigured()) {
redirect(`/login?error=${encodeURIComponent("Google sign-in isn't available right now.")}`)
}
let url: string | undefined let url: string | undefined
try { try {
const res = await auth.api.signInSocial({ const res = await auth.api.signInSocial({
+109
View File
@@ -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)
}
+15
View File
@@ -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 })
}
+36
View File
@@ -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 })
}
+27
View File
@@ -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
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google" import { Geist, Geist_Mono } from "next/font/google"
import { Toaster } from "@/components/ui/toaster" import { Toaster } from "@/components/ui/toaster"
import { UmamiAnalytics } from "@/components/analytics/umami" import { UmamiAnalytics } from "@/components/analytics/umami"
import { CookieConsent } from "@/components/shared/cookie-consent"
import "./globals.css" import "./globals.css"
const geistSans = Geist({ const geistSans = Geist({
@@ -70,6 +71,7 @@ export default function RootLayout({
<body suppressHydrationWarning className="min-h-full flex flex-col bg-[#09090b] text-white"> <body suppressHydrationWarning className="min-h-full flex flex-col bg-[#09090b] text-white">
{children} {children}
<Toaster /> <Toaster />
<CookieConsent />
<UmamiAnalytics /> <UmamiAnalytics />
</body> </body>
</html> </html>
+1
View File
@@ -16,6 +16,7 @@ const pageTitles: Record<string, string> = {
"/expenses": "Expenses", "/expenses": "Expenses",
"/settings/profile": "Settings", "/settings/profile": "Settings",
"/settings/billing": "Billing", "/settings/billing": "Billing",
"/settings/privacy": "Privacy & Data",
"/settings/demo": "Demo Data", "/settings/demo": "Demo Data",
"/ai": "AI Assistant", "/ai": "AI Assistant",
"/reports": "Reports", "/reports": "Reports",
+210
View File
@@ -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<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>
)
}
+23 -1
View File
@@ -7,7 +7,7 @@ import {
LayoutDashboard, Building2, Users, CreditCard, LayoutDashboard, Building2, Users, CreditCard,
Wrench, FileText, Receipt, Settings, LogOut, Wrench, FileText, Receipt, Settings, LogOut,
X, Menu, ChevronRight, Zap, Sparkles, Bot, BarChart3, Hammer, ClipboardList, X, Menu, ChevronRight, Zap, Sparkles, Bot, BarChart3, Hammer, ClipboardList,
PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell, Palette, KeyRound, Plug, Webhook, PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell, Palette, KeyRound, Plug, Webhook, ShieldCheck,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Logo, LogoMark } from "@/components/shared/logo" import { Logo, LogoMark } from "@/components/shared/logo"
@@ -245,6 +245,28 @@ function NavContent({
{!collapsed && "Webhooks"} {!collapsed && "Webhooks"}
</Link> </Link>
<Link
href="/settings/privacy"
onClick={onClose}
title={collapsed ? "Privacy & Data" : undefined}
className={cn(
"group relative flex items-center rounded-xl transition-all duration-200",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
pathname === "/settings/privacy"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{pathname === "/settings/privacy" && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
<ShieldCheck className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/privacy" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Privacy & Data"}
</Link>
{(plan === "landlord" || plan === "lifetime") && ( {(plan === "landlord" || plan === "lifetime") && (
<> <>
<Link <Link
+137
View File
@@ -0,0 +1,137 @@
"use client"
import { useEffect, useSyncExternalStore } from "react"
import Link from "next/link"
import { Cookie } from "lucide-react"
// Cookie/privacy consent banner.
//
// The platform only sets strictly-necessary cookies (auth session, CSRF) and
// uses cookieless Umami analytics — so this banner is disclosure plus an
// analytics opt-out, not a tracking gate. "Essential only" sets the
// `umami.disabled` localStorage flag, which the Umami script honors, so the
// choice takes effect without a reload for subsequent page views.
//
// The choice is stored locally for everyone; signed-in users also get a row in
// consent_log via /api/gdpr/consent (anonymous visitors are a 204 no-op).
const STORAGE_KEY = "pmn-cookie-consent"
const CONSENT_VERSION = 1
type StoredConsent = { v: number; analytics: boolean; ts: string }
// ── localStorage as an external store (SSR-safe, lint-clean) ────────────────
let listeners: Array<() => void> = []
function subscribe(listener: () => void) {
listeners.push(listener)
return () => {
listeners = listeners.filter((l) => l !== listener)
}
}
function notify() {
for (const l of listeners) l()
}
function readStored(): string | null {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
// Storage unavailable (private mode) — treat as "answered" so the banner
// doesn't nag on every render; the choice just can't persist.
return "unavailable"
}
}
function hasValidConsent(raw: string | null): boolean {
if (raw === null) return false
if (raw === "unavailable") return true
try {
return (JSON.parse(raw) as StoredConsent).v === CONSENT_VERSION
} catch {
return false
}
}
function applyAnalyticsChoice(analytics: boolean) {
try {
if (analytics) localStorage.removeItem("umami.disabled")
else localStorage.setItem("umami.disabled", "1")
} catch {
// Storage unavailable — nothing to apply.
}
}
export function CookieConsent() {
// Server snapshot says "answered" so nothing renders during SSR/hydration.
const raw = useSyncExternalStore(subscribe, readStored, () => "unavailable")
const visible = !hasValidConsent(raw)
// Re-apply a returning visitor's analytics opt-out (external system only).
useEffect(() => {
if (raw && raw !== "unavailable") {
try {
applyAnalyticsChoice((JSON.parse(raw) as StoredConsent).analytics)
} catch {
// Corrupt value — banner is showing anyway.
}
}
}, [raw])
function choose(analytics: boolean) {
const stored: StoredConsent = { v: CONSENT_VERSION, analytics, ts: new Date().toISOString() }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
} catch {
// Private mode — still honor the choice for this page view.
}
applyAnalyticsChoice(analytics)
// Record the choice server-side for signed-in users (fire-and-forget).
fetch("/api/gdpr/consent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ analytics }),
}).catch(() => {})
notify()
}
if (!visible) return null
return (
<div className="fixed inset-x-0 bottom-0 z-50 p-4 sm:p-6" role="dialog" aria-label="Cookie consent">
<div className="mx-auto flex max-w-3xl flex-col gap-4 rounded-2xl border border-white/10 bg-[#111118]/95 p-5 shadow-2xl shadow-black/50 backdrop-blur sm:flex-row sm:items-center">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-indigo-500/10 p-2">
<Cookie className="h-5 w-5 text-indigo-400" />
</div>
<p className="text-xs leading-relaxed text-white/60">
We only use strictly-necessary cookies (sign-in and security) plus cookieless,
privacy-friendly analytics. Choose &ldquo;Essential only&rdquo; to opt out of analytics.
Details in our{" "}
<Link href="/cookie-policy" className="text-indigo-400 underline underline-offset-2 hover:text-indigo-300">
Cookie Policy
</Link>
.
</p>
</div>
<div className="flex shrink-0 items-center gap-2 sm:flex-col md:flex-row">
<button
type="button"
onClick={() => choose(true)}
className="flex-1 whitespace-nowrap rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98] sm:w-full"
>
Accept all
</button>
<button
type="button"
onClick={() => choose(false)}
className="flex-1 whitespace-nowrap rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-xs font-semibold text-white/70 transition hover:bg-white/10 sm:w-full"
>
Essential only
</button>
</div>
</div>
</div>
)
}
+2 -2
View File
@@ -1,12 +1,12 @@
// DigitalOcean Function invoked by scheduler triggers (see functions/project.yml). // DigitalOcean Function invoked by scheduler triggers (see functions/project.yml).
// Calls the app's protected cron endpoint (`daily`, `late-fees`, `follow-ups`, // Calls the app's protected cron endpoint (`daily`, `late-fees`, `follow-ups`,
// or `webhooks`, chosen by the trigger body) with the CRON_SECRET bearer token. // `webhooks`, or `gdpr`, chosen by the trigger body) with the CRON_SECRET bearer token.
// nodejs:18 has global fetch. // nodejs:18 has global fetch.
async function main(args) { async function main(args) {
const base = (process.env.APP_BASE_URL || "").replace(/\/+$/, "") const base = (process.env.APP_BASE_URL || "").replace(/\/+$/, "")
const secret = process.env.CRON_SECRET const secret = process.env.CRON_SECRET
const requested = args && args.job const requested = args && args.job
const allowed = ["daily", "late-fees", "follow-ups", "webhooks"] const allowed = ["daily", "late-fees", "follow-ups", "webhooks", "gdpr"]
const job = allowed.includes(requested) ? requested : "daily" const job = allowed.includes(requested) ? requested : "daily"
if (!base || !secret) { if (!base || !secret) {
+8
View File
@@ -54,3 +54,11 @@ triggers:
withBody: withBody:
job: webhooks job: webhooks
function: cron/run function: cron/run
# GDPR: execute account deletions whose 30-day grace period elapsed — 07:00 UTC.
- name: gdpr
sourceType: scheduler
sourceDetails:
cron: '0 7 * * *'
withBody:
job: gdpr
function: cron/run
+35 -1
View File
@@ -3,8 +3,9 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { nextCookies } from "better-auth/next-js" import { nextCookies } from "better-auth/next-js"
import { admin } from "better-auth/plugins" import { admin } from "better-auth/plugins"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { user, session, account, verification, profiles } from "@/lib/db/schema" import { user, session, account, verification, profiles, consent_log } from "@/lib/db/schema"
import { sendEmail, resetPasswordHtml, verifyEmailHtml } from "@/lib/email/send" import { sendEmail, resetPasswordHtml, verifyEmailHtml } from "@/lib/email/send"
import { LEGAL } from "@/lib/legal"
// Bootstrap superadmins from env — no API path lets a user self-promote. // Bootstrap superadmins from env — no API path lets a user self-promote.
const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "") const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "")
@@ -80,6 +81,30 @@ export const auth = betterAuth({
} catch { } catch {
// Never block sign-up on profile creation. // Never block sign-up on profile creation.
} }
// GDPR proof of acceptance: the signup form states that creating an
// account means agreeing to the Terms and Privacy Policy.
try {
await db.insert(consent_log).values([
{
user_id: u.id,
email: u.email,
kind: "terms" as const,
granted: true,
policy_version: LEGAL.lastUpdated,
source: "signup",
},
{
user_id: u.id,
email: u.email,
kind: "privacy" as const,
granted: true,
policy_version: LEGAL.lastUpdated,
source: "signup",
},
])
} catch {
// Never block sign-up on consent logging.
}
}, },
}, },
}, },
@@ -91,3 +116,12 @@ export const auth = betterAuth({
], ],
}) })
/**
* Whether Google OAuth is configured. The auth pages hide the "Continue with
* Google" button unless BOTH credentials are present, so users never see a
* social option that can't complete. Mirrors socialProviders.google above.
*/
export function isGoogleConfigured(): boolean {
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET)
}
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE "account_deletion_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"email" text,
"status" text DEFAULT 'pending' NOT NULL,
"reason" text,
"scheduled_for" timestamp with time zone NOT NULL,
"cancelled_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "consent_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text,
"email" text,
"kind" text NOT NULL,
"granted" boolean NOT NULL,
"policy_version" text,
"source" text,
"ip_address" text,
"user_agent" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "account_deletion_requests_pending_user_idx" ON "account_deletion_requests" USING btree ("user_id") WHERE status = 'pending';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -78,6 +78,13 @@
"when": 1783017593260, "when": 1783017593260,
"tag": "0010_esign_connections", "tag": "0010_esign_connections",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1783071567630,
"tag": "0011_gdpr",
"breakpoints": true
} }
] ]
} }
+53
View File
@@ -11,6 +11,7 @@ import {
date, date,
jsonb, jsonb,
doublePrecision, doublePrecision,
uniqueIndex,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
// ============================================================ // ============================================================
@@ -715,6 +716,58 @@ export const webhook_deliveries = pgTable("webhook_deliveries", {
updated_at: updatedAt(), updated_at: updatedAt(),
}) })
// ============================================================
// ACCOUNT DELETION REQUESTS (GDPR right to erasure)
// ============================================================
// A user's self-service "delete my account" request. Deletion is deferred by a
// grace period (LEGAL.dataDeletionDays) during which the user can cancel; the
// gdpr cron then hard-deletes the account, its data, and its stored files.
// user_id is intentionally NOT a cascading FK — the completed request must
// survive the user's deletion as evidence the DSAR was honored. `email` is
// kept only while the request is pending (to notify) and nulled on completion.
export const account_deletion_requests = pgTable(
"account_deletion_requests",
{
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id").notNull(),
email: text("email"),
status: text("status").$type<"pending" | "cancelled" | "completed">().notNull().default("pending"),
reason: text("reason"),
scheduled_for: tstz("scheduled_for").notNull(),
cancelled_at: tstz("cancelled_at"),
completed_at: tstz("completed_at"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
created_at: createdAt(),
updated_at: updatedAt(),
},
(t) => [
// At most ONE open request per user — the request/cancel flow relies on this.
uniqueIndex("account_deletion_requests_pending_user_idx")
.on(t.user_id)
.where(sql`status = 'pending'`),
]
)
// ============================================================
// CONSENT LOG (GDPR proof of consent / acceptance)
// ============================================================
// Records when a person accepted the Terms/Privacy Policy (at signup) or made a
// cookie/marketing consent choice. user_id has no FK so the record survives
// account deletion as compliance evidence; identifying fields (email, ip) are
// anonymized by the deletion flow.
export const consent_log = pgTable("consent_log", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id"),
email: text("email"),
kind: text("kind").$type<"terms" | "privacy" | "cookies" | "marketing">().notNull(),
granted: boolean("granted").notNull(),
policy_version: text("policy_version"),
source: text("source"),
ip_address: text("ip_address"),
user_agent: text("user_agent"),
created_at: createdAt(),
})
// ============================================================ // ============================================================
// RELATIONS (for Drizzle relational queries) // RELATIONS (for Drizzle relational queries)
// ============================================================ // ============================================================
+45
View File
@@ -221,6 +221,51 @@ export function teamInviteHtml({
}) })
} }
export function accountDeletionRequestedHtml({
name,
scheduledDate,
graceDays,
}: {
name: string
scheduledDate: string
graceDays: number
}) {
return emailShell({
preheader: `Your account is scheduled for permanent deletion on ${scheduledDate}.`,
eyebrow: "Account deletion",
accent: BRAND.red,
title: "Your account deletion is scheduled",
intro: `Hi ${escapeHtml(name)}, we received your request to delete your account and all associated data.`,
body:
detailTable(
[
{ label: "Deletion date", value: scheduledDate, accent: true },
{ label: "Grace period", value: `${graceDays} days` },
],
BRAND.red
) +
paragraph(
"Until then your account stays fully usable, and you can cancel the deletion at any time from Settings → Privacy & Data. After the deletion date, ALL your properties, tenants, payments, documents, and uploaded files are permanently erased — this cannot be undone."
),
footerNote:
"If you did not request this, sign in and cancel the deletion immediately, then change your password.",
})
}
export function accountDeletionCompletedHtml() {
return emailShell({
preheader: "Your account and personal data have been permanently deleted.",
eyebrow: "Account deletion",
title: "Your account has been deleted",
intro:
"As requested, your account and the personal data associated with it have been permanently deleted from our systems.",
body: paragraph(
"Financial records we are legally required to retain (for example, invoices held by our payment processor) are kept only for as long as the law requires. Everything else — your properties, tenants, documents, and uploaded files — is gone."
),
footerNote: "Thanks for having used Property Management Network. You're welcome back anytime.",
})
}
export function followUpHtml(message: string) { export function followUpHtml(message: string) {
return emailShell({ return emailShell({
preheader: message.slice(0, 140), preheader: message.slice(0, 140),
+189
View File
@@ -0,0 +1,189 @@
import { and, eq, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import {
user as userTable,
profiles,
verification,
consent_log,
admin_audit_log,
account_deletion_requests,
} from "@/lib/db/schema"
import { deleteUserStorage } from "@/lib/storage"
import { stripe } from "@/lib/stripe/client"
import { sendEmail, accountDeletionCompletedHtml } from "@/lib/email/send"
// ============================================================================
// GDPR account deletion (Article 17 — right to erasure).
//
// The DB schema does most of the cascading for us: every data table references
// profiles(id) ON DELETE CASCADE, and profiles references user(id) ON DELETE
// CASCADE — so deleting the auth user row erases the entire portfolio,
// sessions, and linked accounts in one statement. What the cascade CANNOT
// reach lives here: uploaded files in object storage, the Stripe
// subscription/customer, email-keyed verification rows, and PII embedded in
// the FK-less compliance tables (consent_log).
// ============================================================================
export type DeletionOutcome = {
userId: string
filesDeleted: number
stripeSubscription: "cancelled" | "none" | "error"
stripeCustomer: "deleted" | "none" | "error"
/** PayPal has no server-side cancel integration — surfaced so ops can follow up. */
paypalSubscriptionLeftActive: string | null
userRowDeleted: boolean
}
/**
* Irreversibly delete a user's account, data, files, and billing. Safe to call
* for an already-deleted user (it still purges storage and returns cleanly).
*/
export async function executeAccountDeletion(userId: string): Promise<DeletionOutcome> {
const outcome: DeletionOutcome = {
userId,
filesDeleted: 0,
stripeSubscription: "none",
stripeCustomer: "none",
paypalSubscriptionLeftActive: null,
userRowDeleted: false,
}
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, userId),
columns: {
email: true,
stripe_subscription_id: true,
stripe_customer_id: true,
paypal_subscription_id: true,
},
})
// 1. Billing — stop money moving before the data disappears.
if (profile?.stripe_subscription_id) {
try {
await stripe.subscriptions.cancel(profile.stripe_subscription_id)
outcome.stripeSubscription = "cancelled"
} catch (e) {
// Already-cancelled subscriptions throw; treat "not found"-style errors as done.
outcome.stripeSubscription = isStripeGone(e) ? "cancelled" : "error"
}
}
if (profile?.stripe_customer_id) {
try {
await stripe.customers.del(profile.stripe_customer_id)
outcome.stripeCustomer = "deleted"
} catch (e) {
outcome.stripeCustomer = isStripeGone(e) ? "deleted" : "error"
}
}
if (profile?.paypal_subscription_id) {
outcome.paypalSubscriptionLeftActive = profile.paypal_subscription_id
}
// 2. Stored files (Spaces / local disk) — outside the DB cascade.
try {
outcome.filesDeleted = await deleteUserStorage(userId)
} catch (e) {
console.error(`[gdpr] storage purge failed for ${userId}:`, e)
}
// 3. PII in FK-less compliance tables: keep the consent facts, drop identifiers.
await db
.update(consent_log)
.set({ email: null, ip_address: null, user_agent: null })
.where(eq(consent_log.user_id, userId))
// 4. Email-keyed verification tokens (password reset / email verification).
if (profile?.email) {
await db.delete(verification).where(eq(verification.identifier, profile.email))
}
// 5. The user row — cascades profiles → every portfolio table, sessions, accounts.
const deleted = await db.delete(userTable).where(eq(userTable.id, userId)).returning({ id: userTable.id })
outcome.userRowDeleted = deleted.length > 0
// Close out any open self-service request (covers admin-initiated deletes too).
await db
.update(account_deletion_requests)
.set({ status: "completed", completed_at: new Date().toISOString(), email: null })
.where(
and(
eq(account_deletion_requests.user_id, userId),
eq(account_deletion_requests.status, "pending")
)
)
// 6. Immutable evidence that the erasure ran (admin_id null = system action).
await db.insert(admin_audit_log).values({
admin_id: null,
action: "gdpr_delete_account",
target_user_id: userId,
metadata: { ...outcome },
})
return outcome
}
function isStripeGone(e: unknown): boolean {
const msg = e instanceof Error ? e.message : String(e)
return /no such|already.*cancel|resource_missing/i.test(msg)
}
/**
* Process deletion requests whose grace period has elapsed. Called by the
* daily gdpr cron. Failures stay `pending` (with the error recorded) so the
* next run retries them.
*/
export async function processDueDeletions(limit = 25): Promise<{ processed: number; deleted: number }> {
const due = await db
.select()
.from(account_deletion_requests)
.where(
and(
eq(account_deletion_requests.status, "pending"),
lte(account_deletion_requests.scheduled_for, new Date().toISOString())
)
)
.limit(limit)
let deleted = 0
for (const request of due) {
const email = request.email
try {
const outcome = await executeAccountDeletion(request.user_id)
await db
.update(account_deletion_requests)
.set({
status: "completed",
completed_at: new Date().toISOString(),
// Data minimization: the request itself must not keep PII after erasure.
email: null,
metadata: { ...request.metadata, outcome },
})
.where(eq(account_deletion_requests.id, request.id))
deleted++
if (email) {
await sendEmail({
to: email,
subject: "Your account and data have been deleted",
html: accountDeletionCompletedHtml(),
})
}
} catch (e) {
console.error(`[gdpr] deletion failed for ${request.user_id}:`, e)
await db
.update(account_deletion_requests)
.set({
metadata: {
...request.metadata,
last_error: e instanceof Error ? e.message : String(e),
last_error_at: new Date().toISOString(),
},
})
.where(eq(account_deletion_requests.id, request.id))
}
}
return { processed: due.length, deleted }
}
+186
View File
@@ -0,0 +1,186 @@
import { desc, eq, or } from "drizzle-orm"
import { db } from "@/lib/db"
import {
user as userTable,
session,
account,
profiles,
properties,
units,
tenants,
rent_payments,
maintenance_requests,
leases,
expenses,
documents,
notifications,
usage_events,
vendors,
inspections,
ai_recommendations,
ai_predictions,
follow_up_rules,
follow_up_log,
activity_log,
admin_audit_log,
account_members,
api_keys,
accounting_connections,
esign_connections,
signature_requests,
webhook_endpoints,
consent_log,
account_deletion_requests,
} from "@/lib/db/schema"
// ============================================================================
// GDPR data export (Articles 15 & 20 — access + portability).
//
// Produces a single machine-readable JSON object containing every record the
// platform stores about a user, EXCLUDING credentials and third-party secrets
// (password hashes, OAuth/API tokens, session tokens). What's excluded is
// declared in `omitted` so the export is honest about its own boundaries.
// ============================================================================
export async function buildUserDataExport(userId: string) {
const [
authUser,
profile,
sessions,
linkedAccounts,
propertyRows,
unitRows,
tenantRows,
paymentRows,
maintenanceRows,
leaseRows,
expenseRows,
documentRows,
vendorRows,
inspectionRows,
] = await Promise.all([
db.query.user.findFirst({
where: eq(userTable.id, userId),
columns: { id: true, name: true, email: true, emailVerified: true, image: true, createdAt: true },
}),
db.query.profiles.findFirst({ where: eq(profiles.id, userId) }),
db.query.session.findMany({
where: eq(session.userId, userId),
columns: { token: false }, // active credential — never exported
orderBy: desc(session.createdAt),
}),
db.query.account.findMany({
where: eq(account.userId, userId),
columns: { id: true, providerId: true, accountId: true, scope: true, createdAt: true },
}),
db.query.properties.findMany({ where: eq(properties.user_id, userId) }),
db.query.units.findMany({ where: eq(units.user_id, userId) }),
db.query.tenants.findMany({ where: eq(tenants.user_id, userId) }),
db.query.rent_payments.findMany({ where: eq(rent_payments.user_id, userId) }),
db.query.maintenance_requests.findMany({ where: eq(maintenance_requests.user_id, userId) }),
db.query.leases.findMany({ where: eq(leases.user_id, userId) }),
db.query.expenses.findMany({ where: eq(expenses.user_id, userId) }),
db.query.documents.findMany({ where: eq(documents.user_id, userId) }),
db.query.vendors.findMany({ where: eq(vendors.user_id, userId) }),
db.query.inspections.findMany({ where: eq(inspections.user_id, userId) }),
])
const [
notificationRows,
usageRows,
activityRows,
recommendationRows,
predictionRows,
followUpRuleRows,
followUpLogRows,
apiKeyRows,
webhookRows,
accountingRows,
esignRows,
signatureRows,
teamRows,
adminActionsOnUser,
consentRows,
deletionRows,
] = await Promise.all([
db.query.notifications.findMany({ where: eq(notifications.user_id, userId) }),
db.query.usage_events.findMany({ where: eq(usage_events.user_id, userId) }),
db.query.activity_log.findMany({ where: eq(activity_log.user_id, userId) }),
db.query.ai_recommendations.findMany({ where: eq(ai_recommendations.user_id, userId) }),
db.query.ai_predictions.findMany({ where: eq(ai_predictions.user_id, userId) }),
db.query.follow_up_rules.findMany({ where: eq(follow_up_rules.user_id, userId) }),
db.query.follow_up_log.findMany({ where: eq(follow_up_log.user_id, userId) }),
db.query.api_keys.findMany({
where: eq(api_keys.user_id, userId),
columns: { key_hash: false },
}),
db.query.webhook_endpoints.findMany({ where: eq(webhook_endpoints.user_id, userId) }),
db.query.accounting_connections.findMany({
where: eq(accounting_connections.user_id, userId),
columns: { access_token: false, refresh_token: false },
}),
db.query.esign_connections.findMany({
where: eq(esign_connections.user_id, userId),
columns: { access_token: false, refresh_token: false },
}),
db.query.signature_requests.findMany({ where: eq(signature_requests.user_id, userId) }),
db.query.account_members.findMany({
where: or(eq(account_members.owner_id, userId), eq(account_members.member_id, userId)),
columns: { invite_token: false },
}),
db.query.admin_audit_log.findMany({
where: eq(admin_audit_log.target_user_id, userId),
columns: { action: true, created_at: true },
}),
db.query.consent_log.findMany({ where: eq(consent_log.user_id, userId) }),
db.query.account_deletion_requests.findMany({
where: eq(account_deletion_requests.user_id, userId),
}),
])
return {
format: "propertymanagement.network/data-export",
version: 1,
generated_at: new Date().toISOString(),
omitted: [
"password hashes, OAuth/refresh tokens, session tokens, and API-key hashes (credentials are never exported)",
"webhook delivery logs (operational copies of the event records included above)",
"uploaded file BYTES — file metadata is under portfolio.documents; download the files themselves from the Documents page",
],
data_subject: { user: authUser ?? null, profile: profile ?? null },
security: { sessions, linked_sign_in_providers: linkedAccounts },
portfolio: {
properties: propertyRows,
units: unitRows,
tenants: tenantRows,
rent_payments: paymentRows,
maintenance_requests: maintenanceRows,
leases: leaseRows,
expenses: expenseRows,
documents: documentRows,
vendors: vendorRows,
inspections: inspectionRows,
},
communications: { notifications: notificationRows, follow_up_log: followUpLogRows },
automation: {
follow_up_rules: followUpRuleRows,
webhook_endpoints: webhookRows,
api_keys: apiKeyRows,
},
ai: { recommendations: recommendationRows, predictions: predictionRows },
integrations: {
accounting_connections: accountingRows,
esign_connections: esignRows,
signature_requests: signatureRows,
},
team: { memberships: teamRows },
activity: { activity_log: activityRows, usage_events: usageRows },
privacy: {
consent_log: consentRows,
deletion_requests: deletionRows,
admin_actions_affecting_you: adminActionsOnUser,
},
}
}
export type UserDataExport = Awaited<ReturnType<typeof buildUserDataExport>>
+30
View File
@@ -319,6 +319,36 @@ export async function deleteFile(key: string, ownerId: string): Promise<void> {
} }
} }
/**
* Permanently delete EVERY stored object in a user's namespace (`<userId>/…`),
* across whichever backend is active. Used by GDPR account deletion — there is
* no undo. Returns the number of objects removed (best effort; local-disk
* removals aren't counted individually).
*/
export async function deleteUserStorage(userId: string): Promise<number> {
const prefix = `${sanitizeSegment(userId)}/`
if (usingSpaces()) {
let deleted = 0
let token: string | undefined
do {
const res = await s3().send(
new ListObjectsV2Command({ Bucket: SPACES_BUCKET, Prefix: prefix, ContinuationToken: token })
)
for (const obj of res.Contents ?? []) {
if (!obj.Key) continue
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: obj.Key }))
deleted++
}
token = res.IsTruncated ? res.NextContinuationToken : undefined
} while (token)
return deleted
}
await fs.rm(path.join(STORAGE_DIR, sanitizeSegment(userId)), { recursive: true, force: true })
return 0
}
async function walkDirSize(dir: string): Promise<number> { async function walkDirSize(dir: string): Promise<number> {
let entries let entries
try { try {
+36 -25
View File
@@ -41,26 +41,46 @@ function sentryIngestOrigin(): string | null {
} }
} }
// Build the per-request Content-Security-Policy. `script-src` carries a // Origin serving the Umami analytics script (script.js) and receiving its event
// per-request nonce instead of 'unsafe-inline'. `style-src` keeps // beacons (POST /api/send). Mirrors the component default so the CSP allows both
// 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline // loading the script AND sending events; stays in sync with NEXT_PUBLIC_UMAMI_SRC
// styles and removing it would break the UI. Next.js reads the nonce from the // when overridden.
// `content-security-policy` request header and applies it to its own inline function umamiOrigin(): string {
// scripts automatically. const src = process.env.NEXT_PUBLIC_UMAMI_SRC || "https://fickanalytics.phluit.net/script.js"
function buildCsp(nonce: string): string { try {
return new URL(src).origin
} catch {
return ""
}
}
// Build the Content-Security-Policy. `script-src` uses 'unsafe-inline' because
// Next.js 16's Turbopack build does NOT stamp a per-request nonce onto its
// inline hydration scripts (`self.__next_f.push(...)`). A nonce-based policy
// therefore blocks those inline scripts and the app never hydrates (blank page).
// `style-src` also keeps 'unsafe-inline' (Radix / Tailwind / framer-motion inject
// inline styles). NOTE: to restore the stricter nonce-based script policy, build
// with webpack (`next build --webpack`) so Next applies the nonce to its scripts.
function buildCsp(): string {
const isDev = process.env.NODE_ENV !== "production" const isDev = process.env.NODE_ENV !== "production"
const sentry = sentryIngestOrigin() const sentry = sentryIngestOrigin()
// In development, Next.js/React and Turbopack HMR require eval() for hot // Dev additionally needs 'unsafe-eval' (Turbopack HMR) plus a dev websocket
// reloading and debugging features, and open a dev websocket. These are NOT // (added to connect-src below).
// added in production, where the nonce-based policy stays strict. const umami = umamiOrigin()
const scriptSrc = isDev const scriptSrc = [
? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com` "script-src 'self' 'unsafe-inline'",
: `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com` isDev ? "'unsafe-eval'" : "",
"https://challenges.cloudflare.com",
umami, // load the Umami analytics script
]
.filter(Boolean)
.join(" ")
const connectSrc = [ const connectSrc = [
"connect-src 'self'", "connect-src 'self'",
isDev ? "ws: wss:" : "", isDev ? "ws: wss:" : "",
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com", "https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
umami, // Umami event beacons (POST /api/send)
sentry ?? "", sentry ?? "",
] ]
.filter(Boolean) .filter(Boolean)
@@ -135,19 +155,10 @@ export async function proxy(request: NextRequest) {
dropStaleSessionCookie = state === "invalid" dropStaleSessionCookie = state === "invalid"
} }
// Per-request CSP nonce. UUID contains only hex + dashes, so it never const csp = buildCsp()
// includes HTML-escape characters (which Next rejects in nonces).
const nonce = crypto.randomUUID()
const csp = buildCsp(nonce)
// Forward the nonce + CSP on the request headers so Next.js can pick up the const response = NextResponse.next()
// nonce and apply it to its own inline scripts during render. // Set the CSP on the outgoing response so the browser enforces it.
const requestHeaders = new Headers(request.headers)
requestHeaders.set("x-nonce", nonce)
requestHeaders.set("Content-Security-Policy", csp)
const response = NextResponse.next({ request: { headers: requestHeaders } })
// Also set the CSP on the outgoing response so the browser enforces it.
response.headers.set("Content-Security-Policy", csp) response.headers.set("Content-Security-Policy", csp)
if (dropStaleSessionCookie) { if (dropStaleSessionCookie) {
+130
View File
@@ -0,0 +1,130 @@
/**
* Exercises the REAL GDPR data-export and account-deletion paths against the
* live (dev) DB to prove the system works end-to-end: seeds a throwaway user
* with portfolio data + a stored file, exports, then runs the deletion drain
* and asserts everything is gone (and the compliance evidence remains).
* Run: npx tsx scripts/verify-gdpr.ts
*/
import { config } from "dotenv"
config({ path: ".env.local" })
process.env.DATABASE_SSL = process.env.DATABASE_SSL ?? "disable"
const TEST_USER_ID = "gdpr-verify-user"
const TEST_EMAIL = "gdpr-verify@example.com"
let failures = 0
function check(label: string, ok: boolean, detail?: string) {
console.log(` ${ok ? "✓" : "✗"} ${label}${!ok && detail ? `${detail}` : ""}`)
if (!ok) failures++
}
async function main() {
const { db, pool } = await import("../lib/db")
const s = await import("../lib/db/schema")
const { eq, and } = await import("drizzle-orm")
const storage = await import("../lib/storage")
const { buildUserDataExport } = await import("../lib/gdpr/export")
const { processDueDeletions } = await import("../lib/gdpr/delete")
// ── cleanup any prior run ──────────────────────────────────────────────────
await db.delete(s.user).where(eq(s.user.id, TEST_USER_ID))
await db.delete(s.consent_log).where(eq(s.consent_log.user_id, TEST_USER_ID))
await db.delete(s.account_deletion_requests).where(eq(s.account_deletion_requests.user_id, TEST_USER_ID))
await db.delete(s.admin_audit_log).where(eq(s.admin_audit_log.target_user_id, TEST_USER_ID))
// ── seed ───────────────────────────────────────────────────────────────────
console.log("── Seeding throwaway user + portfolio ───")
await db.insert(s.user).values({ id: TEST_USER_ID, name: "GDPR Verify", email: TEST_EMAIL })
await db.insert(s.profiles).values({ id: TEST_USER_ID, email: TEST_EMAIL, full_name: "GDPR Verify" })
const [prop] = await db
.insert(s.properties)
.values({ user_id: TEST_USER_ID, name: "Test House", address_line1: "1 Test St", city: "Testville" })
.returning()
const [tenant] = await db
.insert(s.tenants)
.values({ user_id: TEST_USER_ID, property_id: prop.id, first_name: "Tina", last_name: "Tenant", email: "tina@example.com" })
.returning()
await db.insert(s.rent_payments).values({
user_id: TEST_USER_ID, tenant_id: tenant.id, property_id: prop.id, amount: 1200, due_date: "2026-07-01",
})
await db.insert(s.api_keys).values({
user_id: TEST_USER_ID, name: "test key", key_hash: `hash-${Date.now()}`, key_prefix: "pmn_test",
})
await db.insert(s.consent_log).values([
{ user_id: TEST_USER_ID, email: TEST_EMAIL, kind: "terms", granted: true, source: "signup" },
{ user_id: TEST_USER_ID, email: TEST_EMAIL, kind: "privacy", granted: true, source: "signup" },
])
const { key: fileKey } = await storage.saveBuffer(Buffer.from("gdpr verify payload"), {
userId: TEST_USER_ID, scope: "verify", ext: "txt",
})
console.log(` seeded property=${prop.id.slice(0, 8)} tenant=${tenant.id.slice(0, 8)} file=${fileKey}`)
// ── export ─────────────────────────────────────────────────────────────────
console.log("── Data export (Articles 15/20) ─────────")
const exp = await buildUserDataExport(TEST_USER_ID)
check("subject user present", exp.data_subject.user?.email === TEST_EMAIL)
check("profile present", exp.data_subject.profile?.id === TEST_USER_ID)
check("property exported", exp.portfolio.properties.length === 1)
check("tenant exported", exp.portfolio.tenants.length === 1)
check("payment exported", exp.portfolio.rent_payments.length === 1)
check("consent history exported", exp.privacy.consent_log.length === 2)
check("api key exported WITHOUT hash",
exp.automation.api_keys.length === 1 && !("key_hash" in exp.automation.api_keys[0]))
const serialized = JSON.stringify(exp)
check("no key_hash anywhere in export", !serialized.includes("key_hash"))
check("no password field anywhere in export", !serialized.includes('"password"'))
// ── deletion drain ─────────────────────────────────────────────────────────
console.log("── Deletion (Article 17) ────────────────")
await db.insert(s.account_deletion_requests).values({
user_id: TEST_USER_ID,
email: null, // null → no completion email attempt from the drain
scheduled_for: new Date(Date.now() - 60_000).toISOString(), // due 1 min ago
})
const { processed, deleted } = await processDueDeletions(50)
check("drain picked up the request", processed >= 1)
check("drain deleted the account", deleted >= 1)
const userAfter = await db.query.user.findFirst({ where: eq(s.user.id, TEST_USER_ID) })
check("user row deleted", !userAfter)
const profileAfter = await db.query.profiles.findFirst({ where: eq(s.profiles.id, TEST_USER_ID) })
check("profile cascaded", !profileAfter)
const propsAfter = await db.query.properties.findMany({ where: eq(s.properties.user_id, TEST_USER_ID) })
check("properties cascaded", propsAfter.length === 0)
const tenantsAfter = await db.query.tenants.findMany({ where: eq(s.tenants.user_id, TEST_USER_ID) })
check("tenants cascaded", tenantsAfter.length === 0)
const keysAfter = await db.query.api_keys.findMany({ where: eq(s.api_keys.user_id, TEST_USER_ID) })
check("api keys cascaded", keysAfter.length === 0)
const request = await db.query.account_deletion_requests.findFirst({
where: eq(s.account_deletion_requests.user_id, TEST_USER_ID),
})
check("request marked completed", request?.status === "completed")
check("request email nulled", request?.email === null)
const consents = await db.query.consent_log.findMany({ where: eq(s.consent_log.user_id, TEST_USER_ID) })
check("consent facts retained", consents.length === 2)
check("consent PII anonymized", consents.every((c) => c.email === null && c.ip_address === null))
const audit = await db.query.admin_audit_log.findFirst({
where: and(eq(s.admin_audit_log.target_user_id, TEST_USER_ID), eq(s.admin_audit_log.action, "gdpr_delete_account")),
})
check("audit evidence written (system action, admin_id null)", !!audit && audit.admin_id === null)
const bytesAfter = await storage.getUserStorageBytes(TEST_USER_ID)
check("stored files purged", bytesAfter === 0, `${bytesAfter} bytes remain`)
// ── cleanup compliance rows from the test run ──────────────────────────────
await db.delete(s.consent_log).where(eq(s.consent_log.user_id, TEST_USER_ID))
await db.delete(s.account_deletion_requests).where(eq(s.account_deletion_requests.user_id, TEST_USER_ID))
await db.delete(s.admin_audit_log).where(eq(s.admin_audit_log.target_user_id, TEST_USER_ID))
console.log(failures === 0 ? "\nALL CHECKS PASSED ✓" : `\n${failures} CHECK(S) FAILED ✗`)
await pool.end()
process.exit(failures === 0 ? 0 : 1)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})