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