Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a555c715e | ||
|
|
0d11018019 |
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { account_deletion_requests } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { LEGAL } from "@/lib/legal"
|
||||
import { PrivacyManager } from "@/components/dashboard/privacy-manager"
|
||||
|
||||
export const metadata = { title: "Privacy & Data" }
|
||||
|
||||
export default async function PrivacySettingsPage() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const pending = await db.query.account_deletion_requests.findFirst({
|
||||
where: and(
|
||||
eq(account_deletion_requests.user_id, user.id),
|
||||
eq(account_deletion_requests.status, "pending")
|
||||
),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Privacy & Data</h2>
|
||||
<p className="text-sm text-white/40">
|
||||
Exercise your data rights under the GDPR — export a copy of your data or delete your
|
||||
account. Details are in our{" "}
|
||||
<Link
|
||||
href="/gdpr"
|
||||
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
|
||||
>
|
||||
GDPR & Data Rights
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>{" "}
|
||||
pages.
|
||||
</p>
|
||||
</div>
|
||||
<PrivacyManager
|
||||
accountEmail={user.email}
|
||||
graceDays={LEGAL.dataDeletionDays}
|
||||
pendingDeletion={
|
||||
pending ? { scheduled_for: pending.scheduled_for, created_at: pending.created_at } : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -134,7 +134,27 @@ export default function GdprPage() {
|
||||
|
||||
<Section id="exercise" heading="8. How to exercise your rights">
|
||||
<p>
|
||||
To exercise any of the rights described above, contact us at{" "}
|
||||
You can exercise the most common rights yourself, instantly, from{" "}
|
||||
<strong>Settings → Privacy & Data</strong> in your dashboard:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Access & portability</strong> — download a complete,
|
||||
machine-readable JSON export of your personal data and portfolio records.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Erasure</strong> — delete your account. Deletion is scheduled{" "}
|
||||
{LEGAL.dataDeletionDays} days out (during which you can cancel), after which your
|
||||
account, data, and uploaded files are permanently erased and any active
|
||||
subscription is cancelled.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Rectification</strong> — correct your details at any time in{" "}
|
||||
<strong>Settings → Profile</strong>.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
For any other request, contact us at{" "}
|
||||
<a href={`mailto:${LEGAL.privacyEmail}`}>{LEGAL.privacyEmail}</a>. For
|
||||
data-protection matters, you may also contact our data-protection team at{" "}
|
||||
<a href={`mailto:${LEGAL.dpoEmail}`}>{LEGAL.dpoEmail}</a>. You also have the right
|
||||
|
||||
@@ -12,6 +12,7 @@ import { setAiProvider, type AiProvider } from "@/lib/ai/provider"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db"
|
||||
import { profiles, user as userTable } from "@/lib/db/schema"
|
||||
import { executeAccountDeletion } from "@/lib/gdpr/delete"
|
||||
|
||||
// ── gate ────────────────────────────────────────────────────────────────────
|
||||
// Every server action re-verifies the caller is an admin. NEVER skip — these
|
||||
@@ -109,15 +110,15 @@ export async function deleteUser(userId: string) {
|
||||
const a = await guard()
|
||||
if (userId === a.user.id) throw new Error("You cannot delete yourself")
|
||||
|
||||
await auth.api.removeUser({
|
||||
body: { userId },
|
||||
headers: await headers(),
|
||||
})
|
||||
// Full GDPR-grade purge: cancels Stripe billing, deletes stored files, and
|
||||
// removes the user row (FK cascade erases the whole portfolio + sessions).
|
||||
const outcome = await executeAccountDeletion(userId)
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "delete_user",
|
||||
targetUserId: userId,
|
||||
metadata: { ...outcome },
|
||||
})
|
||||
|
||||
redirect("/admin/users")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { account_deletion_requests } from "@/lib/db/schema"
|
||||
import { getSessionUser, isAdminUser } from "@/lib/session"
|
||||
import { LEGAL } from "@/lib/legal"
|
||||
import { sendEmail, accountDeletionRequestedHtml } from "@/lib/email/send"
|
||||
|
||||
// ============================================================================
|
||||
// GDPR self-service actions (Settings → Privacy & Data).
|
||||
//
|
||||
// Deletion is a two-step, grace-period flow: the request schedules a hard
|
||||
// delete LEGAL.dataDeletionDays out (the retention window promised on /gdpr);
|
||||
// the gdpr cron executes it. Until then the account stays usable and the user
|
||||
// can cancel. Data export is a GET route (/api/gdpr/export), not an action,
|
||||
// so the browser can download it as a file.
|
||||
// ============================================================================
|
||||
|
||||
const SETTINGS_PATH = "/settings/privacy"
|
||||
|
||||
export type DeletionRequestDTO = {
|
||||
id: string
|
||||
status: "pending" | "cancelled" | "completed"
|
||||
scheduled_for: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function requestAccountDeletion(input: {
|
||||
confirmEmail: string
|
||||
reason?: string
|
||||
}): Promise<DeletionRequestDTO> {
|
||||
const user = await getSessionUser()
|
||||
if (!user) throw new Error("Unauthorized")
|
||||
|
||||
// Admins manage the platform — deleting one from self-service risks locking
|
||||
// everyone out. They can be removed via the admin panel by another admin.
|
||||
if (isAdminUser(user as { id?: string; email?: string; role?: string | null })) {
|
||||
throw new Error("Admin accounts cannot be deleted from self-service. Contact another administrator.")
|
||||
}
|
||||
|
||||
const typed = (input.confirmEmail ?? "").trim().toLowerCase()
|
||||
if (!typed || typed !== user.email.toLowerCase()) {
|
||||
throw new Error("The email you typed doesn't match your account email.")
|
||||
}
|
||||
|
||||
const existing = await db.query.account_deletion_requests.findFirst({
|
||||
where: and(
|
||||
eq(account_deletion_requests.user_id, user.id),
|
||||
eq(account_deletion_requests.status, "pending")
|
||||
),
|
||||
})
|
||||
if (existing) throw new Error("Your account is already scheduled for deletion.")
|
||||
|
||||
const scheduledFor = new Date(Date.now() + LEGAL.dataDeletionDays * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
let row: typeof account_deletion_requests.$inferSelect
|
||||
try {
|
||||
;[row] = await db
|
||||
.insert(account_deletion_requests)
|
||||
.values({
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
reason: (input.reason ?? "").trim().slice(0, 500) || null,
|
||||
scheduled_for: scheduledFor,
|
||||
})
|
||||
.returning()
|
||||
} catch {
|
||||
// The partial unique index makes a double-submit race land here.
|
||||
throw new Error("Your account is already scheduled for deletion.")
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Your account deletion is scheduled",
|
||||
html: accountDeletionRequestedHtml({
|
||||
name: user.name || user.email,
|
||||
scheduledDate: new Date(scheduledFor).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
graceDays: LEGAL.dataDeletionDays,
|
||||
}),
|
||||
})
|
||||
|
||||
revalidatePath(SETTINGS_PATH)
|
||||
return { id: row.id, status: row.status, scheduled_for: row.scheduled_for, created_at: row.created_at }
|
||||
}
|
||||
|
||||
export async function cancelAccountDeletion(): Promise<void> {
|
||||
const user = await getSessionUser()
|
||||
if (!user) throw new Error("Unauthorized")
|
||||
|
||||
const [row] = await db
|
||||
.update(account_deletion_requests)
|
||||
.set({ status: "cancelled", cancelled_at: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(account_deletion_requests.user_id, user.id),
|
||||
eq(account_deletion_requests.status, "pending")
|
||||
)
|
||||
)
|
||||
.returning({ id: account_deletion_requests.id })
|
||||
if (!row) throw new Error("No pending deletion request found.")
|
||||
|
||||
revalidatePath(SETTINGS_PATH)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { isAuthorizedCron } from "@/lib/cron-auth"
|
||||
import { processDueDeletions } from "@/lib/gdpr/delete"
|
||||
|
||||
// GDPR deletion drain: hard-deletes accounts whose grace period
|
||||
// (LEGAL.dataDeletionDays after the request) has elapsed. Scheduled daily via
|
||||
// DigitalOcean Functions — see functions/project.yml.
|
||||
export async function GET(request: Request) {
|
||||
if (!isAuthorizedCron(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { processed, deleted } = await processDueDeletions(25)
|
||||
return NextResponse.json({ processed, deleted })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { headers } from "next/headers"
|
||||
import { db } from "@/lib/db"
|
||||
import { consent_log } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { LEGAL } from "@/lib/legal"
|
||||
|
||||
// Records a cookie-consent choice from the banner. Only signed-in users are
|
||||
// logged — anonymous visitors keep their choice in localStorage only, so this
|
||||
// endpoint can't be used to spam the consent table.
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return new NextResponse(null, { status: 204 })
|
||||
|
||||
let analytics = false
|
||||
try {
|
||||
const body = await request.json()
|
||||
analytics = body?.analytics === true
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const h = await headers()
|
||||
await db.insert(consent_log).values({
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
kind: "cookies",
|
||||
granted: analytics,
|
||||
policy_version: LEGAL.lastUpdated,
|
||||
source: "cookie-banner",
|
||||
ip_address: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip"),
|
||||
user_agent: h.get("user-agent"),
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { db } from "@/lib/db"
|
||||
import { usage_events } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { buildUserDataExport } from "@/lib/gdpr/export"
|
||||
|
||||
// GDPR data export (Articles 15/20) — downloads everything the platform stores
|
||||
// about the signed-in user as a single JSON file. Sensitive credentials are
|
||||
// excluded by the builder (see lib/gdpr/export.ts).
|
||||
export async function GET() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const data = await buildUserDataExport(user.id)
|
||||
|
||||
// DSAR evidence: record that the export was served.
|
||||
await db.insert(usage_events).values({ user_id: user.id, event_type: "gdpr_data_export" })
|
||||
|
||||
const filename = `pmn-data-export-${new Date().toISOString().slice(0, 10)}.json`
|
||||
return new NextResponse(JSON.stringify(data, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono } from "next/font/google"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { UmamiAnalytics } from "@/components/analytics/umami"
|
||||
import { CookieConsent } from "@/components/shared/cookie-consent"
|
||||
import "./globals.css"
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -70,6 +71,7 @@ export default function RootLayout({
|
||||
<body suppressHydrationWarning className="min-h-full flex flex-col bg-[#09090b] text-white">
|
||||
{children}
|
||||
<Toaster />
|
||||
<CookieConsent />
|
||||
<UmamiAnalytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,6 +16,7 @@ const pageTitles: Record<string, string> = {
|
||||
"/expenses": "Expenses",
|
||||
"/settings/profile": "Settings",
|
||||
"/settings/billing": "Billing",
|
||||
"/settings/privacy": "Privacy & Data",
|
||||
"/settings/demo": "Demo Data",
|
||||
"/ai": "AI Assistant",
|
||||
"/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,
|
||||
Wrench, FileText, Receipt, Settings, LogOut,
|
||||
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"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Logo, LogoMark } from "@/components/shared/logo"
|
||||
@@ -245,6 +245,28 @@ function NavContent({
|
||||
{!collapsed && "Webhooks"}
|
||||
</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") && (
|
||||
<>
|
||||
<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).
|
||||
// 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.
|
||||
async function main(args) {
|
||||
const base = (process.env.APP_BASE_URL || "").replace(/\/+$/, "")
|
||||
const secret = process.env.CRON_SECRET
|
||||
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"
|
||||
|
||||
if (!base || !secret) {
|
||||
|
||||
@@ -54,3 +54,11 @@ triggers:
|
||||
withBody:
|
||||
job: webhooks
|
||||
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
|
||||
|
||||
+26
-1
@@ -3,8 +3,9 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
import { nextCookies } from "better-auth/next-js"
|
||||
import { admin } from "better-auth/plugins"
|
||||
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 { LEGAL } from "@/lib/legal"
|
||||
|
||||
// Bootstrap superadmins from env — no API path lets a user self-promote.
|
||||
const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "")
|
||||
@@ -80,6 +81,30 @@ export const auth = betterAuth({
|
||||
} catch {
|
||||
// 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.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
"tag": "0010_esign_connections",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1783071567630,
|
||||
"tag": "0011_gdpr",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
date,
|
||||
jsonb,
|
||||
doublePrecision,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
// ============================================================
|
||||
@@ -715,6 +716,58 @@ export const webhook_deliveries = pgTable("webhook_deliveries", {
|
||||
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)
|
||||
// ============================================================
|
||||
|
||||
@@ -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) {
|
||||
return emailShell({
|
||||
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> {
|
||||
let entries
|
||||
try {
|
||||
|
||||
@@ -41,22 +41,22 @@ function sentryIngestOrigin(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Build the per-request Content-Security-Policy. `script-src` carries a
|
||||
// per-request nonce instead of 'unsafe-inline'. `style-src` keeps
|
||||
// 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline
|
||||
// styles and removing it would break the UI. Next.js reads the nonce from the
|
||||
// `content-security-policy` request header and applies it to its own inline
|
||||
// scripts automatically.
|
||||
function buildCsp(nonce: string): string {
|
||||
// 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 sentry = sentryIngestOrigin()
|
||||
|
||||
// In development, Next.js/React and Turbopack HMR require eval() for hot
|
||||
// reloading and debugging features, and open a dev websocket. These are NOT
|
||||
// added in production, where the nonce-based policy stays strict.
|
||||
// Dev additionally needs 'unsafe-eval' (Turbopack HMR) plus a dev websocket
|
||||
// (added to connect-src below).
|
||||
const scriptSrc = isDev
|
||||
? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com`
|
||||
: `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com`
|
||||
? `script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com`
|
||||
: `script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com`
|
||||
const connectSrc = [
|
||||
"connect-src 'self'",
|
||||
isDev ? "ws: wss:" : "",
|
||||
@@ -135,19 +135,10 @@ export async function proxy(request: NextRequest) {
|
||||
dropStaleSessionCookie = state === "invalid"
|
||||
}
|
||||
|
||||
// Per-request CSP nonce. UUID contains only hex + dashes, so it never
|
||||
// includes HTML-escape characters (which Next rejects in nonces).
|
||||
const nonce = crypto.randomUUID()
|
||||
const csp = buildCsp(nonce)
|
||||
const csp = buildCsp()
|
||||
|
||||
// Forward the nonce + CSP on the request headers so Next.js can pick up the
|
||||
// nonce and apply it to its own inline scripts during render.
|
||||
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.
|
||||
const response = NextResponse.next()
|
||||
// Set the CSP on the outgoing response so the browser enforces it.
|
||||
response.headers.set("Content-Security-Policy", csp)
|
||||
|
||||
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