From 1d025987864ff417ddc427530d273f5dd0272aaa Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:27:16 -0400 Subject: [PATCH] chore: sync in-progress work across marketing, admin, API and tests Snapshot of uncommitted work that had accumulated in the tree alongside the Turnstile changes: - marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and structured data - admin billing actions and a per-user portfolio view, plus an admin error boundary - rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface - CSP and proxy adjustments, accounting/webhook lib updates - Playwright config and an e2e/unit test suite - next bumped to ^16.3.4 with the lockfile regenerated - generated AGENTS.md / CLAUDE.md Authored by other sessions working in this tree; committed here so the Turnstile work could be pushed without leaving the tree dirty. Typecheck passes. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 7 + .env.production.example | 9 + .gitignore | 6 + AGENTS.md | 9 + CLAUDE.md | 1 + app/(admin)/admin/error.tsx | 57 ++ app/(admin)/admin/users/[id]/page.tsx | 27 + .../admin/users/[id]/portfolio/page.tsx | 293 ++++++ app/(auth)/signup/page.tsx | 12 + app/(marketing)/acceptable-use/page.tsx | 7 +- app/(marketing)/api-docs/page.tsx | 10 +- app/(marketing)/cookie-policy/page.tsx | 10 +- app/(marketing)/disclaimer/page.tsx | 10 +- app/(marketing)/dpa/page.tsx | 7 +- app/(marketing)/gdpr/page.tsx | 7 +- app/(marketing)/layout.tsx | 4 +- app/(marketing)/page.tsx | 22 +- app/(marketing)/privacy/page.tsx | 10 +- app/(marketing)/refund-policy/page.tsx | 9 +- app/(marketing)/subprocessors/page.tsx | 7 +- app/(marketing)/tenant-portal-info/page.tsx | 10 +- app/(marketing)/terms/page.tsx | 7 +- app/actions/admin.ts | 143 ++- app/api/ai/predictions/route.ts | 21 +- app/api/ai/recommendations/route.ts | 27 +- app/api/ai/rent-receipt/route.ts | 4 +- app/api/calendar/[token]/route.ts | 7 +- app/api/maintenance/route.ts | 13 +- app/api/properties/route.ts | 17 +- app/api/stripe/webhook/route.ts | 14 +- app/api/tenants/route.ts | 17 +- app/api/upload/route.ts | 6 + app/api/v1/maintenance/[id]/route.ts | 5 + app/api/v1/maintenance/route.ts | 9 + app/api/v1/payments/route.ts | 9 + app/api/v1/properties/route.ts | 18 + app/api/v1/tenants/route.ts | 5 + app/api/v1/webhooks/[id]/route.ts | 13 + app/api/v1/webhooks/route.ts | 9 + app/not-found.tsx | 9 + app/team/accept/[token]/page.tsx | 7 +- components/admin/admin-charts.tsx | 24 +- components/admin/billing-actions.tsx | 256 +++++ components/admin/user-actions.tsx | 375 +++++-- components/marketing/faq.tsx | 131 ++- components/marketing/footer.tsx | 8 +- components/marketing/navbar.tsx | 8 +- components/marketing/structured-data.tsx | 140 +-- lib/accounting/quickbooks.ts | 2 +- lib/accounting/xero.ts | 2 +- lib/admin/audit.ts | 5 + lib/admin/billing.ts | 314 ++++++ lib/crypto.ts | 20 +- lib/db/admin-queries.ts | 101 ++ lib/hooks/use-user.ts | 36 +- lib/marketing/faqs.ts | 40 + lib/plan-limits.ts | 46 +- lib/rate-limit.ts | 107 ++ lib/seo.ts | 71 ++ lib/session.ts | 17 +- lib/turnstile.ts | 15 +- lib/webhooks/ssrf.ts | 76 +- package-lock.json | 944 ++++++++++-------- package.json | 11 +- playwright.config.ts | 52 + proxy.ts | 75 +- tests/e2e/security.spec.ts | 174 ++++ tests/unit/rate-limit.spec.ts | 84 ++ tests/unit/secrets.spec.ts | 136 +++ tests/unit/ssrf.spec.ts | 135 +++ tests/unit/upload-validation.spec.ts | 161 +++ 71 files changed, 3641 insertions(+), 819 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 app/(admin)/admin/error.tsx create mode 100644 app/(admin)/admin/users/[id]/portfolio/page.tsx create mode 100644 components/admin/billing-actions.tsx create mode 100644 lib/admin/billing.ts create mode 100644 lib/marketing/faqs.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/seo.ts create mode 100644 playwright.config.ts create mode 100644 tests/e2e/security.spec.ts create mode 100644 tests/unit/rate-limit.spec.ts create mode 100644 tests/unit/secrets.spec.ts create mode 100644 tests/unit/ssrf.spec.ts create mode 100644 tests/unit/upload-validation.spec.ts diff --git a/.env.example b/.env.example index d15e863..e628ba3 100644 --- a/.env.example +++ b/.env.example @@ -126,3 +126,10 @@ GEOCODER_USER_AGENT=PropertyManagementNetwork/1.0 (https://propertymanagement.ne # Leave both blank to disable the captcha (auth forms still work). NEXT_PUBLIC_TURNSTILE_SITE_KEY= TURNSTILE_SECRET_KEY= + +# === SECRETS AT REST === +# AES-256-GCM key for the OAuth tokens stored for QuickBooks / Xero / DocuSign / +# Dropbox Sign. If unset, the key is derived from BETTER_AUTH_SECRET — which +# means rotating BETTER_AUTH_SECRET would make every stored token undecryptable. +# Set a dedicated value in production so the two can rotate independently. +ACCOUNTING_ENCRYPTION_KEY=replace-with-a-random-string diff --git a/.env.production.example b/.env.production.example index f33a8d4..1142c8e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -135,5 +135,14 @@ CRON_SECRET=replace-with-a-random-string # Create a widget at https://dash.cloudflare.com/?to=/:account/turnstile # NEXT_PUBLIC_TURNSTILE_SITE_KEY is inlined at build time — also set it as a # Build Variable in Coolify. Leave both blank to disable the captcha. +# NOTE: in production a blank TURNSTILE_SECRET_KEY now FAILS CLOSED — auth +# forms are rejected rather than silently losing bot protection. NEXT_PUBLIC_TURNSTILE_SITE_KEY= TURNSTILE_SECRET_KEY= + +# === SECRETS AT REST === +# AES-256-GCM key for the OAuth tokens stored for QuickBooks / Xero / DocuSign / +# Dropbox Sign. If unset, the key is derived from BETTER_AUTH_SECRET — which +# means rotating BETTER_AUTH_SECRET would make every stored token undecryptable. +# Set a dedicated value in production so the two can rotate independently. +ACCOUNTING_ENCRYPTION_KEY=replace-with-a-random-string diff --git a/.gitignore b/.gitignore index 41be56f..1ad5bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,9 @@ next-env.d.ts DOCS/ .env*.local + +# playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/app/(admin)/admin/error.tsx b/app/(admin)/admin/error.tsx new file mode 100644 index 0000000..1a35d51 --- /dev/null +++ b/app/(admin)/admin/error.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useEffect } from "react" +import Link from "next/link" +import { AlertTriangle, RefreshCw, ArrowLeft } from "lucide-react" + +/** + * Error boundary for the admin surface. Without this, a failure in any admin + * page (a Stripe call, an aggregate query) fell through to app/global-error.tsx, + * which replaces the whole document and drops the admin chrome — leaving no way + * back except editing the URL. + * + * Admin pages read across every account, so the message is shown verbatim: the + * audience is staff, and the detail is what makes the failure diagnosable. + */ +export default function AdminError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error("[admin]", error) + }, [error]) + + return ( +
+
+ +
+

Admin page failed to load

+

+ {error.message || "An unexpected error occurred."} +

+ {error.digest && ( +

digest: {error.digest}

+ )} +
+ + + + Back to overview + +
+
+ ) +} diff --git a/app/(admin)/admin/users/[id]/page.tsx b/app/(admin)/admin/users/[id]/page.tsx index b0c82ee..0141585 100644 --- a/app/(admin)/admin/users/[id]/page.tsx +++ b/app/(admin)/admin/users/[id]/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link" import { notFound } from "next/navigation" import { Building2, @@ -11,12 +12,15 @@ import { ShieldAlert, Ban, Activity, + Table2, } from "lucide-react" import { getUserDetail } from "@/lib/db/admin-queries" import { requireAdmin } from "@/lib/session" import { BackButton } from "@/components/ui/back-button" import { CopyButton } from "@/components/shared/copy-button" import { UserActions } from "@/components/admin/user-actions" +import { BillingActions } from "@/components/admin/billing-actions" +import { getSubscriptionSummary, listUserCharges } from "@/lib/admin/billing" import { formatDate, initials, cn } from "@/lib/utils" export const dynamic = "force-dynamic" @@ -49,6 +53,15 @@ export default async function AdminUserDetailPage({ if (!detail) notFound() + // Live billing state, read straight from Stripe rather than the mirrored + // columns — the admin needs the truth, not our cached copy of it. Both helpers + // return empty/null rather than throwing when Stripe is unreachable or unset, + // so the page still renders without billing. + const [subscription, charges] = await Promise.all([ + getSubscriptionSummary(id), + listUserCharges(id, 10), + ]) + const { profile, account, counts, recentActivity } = detail const isSelf = me.id === profile.id const planKey = profile.plan ?? "starter" @@ -107,6 +120,15 @@ export default async function AdminUserDetailPage({ {/* Counts grid */} +
+

