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) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-09-05 16:27:16 -04:00
co-authored by Claude Opus 5
parent 8f90347659
commit 1d02598786
71 changed files with 3641 additions and 819 deletions
+7
View File
@@ -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
+9
View File
@@ -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
+6
View File
@@ -49,3 +49,9 @@ next-env.d.ts
DOCS/
.env*.local
# playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+57
View File
@@ -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 (
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-xl border border-red-500/10 bg-red-500/5 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-500/10">
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
<h2 className="text-base font-semibold text-white">Admin page failed to load</h2>
<p className="mt-2 max-w-md text-sm text-white/50">
{error.message || "An unexpected error occurred."}
</p>
{error.digest && (
<p className="mt-1 font-mono text-xs text-white/25">digest: {error.digest}</p>
)}
<div className="mt-6 flex items-center gap-3">
<button
onClick={reset}
className="flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 transition hover:border-white/20 hover:text-white"
>
<RefreshCw className="h-4 w-4" />
Try again
</button>
<Link
href="/admin"
className="flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 transition hover:border-white/20 hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
Back to overview
</Link>
</div>
</div>
)
}
+27
View File
@@ -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({
</div>
{/* Counts grid */}
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Portfolio</h2>
<Link
href={`/admin/users/${profile.id}/portfolio`}
className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 transition hover:border-white/20 hover:text-white"
>
<Table2 className="h-3.5 w-3.5" /> View records
</Link>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{COUNT_META.map(({ key, label, icon: Icon }) => (
<div
@@ -197,6 +219,9 @@ export default async function AdminUserDetailPage({
</ul>
)}
</div>
{/* Payments + refunds */}
<BillingActions userId={profile.id} charges={charges} />
</div>
{/* Right: actions */}
@@ -207,6 +232,8 @@ export default async function AdminUserDetailPage({
currentPlan={planKey}
banned={!!account?.banned}
isSelf={isSelf}
isAdminRole={account?.role === "admin"}
subscription={subscription}
/>
</div>
</div>
@@ -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<string, string> = {
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 <span className="text-white/25"></span>
return (
<span
className={cn(
"rounded-md px-1.5 py-0.5 text-xs font-medium",
STATUS_TONE[value] ?? "bg-white/[0.06] text-white/50"
)}
>
{value.replace(/_/g, " ")}
</span>
)
}
function Section({
title,
icon: Icon,
count,
shown,
children,
}: {
title: string
icon: typeof Building2
count: number
shown: number
children: React.ReactNode
}) {
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-white">
<Icon className="h-4 w-4 text-white/40" />
<h2 className="text-sm font-semibold">{title}</h2>
<span className="text-xs text-white/30">({count})</span>
</div>
{shown < count && (
<span className="text-xs text-white/30">showing first {shown}</span>
)}
</div>
{count === 0 ? (
<p className="text-xs text-white/30">None.</p>
) : (
<div className="overflow-x-auto">{children}</div>
)}
</div>
)
}
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<string, number>()
for (const u of portfolio.units) {
unitsByProperty.set(u.property_id ?? "", (unitsByProperty.get(u.property_id ?? "") ?? 0) + 1)
}
return (
<div className="space-y-6">
<BackButton href={`/admin/users/${id}`} label="Back to user" />
<div>
<h1 className="text-xl font-bold text-white">Portfolio</h1>
<p className="mt-0.5 text-sm text-white/40">
Read-only view of {profile.email}&apos;s records. Nothing here can be edited.
</p>
</div>
<Section
title="Properties"
icon={Building2}
count={counts.propertyCount}
shown={portfolio.properties.length}
>
<table className="w-full min-w-[560px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Name</th>
<th className={TH}>Address</th>
<th className={TH}>Units</th>
<th className={TH}>Added</th>
</tr>
</thead>
<tbody>
{portfolio.properties.map((p) => (
<tr key={p.id} className="border-b border-white/[0.04] last:border-0">
<td className={cn(TD, "text-white")}>{p.name}</td>
<td className={TD}>
{[p.address_line1, p.city, p.state].filter(Boolean).join(", ") || "—"}
</td>
<td className={TD}>{unitsByProperty.get(p.id) ?? p.total_units ?? 0}</td>
<td className={TD}>{p.created_at ? formatDate(p.created_at) : "—"}</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section title="Units" icon={Home} count={counts.unitCount} shown={portfolio.units.length}>
<table className="w-full min-w-[420px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Unit</th>
<th className={TH}>Rent</th>
<th className={TH}>Status</th>
</tr>
</thead>
<tbody>
{portfolio.units.map((u) => (
<tr key={u.id} className="border-b border-white/[0.04] last:border-0">
<td className={cn(TD, "text-white")}>{u.unit_number}</td>
<td className={TD}>
{u.rent_amount != null ? formatCurrency(Number(u.rent_amount)) : "—"}
</td>
<td className={TD}>
<Pill value={u.status} />
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section
title="Tenants"
icon={UsersIcon}
count={counts.tenantCount}
shown={portfolio.tenants.length}
>
<table className="w-full min-w-[600px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Name</th>
<th className={TH}>Email</th>
<th className={TH}>Phone</th>
<th className={TH}>Status</th>
<th className={TH}>Moved in</th>
</tr>
</thead>
<tbody>
{portfolio.tenants.map((t) => (
<tr key={t.id} className="border-b border-white/[0.04] last:border-0">
<td className={cn(TD, "text-white")}>
{t.first_name} {t.last_name}
</td>
<td className={TD}>{t.email ?? "—"}</td>
<td className={TD}>{t.phone ?? "—"}</td>
<td className={TD}>
<Pill value={t.status} />
</td>
<td className={TD}>{t.move_in_date ? formatDate(t.move_in_date) : "—"}</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section
title="Leases"
icon={FileText}
count={counts.leaseCount}
shown={portfolio.leases.length}
>
<table className="w-full min-w-[480px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Term</th>
<th className={TH}>Rent</th>
<th className={TH}>Status</th>
</tr>
</thead>
<tbody>
{portfolio.leases.map((l) => (
<tr key={l.id} className="border-b border-white/[0.04] last:border-0">
<td className={TD}>
{l.lease_start ? formatDate(l.lease_start) : "—"} {" "}
{l.lease_end ? formatDate(l.lease_end) : "—"}
</td>
<td className={TD}>
{l.rent_amount != null ? formatCurrency(Number(l.rent_amount)) : "—"}
</td>
<td className={TD}>
<Pill value={l.status} />
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section
title="Recent rent payments"
icon={CreditCard}
count={counts.paymentCount}
shown={portfolio.payments.length}
>
<table className="w-full min-w-[480px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Due</th>
<th className={TH}>Amount</th>
<th className={TH}>Paid</th>
<th className={TH}>Status</th>
</tr>
</thead>
<tbody>
{portfolio.payments.map((p) => (
<tr key={p.id} className="border-b border-white/[0.04] last:border-0">
<td className={TD}>{p.due_date ? formatDate(p.due_date) : "—"}</td>
<td className={cn(TD, "text-white")}>{formatCurrency(Number(p.amount))}</td>
<td className={TD}>{p.paid_date ? formatDate(p.paid_date) : "—"}</td>
<td className={TD}>
<Pill value={p.status} />
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section
title="Recent maintenance"
icon={Wrench}
count={counts.maintenanceCount}
shown={portfolio.maintenance.length}
>
<table className="w-full min-w-[480px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Title</th>
<th className={TH}>Priority</th>
<th className={TH}>Status</th>
<th className={TH}>Opened</th>
</tr>
</thead>
<tbody>
{portfolio.maintenance.map((m) => (
<tr key={m.id} className="border-b border-white/[0.04] last:border-0">
<td className={cn(TD, "text-white")}>{m.title}</td>
<td className={TD}>{m.priority ?? "—"}</td>
<td className={TD}>
<Pill value={m.status} />
</td>
<td className={TD}>{m.created_at ? formatDate(m.created_at) : "—"}</td>
</tr>
))}
</tbody>
</table>
</Section>
</div>
)
}
+12
View File
@@ -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,
+4 -3
View File
@@ -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 (
+6 -4
View File
@@ -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).
+5 -5
View File
@@ -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 }[] = [
{
+5 -5
View File
@@ -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 (
+4 -3
View File
@@ -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 (
+4 -3
View File
@@ -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 (
+2 -2
View File
@@ -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 (
<div className="min-h-screen bg-[#09090b] text-white">
<StructuredData />
<SiteStructuredData />
<Navbar />
{children}
<Footer />
+11 -11
View File
@@ -7,23 +7,23 @@ import { Testimonials } from "@/components/marketing/testimonials"
import { PricingSection } from "@/components/marketing/pricing-section"
import { FAQ } from "@/components/marketing/faq"
import { CtaBanner } from "@/components/marketing/cta-banner"
import { HomeStructuredData } from "@/components/marketing/structured-data"
import { annualEnabled } from "@/lib/stripe/plans"
import { pageMetadata } from "@/lib/seo"
export const metadata = {
title: { absolute: "Property Management Software for Independent Landlords" },
description: "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
alternates: { canonical: "/" },
openGraph: {
title: "Property management without the chaos",
description: "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
url: "/",
type: "website",
},
}
export const metadata = pageMetadata({
title: "Property Management Software for Independent Landlords",
absoluteTitle: true,
description:
"Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
path: "/",
socialTitle: "Property management without the chaos",
})
export default function LandingPage() {
return (
<>
<HomeStructuredData />
<Hero />
<Marquee />
<Problem />
+5 -5
View File
@@ -1,12 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = {
export const metadata = pageMetadata({
title: "Privacy Policy",
description:
"How we collect, use, share, and protect personal data, and the privacy rights available to you under the GDPR and California law.",
alternates: { canonical: "/privacy" },
}
description: "How we collect, use, share, and protect personal data, and the privacy rights available to you under the GDPR and California law.",
path: "/privacy",
})
export default function Page() {
return (
+5 -4
View File
@@ -1,11 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = {
title: "Refund & Cancellation Policy",
export const metadata = pageMetadata({
title: "Refund & Cancellation",
description: `How subscriptions, cancellations, and refunds work for the ${LEGAL.service} platform.`,
alternates: { canonical: "/refund-policy" },
}
path: "/refund-policy",
})
export default function Page() {
return (
+4 -3
View File
@@ -1,12 +1,13 @@
import Link from "next/link"
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = {
export const metadata = pageMetadata({
title: "Sub-processors",
description: `The third-party sub-processors that ${LEGAL.entity} engages to process personal data on behalf of Customers of ${LEGAL.service}.`,
alternates: { canonical: "/subprocessors" },
}
path: "/subprocessors",
})
export default function SubprocessorsPage() {
return (
+6 -4
View File
@@ -1,11 +1,13 @@
import Link from "next/link"
import { Building2, CheckCircle2, Smartphone, Bell, FileText, ArrowRight } from "lucide-react"
import { pageMetadata } from "@/lib/seo"
export const metadata = {
export const metadata = pageMetadata({
title: "Tenant Portal",
description: "Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.",
alternates: { canonical: "/tenant-portal-info" },
}
description:
"Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.",
path: "/tenant-portal-info",
})
const FEATURES = [
{ icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20", title: "Online Rent Payment", desc: "Tenants pay rent securely via Stripe — card or bank transfer. Auto-receipts sent by email." },
+4 -3
View File
@@ -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: "Terms of Service",
description: `The terms and conditions that govern your access to and use of the ${LEGAL.service} platform.`,
alternates: { canonical: "/terms" },
}
path: "/terms",
})
export default function Page() {
return (
+139 -4
View File
@@ -13,6 +13,13 @@ 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"
import {
changePlanInStripe,
cancelSubscription,
resumeSubscription,
refundCharge,
} from "@/lib/admin/billing"
import type { Plan } from "@/types"
// ── gate ────────────────────────────────────────────────────────────────────
// Every server action re-verifies the caller is an admin. NEVER skip — these
@@ -26,24 +33,152 @@ async function guard() {
const planSchema = z.enum(["starter", "pro", "landlord", "lifetime"])
// ── change plan ─────────────────────────────────────────────────────────────
export async function changeUserPlan(userId: string, plan: string) {
// TWO DISTINCT OPERATIONS, deliberately not merged:
//
// "comp" — grant the entitlement in our database only. Stripe is untouched,
// so the user is billed exactly as before. This is the right choice
// for a free upgrade, a support gesture, or a user with no
// subscription at all.
// "stripe" — actually move their Stripe subscription (prorated), then mirror
// it locally. This CHARGES OR CREDITS REAL MONEY.
//
// Before this split, the only behaviour was "comp" while the UI called it
// "change plan" — so an admin granting Pro left the customer on their old
// Stripe subscription, silently desyncing entitlement from billing.
export async function changeUserPlan(
userId: string,
plan: string,
mode: "comp" | "stripe" = "comp",
interval: "month" | "year" = "month"
) {
const a = await guard()
const nextPlan = planSchema.parse(plan)
const existing = await db.query.profiles.findFirst({ where: eq(profiles.id, userId) })
const oldPlan = existing?.plan ?? null
if (mode === "stripe") {
const result = await changePlanInStripe(userId, nextPlan as Plan, interval)
if (!result.ok) return { ok: false as const, error: result.error }
await logAdminAction({
adminId: a.user.id,
action: "plan_change_stripe",
targetUserId: userId,
metadata: { from: oldPlan, to: nextPlan, interval, detail: result.detail },
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: result.detail }
}
// Comp: entitlement only. Recorded as such so the audit trail distinguishes a
// deliberate free grant from a paid upgrade.
await db.update(profiles).set({ plan: nextPlan }).where(eq(profiles.id, userId))
await logAdminAction({
adminId: a.user.id,
action: "plan_change",
targetUserId: userId,
metadata: { from: oldPlan, to: nextPlan },
metadata: { from: oldPlan, to: nextPlan, mode: "comp", billingUnchanged: true },
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true }
return {
ok: true as const,
detail: `Plan set to ${nextPlan} as a comp. Stripe billing was NOT changed.`,
}
}
// ── subscription lifecycle ──────────────────────────────────────────────────
export async function cancelUserSubscription(userId: string, immediate = false) {
const a = await guard()
const result = await cancelSubscription(userId, immediate)
if (!result.ok) return { ok: false as const, error: result.error }
await logAdminAction({
adminId: a.user.id,
action: "cancel_subscription",
targetUserId: userId,
metadata: { immediate, detail: result.detail },
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: result.detail }
}
export async function resumeUserSubscription(userId: string) {
const a = await guard()
const result = await resumeSubscription(userId)
if (!result.ok) return { ok: false as const, error: result.error }
await logAdminAction({
adminId: a.user.id,
action: "resume_subscription",
targetUserId: userId,
metadata: { detail: result.detail },
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: result.detail }
}
// ── refunds ─────────────────────────────────────────────────────────────────
// `amountCents` omitted refunds everything still outstanding on the charge. The
// charge is verified to belong to this user inside refundCharge().
export async function refundUserCharge(
userId: string,
chargeId: string,
amountCents?: number,
reason?: "duplicate" | "fraudulent" | "requested_by_customer"
) {
const a = await guard()
const result = await refundCharge(userId, chargeId, amountCents, reason)
if (!result.ok) return { ok: false as const, error: result.error }
await logAdminAction({
adminId: a.user.id,
action: "refund",
targetUserId: userId,
metadata: {
chargeId,
refundId: result.data.refundId,
amountCents: result.data.amount,
reason: reason ?? null,
},
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: result.detail }
}
// ── admin role management ───────────────────────────────────────────────────
// Promotes/demotes via the Better Auth admin plugin, which writes `user.role`.
// This replaces the previous situation where the ONLY way to create an admin was
// editing ADMIN_USER_IDS in env and redeploying.
//
// Self-demotion is blocked: an admin removing their own last access would need a
// redeploy to undo, and ADMIN_USER_IDS remains the break-glass path.
export async function setUserRole(userId: string, role: "admin" | "user") {
const a = await guard()
if (userId === a.user.id) {
throw new Error("You cannot change your own role. Ask another admin.")
}
await auth.api.setRole({
body: { userId, role },
headers: await headers(),
})
await logAdminAction({
adminId: a.user.id,
action: "set_role",
targetUserId: userId,
metadata: { role },
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: role === "admin" ? "User promoted to admin." : "Admin access revoked." }
}
// ── ban ─────────────────────────────────────────────────────────────────────
@@ -132,7 +267,7 @@ export async function markEmailVerified(userId: string) {
await logAdminAction({
adminId: a.user.id,
action: "resend_verification",
action: "mark_email_verified",
targetUserId: userId,
metadata: { markedVerified: true },
})
+17 -4
View File
@@ -35,6 +35,19 @@ export async function GET() {
return NextResponse.json(data)
}
// Shape of one item in the model's JSON response. Every field is optional
// because the model is not a trusted schema — the insert below supplies a
// fallback for each, so a missing key degrades instead of throwing.
type AiPrediction = {
type?: string
title?: string
prediction?: string
confidence?: string
timeframe?: string
risk_level?: string
data?: Record<string, unknown> | null
}
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
@@ -184,7 +197,7 @@ Only return valid JSON, no other text.`
json: true,
})
let predictions: any[] = []
let predictions: AiPrediction[] = []
try {
const parsed = JSON.parse(content || "{}")
predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? [])
@@ -195,11 +208,11 @@ Only return valid JSON, no other text.`
// Replace old predictions
await db.delete(ai_predictions).where(eq(ai_predictions.user_id, ownerId))
const toInsert = predictions.map((p: any) => ({
const toInsert = predictions.map((p: AiPrediction) => ({
user_id: ownerId,
type: p.type ?? "growth_opportunity",
title: p.title,
prediction: p.prediction,
title: p.title ?? "Untitled prediction",
prediction: p.prediction ?? "",
confidence: p.confidence ?? "medium",
timeframe: p.timeframe ?? "Next 30 days",
risk_level: p.risk_level ?? "low",
+20 -7
View File
@@ -34,6 +34,18 @@ export async function GET() {
return NextResponse.json(data)
}
// Shape of one item in the model's JSON response. Optional for the same reason
// as AiPrediction: the model output is untrusted input, not a schema.
type AiRecommendation = {
type?: string
title?: string
description?: string
impact?: string
priority?: string
action_label?: string
action_data?: Record<string, unknown> | null
}
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
@@ -169,7 +181,7 @@ Return a JSON object with key "recommendations" containing an array. Each recomm
Only return valid JSON, no other text.`
let recommendations: any[] = []
let recommendations: AiRecommendation[] = []
try {
const content = await aiComplete({
messages: [{ role: "user", content: prompt }],
@@ -178,8 +190,9 @@ Only return valid JSON, no other text.`
})
const parsed = JSON.parse(content || "{}")
recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? [])
} catch (err: any) {
return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 })
} catch (err) {
const message = err instanceof Error ? err.message : "AI generation failed"
return NextResponse.json({ error: message }, { status: 500 })
}
// Delete old pending recommendations and insert new ones
@@ -187,12 +200,12 @@ Only return valid JSON, no other text.`
.delete(ai_recommendations)
.where(and(eq(ai_recommendations.user_id, ownerId), eq(ai_recommendations.status, "pending")))
const toInsert = recommendations.map((r: any) => ({
const toInsert = recommendations.map((r: AiRecommendation) => ({
user_id: ownerId,
type: r.type ?? "opportunity",
title: r.title,
description: r.description,
impact: r.impact,
title: r.title ?? "Untitled recommendation",
description: r.description ?? "",
impact: r.impact ?? "",
priority: r.priority ?? "medium",
status: "pending",
action_label: r.action_label ?? "Apply",
+3 -1
View File
@@ -38,7 +38,9 @@ export async function POST(request: Request) {
}
// Pass only the whitelisted, validated fields to the model.
const { payment_id, ...receiptFields } = parsed.data
// payment_id identifies the row but must not reach the model — destructured
// out deliberately, hence the leading underscore.
const { payment_id: _payment_id, ...receiptFields } = parsed.data
const text = await aiComplete({
messages: [
+6 -1
View File
@@ -115,7 +115,12 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token:
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="property-management-network.ics"',
"Cache-Control": "public, max-age=3600",
// PRIVATE, never shared-cacheable: the only credential is the token in the
// URL, and the body carries tenant names, rent amounts, property addresses
// and lease dates. A `public` cache directive would let any intermediary
// or CDN retain that PII.
"Cache-Control": "private, max-age=3600",
"X-Robots-Tag": "noindex, nofollow",
},
})
}
+12 -1
View File
@@ -8,6 +8,7 @@ import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { enforceRateLimit, clientIp } from "@/lib/rate-limit"
const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"]
const VALID_PRIORITIES = ["low", "medium", "high", "emergency"]
@@ -62,12 +63,22 @@ export async function POST(request: Request) {
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
userId = ctx.ownerId
} else {
// Tenant portal submission — verify portal_token
// Tenant portal submission — verify portal_token.
//
// This is the one unauthenticated write path in the app, so it carries its
// own limits: a per-IP budget that also caps portal-token guessing, and a
// tighter per-token budget so a leaked token cannot flood a landlord's queue.
const ipLimited = enforceRateLimit(`portal-maintenance-ip:${clientIp(request)}`, 20, 3600)
if (ipLimited) return ipLimited
const portalToken = body.portal_token as string | undefined
if (!portalToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const tokenLimited = enforceRateLimit(`portal-maintenance:${portalToken}`, 10, 3600)
if (tokenLimited) return tokenLimited
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.portal_token, portalToken),
columns: { id: true, user_id: true, property_id: true, unit_id: true },
+4 -13
View File
@@ -1,11 +1,10 @@
import { NextResponse } from "next/server"
import { desc, eq, sql } from "drizzle-orm"
import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { propertySchema } from "@/lib/validations"
import { getUserPlan } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { checkPropertyLimit } from "@/lib/plan-limits"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding"
@@ -37,16 +36,8 @@ export async function POST(request: Request) {
const parsed = propertySchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Check plan limit
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(properties)
.where(eq(properties.user_id, ownerId))
const plan = await getUserPlan(ownerId)
if (!checkLimit(plan, "maxProperties", count)) {
return NextResponse.json({ error: "Plan limit reached. Upgrade to add more properties." }, { status: 403 })
}
const limitError = await checkPropertyLimit(ownerId)
if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
// Best-effort geocode so the property shows up on the map (never blocks save).
const coords = await geocodeAddress(parsed.data)
+12 -2
View File
@@ -5,6 +5,16 @@ import { db } from "@/lib/db"
import { profiles, rent_payments } from "@/lib/db/schema"
import type Stripe from "stripe"
/**
* `current_period_end` is present on the webhook payload but absent from the
* Subscription type in this pinned API version, so it is read through a narrow
* accessor rather than casting the whole object to `any`.
*/
function subscriptionPeriodEnd(sub: Stripe.Subscription): number | null {
const v = (sub as unknown as { current_period_end?: unknown }).current_period_end
return typeof v === "number" ? v : null
}
export async function POST(request: Request) {
const body = await request.text()
const sig = request.headers.get("stripe-signature")!
@@ -54,8 +64,8 @@ export async function POST(request: Request) {
plan: (plan ?? "starter") as typeof profiles.$inferSelect.plan,
stripe_subscription_id: subscription.id,
subscription_status: subscription.status,
plan_expires_at: (subscription as any).current_period_end
? new Date((subscription as any).current_period_end * 1000).toISOString()
plan_expires_at: subscriptionPeriodEnd(subscription)
? new Date(subscriptionPeriodEnd(subscription)! * 1000).toISOString()
: null,
})
.where(eq(profiles.id, userId))
+3 -14
View File
@@ -5,8 +5,7 @@ import { tenants, units } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { tenantSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
import { getUserPlan } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { checkTenantLimit } from "@/lib/plan-limits"
import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
@@ -64,18 +63,8 @@ export async function POST(request: Request) {
const parsed = tenantSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Enforce per-plan tenant limit (Starter = 3).
const plan = await getUserPlan(ownerId)
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(tenants)
.where(eq(tenants.user_id, ownerId))
if (!checkLimit(plan, "maxTenants", count)) {
return NextResponse.json(
{ error: "Plan limit reached. Upgrade to add more tenants." },
{ status: 403 }
)
}
const limitError = await checkTenantLimit(ownerId)
if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
if (
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
+6
View File
@@ -9,6 +9,7 @@ import {
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
import { enforceRateLimit } from "@/lib/rate-limit"
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"]
@@ -24,6 +25,11 @@ export async function POST(request: Request) {
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
// Storage quota caps total bytes, but not the rate of writes — throttle so a
// single account cannot hammer Spaces (or fill a plan's quota) in one burst.
const limited = enforceRateLimit(`upload:${ownerId}`, 60, 60)
if (limited) return limited
const fd = await request.formData()
const file = fd.get("file") as File | null
const scopeRaw = (fd.get("scope") as string) || "misc"
+5
View File
@@ -6,6 +6,7 @@ import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — update a single maintenance request. Bearer API-key
@@ -26,6 +27,10 @@ const maintenancePatchSchema = maintenanceSchema.partial().extend({
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const { id } = await params
+9
View File
@@ -5,6 +5,7 @@ import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — maintenance requests. Bearer API-key auth.
@@ -22,6 +23,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const priority = searchParams.get("priority")
@@ -55,6 +60,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
+9
View File
@@ -5,6 +5,7 @@ import { rent_payments } from "@/lib/db/schema"
import { rentPaymentSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — rent payments. Bearer API-key auth. Maps to the
@@ -21,6 +22,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const tenantId = searchParams.get("tenant_id")
@@ -57,6 +62,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
+18
View File
@@ -4,6 +4,8 @@ import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { propertySchema } from "@/lib/validations"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { checkPropertyLimit } from "@/lib/plan-limits"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding"
@@ -19,6 +21,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const data = await db.query.properties.findMany({
where: eq(properties.user_id, ctx.ownerId),
with: { units: { columns: { id: true, status: true } } },
@@ -31,6 +37,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
@@ -42,6 +52,14 @@ export async function POST(request: Request) {
)
}
// Same plan cap the session route enforces — the public API is a creation
// entry point too, and skipping this here let a Starter key create unlimited
// properties.
const limitError = await checkPropertyLimit(ctx.ownerId)
if (limitError) {
return NextResponse.json({ error: { code: 403, message: limitError } }, { status: 403 })
}
const coords = await geocodeAddress(parsed.data)
const [data] = await db
+5
View File
@@ -3,6 +3,7 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { tenants } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
// Public REST API (v1) — Bearer API-key auth. Scoped by the resolved owner id.
@@ -13,6 +14,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
const status = searchParams.get("status")
+13
View File
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
@@ -32,6 +33,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { id } = await params
const data = await db.query.webhook_endpoints.findFirst({
where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ctx.ownerId)),
@@ -44,6 +49,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const { id } = await params
@@ -93,6 +102,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const { id } = await params
+9
View File
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
@@ -37,6 +38,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const data = await db
.select(PUBLIC_COLUMNS)
.from(webhook_endpoints)
@@ -49,6 +54,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
+9
View File
@@ -1,5 +1,14 @@
import type { Metadata } from "next"
import Link from "next/link"
// A 404 already returns the right status code, but without its own title it
// would surface the site-wide default title in tabs, share previews and logs.
// Next.js emits its own `noindex` for not-found, so no robots field here —
// adding one only produces a second, redundant <meta name="robots">.
export const metadata: Metadata = {
title: "Page not found",
}
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#09090b] text-white">
+6 -1
View File
@@ -5,7 +5,12 @@ import { acceptInvite } from "@/app/actions/team"
import { Logo } from "@/components/shared/logo"
import { XCircle } from "lucide-react"
export const metadata = { title: "Accept Team Invite" }
// The URL carries a single-use invite token, so this page is noindex/nofollow
// to keep tokens out of search results.
export const metadata = {
title: "Accept team invite",
robots: { index: false, follow: false },
}
export default async function AcceptInvitePage({
params,
+15 -9
View File
@@ -19,15 +19,21 @@ export function PlanDonut({ data }: { data: Record<string, number> }) {
const radius = (size - stroke) / 2
const circumference = 2 * Math.PI * radius
// Build cumulative arc segments
let cumulative = 0
const segments = PLAN_META.map((p) => {
const value = data[p.key] ?? 0
const fraction = total > 0 ? value / total : 0
const dash = fraction * circumference
const offset = cumulative * circumference
cumulative += fraction
return { ...p, value, fraction, dash, offset }
// Build cumulative arc segments. The running offset is derived per segment
// from the slices before it rather than mutated across the map callback —
// reassigning a closed-over local during render is what react-hooks
// /immutability flags, and it misbehaves under re-render.
const fractions = PLAN_META.map((p) => (total > 0 ? (data[p.key] ?? 0) / total : 0))
const segments = PLAN_META.map((p, i) => {
const fraction = fractions[i]
const precedingFraction = fractions.slice(0, i).reduce((sum, f) => sum + f, 0)
return {
...p,
value: data[p.key] ?? 0,
fraction,
dash: fraction * circumference,
offset: precedingFraction * circumference,
}
})
return (
+256
View File
@@ -0,0 +1,256 @@
"use client"
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Receipt, RotateCcw, ExternalLink } from "lucide-react"
import { refundUserCharge } from "@/app/actions/admin"
import { Select } from "@/components/ui/select"
import { cn } from "@/lib/utils"
export type ChargeRow = {
id: string
amount: number
amountRefunded: number
currency: string
created: number
status: string
refunded: boolean
description: string | null
receiptUrl: string | null
}
const REASON_OPTIONS = [
{ value: "requested_by_customer", label: "Requested by customer" },
{ value: "duplicate", label: "Duplicate charge" },
{ value: "fraudulent", label: "Fraudulent" },
]
function money(cents: number, currency: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(cents / 100)
}
/**
* Recent Stripe charges with a refund control per row.
*
* Partial refunds are entered in DOLLARS and converted to integer cents here;
* the server re-validates the amount against what is actually still refundable
* on the charge, so a stale page cannot over-refund.
*/
export function BillingActions({ userId, charges }: { userId: string; charges: ChargeRow[] }) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [target, setTarget] = useState<ChargeRow | null>(null)
const [amount, setAmount] = useState("")
const [reason, setReason] = useState("requested_by_customer")
function openRefund(c: ChargeRow) {
setTarget(c)
// Default to the full remaining amount, which is the common case.
setAmount(((c.amount - c.amountRefunded) / 100).toFixed(2))
setReason("requested_by_customer")
}
function submitRefund() {
if (!target) return
const remaining = target.amount - target.amountRefunded
const parsed = Math.round(parseFloat(amount) * 100)
if (!Number.isFinite(parsed) || parsed <= 0) {
toast.error("Enter a refund amount greater than zero.")
return
}
if (parsed > remaining) {
toast.error(`Only ${money(remaining, target.currency)} is still refundable on that charge.`)
return
}
// A full refund sends no amount so Stripe refunds the exact remainder —
// avoids a rounding mismatch on odd amounts.
const amountCents = parsed === remaining ? undefined : parsed
startTransition(async () => {
try {
const res = await refundUserCharge(
userId,
target.id,
amountCents,
reason as "duplicate" | "fraudulent" | "requested_by_customer"
)
if (res.ok === false) {
toast.error(res.error ?? "Refund failed")
return
}
toast.success(res.detail ?? "Refund issued")
setTarget(null)
router.refresh()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Refund failed")
}
})
}
if (!charges.length) {
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<Receipt className="h-4 w-4 text-emerald-400" />
<h2 className="text-sm font-semibold">Payments</h2>
</div>
<p className="mt-3 text-xs text-white/40">
No Stripe charges found for this user.
</p>
</div>
)
}
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<Receipt className="h-4 w-4 text-emerald-400" />
<h2 className="text-sm font-semibold">Payments</h2>
</div>
<p className="mt-1 text-xs text-white/40">Most recent charges from Stripe.</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[520px] text-left text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-xs text-white/40">
<th className="pb-2 font-medium">Date</th>
<th className="pb-2 font-medium">Amount</th>
<th className="pb-2 font-medium">Status</th>
<th className="pb-2 text-right font-medium">Action</th>
</tr>
</thead>
<tbody>
{charges.map((c) => {
const remaining = c.amount - c.amountRefunded
const fullyRefunded = c.refunded || remaining <= 0
return (
<tr key={c.id} className="border-b border-white/[0.04] last:border-0">
<td className="py-3 text-white/70">
{new Date(c.created * 1000).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</td>
<td className="py-3 text-white">
{money(c.amount, c.currency)}
{c.amountRefunded > 0 && (
<span className="ml-1.5 text-xs text-amber-300/80">
{money(c.amountRefunded, c.currency)} refunded
</span>
)}
</td>
<td className="py-3">
<span
className={cn(
"rounded-md px-1.5 py-0.5 text-xs font-medium",
fullyRefunded
? "bg-amber-500/10 text-amber-300"
: c.status === "succeeded"
? "bg-emerald-500/10 text-emerald-300"
: "bg-white/[0.06] text-white/50"
)}
>
{fullyRefunded ? "refunded" : c.status}
</span>
</td>
<td className="py-3 text-right">
<div className="inline-flex items-center gap-2">
{c.receiptUrl && (
<a
href={c.receiptUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-2 py-1 text-xs text-white/50 transition hover:text-white"
>
Receipt <ExternalLink className="h-3 w-3" />
</a>
)}
<button
onClick={() => openRefund(c)}
disabled={isPending || fullyRefunded || c.status !== "succeeded"}
className="inline-flex items-center gap-1 rounded-lg border border-red-500/25 bg-red-500/10 px-2 py-1 text-xs font-medium text-red-300 transition hover:bg-red-500/20 disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" /> Refund
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Refund modal — amount + reason + confirm in ONE step, so the amount
field is never hidden behind a confirmation dialog. */}
{target && (
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => !isPending && setTarget(null)}
/>
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] p-6 shadow-2xl shadow-black/80">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/20 bg-red-500/10 text-red-400">
<RotateCcw className="h-6 w-6" />
</div>
<h2 className="text-center text-base font-bold text-white">Refund payment</h2>
<p className="mt-2 text-center text-sm text-white/50">
{money(target.amount, target.currency)} charged on{" "}
{new Date(target.created * 1000).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
. Up to {money(target.amount - target.amountRefunded, target.currency)} can be
refunded. This cannot be undone from here.
</p>
<div className="mt-4 space-y-3">
<div>
<label className="mb-1.5 block text-xs font-medium text-white/40">
Amount ({target.currency.toUpperCase()})
</label>
<input
value={amount}
onChange={(e) => setAmount(e.target.value)}
inputMode="decimal"
autoFocus
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2.5 text-sm text-white outline-none transition focus:border-red-500/50 focus:ring-1 focus:ring-red-500/30"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-white/40">Reason</label>
<Select value={reason} onChange={setReason} options={REASON_OPTIONS} />
</div>
</div>
<div className="mt-6 flex gap-3">
<button
onClick={() => setTarget(null)}
disabled={isPending}
className="flex-1 rounded-xl border border-white/10 py-2.5 text-sm font-medium text-white/50 transition hover:border-white/20 hover:text-white disabled:opacity-50"
>
Cancel
</button>
<button
onClick={submitRefund}
disabled={isPending}
className="flex-1 rounded-xl bg-red-600 py-2.5 text-sm font-semibold text-white transition hover:bg-red-500 disabled:opacity-50"
>
{isPending ? "Refunding…" : "Issue refund"}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+309 -66
View File
@@ -3,7 +3,17 @@
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Ban, ShieldCheck, UserCog, MailCheck, Trash2, Crown } from "lucide-react"
import {
Ban,
ShieldCheck,
UserCog,
MailCheck,
Trash2,
Crown,
CreditCard,
PlayCircle,
ShieldAlert,
} from "lucide-react"
import {
changeUserPlan,
banUser,
@@ -11,17 +21,31 @@ import {
impersonateUser,
markEmailVerified,
deleteUser,
cancelUserSubscription,
resumeUserSubscription,
setUserRole,
} from "@/app/actions/admin"
import { Select } from "@/components/ui/select"
import { ConfirmModal } from "@/components/ui/confirm-modal"
import { cn } from "@/lib/utils"
export type SubscriptionInfo = {
id: string
status: string
cancelAtPeriodEnd: boolean
currentPeriodEnd: number | null
amount: number | null
interval: string | null
} | null
interface UserActionsProps {
userId: string
email: string
currentPlan: string
banned: boolean
isSelf: boolean
isAdminRole: boolean
subscription: SubscriptionInfo
}
const PLAN_OPTIONS = [
@@ -31,25 +55,60 @@ const PLAN_OPTIONS = [
{ value: "lifetime", label: "Lifetime" },
]
// "comp" writes the entitlement only; "stripe" moves real billing. Keeping these
// as an explicit choice is the whole point — see changeUserPlan in
// app/actions/admin.ts.
const MODE_OPTIONS = [
{ value: "comp", label: "Comp — entitlement only, no billing change" },
{ value: "stripe", label: "Sync to Stripe — charges/credits the customer" },
]
const INTERVAL_OPTIONS = [
{ value: "month", label: "Monthly" },
{ value: "year", label: "Yearly" },
]
function errMessage(e: unknown) {
return e instanceof Error ? e.message : "Something went wrong"
}
export function UserActions({ userId, email, currentPlan, banned, isSelf }: UserActionsProps) {
/** Server actions here return either a thrown Error or `{ ok: false, error }`. */
type ActionResult = { ok?: boolean; error?: string; detail?: string } | void | unknown
export function UserActions({
userId,
email,
currentPlan,
banned,
isSelf,
isAdminRole,
subscription,
}: UserActionsProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [plan, setPlan] = useState(currentPlan)
const [planMode, setPlanMode] = useState<"comp" | "stripe">("comp")
const [interval, setInterval] = useState<"month" | "year">("month")
const [showBan, setShowBan] = useState(false)
const [banReason, setBanReason] = useState("")
const [showImpersonate, setShowImpersonate] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const [showStripePlan, setShowStripePlan] = useState(false)
const [showCancelNow, setShowCancelNow] = useState(false)
const [showRole, setShowRole] = useState(false)
// Run a server action inside a transition; toast on success/error, then refresh.
function run(fn: () => Promise<unknown>, successMsg: string, after?: () => void) {
// Run a server action inside a transition. Handles BOTH failure shapes: a
// thrown Error (guard/validation) and a returned { ok: false, error } (an
// expected Stripe condition, e.g. "no subscription to modify").
function run(fn: () => Promise<ActionResult>, fallbackMsg: string, after?: () => void) {
startTransition(async () => {
try {
await fn()
toast.success(successMsg)
const res = (await fn()) as { ok?: boolean; error?: string; detail?: string } | undefined
if (res && res.ok === false) {
toast.error(res.error ?? "Action failed")
return
}
toast.success(res?.detail ?? fallbackMsg)
after?.()
router.refresh()
} catch (e) {
@@ -58,67 +117,71 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
})
}
function onApplyPlan() {
if (plan === currentPlan) {
function applyPlan(mode: "comp" | "stripe") {
if (plan === currentPlan && mode === "comp") {
toast.message("Plan unchanged")
return
}
run(() => changeUserPlan(userId, plan), "Plan updated")
run(
() => changeUserPlan(userId, plan, mode, interval),
mode === "stripe" ? "Stripe subscription updated" : "Plan comped",
() => setShowStripePlan(false)
)
}
function onBan() {
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => {
setShowBan(false)
setBanReason("")
})
}
function onUnban() {
run(() => unbanUser(userId), "User unbanned")
}
function onImpersonate() {
// impersonateUser redirects to /dashboard on success — no toast needed.
startTransition(async () => {
try {
await impersonateUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowImpersonate(false)
}
})
}
function onVerify() {
run(() => markEmailVerified(userId), "Email marked as verified")
}
function onDelete() {
// deleteUser redirects to /admin/users on success.
startTransition(async () => {
try {
await deleteUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowDelete(false)
}
})
function onApplyPlan() {
// Moving real money always gets a confirmation step.
if (planMode === "stripe") {
setShowStripePlan(true)
return
}
applyPlan("comp")
}
const btnBase =
"w-full inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
const btnGhost =
"border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white"
const periodEnd = subscription?.currentPeriodEnd
? new Date(subscription.currentPeriodEnd * 1000).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: null
return (
<div className="space-y-4">
{/* Plan */}
{/* ── Plan ───────────────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<Crown className="h-4 w-4 text-amber-400" />
<h3 className="text-sm font-semibold">Change plan</h3>
</div>
<p className="mt-1 text-xs text-white/40">Override the user&apos;s subscription tier.</p>
<p className="mt-1 text-xs text-white/40">
A comp grants access without touching billing. Syncing to Stripe changes what the
customer actually pays.
</p>
<div className="mt-4 space-y-3">
<Select value={plan} onChange={setPlan} options={PLAN_OPTIONS} />
<Select
value={planMode}
onChange={(v) => setPlanMode(v as "comp" | "stripe")}
options={MODE_OPTIONS}
/>
{planMode === "stripe" && (
<Select
value={interval}
onChange={(v) => setInterval(v as "month" | "year")}
options={INTERVAL_OPTIONS}
/>
)}
{planMode === "stripe" && (
<p className="rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-xs text-amber-300/90">
This charges or credits the customer immediately via proration.
</p>
)}
<button
onClick={onApplyPlan}
disabled={isPending}
@@ -129,19 +192,125 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
</div>
</div>
{/* Account actions */}
{/* ── Subscription ───────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<CreditCard className="h-4 w-4 text-sky-400" />
<h3 className="text-sm font-semibold">Subscription</h3>
</div>
{subscription ? (
<>
<div className="mt-3 space-y-1 text-xs text-white/50">
<div>
Status:{" "}
<span
className={cn(
"font-medium",
subscription.status === "active" ? "text-emerald-300" : "text-amber-300"
)}
>
{subscription.status}
</span>
</div>
{subscription.amount !== null && (
<div>
${(subscription.amount / 100).toFixed(2)}
{subscription.interval ? ` / ${subscription.interval}` : ""}
</div>
)}
{periodEnd && (
<div>
{subscription.cancelAtPeriodEnd ? "Cancels" : "Renews"} {periodEnd}
</div>
)}
</div>
<div className="mt-4 space-y-2.5">
{subscription.cancelAtPeriodEnd ? (
<button
onClick={() => run(() => resumeUserSubscription(userId), "Subscription resumed")}
disabled={isPending}
className={cn(
btnBase,
"border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20"
)}
>
<PlayCircle className="h-4 w-4" /> Resume subscription
</button>
) : (
<button
onClick={() =>
run(() => cancelUserSubscription(userId, false), "Cancellation scheduled")
}
disabled={isPending}
className={cn(btnBase, btnGhost)}
>
Cancel at period end
</button>
)}
<button
onClick={() => setShowCancelNow(true)}
disabled={isPending}
className={cn(
btnBase,
"border border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20"
)}
>
Cancel immediately
</button>
</div>
</>
) : (
<p className="mt-3 text-xs text-white/40">
No active Stripe subscription. Plan changes for this user must be comps.
</p>
)}
</div>
{/* ── Admin role ─────────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<ShieldAlert className="h-4 w-4 text-violet-400" />
<h3 className="text-sm font-semibold">Admin access</h3>
</div>
<p className="mt-1 text-xs text-white/40">
{isAdminRole
? "This user has full admin access to the platform."
: "Grant full access to the admin dashboard and every account."}
</p>
<button
onClick={() => setShowRole(true)}
disabled={isPending || isSelf}
title={isSelf ? "You cannot change your own role" : undefined}
className={cn(
btnBase,
"mt-4",
isAdminRole
? "border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
: "border border-violet-500/30 bg-violet-500/10 text-violet-300 hover:bg-violet-500/20"
)}
>
<ShieldAlert className="h-4 w-4" />
{isAdminRole ? "Revoke admin access" : "Make admin"}
</button>
</div>
{/* ── Account actions ────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<UserCog className="h-4 w-4 text-rose-400" />
<h3 className="text-sm font-semibold">Account</h3>
</div>
<div className="mt-4 space-y-2.5">
{/* Ban / Unban */}
{banned ? (
<button
onClick={onUnban}
onClick={() => run(() => unbanUser(userId), "User unbanned")}
disabled={isPending}
className={cn(btnBase, "border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20")}
className={cn(
btnBase,
"border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20"
)}
>
<ShieldCheck className="h-4 w-4" /> Unban user
</button>
@@ -150,34 +319,35 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
onClick={() => setShowBan(true)}
disabled={isPending || isSelf}
title={isSelf ? "You cannot ban yourself" : undefined}
className={cn(btnBase, "border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20")}
className={cn(
btnBase,
"border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
)}
>
<Ban className="h-4 w-4" /> Ban user
</button>
)}
{/* Impersonate */}
<button
onClick={() => setShowImpersonate(true)}
disabled={isPending || isSelf}
title={isSelf ? "You cannot impersonate yourself" : undefined}
className={cn(btnBase, "border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white")}
className={cn(btnBase, btnGhost)}
>
<UserCog className="h-4 w-4" /> Impersonate
</button>
{/* Verify email */}
<button
onClick={onVerify}
onClick={() => run(() => markEmailVerified(userId), "Email marked as verified")}
disabled={isPending}
className={cn(btnBase, "border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white")}
className={cn(btnBase, btnGhost)}
>
<MailCheck className="h-4 w-4" /> Mark email verified
</button>
</div>
</div>
{/* Danger zone */}
{/* ── Danger zone ────────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-red-500/20 bg-red-500/[0.03] p-5">
<div className="flex items-center gap-2 text-red-400">
<Trash2 className="h-4 w-4" />
@@ -199,7 +369,10 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
{/* Ban modal (with reason input) */}
{showBan && (
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => !isPending && setShowBan(false)} />
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => !isPending && setShowBan(false)}
/>
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] p-6 shadow-2xl shadow-black/80">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-amber-500/20 bg-amber-500/10 text-amber-400">
<Ban className="h-6 w-6" />
@@ -209,7 +382,9 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
The user will be signed out and blocked from signing in until unbanned.
</p>
<div className="mt-4">
<label className="mb-1.5 block text-xs font-medium text-white/40">Reason (optional)</label>
<label className="mb-1.5 block text-xs font-medium text-white/40">
Reason (optional)
</label>
<input
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
@@ -226,7 +401,12 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
Cancel
</button>
<button
onClick={onBan}
onClick={() =>
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => {
setShowBan(false)
setBanReason("")
})
}
disabled={isPending}
className="flex-1 rounded-xl bg-amber-600 py-2.5 text-sm font-semibold text-white transition hover:bg-amber-500 disabled:opacity-50"
>
@@ -237,7 +417,53 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
</div>
)}
{/* Impersonate confirm */}
<ConfirmModal
open={showStripePlan}
variant="warning"
title={`Move ${email} to ${plan} in Stripe?`}
description="This updates their live Stripe subscription with proration — the customer will be charged or credited the difference immediately. Choose the Comp option instead to grant access without billing them."
confirmLabel="Update billing"
loading={isPending}
onConfirm={() => applyPlan("stripe")}
onCancel={() => setShowStripePlan(false)}
/>
<ConfirmModal
open={showCancelNow}
variant="danger"
title="Cancel immediately?"
description="Access ends right now and the plan resets to Starter. No refund is issued automatically — refund the charge separately if that's intended. To let them keep what they paid for, cancel at period end instead."
confirmLabel="Cancel now"
loading={isPending}
onConfirm={() =>
run(() => cancelUserSubscription(userId, true), "Subscription canceled", () =>
setShowCancelNow(false)
)
}
onCancel={() => setShowCancelNow(false)}
/>
<ConfirmModal
open={showRole}
variant={isAdminRole ? "warning" : "danger"}
title={isAdminRole ? `Revoke admin from ${email}?` : `Make ${email} an admin?`}
description={
isAdminRole
? "They will lose access to the admin dashboard immediately."
: "They will gain full access to every account on the platform, including billing actions and user deletion. Grant this only to staff you trust completely."
}
confirmLabel={isAdminRole ? "Revoke access" : "Make admin"}
loading={isPending}
onConfirm={() =>
run(
() => setUserRole(userId, isAdminRole ? "user" : "admin"),
isAdminRole ? "Admin access revoked" : "User promoted to admin",
() => setShowRole(false)
)
}
onCancel={() => setShowRole(false)}
/>
<ConfirmModal
open={showImpersonate}
variant="warning"
@@ -245,11 +471,19 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
description="You will be signed in as this user and redirected to their dashboard. Your admin session can be restored from the impersonation banner."
confirmLabel="Impersonate"
loading={isPending}
onConfirm={onImpersonate}
onConfirm={() => {
startTransition(async () => {
try {
await impersonateUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowImpersonate(false)
}
})
}}
onCancel={() => setShowImpersonate(false)}
/>
{/* Delete confirm */}
<ConfirmModal
open={showDelete}
variant="danger"
@@ -257,7 +491,16 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
description="This permanently deletes the user and ALL their data — properties, units, tenants, leases, payments and more. This action cannot be undone."
confirmLabel="Delete user"
loading={isPending}
onConfirm={onDelete}
onConfirm={() => {
startTransition(async () => {
try {
await deleteUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowDelete(false)
}
})
}}
onCancel={() => setShowDelete(false)}
/>
</div>
+58 -73
View File
@@ -1,43 +1,9 @@
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { motion } from "framer-motion"
import { Plus, Minus } from "lucide-react"
const FAQS = [
{
q: "Is there really a free plan?",
a: "Yes — the Starter plan is free forever. You get 1 property, up to 3 tenants, rent tracking, maintenance requests, and lease management. No credit card needed to sign up.",
},
{
q: "What happens when my trial ends?",
a: "There's no trial — paid plans start immediately when you upgrade. If you're on the free Starter plan, it never expires. You upgrade when you outgrow it.",
},
{
q: "Can I cancel anytime?",
a: "Yes. Cancel any time from your billing portal — no questions, no fees. Your data stays accessible for 30 days after cancellation so you can export everything.",
},
{
q: "Do tenants need to create an account?",
a: "No. Each tenant gets a unique private link to their portal — no account creation, no password. They can view rent history and submit maintenance requests instantly.",
},
{
q: "Does Property Management Network handle actual rent collection?",
a: "Yes — the Pro and Landlord plans include Stripe payment link generation. You can create a payment link for each tenant and they pay directly via card or bank transfer.",
},
{
q: "Is my data secure?",
a: "Yes. Every request is authenticated and scoped to your account, so landlords only ever see their own data and tenants only see their own records. All data is encrypted at rest and in transit.",
},
{
q: "Can I manage multiple properties?",
a: "The Starter plan supports 1 property. Pro supports up to 10. Landlord and Lifetime plans support unlimited properties and units.",
},
{
q: "What's included in the Lifetime deal?",
a: "The Lifetime plan is a one-time payment of $199. You get everything in the Landlord plan — unlimited properties, tenants, 25GB storage, AI calls, team access — with no recurring fees, ever. All future updates included.",
},
]
import { FAQS } from "@/lib/marketing/faqs"
export function FAQ() {
const [open, setOpen] = useState<number | null>(null)
@@ -56,45 +22,64 @@ export function FAQ() {
</motion.div>
<div className="space-y-3">
{FAQS.map((faq, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05 }}
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
>
<button
onClick={() => setOpen(open === i ? null : i)}
className="flex w-full items-center justify-between px-5 py-4 text-left"
{FAQS.map((faq, i) => {
const isOpen = open === i
return (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05 }}
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
>
<span className="text-sm font-medium text-white pr-4">{faq.q}</span>
<div className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${
open === i ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10"
}`}>
{open === i
? <Minus className="h-3 w-3 text-indigo-400" />
: <Plus className="h-3 w-3 text-white/50" />
}
</div>
</button>
<AnimatePresence initial={false}>
{open === i && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
<h3>
<button
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
aria-controls={`faq-answer-${i}`}
id={`faq-question-${i}`}
className="flex w-full items-center justify-between px-5 py-4 text-left"
>
<p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3">
{faq.a}
</p>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
<span className="text-sm font-medium text-white pr-4">{faq.q}</span>
<div
className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${
isOpen ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10"
}`}
>
{isOpen ? (
<Minus className="h-3 w-3 text-indigo-400" />
) : (
<Plus className="h-3 w-3 text-white/50" />
)}
</div>
</button>
</h3>
{/*
The answer stays mounted and is collapsed by animating its height
rather than being conditionally rendered. Google requires the
answer text behind an FAQ accordion to be present in the served
HTML — unmounting it when closed would leave the FAQPage JSON-LD
in components/marketing/structured-data.tsx describing content no
crawler can see.
*/}
<motion.div
id={`faq-answer-${i}`}
role="region"
aria-labelledby={`faq-question-${i}`}
initial={false}
animate={{ height: isOpen ? "auto" : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3">
{faq.a}
</p>
</motion.div>
</motion.div>
)
})}
</div>
</section>
)
+4 -4
View File
@@ -5,10 +5,10 @@ import { LEGAL_PAGES } from "@/lib/legal"
const LINKS = {
Product: [
{ label: "Features", href: "#features" },
{ label: "Pricing", href: "#pricing" },
{ label: "How it works", href: "#how-it-works" },
{ label: "FAQ", href: "#faq" },
{ label: "Features", href: "/#features" },
{ label: "Pricing", href: "/#pricing" },
{ label: "How it works", href: "/#how-it-works" },
{ label: "FAQ", href: "/#faq" },
],
Platform: [
{ label: "Dashboard", href: "/login" },
+4 -4
View File
@@ -7,10 +7,10 @@ import { Menu, X, ArrowRight } from "lucide-react"
import { Logo } from "@/components/shared/logo"
const NAV_LINKS = [
{ label: "Features", href: "#features" },
{ label: "How it works", href: "#how-it-works" },
{ label: "Pricing", href: "#pricing" },
{ label: "FAQ", href: "#faq" },
{ label: "Features", href: "/#features" },
{ label: "How it works", href: "/#how-it-works" },
{ label: "Pricing", href: "/#pricing" },
{ label: "FAQ", href: "/#faq" },
]
export function Navbar() {
+75 -65
View File
@@ -1,59 +1,28 @@
import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans"
import { FAQS } from "@/lib/marketing/faqs"
import type { Plan } from "@/types"
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth).
// Annual billing is auto-provisioned at checkout and has no fixed amount here, so
// we advertise only the monthly / one-time base prices that actually exist.
const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"]
const planOffers = planOrder.map((plan) => ({
"@type": "Offer",
name: getPlanLabel(plan),
price: String(PLAN_AMOUNTS[plan]),
priceCurrency: "USD",
}))
function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
// JSON.stringify output is escaped for the closing-tag sequence so a value
// containing "</script>" can't break out of the block.
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\u003c") }}
/>
)
}
// Mirrors the visible FAQ content in components/marketing/faq.tsx.
// Keep these in sync with that source so the JSON-LD matches what users see.
const faqs = [
{
q: "Is there really a free plan?",
a: "Yes — the Starter plan is free forever. You get 1 property, up to 3 tenants, rent tracking, maintenance requests, and lease management. No credit card needed to sign up.",
},
{
q: "What happens when my trial ends?",
a: "There's no trial — paid plans start immediately when you upgrade. If you're on the free Starter plan, it never expires. You upgrade when you outgrow it.",
},
{
q: "Can I cancel anytime?",
a: "Yes. Cancel any time from your billing portal — no questions, no fees. Your data stays accessible for 30 days after cancellation so you can export everything.",
},
{
q: "Do tenants need to create an account?",
a: "No. Each tenant gets a unique private link to their portal — no account creation, no password. They can view rent history and submit maintenance requests instantly.",
},
{
q: "Does Property Management Network handle actual rent collection?",
a: "Yes — the Pro and Landlord plans include Stripe payment link generation. You can create a payment link for each tenant and they pay directly via card or bank transfer.",
},
{
q: "Is my data secure?",
a: "Yes. Every request is authenticated and scoped to your account, so landlords only ever see their own data and tenants only see their own records. All data is encrypted at rest and in transit.",
},
{
q: "Can I manage multiple properties?",
a: "The Starter plan supports 1 property. Pro supports up to 10. Landlord and Lifetime plans support unlimited properties and units.",
},
{
q: "What's included in the Lifetime deal?",
a: "The Lifetime plan is a one-time payment of $199. You get everything in the Landlord plan — unlimited properties, tenants, 25GB storage, AI calls, team access — with no recurring fees, ever. All future updates included.",
},
]
// ── Site-wide entities ───────────────────────────────────────────
// Organization and WebSite describe the publisher and the site itself, so they
// are valid on every page of the marketing surface.
const organization: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${base}/#organization`,
name: "Property Management Network",
url: base,
logo: `${base}/logo-mark.png`,
@@ -71,25 +40,76 @@ const organization: Record<string, unknown> = {
const website: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${base}/#website`,
name: "Property Management Network",
url: base,
publisher: { "@id": `${base}/#organization` },
}
/**
* Organization + WebSite JSON-LD. Safe to render on every marketing page —
* both describe the site as a whole rather than the content of one page.
*/
export function SiteStructuredData() {
return (
<>
<JsonLd data={organization} />
<JsonLd data={website} />
</>
)
}
// ── Home-page-only entities ──────────────────────────────────────
// SoftwareApplication describes the product presented on the landing page, and
// FAQPage MUST only be emitted where the same questions and answers are visible
// to the user (Google's FAQ structured data policy). Both therefore belong to
// `/` alone and must NOT be moved into the shared marketing layout.
// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth).
// Annual billing is auto-provisioned at checkout and has no fixed amount here, so
// we advertise only the monthly / one-time base prices that actually exist.
const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"]
const planPrices = planOrder.map((plan) => PLAN_AMOUNTS[plan])
const planOffers = planOrder.map((plan) => ({
"@type": "Offer",
name: getPlanLabel(plan),
price: String(PLAN_AMOUNTS[plan]),
priceCurrency: "USD",
url: `${base}/#pricing`,
availability: "https://schema.org/InStock",
}))
const softwareApplication: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"@id": `${base}/#software`,
name: "Property Management Network",
url: base,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
description:
"Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.",
offers: planOffers,
publisher: { "@id": `${base}/#organization` },
// AggregateOffer is the correct wrapper for a product sold at several price
// points; the individual plan Offers are nested inside it.
offers: {
"@type": "AggregateOffer",
priceCurrency: "USD",
lowPrice: String(Math.min(...planPrices)),
highPrice: String(Math.max(...planPrices)),
offerCount: planOffers.length,
offers: planOffers,
},
}
// Mirrors the visible FAQ rendered by components/marketing/faq.tsx — both read
// the same lib/marketing/faqs.ts list, so the markup can never drift from the
// copy on the page.
const faqPage: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@id": `${base}/#faq`,
mainEntity: FAQS.map((faq) => ({
"@type": "Question",
name: faq.q,
acceptedAnswer: {
@@ -99,25 +119,15 @@ const faqPage: Record<string, unknown> = {
})),
}
export function StructuredData() {
/**
* SoftwareApplication + FAQPage JSON-LD. Render this ONLY on `/`, which is the
* page that actually shows the pricing table and the FAQ accordion.
*/
export function HomeStructuredData() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organization) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(website) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplication) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqPage) }}
/>
<JsonLd data={softwareApplication} />
<JsonLd data={faqPage} />
</>
)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types"
import type { AccountingProvider, OAuthTokens } from "./types"
import { redirectUri } from "./types"
// QuickBooks Online. Docs: https://developer.intuit.com/app/developer/qbo/docs/develop
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types"
import type { AccountingProvider, OAuthTokens } from "./types"
import { redirectUri } from "./types"
// Xero. Docs: https://developer.xero.com/documentation/guides/oauth2/
+5
View File
@@ -11,8 +11,13 @@ export type AdminAction =
| "stop_impersonate"
| "delete_user"
| "resend_verification"
| "mark_email_verified"
| "maintenance_mode"
| "ai_provider"
| "plan_change_stripe"
| "cancel_subscription"
| "resume_subscription"
| "refund"
/**
* Append one immutable row to admin_audit_log. Call this for EVERY mutating
+314
View File
@@ -0,0 +1,314 @@
import { eq } from "drizzle-orm"
import type Stripe from "stripe"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { stripe } from "@/lib/stripe/client"
import { resolvePriceId } from "@/lib/stripe/prices"
import type { Plan } from "@/types"
// ============================================================================
// Admin-side billing operations.
//
// Everything here TOUCHES REAL MONEY. Two rules the callers depend on:
//
// 1. Every function returns a discriminated result instead of throwing on an
// expected condition (no subscription, unsupported transition). The admin
// UI shows the message; only genuine Stripe/network faults throw.
// 2. When a subscription's price changes we ALSO rewrite its metadata.plan.
// The Stripe webhook (app/api/stripe/webhook/route.ts) derives the app plan
// from `subscription.metadata.plan` — leaving it stale would make the
// webhook immediately revert the change we just made.
// ============================================================================
export type BillingResult<T = undefined> =
| ({ ok: true } & (T extends undefined ? { detail?: string } : { data: T; detail?: string }))
| { ok: false; error: string }
const fail = (error: string): { ok: false; error: string } => ({ ok: false, error })
/** Plans that map to a recurring Stripe subscription. */
const RECURRING_PLANS: Plan[] = ["pro", "landlord"]
async function getProfile(userId: string) {
return db.query.profiles.findFirst({
where: eq(profiles.id, userId),
columns: {
id: true,
email: true,
plan: true,
stripe_customer_id: true,
stripe_subscription_id: true,
subscription_status: true,
},
})
}
/**
* Move a user to `plan` IN STRIPE, then mirror it locally.
*
* Supported transitions:
* paid → paid swap the subscription item's price (prorated)
* paid → starter cancel the subscription at period end
*
* Not supported (returns ok:false, never a silent no-op):
* → lifetime a one-time payment, not a subscription change
* no subscription nothing to modify — use a comp instead
*/
export async function changePlanInStripe(
userId: string,
plan: Plan,
interval: "month" | "year" = "month"
): Promise<BillingResult> {
const profile = await getProfile(userId)
if (!profile) return fail("User not found.")
if (plan === "lifetime") {
return fail(
"Lifetime is a one-time purchase, not a subscription. Grant it as a comp, " +
"or have the user complete lifetime checkout."
)
}
const subId = profile.stripe_subscription_id
if (!subId) {
return fail(
"This user has no active Stripe subscription to modify. Grant the plan as a comp instead."
)
}
let subscription: Stripe.Subscription
try {
subscription = await stripe.subscriptions.retrieve(subId)
} catch {
return fail("Could not load the subscription from Stripe — it may have been deleted.")
}
if (subscription.status === "canceled") {
return fail("That subscription is already canceled. Grant the plan as a comp instead.")
}
// ── downgrade to free: cancel at period end so they keep what they paid for ──
if (plan === "starter") {
await stripe.subscriptions.update(subId, { cancel_at_period_end: true })
await db
.update(profiles)
.set({ subscription_status: "canceling" })
.where(eq(profiles.id, userId))
return {
ok: true,
detail:
"Subscription set to cancel at the end of the current period. The plan stays active until then.",
}
}
// ── paid → paid: swap the price on the existing item ────────────────────────
if (!RECURRING_PLANS.includes(plan)) return fail(`Unsupported plan: ${plan}`)
const priceId = await resolvePriceId(plan, interval)
if (!priceId) return fail("Could not resolve the Stripe price for that plan.")
const item = subscription.items.data[0]
if (!item) return fail("That subscription has no line items to modify.")
await stripe.subscriptions.update(subId, {
items: [{ id: item.id, price: priceId }],
// Bill the difference now rather than silently absorbing it.
proration_behavior: "create_prorations",
cancel_at_period_end: false,
// MUST stay in sync — the webhook reads plan from here.
metadata: { ...subscription.metadata, user_id: userId, plan },
})
await db
.update(profiles)
.set({ plan, subscription_status: "active" })
.where(eq(profiles.id, userId))
return { ok: true, detail: `Stripe subscription moved to ${plan} (${interval}ly), prorated.` }
}
/**
* Cancel a subscription. `immediate` ends access now and is the option that can
* surprise a paying customer, so the UI confirms it separately from the
* cancel-at-period-end default.
*/
export async function cancelSubscription(
userId: string,
immediate = false
): Promise<BillingResult> {
const profile = await getProfile(userId)
if (!profile) return fail("User not found.")
const subId = profile.stripe_subscription_id
if (!subId) return fail("This user has no active Stripe subscription.")
try {
if (immediate) {
await stripe.subscriptions.cancel(subId)
await db
.update(profiles)
.set({
plan: "starter",
subscription_status: "canceled",
stripe_subscription_id: null,
plan_expires_at: null,
})
.where(eq(profiles.id, userId))
return { ok: true, detail: "Subscription canceled immediately and plan reset to Starter." }
}
await stripe.subscriptions.update(subId, { cancel_at_period_end: true })
await db
.update(profiles)
.set({ subscription_status: "canceling" })
.where(eq(profiles.id, userId))
return { ok: true, detail: "Subscription will cancel at the end of the current period." }
} catch (e) {
return fail((e as Error).message.slice(0, 300))
}
}
/** Undo a pending cancel-at-period-end. */
export async function resumeSubscription(userId: string): Promise<BillingResult> {
const profile = await getProfile(userId)
if (!profile) return fail("User not found.")
const subId = profile.stripe_subscription_id
if (!subId) return fail("This user has no subscription to resume.")
try {
await stripe.subscriptions.update(subId, { cancel_at_period_end: false })
await db
.update(profiles)
.set({ subscription_status: "active" })
.where(eq(profiles.id, userId))
return { ok: true, detail: "Scheduled cancellation removed — the subscription will renew." }
} catch (e) {
return fail((e as Error).message.slice(0, 300))
}
}
export type ChargeRow = {
id: string
amount: number
amountRefunded: number
currency: string
created: number
status: string
refunded: boolean
description: string | null
receiptUrl: string | null
}
/** Recent charges for a user, newest first — the list the refund UI works from. */
export async function listUserCharges(userId: string, limit = 10): Promise<ChargeRow[]> {
const profile = await getProfile(userId)
if (!profile?.stripe_customer_id) return []
try {
const charges = await stripe.charges.list({
customer: profile.stripe_customer_id,
limit,
})
return charges.data.map((c) => ({
id: c.id,
amount: c.amount,
amountRefunded: c.amount_refunded,
currency: c.currency,
created: c.created,
status: c.status,
refunded: c.refunded,
description: c.description,
receiptUrl: c.receipt_url,
}))
} catch {
// Stripe unreachable or key unset — the page still renders without billing.
return []
}
}
/**
* Refund a charge, fully or partially. `amountCents` omitted = full refund of
* whatever remains unrefunded.
*
* The charge is re-read and verified to belong to THIS user's Stripe customer
* before refunding: the charge id arrives from the client, and without that
* check an admin action could be replayed with any charge id in the account.
*/
export async function refundCharge(
userId: string,
chargeId: string,
amountCents?: number,
reason?: "duplicate" | "fraudulent" | "requested_by_customer"
): Promise<BillingResult<{ refundId: string; amount: number }>> {
const profile = await getProfile(userId)
if (!profile?.stripe_customer_id) return fail("This user has no Stripe customer record.")
let charge: Stripe.Charge
try {
charge = await stripe.charges.retrieve(chargeId)
} catch {
return fail("Could not load that charge from Stripe.")
}
const chargeCustomer = typeof charge.customer === "string" ? charge.customer : charge.customer?.id
if (chargeCustomer !== profile.stripe_customer_id) {
return fail("That charge does not belong to this user.")
}
if (charge.refunded) return fail("That charge is already fully refunded.")
const remaining = charge.amount - charge.amount_refunded
if (remaining <= 0) return fail("Nothing left to refund on that charge.")
if (amountCents !== undefined) {
if (!Number.isInteger(amountCents) || amountCents <= 0) {
return fail("Refund amount must be a positive number of cents.")
}
if (amountCents > remaining) {
return fail(`Refund exceeds the ${(remaining / 100).toFixed(2)} still available on that charge.`)
}
}
try {
const refund = await stripe.refunds.create({
charge: chargeId,
...(amountCents !== undefined ? { amount: amountCents } : {}),
...(reason ? { reason } : {}),
})
return {
ok: true,
data: { refundId: refund.id, amount: refund.amount },
detail: `Refunded ${(refund.amount / 100).toFixed(2)} ${charge.currency.toUpperCase()}.`,
}
} catch (e) {
return fail((e as Error).message.slice(0, 300))
}
}
export type SubscriptionSummary = {
id: string
status: string
cancelAtPeriodEnd: boolean
currentPeriodEnd: number | null
priceNickname: string | null
amount: number | null
interval: string | null
} | null
/** Live subscription state straight from Stripe, for the admin user page. */
export async function getSubscriptionSummary(userId: string): Promise<SubscriptionSummary> {
const profile = await getProfile(userId)
if (!profile?.stripe_subscription_id) return null
try {
const s = await stripe.subscriptions.retrieve(profile.stripe_subscription_id)
const item = s.items.data[0]
return {
id: s.id,
status: s.status,
cancelAtPeriodEnd: s.cancel_at_period_end,
currentPeriodEnd: (item as unknown as { current_period_end?: number })?.current_period_end ?? null,
priceNickname: item?.price?.nickname ?? null,
amount: item?.price?.unit_amount ?? null,
interval: item?.price?.recurring?.interval ?? null,
}
} catch {
return null
}
}
+19 -1
View File
@@ -3,9 +3,27 @@ import crypto from "crypto"
// AES-256-GCM encryption for secrets at rest (OAuth tokens). The key is derived
// from ACCOUNTING_ENCRYPTION_KEY, falling back to BETTER_AUTH_SECRET, via SHA-256
// so no additional configuration is required.
let warnedAboutFallbackKey = false
function getKey(): Buffer {
const secret = process.env.ACCOUNTING_ENCRYPTION_KEY || process.env.BETTER_AUTH_SECRET
const dedicated = process.env.ACCOUNTING_ENCRYPTION_KEY
const secret = dedicated || process.env.BETTER_AUTH_SECRET
if (!secret) throw new Error("No encryption key configured (BETTER_AUTH_SECRET)")
// Riding on BETTER_AUTH_SECRET works, but couples two independent rotation
// schedules: rotating the auth secret would silently make every stored OAuth
// token undecryptable, with no migration path and no error until a user's
// next accounting sync fails. Warn once so this is caught before that
// happens rather than after.
if (!dedicated && !warnedAboutFallbackKey) {
warnedAboutFallbackKey = true
console.warn(
"[crypto] ACCOUNTING_ENCRYPTION_KEY is not set — deriving the at-rest key " +
"from BETTER_AUTH_SECRET. Rotating BETTER_AUTH_SECRET will make all " +
"stored OAuth tokens undecryptable. Set a dedicated key in production."
)
}
return crypto.createHash("sha256").update(secret).digest()
}
+101
View File
@@ -401,6 +401,107 @@ export async function getUserDetail(id: string) {
}
}
// ── per-user portfolio (admin support view) ───────────────────────────────────
// Read-only window into ONE user's actual records. Before this existed an admin
// could see only aggregate counts, so answering "what does this customer
// actually have?" meant impersonating them — which mutates their session and
// shows up in their own audit trail. This is deliberately read-only: it answers
// support questions without touching anything.
//
// Every query is scoped by user_id. Admin queries bypass the app's normal
// ownership scoping, so the caller MUST have passed requireAdmin()/getAdminSession().
export async function getUserPortfolio(userId: string) {
const [propertyRows, unitRows, tenantRows, leaseRows, paymentRows, maintenanceRows] =
await Promise.all([
db
.select({
id: properties.id,
name: properties.name,
address_line1: properties.address_line1,
city: properties.city,
state: properties.state,
total_units: properties.total_units,
created_at: properties.created_at,
})
.from(properties)
.where(eq(properties.user_id, userId))
.orderBy(desc(properties.created_at))
.limit(100),
db
.select({
id: units.id,
property_id: units.property_id,
unit_number: units.unit_number,
rent_amount: units.rent_amount,
status: units.status,
})
.from(units)
.where(eq(units.user_id, userId))
.orderBy(desc(units.created_at))
.limit(200),
db
.select({
id: tenants.id,
first_name: tenants.first_name,
last_name: tenants.last_name,
email: tenants.email,
phone: tenants.phone,
status: tenants.status,
move_in_date: tenants.move_in_date,
})
.from(tenants)
.where(eq(tenants.user_id, userId))
.orderBy(desc(tenants.created_at))
.limit(100),
db
.select({
id: leases.id,
tenant_id: leases.tenant_id,
lease_start: leases.lease_start,
lease_end: leases.lease_end,
rent_amount: leases.rent_amount,
status: leases.status,
})
.from(leases)
.where(eq(leases.user_id, userId))
.orderBy(desc(leases.created_at))
.limit(100),
db
.select({
id: rent_payments.id,
amount: rent_payments.amount,
due_date: rent_payments.due_date,
paid_date: rent_payments.paid_date,
status: rent_payments.status,
})
.from(rent_payments)
.where(eq(rent_payments.user_id, userId))
.orderBy(desc(rent_payments.due_date))
.limit(50),
db
.select({
id: maintenance_requests.id,
title: maintenance_requests.title,
priority: maintenance_requests.priority,
status: maintenance_requests.status,
created_at: maintenance_requests.created_at,
})
.from(maintenance_requests)
.where(eq(maintenance_requests.user_id, userId))
.orderBy(desc(maintenance_requests.created_at))
.limit(50),
])
return {
properties: propertyRows,
units: unitRows,
tenants: tenantRows,
leases: leaseRows,
payments: paymentRows,
maintenance: maintenanceRows,
}
}
// ── CSV helpers (shared by admin export routes) ─────────────────────────────────
export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]) {
const esc = (v: string | number | null | undefined) => {
+18 -18
View File
@@ -12,43 +12,43 @@ interface UserState {
export function useUser(): UserState {
const { data: session, isPending } = useSession()
const [profile, setProfile] = useState<Profile | null>(null)
const [profileLoading, setProfileLoading] = useState(true)
// The fetched profile is stored together with the user id it belongs to, so
// "loaded" is DERIVED rather than tracked in a second state variable. That
// removes the synchronous setState in the effect body (which caused cascading
// renders) and, as a bonus, stops a previous user's profile from flashing
// while a new one loads.
const [fetched, setFetched] = useState<{ userId: string; profile: Profile | null } | null>(null)
const userId = session?.user?.id
useEffect(() => {
if (!userId) return
let active = true
if (!userId) {
setProfile(null)
setProfileLoading(false)
return
}
setProfileLoading(true)
fetch("/api/profile")
.then((r) => (r.ok ? r.json() : { profile: null }))
.then((data) => {
if (active) {
setProfile(data.profile ?? null)
setProfileLoading(false)
}
if (active) setFetched({ userId, profile: data.profile ?? null })
})
.catch(() => {
if (active) {
setProfile(null)
setProfileLoading(false)
}
if (active) setFetched({ userId, profile: null })
})
return () => {
active = false
}
}, [userId])
const isCurrent = !!userId && fetched?.userId === userId
return {
user: session?.user
? { id: session.user.id, email: session.user.email, name: session.user.name }
: null,
profile,
loading: isPending || profileLoading,
profile: isCurrent ? (fetched?.profile ?? null) : null,
// Signed out: nothing to load. Signed in: loading until this user's profile
// has actually come back.
loading: isPending || (!!userId && !isCurrent),
}
}
+40
View File
@@ -0,0 +1,40 @@
// Single source of truth for the marketing FAQ.
//
// This list is rendered as visible copy by components/marketing/faq.tsx AND
// serialised into the FAQPage JSON-LD in components/marketing/structured-data.tsx.
// Google requires FAQ structured data to match content that is visible on the
// same page, so both consumers MUST read from here — never from a second copy.
export const FAQS = [
{
q: "Is there really a free plan?",
a: "Yes — the Starter plan is free forever. You get 1 property, up to 3 tenants, rent tracking, maintenance requests, and lease management. No credit card needed to sign up.",
},
{
q: "What happens when my trial ends?",
a: "There's no trial — paid plans start immediately when you upgrade. If you're on the free Starter plan, it never expires. You upgrade when you outgrow it.",
},
{
q: "Can I cancel anytime?",
a: "Yes. Cancel any time from your billing portal — no questions, no fees. Your data stays accessible for 30 days after cancellation so you can export everything.",
},
{
q: "Do tenants need to create an account?",
a: "No. Each tenant gets a unique private link to their portal — no account creation, no password. They can view rent history and submit maintenance requests instantly.",
},
{
q: "Does Property Management Network handle actual rent collection?",
a: "Yes — the Pro and Landlord plans include Stripe payment link generation. You can create a payment link for each tenant and they pay directly via card or bank transfer.",
},
{
q: "Is my data secure?",
a: "Yes. Every request is authenticated and scoped to your account, so landlords only ever see their own data and tenants only see their own records. All data is encrypted at rest and in transit.",
},
{
q: "Can I manage multiple properties?",
a: "The Starter plan supports 1 property. Pro supports up to 10. Landlord and Lifetime plans support unlimited properties and units.",
},
{
q: "What's included in the Lifetime deal?",
a: "The Lifetime plan is a one-time payment of $199. You get everything in the Landlord plan — unlimited properties, tenants, 25GB storage, AI calls, team access — with no recurring fees, ever. All future updates included.",
},
] as const
+43 -3
View File
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"
import { eq, sql } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { PLAN_LIMITS } from "@/lib/stripe/plans"
import { profiles, properties, tenants } from "@/lib/db/schema"
import { PLAN_LIMITS, checkLimit } from "@/lib/stripe/plans"
import { getUserStorageBytes } from "@/lib/storage"
import type { Plan } from "@/types"
@@ -39,3 +39,43 @@ export async function checkStorageLimit(
}
return null
}
// ── countable resource caps ──────────────────────────────────────────────────
// These live here, not inline in route handlers, because there is more than one
// way into the app: the session routes AND the public v1 API both create
// properties. When the check was written inline, v1 simply did not have it and a
// Starter user could mint an API key and create unlimited properties. Every
// creation path MUST call the helper for its resource — mirroring the same rule
// the upload allowlist follows in lib/storage.ts.
/**
* Returns a user-facing error message if the owner is at their plan's property
* cap, otherwise null. Call before every property insert, on every entry point.
*/
export async function checkPropertyLimit(ownerId: string): Promise<string | null> {
const plan = await getUserPlan(ownerId)
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(properties)
.where(eq(properties.user_id, ownerId))
if (!checkLimit(plan, "maxProperties", count)) {
return "Plan limit reached. Upgrade to add more properties."
}
return null
}
/**
* Returns a user-facing error message if the owner is at their plan's tenant
* cap, otherwise null. Call before every tenant insert, on every entry point.
*/
export async function checkTenantLimit(ownerId: string): Promise<string | null> {
const plan = await getUserPlan(ownerId)
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(tenants)
.where(eq(tenants.user_id, ownerId))
if (!checkLimit(plan, "maxTenants", count)) {
return "Plan limit reached. Upgrade to add more tenants."
}
return null
}
+107
View File
@@ -0,0 +1,107 @@
import { NextResponse } from "next/server"
// ============================================================================
// Fixed-window rate limiting for endpoints Better Auth does not cover.
//
// Better Auth throttles its own /api/auth/* endpoints (see lib/auth.ts). Nothing
// else was limited: the public v1 API, uploads, and the unauthenticated tenant
// portal maintenance submission were all unbounded. This closes that gap.
//
// SCOPE / LIMITATION: counters live in the process, so the limit is enforced
// PER INSTANCE. On a single container (the current Dokploy deployment) that is
// the real limit; if the app is ever scaled horizontally, an attacker's
// effective ceiling multiplies by the replica count. That is still a bounded,
// large improvement over "no limit at all", and the swap to a shared store
// (Postgres or Redis) only has to replace `hit()` below. Do NOT let the absence
// of a shared store be a reason to ship no limit.
// ============================================================================
type Counter = { count: number; resetAt: number }
const buckets = new Map<string, Counter>()
// Drop expired counters so a long-lived process doesn't accumulate a key per
// distinct IP forever. Runs opportunistically on write, not on a timer.
let lastSweep = 0
function sweep(now: number): void {
if (now - lastSweep < 60_000) return
lastSweep = now
for (const [key, c] of buckets) {
if (c.resetAt <= now) buckets.delete(key)
}
}
export type RateLimitResult = {
ok: boolean
/** Requests remaining in the current window. */
remaining: number
/** Seconds until the window resets — sent as Retry-After on a 429. */
retryAfter: number
limit: number
}
/**
* Record a hit against `key` and report whether it is within `limit` per
* `windowSeconds`. Callers pass a namespaced key (e.g. `v1:<userId>`) so
* different endpoints never share a bucket.
*/
export function hit(key: string, limit: number, windowSeconds: number): RateLimitResult {
const now = Date.now()
sweep(now)
const existing = buckets.get(key)
if (!existing || existing.resetAt <= now) {
const resetAt = now + windowSeconds * 1000
buckets.set(key, { count: 1, resetAt })
return { ok: true, remaining: limit - 1, retryAfter: windowSeconds, limit }
}
existing.count++
const retryAfter = Math.max(1, Math.ceil((existing.resetAt - now) / 1000))
return {
ok: existing.count <= limit,
remaining: Math.max(0, limit - existing.count),
retryAfter,
limit,
}
}
/**
* The caller's IP, taken from the proxy headers Traefik/Dokploy set. Falls back
* to a constant so a missing header degrades to one shared bucket (fail closed
* on volume) rather than to no limit at all.
*/
export function clientIp(request: Request): string {
const fwd = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
return fwd || request.headers.get("x-real-ip") || "unknown"
}
/** Standard 429 with rate-limit headers, or null when the request is allowed. */
export function rateLimitResponse(result: RateLimitResult): NextResponse | null {
if (result.ok) return null
return NextResponse.json(
{ error: "Too many requests. Please slow down and try again shortly." },
{
status: 429,
headers: {
"Retry-After": String(result.retryAfter),
"X-RateLimit-Limit": String(result.limit),
"X-RateLimit-Remaining": "0",
},
}
)
}
/**
* Convenience wrapper: hit the bucket and return a ready 429 if over the limit.
*
* const limited = enforceRateLimit(`upload:${ownerId}`, 30, 60)
* if (limited) return limited
*/
export function enforceRateLimit(
key: string,
limit: number,
windowSeconds: number
): NextResponse | null {
return rateLimitResponse(hit(key, limit, windowSeconds))
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next"
export const SITE_NAME = "Property Management Network"
// The generated Open Graph card (app/opengraph-image.tsx), served at this path.
// Declared explicitly because a page that sets its own `openGraph` object
// replaces the inherited one — including the image Next.js attaches from the
// file convention — which silently leaves that page with no share image.
const OG_IMAGE = "/opengraph-image"
type PageSeo = {
/**
* Page title. Rendered through the root layout's "%s | Property Management
* Network" template unless `absoluteTitle` is set.
*/
title: string
/** Render `title` verbatim, without the site-name suffix. */
absoluteTitle?: boolean
description: string
/** Canonical path, root-relative and without a trailing slash, e.g. "/terms". */
path: string
/**
* Headline used for the Open Graph / Twitter card when the share copy should
* differ from the page title. Defaults to the resolved page title.
*/
socialTitle?: string
/** Set false for pages that must stay out of the index. */
index?: boolean
}
/**
* Builds a complete, self-consistent metadata block for a public page.
*
* Every public page must go through this helper so that canonical, og:url,
* og:title, og:image and the Twitter card always agree with each other and with
* the page's real URL. Setting these ad hoc per page is what previously left the
* landing page with no share image and every legal page pointing og:url at "/".
*/
export function pageMetadata({
title,
absoluteTitle = false,
description,
path,
socialTitle,
index = true,
}: PageSeo): Metadata {
const fullTitle = absoluteTitle ? title : `${title} | ${SITE_NAME}`
const social = socialTitle ?? fullTitle
return {
title: absoluteTitle ? { absolute: title } : title,
description,
alternates: { canonical: path },
openGraph: {
title: social,
description,
url: path,
siteName: SITE_NAME,
locale: "en_US",
type: "website",
images: [OG_IMAGE],
},
twitter: {
card: "summary_large_image",
title: social,
description,
images: [OG_IMAGE],
},
...(index ? {} : { robots: { index: false, follow: true } }),
}
}
+13 -4
View File
@@ -35,12 +35,21 @@ const ADMIN_EMAILS = (process.env.ADMIN_EMAILS ?? "")
.filter(Boolean)
export function isAdminUser(
u: { id?: string; email?: string; role?: string | null } | null | undefined
u:
| { id?: string; email?: string; emailVerified?: boolean; role?: string | null }
| null
| undefined
): boolean {
if (!u) return false
if (u.role === "admin") return true
// ADMIN_USER_IDS is the unconditional bootstrap path: an id can only come from
// a row we created, so it is not attacker-selectable.
if (u.id && ADMIN_USER_IDS.includes(u.id)) return true
if (u.email && ADMIN_EMAILS.includes(u.email.toLowerCase())) return true
// ADMIN_EMAILS is matched on a user-supplied string, so it additionally
// requires a VERIFIED address. Otherwise, in any environment where
// REQUIRE_EMAIL_VERIFICATION is off, simply signing up with a listed address
// would grant admin without ever controlling the mailbox.
if (u.email && u.emailVerified && ADMIN_EMAILS.includes(u.email.toLowerCase())) return true
return false
}
@@ -52,7 +61,7 @@ export function isAdminUser(
export async function getAdminSession() {
const session = await auth.api.getSession({ headers: await headers() })
const user = session?.user ?? null
if (!isAdminUser(user as { role?: string | null })) return null
if (!isAdminUser(user)) return null
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user!.id) })
return { user: user!, profile: profile ?? null }
}
@@ -65,7 +74,7 @@ export async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() })
const user = session?.user ?? null
if (!user) redirect("/login")
if (!isAdminUser(user as { role?: string | null })) redirect("/dashboard")
if (!isAdminUser(user)) redirect("/dashboard")
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user.id) })
return { user, profile: profile ?? null }
}
+14 -1
View File
@@ -13,7 +13,20 @@ export async function verifyTurnstile(
remoteIp?: string | null
): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY
if (!secret) return true // integration disabled — do not block auth
if (!secret) {
// Fail open ONLY outside production. In production a missing secret is a
// misconfiguration, not a deployment choice: silently dropping bot
// protection from login / signup / forgot-password is worse than a loud
// failure, so refuse the request and log once per occurrence.
if (process.env.NODE_ENV === "production") {
console.error(
"[turnstile] TURNSTILE_SECRET_KEY is not set in production — " +
"rejecting the request rather than silently disabling bot protection."
)
return false
}
return true // integration disabled in dev — do not block auth
}
if (!token) return false
try {
+65 -11
View File
@@ -39,18 +39,72 @@ function isPrivateIPv4(ip: string): boolean {
return false
}
/** True for IPv6 loopback, unspecified, ULA, link-local, multicast, or mapped-v4. */
/**
* Expand an IPv6 literal to its eight numeric hextets, or null if unparseable.
* Handles "::" compression and a trailing dotted-quad (::ffff:1.2.3.4).
*/
function ipv6Hextets(input: string): number[] | null {
let s = input.toLowerCase().split("%")[0].replace(/^\[|\]$/g, "")
// Fold a trailing dotted-quad into two hextets so one code path handles both
// spellings of an IPv4-mapped address.
const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (dotted && dotted.index !== undefined) {
const parts = dotted[1].split(".").map((n) => parseInt(n, 10))
if (parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null
s =
s.slice(0, dotted.index) +
(((parts[0] << 8) | parts[1]) >>> 0).toString(16) +
":" +
(((parts[2] << 8) | parts[3]) >>> 0).toString(16)
}
const halves = s.split("::")
if (halves.length > 2) return null
const split = (chunk: string) => (chunk ? chunk.split(":").filter(Boolean) : [])
const head = split(halves[0])
const tail = halves.length === 2 ? split(halves[1]) : []
const groups =
halves.length === 2
? [...head, ...Array(Math.max(0, 8 - head.length - tail.length)).fill("0"), ...tail]
: head
if (groups.length !== 8) return null
const out: number[] = []
for (const g of groups) {
if (!/^[0-9a-f]{1,4}$/.test(g)) return null
out.push(parseInt(g, 16))
}
return out
}
/**
* True for IPv6 loopback, unspecified, ULA, link-local, multicast, or any
* address embedding a private IPv4 address.
*
* The embedded-IPv4 check works on the NUMERIC hextets, not on the text. Node's
* URL parser rewrites `::ffff:169.254.169.254` to `::ffff:a9fe:a9fe`, so a
* previous version that only matched the dotted-quad spelling let the cloud
* metadata endpoint — and every private range — straight through.
*/
function isPrivateIPv6(ip: string): boolean {
const addr = ip.toLowerCase().split("%")[0] // strip zone id
if (addr === "::1" || addr === "::") return true
// IPv4-mapped / -compatible (e.g. ::ffff:169.254.169.254) — check the v4 part.
const mapped = addr.match(/(?:^::ffff:|^::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (mapped) return isPrivateIPv4(mapped[1])
const head = addr.replace(/^\[|\]$/g, "")
if (head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb"))
return true // fe80::/10 link-local
if (head.startsWith("fc") || head.startsWith("fd")) return true // fc00::/7 unique-local
if (head.startsWith("ff")) return true // ff00::/8 multicast
const h = ipv6Hextets(ip)
if (!h) return true // unparseable → treat as unsafe
// ::/96 (covers :: and ::1) and ::ffff:0:0/96 both carry an IPv4 address in
// the low 32 bits. Decode it and reuse the IPv4 rules.
const embedsIPv4 =
h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 &&
(h[5] === 0 || h[5] === 0xffff)
if (embedsIPv4) {
const v4 = [h[6] >> 8, h[6] & 0xff, h[7] >> 8, h[7] & 0xff].join(".")
return isPrivateIPv4(v4)
}
const first = h[0]
if ((first & 0xffc0) === 0xfe80) return true // fe80::/10 link-local
if ((first & 0xfe00) === 0xfc00) return true // fc00::/7 unique-local
if ((first & 0xff00) === 0xff00) return true // ff00::/8 multicast
return false
}
+505 -439
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -11,7 +11,10 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"test": "playwright test --project=unit",
"test:unit": "playwright test --project=unit",
"test:e2e": "playwright test --project=e2e"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.110.0",
@@ -25,7 +28,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@sentry/nextjs": "^10.63.0",
"@types/papaparse": "^5.5.2",
"better-auth": "^1.6.20",
"better-auth": "^1.7.2",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.4.2",
@@ -34,7 +37,7 @@
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"next": "^16.3.4",
"nodemailer": "^9.0.3",
"openai": "^6.33.0",
"papaparse": "^5.5.3",
@@ -58,7 +61,7 @@
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"eslint-config-next": "^16.3.4",
"tailwindcss": "^4",
"typescript": "^5"
}
+52
View File
@@ -0,0 +1,52 @@
import { defineConfig, devices } from "@playwright/test"
/**
* Two projects with very different requirements:
*
* unit — pure logic (rate limiting, SSRF guard, upload validation, API-key
* hashing, crypto, plan limits). No browser, no database, no network.
* Runs anywhere, including CI on a bare container, in about a second.
*
* e2e — real browser against a running app. Needs DATABASE_URL to point at a
* migrated Postgres, so it is NOT part of the default run. Enable with
* `npm run test:e2e` once a database is available.
*
* `npm test` runs the unit project only, so a missing database can never make
* the default test command fail for the wrong reason.
*/
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [["github"], ["list"]] : [["list"]],
projects: [
{
name: "unit",
testDir: "./tests/unit",
use: {},
},
{
name: "e2e",
testDir: "./tests/e2e",
use: {
...devices["Desktop Chrome"],
baseURL: process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000",
trace: "on-first-retry",
},
},
],
// Only started for the e2e project; `npm test` (unit) never boots the app.
...(process.env.E2E === "1"
? {
webServer: {
command: "npm run start",
url: process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
}
: {}),
})
+63 -12
View File
@@ -54,14 +54,32 @@ function umamiOrigin(): 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 {
// Build the Content-Security-Policy.
//
// `script-src` is NONCE-based on the app surface and 'unsafe-inline' elsewhere.
//
// The nonce is minted per request and set on the REQUEST headers, which is how
// Next discovers it and stamps it onto its inline hydration scripts; we echo the
// policy on the response. Browsers ignore 'unsafe-inline' once a nonce is
// present, so an injected inline <script> cannot execute.
//
// Why it is not applied everywhere: statically PRERENDERED pages are written to
// disk at build time with no nonce on their script tags, so serving them with a
// fresh per-request nonce blocks every script on the page. See
// `wantsNonce()` — the nonce is scoped to the dynamically rendered app surface,
// which is also the only place user-supplied data (tenant names, property names,
// maintenance descriptions) is rendered, and therefore the only place inline
// script injection is a real risk. Marketing and legal pages render
// developer-authored content and keep the permissive policy.
//
// 'strict-dynamic' is deliberately NOT used: it would make the browser ignore
// the host allowlist below, which is exactly what lets the Turnstile and Umami
// script tags load.
//
// `style-src` deliberately keeps 'unsafe-inline': Radix, Tailwind and
// framer-motion all set inline styles from JS, and style injection is not an
// execution primitive.
function buildCsp(nonce: string | null): string {
const isDev = process.env.NODE_ENV !== "production"
const sentry = sentryIngestOrigin()
@@ -69,7 +87,7 @@ function buildCsp(): string {
// (added to connect-src below).
const umami = umamiOrigin()
const scriptSrc = [
"script-src 'self' 'unsafe-inline'",
nonce ? `script-src 'self' 'nonce-${nonce}'` : "script-src 'self' 'unsafe-inline'",
isDev ? "'unsafe-eval'" : "",
"https://challenges.cloudflare.com",
umami, // load the Umami analytics script
@@ -104,6 +122,21 @@ function buildCsp(): string {
].join("; ")
}
/**
* True for paths that Next renders per request, and where the rendered HTML can
* contain user-supplied strings. These get the strict nonce policy. Everything
* else — the marketing site and the statically prerendered legal pages — keeps
* 'unsafe-inline', because a prerendered page's scripts carry no nonce and would
* all be blocked.
*/
function wantsNonce(pathname: string): boolean {
if (pathname.startsWith("/tenant-portal/")) return true // token-addressed, renders tenant data
return (
PROTECTED_PATHS.some((p) => pathname.startsWith(p)) ||
AUTH_PATHS.some((p) => pathname.startsWith(p))
)
}
// Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off
// an auth page we confirm the session is actually alive — otherwise a stale
// cookie loops forever: /dashboard → /login (server sees no session) →
@@ -155,10 +188,28 @@ export async function proxy(request: NextRequest) {
dropStaleSessionCookie = state === "invalid"
}
const csp = buildCsp()
// Per-request nonce, on the app surface only. Web Crypto is used rather than
// node:crypto so this keeps working if the proxy moves to the edge runtime.
let nonce: string | null = null
if (wantsNonce(pathname)) {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
nonce = btoa(String.fromCharCode(...bytes))
}
const response = NextResponse.next()
// Set the CSP on the outgoing response so the browser enforces it.
const csp = buildCsp(nonce)
// Next reads the CSP off the INCOMING request to discover the nonce and stamp
// it onto its own inline scripts — setting it only on the response would leave
// those scripts unnonced and the page would never hydrate.
const requestHeaders = new Headers(request.headers)
if (nonce) {
requestHeaders.set("x-nonce", nonce)
requestHeaders.set("Content-Security-Policy", csp)
}
const response = NextResponse.next({ request: { headers: requestHeaders } })
// Echo it on the outgoing response so the browser enforces it.
response.headers.set("Content-Security-Policy", csp)
if (dropStaleSessionCookie) {
+174
View File
@@ -0,0 +1,174 @@
import { test, expect } from "@playwright/test"
/**
* Security-header and access-control smoke tests against a running build.
*
* Deliberately scoped to behaviour that does NOT require a seeded database, so
* this suite is runnable against any deployed instance (set E2E_BASE_URL).
* Checks that DO need one live in the "requires a database" block at the bottom
* and are skipped unless E2E_DB=1.
*
* npm run build && E2E=1 npm run test:e2e
* E2E_BASE_URL=https://staging.example.com npm run test:e2e
*/
const STATIC_PAGES = ["/terms", "/refund-policy", "/subprocessors", "/tenant-portal-info"]
const APP_PAGES = ["/login", "/signup", "/forgot-password"]
const PROTECTED = ["/dashboard", "/properties", "/tenants", "/rent", "/settings", "/admin"]
test.describe("baseline security headers", () => {
test("every response carries the standard hardening headers", async ({ request }) => {
const res = await request.get("/login")
const h = res.headers()
expect(h["x-content-type-options"]).toBe("nosniff")
expect(h["x-frame-options"]).toBe("DENY")
expect(h["referrer-policy"]).toBe("no-referrer")
expect(h["strict-transport-security"]).toContain("max-age=")
})
test("Referrer-Policy is no-referrer so portal tokens cannot leak", async ({ request }) => {
// Tenant-portal and calendar URLs carry their credential in the path, so a
// Referer header would hand it to any third-party resource.
const res = await request.get("/tenant-portal-info")
expect(res.headers()["referrer-policy"]).toBe("no-referrer")
})
test("CSP forbids framing and plugins everywhere", async ({ request }) => {
for (const path of [...STATIC_PAGES, ...APP_PAGES]) {
const csp = (await request.get(path)).headers()["content-security-policy"] ?? ""
expect(csp, `${path} CSP`).toContain("frame-ancestors 'none'")
expect(csp, `${path} CSP`).toContain("object-src 'none'")
expect(csp, `${path} CSP`).toContain("base-uri 'self'")
expect(csp, `${path} CSP`).toContain("form-action 'self'")
}
})
})
test.describe("CSP nonce scoping", () => {
// The app surface gets a strict per-request nonce. Static pages keep
// 'unsafe-inline' because they are prerendered at build time with no nonce on
// their script tags — serving them a fresh nonce would block every script.
for (const path of APP_PAGES) {
test(`${path} uses a nonce and every inline script carries it`, async ({ request }) => {
const res = await request.get(path)
const csp = res.headers()["content-security-policy"] ?? ""
const nonce = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1]
expect(nonce, `${path} should have a nonce in script-src`).toBeTruthy()
expect(csp).not.toContain("script-src 'self' 'unsafe-inline'")
const html = await res.text()
const scripts = html.match(/<script[^>]*>/g) ?? []
const unnonced = scripts.filter((s) => !s.includes(`nonce="${nonce}"`))
expect(unnonced, `${path} has script tags without the nonce`).toEqual([])
})
}
for (const path of STATIC_PAGES) {
test(`${path} keeps the permissive policy so prerendered scripts still run`, async ({
request,
}) => {
const csp = (await request.get(path)).headers()["content-security-policy"] ?? ""
expect(csp).toContain("script-src 'self' 'unsafe-inline'")
expect(csp).not.toMatch(/'nonce-/)
})
}
test("each request gets a fresh nonce", async ({ request }) => {
const nonceOf = async () =>
((await request.get("/login")).headers()["content-security-policy"] ?? "").match(
/'nonce-([A-Za-z0-9+/=]+)'/
)?.[1]
const [a, b] = [await nonceOf(), await nonceOf()]
expect(a).toBeTruthy()
expect(a).not.toBe(b)
})
})
test.describe("unauthenticated access control", () => {
for (const path of PROTECTED) {
test(`${path} redirects an anonymous visitor to /login`, async ({ page }) => {
await page.goto(path)
await expect(page).toHaveURL(/\/login/)
})
}
test("the public API rejects a request with no bearer token", async ({ request }) => {
for (const path of [
"/api/v1/tenants",
"/api/v1/properties",
"/api/v1/maintenance",
"/api/v1/payments",
"/api/v1/webhooks",
]) {
const res = await request.get(path)
expect(res.status(), `${path} should be 401`).toBe(401)
}
})
test("cron endpoints reject an unauthenticated caller", async ({ request }) => {
for (const path of ["/api/cron/daily", "/api/cron/late-fees", "/api/cron/gdpr"]) {
const res = await request.get(path, { failOnStatusCode: false })
expect([401, 403], `${path} should refuse`).toContain(res.status())
}
})
})
test.describe("public endpoints", () => {
test("health responds without touching the database", async ({ request }) => {
const res = await request.get("/api/health")
expect(res.status()).toBe(200)
expect((await res.json()).status).toBe("ok")
})
test("static legal pages render", async ({ page }) => {
for (const path of STATIC_PAGES) {
const res = await page.goto(path)
expect(res?.status(), `${path} should render`).toBe(200)
}
})
test("an unknown calendar token is not found and is never shared-cached", async ({
request,
}) => {
// The feed carries tenant names, rent amounts and addresses behind nothing
// but the token in the URL, so it must never be stored by a shared cache.
const res = await request.get("/api/calendar/definitely-not-a-real-token.ics", {
failOnStatusCode: false,
})
const cache = res.headers()["cache-control"] ?? ""
expect(cache).not.toContain("public")
})
})
test.describe("requires a database", () => {
// A PRESENTED-but-invalid key must be looked up before it can be rejected, so
// unlike the no-token case these cannot short-circuit. Set E2E_DB=1 when
// DATABASE_URL points at a migrated database.
test.skip(
!process.env.E2E_DB,
"Set E2E_DB=1 with a migrated DATABASE_URL to run database-backed checks."
)
test("the public API rejects a malformed bearer token", async ({ request }) => {
const res = await request.get("/api/v1/tenants", {
headers: { authorization: "Bearer pmn_live_totally-made-up" },
failOnStatusCode: false,
})
expect(res.status()).toBe(401)
})
test("session-gated API routes reject an anonymous caller", async ({ request }) => {
for (const path of ["/api/properties", "/api/tenants", "/api/rent", "/api/admin/users"]) {
const res = await request.get(path, { failOnStatusCode: false })
expect([401, 403], `${path} should refuse`).toContain(res.status())
}
})
test("an unknown tenant-portal token 404s rather than leaking", async ({ request }) => {
const res = await request.get("/tenant-portal/definitely-not-a-real-token", {
failOnStatusCode: false,
})
expect(res.status()).toBe(404)
})
})
+84
View File
@@ -0,0 +1,84 @@
import { test, expect } from "@playwright/test"
import { hit, clientIp } from "@/lib/rate-limit"
// The limiter is module-level state shared across the process, so every test
// uses a unique key prefix to stay independent of the others.
let n = 0
const key = (label: string) => `test:${label}:${++n}:${Math.random()}`
test.describe("hit()", () => {
test("allows requests up to the limit and rejects the one after", () => {
const k = key("basic")
for (let i = 0; i < 5; i++) {
expect(hit(k, 5, 60).ok, `request ${i + 1} of 5 should be allowed`).toBe(true)
}
expect(hit(k, 5, 60).ok, "the 6th request should be rejected").toBe(false)
})
test("reports remaining budget accurately", () => {
const k = key("remaining")
expect(hit(k, 3, 60).remaining).toBe(2)
expect(hit(k, 3, 60).remaining).toBe(1)
expect(hit(k, 3, 60).remaining).toBe(0)
// Over the limit, remaining stays clamped at zero rather than going negative.
expect(hit(k, 3, 60).remaining).toBe(0)
})
test("keeps separate keys independent", () => {
const a = key("iso-a")
const b = key("iso-b")
for (let i = 0; i < 3; i++) hit(a, 3, 60)
expect(hit(a, 3, 60).ok, "key A is exhausted").toBe(false)
expect(hit(b, 3, 60).ok, "key B is untouched").toBe(true)
})
test("resets after the window elapses", async () => {
const k = key("window")
// A 1-second window so the test can actually wait it out.
expect(hit(k, 1, 1).ok).toBe(true)
expect(hit(k, 1, 1).ok).toBe(false)
await new Promise((r) => setTimeout(r, 1100))
expect(hit(k, 1, 1).ok, "a fresh window should allow requests again").toBe(true)
})
test("returns a retryAfter of at least one second while limited", () => {
const k = key("retry")
hit(k, 1, 60)
const limited = hit(k, 1, 60)
expect(limited.ok).toBe(false)
expect(limited.retryAfter).toBeGreaterThan(0)
expect(limited.retryAfter).toBeLessThanOrEqual(60)
})
test("reports the configured limit back to the caller", () => {
expect(hit(key("limit"), 42, 60).limit).toBe(42)
})
})
test.describe("clientIp()", () => {
const req = (headers: Record<string, string>) => new Request("https://x.test", { headers })
test("takes the first entry of x-forwarded-for", () => {
expect(clientIp(req({ "x-forwarded-for": "1.2.3.4, 5.6.7.8, 9.9.9.9" }))).toBe("1.2.3.4")
})
test("trims whitespace around the client address", () => {
expect(clientIp(req({ "x-forwarded-for": " 1.2.3.4 , 5.6.7.8" }))).toBe("1.2.3.4")
})
test("falls back to x-real-ip when x-forwarded-for is absent", () => {
expect(clientIp(req({ "x-real-ip": "8.8.8.8" }))).toBe("8.8.8.8")
})
test("degrades to a single shared bucket rather than no limit at all", () => {
// The important property: a request with no proxy headers must still land in
// SOME bucket. Returning a unique value per request would silently disable
// the limit for anyone who can strip headers.
expect(clientIp(req({}))).toBe("unknown")
expect(clientIp(req({}))).toBe("unknown")
})
test("ignores an empty x-forwarded-for and falls through", () => {
expect(clientIp(req({ "x-forwarded-for": "", "x-real-ip": "8.8.4.4" }))).toBe("8.8.4.4")
})
})
+136
View File
@@ -0,0 +1,136 @@
import { test, expect } from "@playwright/test"
import { generateApiKey, hashApiKey } from "@/lib/api-auth"
import { encrypt, decrypt } from "@/lib/crypto"
import { checkLimit, getPlanLabel, PLAN_LIMITS } from "@/lib/stripe/plans"
import type { Plan } from "@/types"
// A dedicated key so the crypto tests never depend on BETTER_AUTH_SECRET being
// set. lib/crypto reads the env lazily inside getKey() rather than at module
// load, so this assignment lands before the first encrypt() call even though
// the import above is hoisted.
process.env.ACCOUNTING_ENCRYPTION_KEY = "test-encryption-key-do-not-use-in-production"
test.describe("API keys", () => {
test("issues a prefixed key with 48 hex characters of entropy", () => {
const { plaintext } = generateApiKey()
expect(plaintext).toMatch(/^pmn_live_[0-9a-f]{48}$/)
})
test("never repeats a key", () => {
const keys = new Set(Array.from({ length: 200 }, () => generateApiKey().plaintext))
expect(keys.size).toBe(200)
})
test("stores only a hash — the plaintext must not be recoverable from it", () => {
const { plaintext, hash } = generateApiKey()
expect(hash).toMatch(/^[0-9a-f]{64}$/) // sha256 hex
expect(hash).not.toContain(plaintext)
expect(plaintext).not.toContain(hash)
})
test("hashing is deterministic, so lookup by hash works", () => {
const { plaintext, hash } = generateApiKey()
expect(hashApiKey(plaintext)).toBe(hash)
expect(hashApiKey(plaintext)).toBe(hashApiKey(plaintext))
})
test("different keys hash differently", () => {
expect(hashApiKey("pmn_live_aaa")).not.toBe(hashApiKey("pmn_live_aab"))
})
test("the display prefix reveals only a short, non-secret fragment", () => {
const { plaintext, prefix } = generateApiKey()
expect(prefix.startsWith("pmn_live_")).toBe(true)
// 8 hex chars shown out of 48 — the rest must not leak.
const shown = prefix.replace("pmn_live_", "").replace("…", "")
expect(shown).toHaveLength(8)
expect(plaintext).toContain(shown)
expect(prefix.length).toBeLessThan(plaintext.length)
})
})
test.describe("secrets at rest (AES-256-GCM)", () => {
test("round-trips a value", async () => {
const secret = "oauth-refresh-token-abc123"
expect(decrypt(encrypt(secret))).toBe(secret)
})
test("round-trips unicode and empty strings", async () => {
for (const v of ["", "ünïcödé ✓ 日本語", "a".repeat(5000)]) {
expect(decrypt(encrypt(v))).toBe(v)
}
})
test("produces a different ciphertext each time (random IV)", async () => {
const a = encrypt("same-value")
const b = encrypt("same-value")
expect(a).not.toBe(b)
})
test("emits the documented iv:tag:ciphertext shape", async () => {
const parts = encrypt("x").split(":")
expect(parts).toHaveLength(3)
for (const p of parts) expect(p.length).toBeGreaterThan(0)
})
test("rejects a tampered ciphertext instead of returning garbage", async () => {
const [iv, tag, data] = encrypt("sensitive").split(":")
// Flip a byte in the payload — GCM's auth tag must catch it.
const buf = Buffer.from(data, "base64")
buf[0] ^= 0xff
expect(() => decrypt([iv, tag, buf.toString("base64")].join(":"))).toThrow()
// A forged auth tag must also fail.
const tagBuf = Buffer.from(tag, "base64")
tagBuf[0] ^= 0xff
expect(() => decrypt([iv, tagBuf.toString("base64"), data].join(":"))).toThrow()
})
})
test.describe("plan limits", () => {
test("Starter is capped at 1 property and 3 tenants", () => {
expect(checkLimit("starter", "maxProperties", 0)).toBe(true)
expect(checkLimit("starter", "maxProperties", 1)).toBe(false)
expect(checkLimit("starter", "maxTenants", 2)).toBe(true)
expect(checkLimit("starter", "maxTenants", 3)).toBe(false)
})
test("Pro allows 10 properties", () => {
expect(checkLimit("pro", "maxProperties", 9)).toBe(true)
expect(checkLimit("pro", "maxProperties", 10)).toBe(false)
})
test("unlimited plans never cap", () => {
for (const plan of ["landlord", "lifetime"] as Plan[]) {
expect(checkLimit(plan, "maxProperties", 10_000)).toBe(true)
expect(checkLimit(plan, "maxTenants", 10_000)).toBe(true)
}
})
test("boolean entitlements are returned directly", () => {
expect(checkLimit("starter", "hasTeamAccess", 0)).toBe(false)
expect(checkLimit("landlord", "hasTeamAccess", 0)).toBe(true)
expect(checkLimit("starter", "hasWhiteLabel", 0)).toBe(false)
expect(checkLimit("lifetime", "hasWhiteLabel", 0)).toBe(true)
})
test("AI is gated off on Starter", () => {
expect(PLAN_LIMITS.starter.maxAiCalls).toBe(0)
expect(PLAN_LIMITS.pro.maxAiCalls).toBeGreaterThan(0)
})
test("every plan has a display label", () => {
for (const plan of ["starter", "pro", "landlord", "lifetime"] as Plan[]) {
expect(getPlanLabel(plan)).toBeTruthy()
}
})
test("limits are monotonic as plans get more expensive", () => {
// A cheaper plan must never allow more than a pricier one.
expect(PLAN_LIMITS.pro.maxProperties).toBeGreaterThan(PLAN_LIMITS.starter.maxProperties)
expect(PLAN_LIMITS.landlord.maxProperties).toBeGreaterThan(PLAN_LIMITS.pro.maxProperties)
expect(PLAN_LIMITS.pro.maxStorageMB).toBeGreaterThan(PLAN_LIMITS.starter.maxStorageMB)
expect(PLAN_LIMITS.landlord.maxStorageMB).toBeGreaterThan(PLAN_LIMITS.pro.maxStorageMB)
})
})
+135
View File
@@ -0,0 +1,135 @@
import { test, expect } from "@playwright/test"
import { assertSafeWebhookUrl, isSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
/**
* Webhook URLs are attacker-supplied and dialled by our server on a schedule,
* which makes this guard the difference between "outbound webhook" and "open
* proxy into our private network". Every case below uses an IP LITERAL or a
* scheme/shape violation so the guard short-circuits before DNS — the suite
* stays hermetic and never resolves a hostname.
*/
async function rejects(url: string) {
await expect(assertSafeWebhookUrl(url), `${url} must be rejected`).rejects.toThrow(
WebhookUrlError
)
}
async function allows(url: string) {
await expect(assertSafeWebhookUrl(url), `${url} must be allowed`).resolves.toBeUndefined()
}
test.describe("scheme and shape", () => {
test("rejects a non-URL", async () => {
await rejects("not a url")
})
test("rejects non-http schemes", async () => {
await rejects("file:///etc/passwd")
await rejects("gopher://8.8.8.8/")
await rejects("ftp://8.8.8.8/")
})
test("rejects embedded credentials", async () => {
// Credentials in the URL are a classic way to smuggle a different
// authority past naive parsing.
await rejects("https://user:pass@8.8.8.8/hook")
})
test("allows plain https to a public address", async () => {
await allows("https://8.8.8.8/hook")
})
})
test.describe("IPv4 private and reserved ranges", () => {
const blocked = [
["0.0.0.0", "this-network"],
["10.0.0.1", "private class A"],
["127.0.0.1", "loopback"],
["100.64.0.1", "CGNAT"],
["169.254.169.254", "cloud metadata"],
["172.16.0.1", "private class B (low)"],
["172.31.255.254", "private class B (high)"],
["192.0.0.1", "IETF protocol assignments"],
["192.168.1.1", "private class C"],
["198.18.0.1", "benchmarking"],
["224.0.0.1", "multicast"],
["255.255.255.255", "broadcast"],
] as const
for (const [ip, label] of blocked) {
test(`rejects ${ip} (${label})`, async () => {
await rejects(`https://${ip}/hook`)
})
}
const allowed = ["8.8.8.8", "1.1.1.1", "172.15.0.1", "172.32.0.1", "192.167.0.1"]
for (const ip of allowed) {
test(`allows public ${ip}`, async () => {
await allows(`https://${ip}/hook`)
})
}
})
test.describe("IPv6", () => {
const blocked = [
["[::1]", "loopback"],
["[::]", "unspecified"],
["[fe80::1]", "link-local"],
["[fd00::1]", "unique-local"],
["[fc00::1]", "unique-local"],
["[ff02::1]", "multicast"],
["[::ffff:169.254.169.254]", "IPv4-mapped metadata"],
["[::ffff:127.0.0.1]", "IPv4-mapped loopback"],
] as const
for (const [host, label] of blocked) {
test(`rejects ${host} (${label})`, async () => {
await rejects(`https://${host}/hook`)
})
}
})
test.describe("IPv6 regression: hex-normalised IPv4-mapped addresses", () => {
// Node's URL parser rewrites ::ffff:169.254.169.254 to ::ffff:a9fe:a9fe. A
// guard that only recognises the dotted-quad spelling therefore treats the
// cloud metadata endpoint as a public address and dials it. These assert the
// NORMALISED forms directly so the bypass cannot silently return.
const mapped = [
["[::ffff:a9fe:a9fe]", "169.254.169.254 — cloud metadata"],
["[::ffff:7f00:1]", "127.0.0.1 — loopback"],
["[::ffff:a00:1]", "10.0.0.1 — private"],
["[::ffff:c0a8:1]", "192.168.0.1 — private"],
["[::ffff:ac10:1]", "172.16.0.1 — private"],
] as const
for (const [host, label] of mapped) {
test(`rejects ${host} (${label})`, async () => {
await rejects(`https://${host}/hook`)
})
}
test("still allows a mapped PUBLIC address", async () => {
// ::ffff:8.8.8.8 — mapped, but the embedded address is public.
await allows("https://[::ffff:808:808]/hook")
})
test("allows a genuinely public IPv6 address", async () => {
await allows("https://[2001:4860:4860::8888]/hook")
})
})
test.describe("localhost by name", () => {
test("rejects localhost and its subdomains", async () => {
await rejects("https://localhost/hook")
await rejects("https://api.localhost/hook")
})
})
test.describe("isSafeWebhookUrl", () => {
test("mirrors assertSafeWebhookUrl without throwing", async () => {
expect(await isSafeWebhookUrl("https://8.8.8.8/hook")).toBe(true)
expect(await isSafeWebhookUrl("https://169.254.169.254/latest/meta-data/")).toBe(false)
expect(await isSafeWebhookUrl("nonsense")).toBe(false)
})
})
+161
View File
@@ -0,0 +1,161 @@
import { test, expect } from "@playwright/test"
import {
ALLOWED_UPLOAD_EXTENSIONS,
isAllowedUploadExt,
extOf,
contentTypeForKey,
contentMatchesExtension,
keyBelongsToOwner,
} from "@/lib/storage"
/**
* Upload validation is the boundary between "a landlord attached a lease PDF"
* and "a tenant stored an HTML file that executes on our origin". Two
* independent gates matter: the extension allowlist and the magic-byte check.
* Neither is sufficient alone.
*/
const sig = (...bytes: number[]) => Buffer.from(bytes)
const PDF = sig(0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37)
const PNG = sig(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
const JPG = sig(0xff, 0xd8, 0xff, 0xe0)
const GIF = sig(0x47, 0x49, 0x46, 0x38, 0x39, 0x61)
const ZIP = sig(0x50, 0x4b, 0x03, 0x04)
const OLE = sig(0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1)
const WEBP = Buffer.concat([sig(0x52, 0x49, 0x46, 0x46), sig(0, 0, 0, 0), sig(0x57, 0x45, 0x42, 0x50)])
const HTML = Buffer.from("<html><script>alert(1)</script>", "utf8")
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg">', "utf8")
test.describe("extension allowlist", () => {
test("accepts every documented extension", () => {
for (const ext of ALLOWED_UPLOAD_EXTENSIONS) {
expect(isAllowedUploadExt(`file.${ext}`), `${ext} should be allowed`).toBe(true)
}
})
test("rejects executable and markup types", () => {
// svg and html can execute JavaScript when served inline from our origin.
for (const name of [
"x.svg",
"x.html",
"x.htm",
"x.js",
"x.mjs",
"x.exe",
"x.sh",
"x.php",
"x.xml",
"x.json",
]) {
expect(isAllowedUploadExt(name), `${name} should be rejected`).toBe(false)
}
})
test("is case-insensitive", () => {
expect(isAllowedUploadExt("SCAN.PDF")).toBe(true)
expect(isAllowedUploadExt("Photo.JPeG")).toBe(true)
// …and stays case-insensitive for the denied set.
expect(isAllowedUploadExt("payload.SVG")).toBe(false)
})
test("uses the LAST extension in a multi-dot name", () => {
// "invoice.pdf.html" is html, not pdf — the classic double-extension trick.
expect(isAllowedUploadExt("invoice.pdf.html")).toBe(false)
expect(isAllowedUploadExt("archive.tar.pdf")).toBe(true)
expect(extOf("invoice.pdf.html")).toBe("html")
})
test("rejects a name with no extension", () => {
expect(isAllowedUploadExt("noextension")).toBe(false)
})
})
test.describe("magic-byte verification", () => {
test("accepts content matching its claimed extension", () => {
expect(contentMatchesExtension(PDF, "pdf")).toBe(true)
expect(contentMatchesExtension(PNG, "png")).toBe(true)
expect(contentMatchesExtension(JPG, "jpg")).toBe(true)
expect(contentMatchesExtension(JPG, "jpeg")).toBe(true)
expect(contentMatchesExtension(GIF, "gif")).toBe(true)
expect(contentMatchesExtension(WEBP, "webp")).toBe(true)
expect(contentMatchesExtension(ZIP, "docx")).toBe(true)
expect(contentMatchesExtension(ZIP, "xlsx")).toBe(true)
expect(contentMatchesExtension(OLE, "doc")).toBe(true)
expect(contentMatchesExtension(OLE, "xls")).toBe(true)
})
test("rejects HTML disguised with an allowed extension", () => {
// The whole point: an allowlisted extension over script content.
expect(contentMatchesExtension(HTML, "pdf")).toBe(false)
expect(contentMatchesExtension(HTML, "png")).toBe(false)
expect(contentMatchesExtension(HTML, "jpg")).toBe(false)
expect(contentMatchesExtension(HTML, "docx")).toBe(false)
expect(contentMatchesExtension(SVG, "png")).toBe(false)
})
test("rejects one image type renamed as another", () => {
expect(contentMatchesExtension(PNG, "pdf")).toBe(false)
expect(contentMatchesExtension(PDF, "png")).toBe(false)
expect(contentMatchesExtension(GIF, "webp")).toBe(false)
})
test("rejects a truncated header that cannot be verified", () => {
expect(contentMatchesExtension(sig(0x25, 0x50), "pdf")).toBe(false)
expect(contentMatchesExtension(Buffer.alloc(0), "png")).toBe(false)
})
test("allows csv and txt, which have no reliable signature", () => {
// Documented behaviour — these are served as attachments, not inline.
expect(contentMatchesExtension(HTML, "csv")).toBe(true)
expect(contentMatchesExtension(HTML, "txt")).toBe(true)
})
})
test.describe("contentTypeForKey", () => {
test("maps known extensions and defaults to octet-stream", () => {
expect(contentTypeForKey("a/b/c.pdf")).toBe("application/pdf")
expect(contentTypeForKey("a/b/c.PNG")).toBe("image/png")
expect(contentTypeForKey("a/b/c.jpeg")).toBe("image/jpeg")
expect(contentTypeForKey("a/b/c.unknown")).toBe("application/octet-stream")
expect(contentTypeForKey("noext")).toBe("application/octet-stream")
})
test("never returns an inline-executable content type", () => {
for (const name of ["x.svg", "x.html", "x.js"]) {
expect(contentTypeForKey(name)).toBe("application/octet-stream")
}
})
})
test.describe("keyBelongsToOwner — cross-tenant isolation", () => {
test("accepts a key in the owner's own namespace", () => {
expect(keyBelongsToOwner("user123/documents/a.pdf", "user123")).toBe(true)
expect(keyBelongsToOwner("/user123/documents/a.pdf", "user123")).toBe(true)
})
test("rejects another tenant's namespace", () => {
expect(keyBelongsToOwner("user999/documents/a.pdf", "user123")).toBe(false)
})
test("rejects a prefix that merely starts with the owner id", () => {
// "user1234" must not satisfy owner "user123".
expect(keyBelongsToOwner("user1234/documents/a.pdf", "user123")).toBe(false)
})
test("rejects traversal attempts", () => {
expect(keyBelongsToOwner("../user999/a.pdf", "user123")).toBe(false)
expect(keyBelongsToOwner("..\\\\user999\\\\a.pdf", "user123")).toBe(false)
})
test("rejects empty inputs rather than defaulting open", () => {
expect(keyBelongsToOwner("", "user123")).toBe(false)
expect(keyBelongsToOwner(null, "user123")).toBe(false)
expect(keyBelongsToOwner(undefined, "user123")).toBe(false)
expect(keyBelongsToOwner("user123/a.pdf", "")).toBe(false)
})
test("handles backslash separators the same as forward slashes", () => {
expect(keyBelongsToOwner("user123\\\\documents\\\\a.pdf", "user123")).toBe(true)
expect(keyBelongsToOwner("user999\\\\documents\\\\a.pdf", "user123")).toBe(false)
})
})