Portfolio

+ + View records + +
{COUNT_META.map(({ key, label, icon: Icon }) => (
)}
+ + {/* Payments + refunds */} +
{/* Right: actions */} @@ -207,6 +232,8 @@ export default async function AdminUserDetailPage({ currentPlan={planKey} banned={!!account?.banned} isSelf={isSelf} + isAdminRole={account?.role === "admin"} + subscription={subscription} /> diff --git a/app/(admin)/admin/users/[id]/portfolio/page.tsx b/app/(admin)/admin/users/[id]/portfolio/page.tsx new file mode 100644 index 0000000..0c08440 --- /dev/null +++ b/app/(admin)/admin/users/[id]/portfolio/page.tsx @@ -0,0 +1,293 @@ +import { notFound } from "next/navigation" +import { Building2, Home, Users as UsersIcon, FileText, CreditCard, Wrench } from "lucide-react" +import { getUserDetail, getUserPortfolio } from "@/lib/db/admin-queries" +import { requireAdmin } from "@/lib/session" +import { BackButton } from "@/components/ui/back-button" +import { formatCurrency, formatDate, cn } from "@/lib/utils" + +export const dynamic = "force-dynamic" + +/** + * Read-only support view of one user's actual records. + * + * This exists so an admin can answer "what does this customer actually have?" + * WITHOUT impersonating them — impersonation mutates the user's session and + * lands in their own activity trail, which is a heavy tool for a support lookup. + * Nothing on this page mutates anything. + */ + +const STATUS_TONE: Record = { + active: "bg-emerald-500/10 text-emerald-300", + occupied: "bg-emerald-500/10 text-emerald-300", + paid: "bg-emerald-500/10 text-emerald-300", + vacant: "bg-white/[0.06] text-white/50", + pending: "bg-amber-500/10 text-amber-300", + open: "bg-amber-500/10 text-amber-300", + in_progress: "bg-sky-500/10 text-sky-300", + overdue: "bg-red-500/10 text-red-300", + expired: "bg-red-500/10 text-red-300", +} + +function Pill({ value }: { value: string | null | undefined }) { + if (!value) return + return ( + + {value.replace(/_/g, " ")} + + ) +} + +function Section({ + title, + icon: Icon, + count, + shown, + children, +}: { + title: string + icon: typeof Building2 + count: number + shown: number + children: React.ReactNode +}) { + return ( +
+
+
+ +

{title}

+ ({count}) +
+ {shown < count && ( + showing first {shown} + )} +
+ {count === 0 ? ( +

None.

+ ) : ( +
{children}
+ )} +
+ ) +} + +const TH = "pb-2 text-left text-xs font-medium text-white/40" +const TD = "py-2.5 text-sm text-white/70" + +export default async function AdminUserPortfolioPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + await requireAdmin() + + const [detail, portfolio] = await Promise.all([getUserDetail(id), getUserPortfolio(id)]) + if (!detail) notFound() + + const { profile, counts } = detail + const unitsByProperty = new Map() + for (const u of portfolio.units) { + unitsByProperty.set(u.property_id ?? "", (unitsByProperty.get(u.property_id ?? "") ?? 0) + 1) + } + + return ( +
+ + +
+

Portfolio

+

+ Read-only view of {profile.email}'s records. Nothing here can be edited. +

+
+ +
+ + + + + + + + + + + {portfolio.properties.map((p) => ( + + + + + + + ))} + +
NameAddressUnitsAdded
{p.name} + {[p.address_line1, p.city, p.state].filter(Boolean).join(", ") || "—"} + {unitsByProperty.get(p.id) ?? p.total_units ?? 0}{p.created_at ? formatDate(p.created_at) : "—"}
+
+ +
+ + + + + + + + + + {portfolio.units.map((u) => ( + + + + + + ))} + +
UnitRentStatus
{u.unit_number} + {u.rent_amount != null ? formatCurrency(Number(u.rent_amount)) : "—"} + + +
+
+ +
+ + + + + + + + + + + + {portfolio.tenants.map((t) => ( + + + + + + + + ))} + +
NameEmailPhoneStatusMoved in
+ {t.first_name} {t.last_name} + {t.email ?? "—"}{t.phone ?? "—"} + + {t.move_in_date ? formatDate(t.move_in_date) : "—"}
+
+ +
+ + + + + + + + + + {portfolio.leases.map((l) => ( + + + + + + ))} + +
TermRentStatus
+ {l.lease_start ? formatDate(l.lease_start) : "—"} →{" "} + {l.lease_end ? formatDate(l.lease_end) : "—"} + + {l.rent_amount != null ? formatCurrency(Number(l.rent_amount)) : "—"} + + +
+
+ +
+ + + + + + + + + + + {portfolio.payments.map((p) => ( + + + + + + + ))} + +
DueAmountPaidStatus
{p.due_date ? formatDate(p.due_date) : "—"}{formatCurrency(Number(p.amount))}{p.paid_date ? formatDate(p.paid_date) : "—"} + +
+
+ +
+ + + + + + + + + + + {portfolio.maintenance.map((m) => ( + + + + + + + ))} + +
TitlePriorityStatusOpened
{m.title}{m.priority ?? "—"} + + {m.created_at ? formatDate(m.created_at) : "—"}
+
+
+ ) +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index 992ac8d..5a1486b 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -3,6 +3,18 @@ import { Logo } from "@/components/shared/logo" import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { signUp, signInWithGoogle } from "@/app/actions/auth" import { isGoogleConfigured } from "@/lib/auth" +import { pageMetadata } from "@/lib/seo" + +// /signup is listed in the sitemap as a conversion landing page, so it needs +// its own title, description and canonical rather than inheriting the "Sign in" +// title from app/(auth)/layout.tsx. +export const metadata = pageMetadata({ + title: "Create your free Property Management Network account", + absoluteTitle: true, + description: + "Create a free landlord account — track rent, maintenance, leases and expenses for your first property. No credit card required.", + path: "/signup", +}) export default async function SignupPage({ searchParams, diff --git a/app/(marketing)/acceptable-use/page.tsx b/app/(marketing)/acceptable-use/page.tsx index 3da5989..0c4f494 100644 --- a/app/(marketing)/acceptable-use/page.tsx +++ b/app/(marketing)/acceptable-use/page.tsx @@ -1,11 +1,12 @@ import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LEGAL } from "@/lib/legal" +import { pageMetadata } from "@/lib/seo" -export const metadata = { +export const metadata = pageMetadata({ title: "Acceptable Use Policy", description: `The rules that govern acceptable use of the ${LEGAL.service} platform and the activities that are prohibited.`, - alternates: { canonical: "/acceptable-use" }, -} + path: "/acceptable-use", +}) export default function Page() { return ( diff --git a/app/(marketing)/api-docs/page.tsx b/app/(marketing)/api-docs/page.tsx index 5b3c2fb..62c9183 100644 --- a/app/(marketing)/api-docs/page.tsx +++ b/app/(marketing)/api-docs/page.tsx @@ -1,12 +1,14 @@ import Link from "next/link" import { Code2, Lock, Zap, BookOpen, Webhook } from "lucide-react" import { WEBHOOK_EVENTS } from "@/lib/webhooks/events" +import { pageMetadata } from "@/lib/seo" -export const metadata = { +export const metadata = pageMetadata({ title: "API Docs", - description: "Property Management Network REST API documentation for developers.", - alternates: { canonical: "/api-docs" }, -} + description: + "REST API reference for Property Management Network — endpoints for properties, tenants, rent payments, maintenance and webhooks, with API key auth.", + path: "/api-docs", +}) // The real, deployed origin. Falls back to a placeholder only when the env var // isn't set (e.g. local docs previews). diff --git a/app/(marketing)/cookie-policy/page.tsx b/app/(marketing)/cookie-policy/page.tsx index bbda96b..94bb56b 100644 --- a/app/(marketing)/cookie-policy/page.tsx +++ b/app/(marketing)/cookie-policy/page.tsx @@ -1,12 +1,12 @@ import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LEGAL } from "@/lib/legal" +import { pageMetadata } from "@/lib/seo" -export const metadata = { +export const metadata = pageMetadata({ title: "Cookie Policy", - description: - "How we use cookies and similar technologies, what we deliberately avoid, and how to manage them in your browser.", - alternates: { canonical: "/cookie-policy" }, -} + description: "How we use cookies and similar technologies, what we deliberately avoid, and how to manage them in your browser.", + path: "/cookie-policy", +}) const COOKIE_ROWS: { name: string; type: string; purpose: string; expires: string }[] = [ { diff --git a/app/(marketing)/disclaimer/page.tsx b/app/(marketing)/disclaimer/page.tsx index 5dc606f..c903069 100644 --- a/app/(marketing)/disclaimer/page.tsx +++ b/app/(marketing)/disclaimer/page.tsx @@ -1,12 +1,12 @@ import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" import { LEGAL } from "@/lib/legal" +import { pageMetadata } from "@/lib/seo" -export const metadata = { +export const metadata = pageMetadata({ title: "Disclaimer", - description: - "Important limitations on the information and outputs provided by the Service, including AI-generated content and financial reports.", - alternates: { canonical: "/disclaimer" }, -} + description: "Important limitations on the information and outputs provided by the Service, including AI-generated content and financial reports.", + path: "/disclaimer", +}) export default function Page() { return ( diff --git a/app/(marketing)/dpa/page.tsx b/app/(marketing)/dpa/page.tsx index 79dec27..d43ec24 100644 --- a/app/(marketing)/dpa/page.tsx +++ b/app/(marketing)/dpa/page.tsx @@ -1,12 +1,13 @@ import Link from "next/link" import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" import { LEGAL } from "@/lib/legal" +import { pageMetadata } from "@/lib/seo" -export const metadata = { +export const metadata = pageMetadata({ title: "Data Processing Addendum", description: `The Data Processing Addendum governing how ${LEGAL.entity} processes personal data on behalf of Customers using ${LEGAL.service}.`, - alternates: { canonical: "/dpa" }, -} + path: "/dpa", +}) export default function DpaPage() { return ( diff --git a/app/(marketing)/gdpr/page.tsx b/app/(marketing)/gdpr/page.tsx index cc74991..9d0515e 100644 --- a/app/(marketing)/gdpr/page.tsx +++ b/app/(marketing)/gdpr/page.tsx @@ -1,12 +1,13 @@ import Link from "next/link" import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LEGAL } from "@/lib/legal" +import { pageMetadata } from "@/lib/seo" -export const metadata = { +export const metadata = pageMetadata({ title: "GDPR & Data Rights", description: `How ${LEGAL.entity} complies with the GDPR and UK GDPR, and the data rights available to you as a data subject.`, - alternates: { canonical: "/gdpr" }, -} + path: "/gdpr", +}) export default function GdprPage() { return ( diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx index e9ae79b..ac04fa7 100644 --- a/app/(marketing)/layout.tsx +++ b/app/(marketing)/layout.tsx @@ -1,6 +1,6 @@ import { Navbar } from "@/components/marketing/navbar" import { Footer } from "@/components/marketing/footer" -import { StructuredData } from "@/components/marketing/structured-data" +import { SiteStructuredData } from "@/components/marketing/structured-data" import { getSession, isAdminUser } from "@/lib/session" import { getMaintenanceMode } from "@/lib/settings" import { MaintenanceScreen } from "@/components/shared/maintenance-screen" @@ -17,7 +17,7 @@ export default async function MarketingLayout({ children }: { children: React.Re return (
- + {children}