2 Commits
Author SHA1 Message Date
Leon SerfatyandClaude Opus 5 1d02598786 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>
2026-09-05 16:27:16 -04:00
Leon SerfatyandClaude Opus 5 8f90347659 feat(auth): finish Turnstile coverage across every auth entry point
Turnstile protected sign-in, sign-up and password-reset, but three gaps
remained:

- updatePassword had no verification and its page had no widget, so the
  final step of the reset flow was unprotected. The reset token is now
  carried through the failure redirect so a failed challenge doesn't
  strand the user on a form whose emailed link can't be replayed.

- /api/auth/[...all] exposed better-auth's handler directly, accepting
  unlimited credential guesses and email sends with no bot protection --
  a full bypass of the page-level checks. Credential-bearing POSTs now
  require a verified token. The gate lives in the route handler, so the
  server actions (which call auth.api.* in-process) are unaffected. GET
  is untouched for OAuth callbacks and verify-email links, and
  /sign-in/social stays open since it only redirects to the provider.

- Turnstile tokens expire after ~5 minutes and the widget never reset,
  so a form left open submitted a stale token and failed with "complete
  the verification challenge" despite the challenge visibly passing.

Verified with Cloudflare's test keys: all four auth forms block an
invalid token, pass a valid one through to real auth logic, and the API
gate returns 403 without a token and 401 with one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:26:16 -04:00
75 changed files with 3744 additions and 821 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). # Leave both blank to disable the captcha (auth forms still work).
NEXT_PUBLIC_TURNSTILE_SITE_KEY= NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_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 # 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 # 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. # 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= NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_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/ DOCS/
.env*.local .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 { notFound } from "next/navigation"
import { import {
Building2, Building2,
@@ -11,12 +12,15 @@ import {
ShieldAlert, ShieldAlert,
Ban, Ban,
Activity, Activity,
Table2,
} from "lucide-react" } from "lucide-react"
import { getUserDetail } from "@/lib/db/admin-queries" import { getUserDetail } from "@/lib/db/admin-queries"
import { requireAdmin } from "@/lib/session" import { requireAdmin } from "@/lib/session"
import { BackButton } from "@/components/ui/back-button" import { BackButton } from "@/components/ui/back-button"
import { CopyButton } from "@/components/shared/copy-button" import { CopyButton } from "@/components/shared/copy-button"
import { UserActions } from "@/components/admin/user-actions" 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" import { formatDate, initials, cn } from "@/lib/utils"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -49,6 +53,15 @@ export default async function AdminUserDetailPage({
if (!detail) notFound() 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 { profile, account, counts, recentActivity } = detail
const isSelf = me.id === profile.id const isSelf = me.id === profile.id
const planKey = profile.plan ?? "starter" const planKey = profile.plan ?? "starter"
@@ -107,6 +120,15 @@ export default async function AdminUserDetailPage({
</div> </div>
{/* Counts grid */} {/* 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"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{COUNT_META.map(({ key, label, icon: Icon }) => ( {COUNT_META.map(({ key, label, icon: Icon }) => (
<div <div
@@ -197,6 +219,9 @@ export default async function AdminUserDetailPage({
</ul> </ul>
)} )}
</div> </div>
{/* Payments + refunds */}
<BillingActions userId={profile.id} charges={charges} />
</div> </div>
{/* Right: actions */} {/* Right: actions */}
@@ -207,6 +232,8 @@ export default async function AdminUserDetailPage({
currentPlan={planKey} currentPlan={planKey}
banned={!!account?.banned} banned={!!account?.banned}
isSelf={isSelf} isSelf={isSelf}
isAdminRole={account?.role === "admin"}
subscription={subscription}
/> />
</div> </div>
</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 { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signUp, signInWithGoogle } from "@/app/actions/auth" import { signUp, signInWithGoogle } from "@/app/actions/auth"
import { isGoogleConfigured } from "@/lib/auth" 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({ export default async function SignupPage({
searchParams, searchParams,
+11
View File
@@ -1,7 +1,16 @@
import type { Metadata } from "next"
import Link from "next/link" import Link from "next/link"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { updatePassword } from "@/app/actions/auth" import { updatePassword } from "@/app/actions/auth"
// A password-reset form reached from a one-time emailed link — nothing here
// should ever enter the index.
export const metadata: Metadata = {
title: "Set a new password",
robots: { index: false, follow: false },
}
export default async function UpdatePasswordPage({ export default async function UpdatePasswordPage({
searchParams, searchParams,
}: { }: {
@@ -43,6 +52,8 @@ export default async function UpdatePasswordPage({
/> />
</div> </div>
<TurnstileWidget />
<button <button
type="submit" type="submit"
className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]" className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
+4 -3
View File
@@ -1,11 +1,12 @@
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Acceptable Use Policy", title: "Acceptable Use Policy",
description: `The rules that govern acceptable use of the ${LEGAL.service} platform and the activities that are prohibited.`, 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() { export default function Page() {
return ( return (
+6 -4
View File
@@ -1,12 +1,14 @@
import Link from "next/link" import Link from "next/link"
import { Code2, Lock, Zap, BookOpen, Webhook } from "lucide-react" import { Code2, Lock, Zap, BookOpen, Webhook } from "lucide-react"
import { WEBHOOK_EVENTS } from "@/lib/webhooks/events" import { WEBHOOK_EVENTS } from "@/lib/webhooks/events"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "API Docs", title: "API Docs",
description: "Property Management Network REST API documentation for developers.", description:
alternates: { canonical: "/api-docs" }, "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 // The real, deployed origin. Falls back to a placeholder only when the env var
// isn't set (e.g. local docs previews). // 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 { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Cookie Policy", title: "Cookie Policy",
description: description: "How we use cookies and similar technologies, what we deliberately avoid, and how to manage them in your browser.",
"How we use cookies and similar technologies, what we deliberately avoid, and how to manage them in your browser.", path: "/cookie-policy",
alternates: { canonical: "/cookie-policy" }, })
}
const COOKIE_ROWS: { name: string; type: string; purpose: string; expires: string }[] = [ 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 { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Disclaimer", title: "Disclaimer",
description: description: "Important limitations on the information and outputs provided by the Service, including AI-generated content and financial reports.",
"Important limitations on the information and outputs provided by the Service, including AI-generated content and financial reports.", path: "/disclaimer",
alternates: { canonical: "/disclaimer" }, })
}
export default function Page() { export default function Page() {
return ( return (
+4 -3
View File
@@ -1,12 +1,13 @@
import Link from "next/link" import Link from "next/link"
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Data Processing Addendum", title: "Data Processing Addendum",
description: `The Data Processing Addendum governing how ${LEGAL.entity} processes personal data on behalf of Customers using ${LEGAL.service}.`, 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() { export default function DpaPage() {
return ( return (
+4 -3
View File
@@ -1,12 +1,13 @@
import Link from "next/link" import Link from "next/link"
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "GDPR & Data Rights", 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.`, 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() { export default function GdprPage() {
return ( return (
+2 -2
View File
@@ -1,6 +1,6 @@
import { Navbar } from "@/components/marketing/navbar" import { Navbar } from "@/components/marketing/navbar"
import { Footer } from "@/components/marketing/footer" 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 { getSession, isAdminUser } from "@/lib/session"
import { getMaintenanceMode } from "@/lib/settings" import { getMaintenanceMode } from "@/lib/settings"
import { MaintenanceScreen } from "@/components/shared/maintenance-screen" import { MaintenanceScreen } from "@/components/shared/maintenance-screen"
@@ -17,7 +17,7 @@ export default async function MarketingLayout({ children }: { children: React.Re
return ( return (
<div className="min-h-screen bg-[#09090b] text-white"> <div className="min-h-screen bg-[#09090b] text-white">
<StructuredData /> <SiteStructuredData />
<Navbar /> <Navbar />
{children} {children}
<Footer /> <Footer />
+11 -11
View File
@@ -7,23 +7,23 @@ import { Testimonials } from "@/components/marketing/testimonials"
import { PricingSection } from "@/components/marketing/pricing-section" import { PricingSection } from "@/components/marketing/pricing-section"
import { FAQ } from "@/components/marketing/faq" import { FAQ } from "@/components/marketing/faq"
import { CtaBanner } from "@/components/marketing/cta-banner" import { CtaBanner } from "@/components/marketing/cta-banner"
import { HomeStructuredData } from "@/components/marketing/structured-data"
import { annualEnabled } from "@/lib/stripe/plans" import { annualEnabled } from "@/lib/stripe/plans"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: { absolute: "Property Management Software for Independent Landlords" }, title: "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.", absoluteTitle: true,
alternates: { canonical: "/" }, description:
openGraph: { "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
title: "Property management without the chaos", path: "/",
description: "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.", socialTitle: "Property management without the chaos",
url: "/", })
type: "website",
},
}
export default function LandingPage() { export default function LandingPage() {
return ( return (
<> <>
<HomeStructuredData />
<Hero /> <Hero />
<Marquee /> <Marquee />
<Problem /> <Problem />
+5 -5
View File
@@ -1,12 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal" import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Privacy Policy", title: "Privacy Policy",
description: description: "How we collect, use, share, and protect personal data, and the privacy rights available to you under the GDPR and California law.",
"How we collect, use, share, and protect personal data, and the privacy rights available to you under the GDPR and California law.", path: "/privacy",
alternates: { canonical: "/privacy" }, })
}
export default function Page() { export default function Page() {
return ( return (
+5 -4
View File
@@ -1,11 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Refund & Cancellation Policy", title: "Refund & Cancellation",
description: `How subscriptions, cancellations, and refunds work for the ${LEGAL.service} platform.`, description: `How subscriptions, cancellations, and refunds work for the ${LEGAL.service} platform.`,
alternates: { canonical: "/refund-policy" }, path: "/refund-policy",
} })
export default function Page() { export default function Page() {
return ( return (
+4 -3
View File
@@ -1,12 +1,13 @@
import Link from "next/link" import Link from "next/link"
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal" import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Sub-processors", title: "Sub-processors",
description: `The third-party sub-processors that ${LEGAL.entity} engages to process personal data on behalf of Customers of ${LEGAL.service}.`, 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() { export default function SubprocessorsPage() {
return ( return (
+6 -4
View File
@@ -1,11 +1,13 @@
import Link from "next/link" import Link from "next/link"
import { Building2, CheckCircle2, Smartphone, Bell, FileText, ArrowRight } from "lucide-react" 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", title: "Tenant Portal",
description: "Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.", description:
alternates: { canonical: "/tenant-portal-info" }, "Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.",
} path: "/tenant-portal-info",
})
const FEATURES = [ 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." }, { 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 { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Terms of Service", title: "Terms of Service",
description: `The terms and conditions that govern your access to and use of the ${LEGAL.service} platform.`, 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() { export default function Page() {
return ( return (
+139 -4
View File
@@ -13,6 +13,13 @@ import { auth } from "@/lib/auth"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { profiles, user as userTable } from "@/lib/db/schema" import { profiles, user as userTable } from "@/lib/db/schema"
import { executeAccountDeletion } from "@/lib/gdpr/delete" import { executeAccountDeletion } from "@/lib/gdpr/delete"
import {
changePlanInStripe,
cancelSubscription,
resumeSubscription,
refundCharge,
} from "@/lib/admin/billing"
import type { Plan } from "@/types"
// ── gate ──────────────────────────────────────────────────────────────────── // ── gate ────────────────────────────────────────────────────────────────────
// Every server action re-verifies the caller is an admin. NEVER skip — these // Every server action re-verifies the caller is an admin. NEVER skip — these
@@ -26,24 +33,152 @@ async function guard() {
const planSchema = z.enum(["starter", "pro", "landlord", "lifetime"]) const planSchema = z.enum(["starter", "pro", "landlord", "lifetime"])
// ── change plan ───────────────────────────────────────────────────────────── // ── 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 a = await guard()
const nextPlan = planSchema.parse(plan) const nextPlan = planSchema.parse(plan)
const existing = await db.query.profiles.findFirst({ where: eq(profiles.id, userId) }) const existing = await db.query.profiles.findFirst({ where: eq(profiles.id, userId) })
const oldPlan = existing?.plan ?? null 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 db.update(profiles).set({ plan: nextPlan }).where(eq(profiles.id, userId))
await logAdminAction({ await logAdminAction({
adminId: a.user.id, adminId: a.user.id,
action: "plan_change", action: "plan_change",
targetUserId: userId, targetUserId: userId,
metadata: { from: oldPlan, to: nextPlan }, metadata: { from: oldPlan, to: nextPlan, mode: "comp", billingUnchanged: true },
}) })
revalidatePath(`/admin/users/${userId}`) 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 ───────────────────────────────────────────────────────────────────── // ── ban ─────────────────────────────────────────────────────────────────────
@@ -132,7 +267,7 @@ export async function markEmailVerified(userId: string) {
await logAdminAction({ await logAdminAction({
adminId: a.user.id, adminId: a.user.id,
action: "resend_verification", action: "mark_email_verified",
targetUserId: userId, targetUserId: userId,
metadata: { markedVerified: true }, metadata: { markedVerified: true },
}) })
+12 -1
View File
@@ -131,15 +131,26 @@ export async function signOut() {
export async function updatePassword(formData: FormData) { export async function updatePassword(formData: FormData) {
const password = formData.get("password") as string const password = formData.get("password") as string
const token = formData.get("token") as string const token = formData.get("token") as string
const captchaToken = formData.get("cf-turnstile-response") as string | null
if (!token) { if (!token) {
redirect(`/update-password?error=${encodeURIComponent("Reset link is invalid or expired.")}`) redirect(`/update-password?error=${encodeURIComponent("Reset link is invalid or expired.")}`)
} }
const h = await headers()
// Same bot protection as the other credential forms. The reset token is
// carried through so a failed challenge doesn't strand the user on a form
// whose link can't be replayed.
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
redirect(
`/update-password?error=${encodeURIComponent(CAPTCHA_ERROR)}&token=${encodeURIComponent(token)}`
)
}
try { try {
await auth.api.resetPassword({ await auth.api.resetPassword({
body: { newPassword: password, token }, body: { newPassword: password, token },
headers: await headers(), headers: h,
}) })
} catch (e) { } catch (e) {
const msg = e instanceof APIError ? e.message : "Could not update password" const msg = e instanceof APIError ? e.message : "Could not update password"
+17 -4
View File
@@ -35,6 +35,19 @@ export async function GET() {
return NextResponse.json(data) 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() { export async function POST() {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
@@ -184,7 +197,7 @@ Only return valid JSON, no other text.`
json: true, json: true,
}) })
let predictions: any[] = [] let predictions: AiPrediction[] = []
try { try {
const parsed = JSON.parse(content || "{}") const parsed = JSON.parse(content || "{}")
predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? []) predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? [])
@@ -195,11 +208,11 @@ Only return valid JSON, no other text.`
// Replace old predictions // Replace old predictions
await db.delete(ai_predictions).where(eq(ai_predictions.user_id, ownerId)) 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, user_id: ownerId,
type: p.type ?? "growth_opportunity", type: p.type ?? "growth_opportunity",
title: p.title, title: p.title ?? "Untitled prediction",
prediction: p.prediction, prediction: p.prediction ?? "",
confidence: p.confidence ?? "medium", confidence: p.confidence ?? "medium",
timeframe: p.timeframe ?? "Next 30 days", timeframe: p.timeframe ?? "Next 30 days",
risk_level: p.risk_level ?? "low", risk_level: p.risk_level ?? "low",
+20 -7
View File
@@ -34,6 +34,18 @@ export async function GET() {
return NextResponse.json(data) 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() { export async function POST() {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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.` Only return valid JSON, no other text.`
let recommendations: any[] = [] let recommendations: AiRecommendation[] = []
try { try {
const content = await aiComplete({ const content = await aiComplete({
messages: [{ role: "user", content: prompt }], messages: [{ role: "user", content: prompt }],
@@ -178,8 +190,9 @@ Only return valid JSON, no other text.`
}) })
const parsed = JSON.parse(content || "{}") const parsed = JSON.parse(content || "{}")
recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? []) recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? [])
} catch (err: any) { } catch (err) {
return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 }) 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 // Delete old pending recommendations and insert new ones
@@ -187,12 +200,12 @@ Only return valid JSON, no other text.`
.delete(ai_recommendations) .delete(ai_recommendations)
.where(and(eq(ai_recommendations.user_id, ownerId), eq(ai_recommendations.status, "pending"))) .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, user_id: ownerId,
type: r.type ?? "opportunity", type: r.type ?? "opportunity",
title: r.title, title: r.title ?? "Untitled recommendation",
description: r.description, description: r.description ?? "",
impact: r.impact, impact: r.impact ?? "",
priority: r.priority ?? "medium", priority: r.priority ?? "medium",
status: "pending", status: "pending",
action_label: r.action_label ?? "Apply", 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. // 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({ const text = await aiComplete({
messages: [ messages: [
+63 -1
View File
@@ -1,4 +1,66 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { toNextJsHandler } from "better-auth/next-js" import { toNextJsHandler } from "better-auth/next-js"
import { verifyTurnstile } from "@/lib/turnstile"
export const { GET, POST } = toNextJsHandler(auth) const handlers = toNextJsHandler(auth)
// The auth pages post to server actions, which call `auth.api.*` in-process and
// run their own verifyTurnstile() check. This route is the *other* door into the
// same endpoints — a direct HTTP POST — and without this gate it accepts
// unlimited credential guesses and email sends with no bot protection at all.
//
// Only credential-bearing / email-triggering POSTs are gated. GET is untouched
// (OAuth callbacks, verify-email links, get-session), and `/sign-in/social` is
// left open because it only starts a redirect to the provider.
const CAPTCHA_PROTECTED = new Set([
"/sign-in/email",
"/sign-up/email",
"/request-password-reset",
"/reset-password",
"/send-verification-email",
])
/**
* Turnstile token from a header (preferred — leaves the body stream untouched)
* or, for clients that submit it inline, from a cloned JSON body.
*/
async function captchaToken(request: Request): Promise<string | null> {
const header =
request.headers.get("x-captcha-response") ??
request.headers.get("cf-turnstile-response")
if (header) return header
try {
const body = (await request.clone().json()) as Record<string, unknown>
const inline = body?.["cf-turnstile-response"] ?? body?.captchaToken
return typeof inline === "string" ? inline : null
} catch {
// Not JSON, or no body — treated as a missing token, which fails closed.
return null
}
}
export const GET = handlers.GET
export async function POST(request: Request) {
const path = new URL(request.url).pathname.replace(/^\/api\/auth/, "")
if (CAPTCHA_PROTECTED.has(path)) {
const ok = await verifyTurnstile(
await captchaToken(request),
request.headers.get("x-forwarded-for")
)
if (!ok) {
return NextResponse.json(
{
message: "Verification challenge required.",
code: "CAPTCHA_VERIFICATION_FAILED",
},
{ status: 403 }
)
}
}
return handlers.POST(request)
}
+6 -1
View File
@@ -115,7 +115,12 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token:
headers: { headers: {
"Content-Type": "text/calendar; charset=utf-8", "Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="property-management-network.ics"', "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 { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { enforceRateLimit, clientIp } from "@/lib/rate-limit"
const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"] const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"]
const VALID_PRIORITIES = ["low", "medium", "high", "emergency"] 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 }) if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
userId = ctx.ownerId userId = ctx.ownerId
} else { } 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 const portalToken = body.portal_token as string | undefined
if (!portalToken) { if (!portalToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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({ const tenant = await db.query.tenants.findFirst({
where: eq(tenants.portal_token, portalToken), where: eq(tenants.portal_token, portalToken),
columns: { id: true, user_id: true, property_id: true, unit_id: true }, 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 { NextResponse } from "next/server"
import { desc, eq, sql } from "drizzle-orm" import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema" import { properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { propertySchema } from "@/lib/validations" import { propertySchema } from "@/lib/validations"
import { getUserPlan } from "@/lib/plan-limits" import { checkPropertyLimit } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding" import { geocodeAddress } from "@/lib/geocoding"
@@ -37,16 +36,8 @@ export async function POST(request: Request) {
const parsed = propertySchema.safeParse(body) const parsed = propertySchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Check plan limit const limitError = await checkPropertyLimit(ownerId)
const [{ count }] = await db if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
.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 })
}
// Best-effort geocode so the property shows up on the map (never blocks save). // Best-effort geocode so the property shows up on the map (never blocks save).
const coords = await geocodeAddress(parsed.data) 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 { profiles, rent_payments } from "@/lib/db/schema"
import type Stripe from "stripe" 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) { export async function POST(request: Request) {
const body = await request.text() const body = await request.text()
const sig = request.headers.get("stripe-signature")! 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, plan: (plan ?? "starter") as typeof profiles.$inferSelect.plan,
stripe_subscription_id: subscription.id, stripe_subscription_id: subscription.id,
subscription_status: subscription.status, subscription_status: subscription.status,
plan_expires_at: (subscription as any).current_period_end plan_expires_at: subscriptionPeriodEnd(subscription)
? new Date((subscription as any).current_period_end * 1000).toISOString() ? new Date(subscriptionPeriodEnd(subscription)! * 1000).toISOString()
: null, : null,
}) })
.where(eq(profiles.id, userId)) .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 { getSessionUser } from "@/lib/session"
import { tenantSchema } from "@/lib/validations" import { tenantSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership" import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
import { getUserPlan } from "@/lib/plan-limits" import { checkTenantLimit } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { logActivity } from "@/lib/activity" import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
@@ -64,18 +63,8 @@ export async function POST(request: Request) {
const parsed = tenantSchema.safeParse(body) const parsed = tenantSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Enforce per-plan tenant limit (Starter = 3). const limitError = await checkTenantLimit(ownerId)
const plan = await getUserPlan(ownerId) if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
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 }
)
}
if ( if (
!(await ownsProperty(ownerId, parsed.data.property_id)) || !(await ownsProperty(ownerId, parsed.data.property_id)) ||
+6
View File
@@ -9,6 +9,7 @@ import {
extOf, extOf,
} from "@/lib/storage" } from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits" import { checkStorageLimit } from "@/lib/plan-limits"
import { enforceRateLimit } from "@/lib/rate-limit"
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"] 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 }) if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId 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 fd = await request.formData()
const file = fd.get("file") as File | null const file = fd.get("file") as File | null
const scopeRaw = (fd.get("scope") as string) || "misc" 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 { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership" import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — update a single maintenance request. Bearer API-key // 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 }> }) { export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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() if (!ctx.canWrite) return forbidden()
const { id } = await params const { id } = await params
+9
View File
@@ -5,6 +5,7 @@ import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations" import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership" import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — maintenance requests. Bearer API-key auth. // 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) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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 { searchParams } = new URL(request.url)
const status = searchParams.get("status") const status = searchParams.get("status")
const priority = searchParams.get("priority") const priority = searchParams.get("priority")
@@ -55,6 +60,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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() if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null) 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 { rentPaymentSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership" import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — rent payments. Bearer API-key auth. Maps to the // 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) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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 { searchParams } = new URL(request.url)
const status = searchParams.get("status") const status = searchParams.get("status")
const tenantId = searchParams.get("tenant_id") const tenantId = searchParams.get("tenant_id")
@@ -57,6 +62,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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() if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null) 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 { properties } from "@/lib/db/schema"
import { propertySchema } from "@/lib/validations" import { propertySchema } from "@/lib/validations"
import { resolveApiRequest } from "@/lib/api-auth" 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 { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding" import { geocodeAddress } from "@/lib/geocoding"
@@ -19,6 +21,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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({ const data = await db.query.properties.findMany({
where: eq(properties.user_id, ctx.ownerId), where: eq(properties.user_id, ctx.ownerId),
with: { units: { columns: { id: true, status: true } } }, with: { units: { columns: { id: true, status: true } } },
@@ -31,6 +37,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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() if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null) 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 coords = await geocodeAddress(parsed.data)
const [data] = await db const [data] = await db
+5
View File
@@ -3,6 +3,7 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { tenants } from "@/lib/db/schema" import { tenants } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth" 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. // 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) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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 { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id") const propertyId = searchParams.get("property_id")
const status = searchParams.get("status") const status = searchParams.get("status")
+13
View File
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema" import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations" import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events" import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf" 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) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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 { id } = await params
const data = await db.query.webhook_endpoints.findFirst({ const data = await db.query.webhook_endpoints.findFirst({
where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ctx.ownerId)), 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 }> }) { export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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() if (!ctx.canWrite) return forbidden()
const { id } = await params 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 }> }) { export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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() if (!ctx.canWrite) return forbidden()
const { id } = await params const { id } = await params
+9
View File
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema" import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations" import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events" import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf" import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
@@ -37,6 +38,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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 const data = await db
.select(PUBLIC_COLUMNS) .select(PUBLIC_COLUMNS)
.from(webhook_endpoints) .from(webhook_endpoints)
@@ -49,6 +54,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() 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() if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null) const body = await request.json().catch(() => null)
+9
View File
@@ -1,5 +1,14 @@
import type { Metadata } from "next"
import Link from "next/link" 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() { export default function NotFound() {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#09090b] text-white"> <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 { Logo } from "@/components/shared/logo"
import { XCircle } from "lucide-react" 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({ export default async function AcceptInvitePage({
params, params,
+15 -9
View File
@@ -19,15 +19,21 @@ export function PlanDonut({ data }: { data: Record<string, number> }) {
const radius = (size - stroke) / 2 const radius = (size - stroke) / 2
const circumference = 2 * Math.PI * radius const circumference = 2 * Math.PI * radius
// Build cumulative arc segments // Build cumulative arc segments. The running offset is derived per segment
let cumulative = 0 // from the slices before it rather than mutated across the map callback —
const segments = PLAN_META.map((p) => { // reassigning a closed-over local during render is what react-hooks
const value = data[p.key] ?? 0 // /immutability flags, and it misbehaves under re-render.
const fraction = total > 0 ? value / total : 0 const fractions = PLAN_META.map((p) => (total > 0 ? (data[p.key] ?? 0) / total : 0))
const dash = fraction * circumference const segments = PLAN_META.map((p, i) => {
const offset = cumulative * circumference const fraction = fractions[i]
cumulative += fraction const precedingFraction = fractions.slice(0, i).reduce((sum, f) => sum + f, 0)
return { ...p, value, fraction, dash, offset } return {
...p,
value: data[p.key] ?? 0,
fraction,
dash: fraction * circumference,
offset: precedingFraction * circumference,
}
}) })
return ( 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>
)
}
+308 -65
View File
@@ -3,7 +3,17 @@
import { useState, useTransition } from "react" import { useState, useTransition } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" 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 { import {
changeUserPlan, changeUserPlan,
banUser, banUser,
@@ -11,17 +21,31 @@ import {
impersonateUser, impersonateUser,
markEmailVerified, markEmailVerified,
deleteUser, deleteUser,
cancelUserSubscription,
resumeUserSubscription,
setUserRole,
} from "@/app/actions/admin" } from "@/app/actions/admin"
import { Select } from "@/components/ui/select" import { Select } from "@/components/ui/select"
import { ConfirmModal } from "@/components/ui/confirm-modal" import { ConfirmModal } from "@/components/ui/confirm-modal"
import { cn } from "@/lib/utils" 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 { interface UserActionsProps {
userId: string userId: string
email: string email: string
currentPlan: string currentPlan: string
banned: boolean banned: boolean
isSelf: boolean isSelf: boolean
isAdminRole: boolean
subscription: SubscriptionInfo
} }
const PLAN_OPTIONS = [ const PLAN_OPTIONS = [
@@ -31,25 +55,60 @@ const PLAN_OPTIONS = [
{ value: "lifetime", label: "Lifetime" }, { 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) { function errMessage(e: unknown) {
return e instanceof Error ? e.message : "Something went wrong" 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 router = useRouter()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [plan, setPlan] = useState(currentPlan) 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 [showBan, setShowBan] = useState(false)
const [banReason, setBanReason] = useState("") const [banReason, setBanReason] = useState("")
const [showImpersonate, setShowImpersonate] = useState(false) const [showImpersonate, setShowImpersonate] = useState(false)
const [showDelete, setShowDelete] = 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. // Run a server action inside a transition. Handles BOTH failure shapes: a
function run(fn: () => Promise<unknown>, successMsg: string, after?: () => void) { // 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 () => { startTransition(async () => {
try { try {
await fn() const res = (await fn()) as { ok?: boolean; error?: string; detail?: string } | undefined
toast.success(successMsg) if (res && res.ok === false) {
toast.error(res.error ?? "Action failed")
return
}
toast.success(res?.detail ?? fallbackMsg)
after?.() after?.()
router.refresh() router.refresh()
} catch (e) { } catch (e) {
@@ -58,67 +117,71 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
}) })
} }
function onApplyPlan() { function applyPlan(mode: "comp" | "stripe") {
if (plan === currentPlan) { if (plan === currentPlan && mode === "comp") {
toast.message("Plan unchanged") toast.message("Plan unchanged")
return return
} }
run(() => changeUserPlan(userId, plan), "Plan updated") run(
() => changeUserPlan(userId, plan, mode, interval),
mode === "stripe" ? "Stripe subscription updated" : "Plan comped",
() => setShowStripePlan(false)
)
} }
function onBan() { function onApplyPlan() {
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => { // Moving real money always gets a confirmation step.
setShowBan(false) if (planMode === "stripe") {
setBanReason("") setShowStripePlan(true)
}) return
} }
applyPlan("comp")
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)
}
})
} }
const btnBase = 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" "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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Plan */} {/* ── Plan ───────────────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5"> <div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white"> <div className="flex items-center gap-2 text-white">
<Crown className="h-4 w-4 text-amber-400" /> <Crown className="h-4 w-4 text-amber-400" />
<h3 className="text-sm font-semibold">Change plan</h3> <h3 className="text-sm font-semibold">Change plan</h3>
</div> </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"> <div className="mt-4 space-y-3">
<Select value={plan} onChange={setPlan} options={PLAN_OPTIONS} /> <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 <button
onClick={onApplyPlan} onClick={onApplyPlan}
disabled={isPending} disabled={isPending}
@@ -129,19 +192,125 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
</div> </div>
</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="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white"> <div className="flex items-center gap-2 text-white">
<UserCog className="h-4 w-4 text-rose-400" /> <UserCog className="h-4 w-4 text-rose-400" />
<h3 className="text-sm font-semibold">Account</h3> <h3 className="text-sm font-semibold">Account</h3>
</div> </div>
<div className="mt-4 space-y-2.5"> <div className="mt-4 space-y-2.5">
{/* Ban / Unban */}
{banned ? ( {banned ? (
<button <button
onClick={onUnban} onClick={() => run(() => unbanUser(userId), "User unbanned")}
disabled={isPending} 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 <ShieldCheck className="h-4 w-4" /> Unban user
</button> </button>
@@ -150,34 +319,35 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
onClick={() => setShowBan(true)} onClick={() => setShowBan(true)}
disabled={isPending || isSelf} disabled={isPending || isSelf}
title={isSelf ? "You cannot ban yourself" : undefined} 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 <Ban className="h-4 w-4" /> Ban user
</button> </button>
)} )}
{/* Impersonate */}
<button <button
onClick={() => setShowImpersonate(true)} onClick={() => setShowImpersonate(true)}
disabled={isPending || isSelf} disabled={isPending || isSelf}
title={isSelf ? "You cannot impersonate yourself" : undefined} 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 <UserCog className="h-4 w-4" /> Impersonate
</button> </button>
{/* Verify email */}
<button <button
onClick={onVerify} onClick={() => run(() => markEmailVerified(userId), "Email marked as verified")}
disabled={isPending} 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 <MailCheck className="h-4 w-4" /> Mark email verified
</button> </button>
</div> </div>
</div> </div>
{/* Danger zone */} {/* ── Danger zone ────────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-red-500/20 bg-red-500/[0.03] p-5"> <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"> <div className="flex items-center gap-2 text-red-400">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -199,7 +369,10 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
{/* Ban modal (with reason input) */} {/* Ban modal (with reason input) */}
{showBan && ( {showBan && (
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4"> <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="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"> <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" /> <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. The user will be signed out and blocked from signing in until unbanned.
</p> </p>
<div className="mt-4"> <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 <input
value={banReason} value={banReason}
onChange={(e) => setBanReason(e.target.value)} onChange={(e) => setBanReason(e.target.value)}
@@ -226,7 +401,12 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
Cancel Cancel
</button> </button>
<button <button
onClick={onBan} onClick={() =>
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => {
setShowBan(false)
setBanReason("")
})
}
disabled={isPending} 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" 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> </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 <ConfirmModal
open={showImpersonate} open={showImpersonate}
variant="warning" 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." 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" confirmLabel="Impersonate"
loading={isPending} loading={isPending}
onConfirm={onImpersonate} onConfirm={() => {
startTransition(async () => {
try {
await impersonateUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowImpersonate(false)
}
})
}}
onCancel={() => setShowImpersonate(false)} onCancel={() => setShowImpersonate(false)}
/> />
{/* Delete confirm */}
<ConfirmModal <ConfirmModal
open={showDelete} open={showDelete}
variant="danger" 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." 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" confirmLabel="Delete user"
loading={isPending} loading={isPending}
onConfirm={onDelete} onConfirm={() => {
startTransition(async () => {
try {
await deleteUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowDelete(false)
}
})
}}
onCancel={() => setShowDelete(false)} onCancel={() => setShowDelete(false)}
/> />
</div> </div>
+38 -53
View File
@@ -1,43 +1,9 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion" import { motion } from "framer-motion"
import { Plus, Minus } from "lucide-react" import { Plus, Minus } from "lucide-react"
import { FAQS } from "@/lib/marketing/faqs"
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.",
},
]
export function FAQ() { export function FAQ() {
const [open, setOpen] = useState<number | null>(null) const [open, setOpen] = useState<number | null>(null)
@@ -56,7 +22,9 @@ export function FAQ() {
</motion.div> </motion.div>
<div className="space-y-3"> <div className="space-y-3">
{FAQS.map((faq, i) => ( {FAQS.map((faq, i) => {
const isOpen = open === i
return (
<motion.div <motion.div
key={i} key={i}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -65,36 +33,53 @@ export function FAQ() {
transition={{ delay: i * 0.05 }} transition={{ delay: i * 0.05 }}
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden" className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
> >
<h3>
<button <button
onClick={() => setOpen(open === i ? null : i)} 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" className="flex w-full items-center justify-between px-5 py-4 text-left"
> >
<span className="text-sm font-medium text-white pr-4">{faq.q}</span> <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 ${ <div
open === i ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10" 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"
{open === i }`}
? <Minus className="h-3 w-3 text-indigo-400" /> >
: <Plus className="h-3 w-3 text-white/50" /> {isOpen ? (
} <Minus className="h-3 w-3 text-indigo-400" />
) : (
<Plus className="h-3 w-3 text-white/50" />
)}
</div> </div>
</button> </button>
<AnimatePresence initial={false}> </h3>
{open === i && ( {/*
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 <motion.div
initial={{ height: 0, opacity: 0 }} id={`faq-answer-${i}`}
animate={{ height: "auto", opacity: 1 }} role="region"
exit={{ height: 0, opacity: 0 }} aria-labelledby={`faq-question-${i}`}
initial={false}
animate={{ height: isOpen ? "auto" : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }} 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"> <p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3">
{faq.a} {faq.a}
</p> </p>
</motion.div> </motion.div>
)}
</AnimatePresence>
</motion.div> </motion.div>
))} )
})}
</div> </div>
</section> </section>
) )
+4 -4
View File
@@ -5,10 +5,10 @@ import { LEGAL_PAGES } from "@/lib/legal"
const LINKS = { const LINKS = {
Product: [ Product: [
{ label: "Features", href: "#features" }, { label: "Features", href: "/#features" },
{ label: "Pricing", href: "#pricing" }, { label: "Pricing", href: "/#pricing" },
{ label: "How it works", href: "#how-it-works" }, { label: "How it works", href: "/#how-it-works" },
{ label: "FAQ", href: "#faq" }, { label: "FAQ", href: "/#faq" },
], ],
Platform: [ Platform: [
{ label: "Dashboard", href: "/login" }, { 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" import { Logo } from "@/components/shared/logo"
const NAV_LINKS = [ const NAV_LINKS = [
{ label: "Features", href: "#features" }, { label: "Features", href: "/#features" },
{ label: "How it works", href: "#how-it-works" }, { label: "How it works", href: "/#how-it-works" },
{ label: "Pricing", href: "#pricing" }, { label: "Pricing", href: "/#pricing" },
{ label: "FAQ", href: "#faq" }, { label: "FAQ", href: "/#faq" },
] ]
export function Navbar() { export function Navbar() {
+74 -64
View File
@@ -1,59 +1,28 @@
import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans" import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans"
import { FAQS } from "@/lib/marketing/faqs"
import type { Plan } from "@/types" import type { Plan } from "@/types"
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network" const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth). function JsonLd({ data }: { data: Record<string, unknown> }) {
// Annual billing is auto-provisioned at checkout and has no fixed amount here, so return (
// we advertise only the monthly / one-time base prices that actually exist. <script
const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"] type="application/ld+json"
const planOffers = planOrder.map((plan) => ({ // JSON.stringify output is escaped for the closing-tag sequence so a value
"@type": "Offer", // containing "</script>" can't break out of the block.
name: getPlanLabel(plan), dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\u003c") }}
price: String(PLAN_AMOUNTS[plan]), />
priceCurrency: "USD", )
})) }
// Mirrors the visible FAQ content in components/marketing/faq.tsx. // ── Site-wide entities ───────────────────────────────────────────
// Keep these in sync with that source so the JSON-LD matches what users see. // Organization and WebSite describe the publisher and the site itself, so they
const faqs = [ // are valid on every page of the marketing surface.
{
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.",
},
]
const organization: Record<string, unknown> = { const organization: Record<string, unknown> = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Organization", "@type": "Organization",
"@id": `${base}/#organization`,
name: "Property Management Network", name: "Property Management Network",
url: base, url: base,
logo: `${base}/logo-mark.png`, logo: `${base}/logo-mark.png`,
@@ -71,25 +40,76 @@ const organization: Record<string, unknown> = {
const website: Record<string, unknown> = { const website: Record<string, unknown> = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
"@id": `${base}/#website`,
name: "Property Management Network", name: "Property Management Network",
url: base, 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> = { const softwareApplication: Record<string, unknown> = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "SoftwareApplication", "@type": "SoftwareApplication",
"@id": `${base}/#software`,
name: "Property Management Network", name: "Property Management Network",
url: base,
applicationCategory: "BusinessApplication", applicationCategory: "BusinessApplication",
operatingSystem: "Web", operatingSystem: "Web",
description: description:
"Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.", "Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.",
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, 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> = { const faqPage: Record<string, unknown> = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "FAQPage", "@type": "FAQPage",
mainEntity: faqs.map((faq) => ({ "@id": `${base}/#faq`,
mainEntity: FAQS.map((faq) => ({
"@type": "Question", "@type": "Question",
name: faq.q, name: faq.q,
acceptedAnswer: { 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 ( return (
<> <>
<script <JsonLd data={softwareApplication} />
type="application/ld+json" <JsonLd data={faqPage} />
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) }}
/>
</> </>
) )
} }
+17
View File
@@ -38,6 +38,23 @@ export function TurnstileWidget({ className }: { className?: string }) {
widgetIdRef.current = window.turnstile.render(containerRef.current, { widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey, sitekey: siteKey,
theme: "dark", theme: "dark",
// A Turnstile token is only valid for ~5 minutes. Without these the
// widget goes quietly stale on a form left open, and the submit fails
// server-side with "complete the verification challenge" even though
// the challenge visibly passed. Re-running it keeps the hidden
// cf-turnstile-response input fresh.
"refresh-expired": "auto",
"expired-callback": () => {
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current)
},
"timeout-callback": () => {
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current)
},
"error-callback": () => {
// Returning false lets Turnstile surface its own error UI rather than
// leaving an empty box the user can't act on.
return false
},
}) })
} }
+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" import { redirectUri } from "./types"
// QuickBooks Online. Docs: https://developer.intuit.com/app/developer/qbo/docs/develop // 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" import { redirectUri } from "./types"
// Xero. Docs: https://developer.xero.com/documentation/guides/oauth2/ // Xero. Docs: https://developer.xero.com/documentation/guides/oauth2/
+5
View File
@@ -11,8 +11,13 @@ export type AdminAction =
| "stop_impersonate" | "stop_impersonate"
| "delete_user" | "delete_user"
| "resend_verification" | "resend_verification"
| "mark_email_verified"
| "maintenance_mode" | "maintenance_mode"
| "ai_provider" | "ai_provider"
| "plan_change_stripe"
| "cancel_subscription"
| "resume_subscription"
| "refund"
/** /**
* Append one immutable row to admin_audit_log. Call this for EVERY mutating * 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 // 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 // from ACCOUNTING_ENCRYPTION_KEY, falling back to BETTER_AUTH_SECRET, via SHA-256
// so no additional configuration is required. // so no additional configuration is required.
let warnedAboutFallbackKey = false
function getKey(): Buffer { 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)") 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() 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) ───────────────────────────────── // ── CSV helpers (shared by admin export routes) ─────────────────────────────────
export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]) { export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]) {
const esc = (v: 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 { export function useUser(): UserState {
const { data: session, isPending } = useSession() 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 const userId = session?.user?.id
useEffect(() => { useEffect(() => {
if (!userId) return
let active = true let active = true
if (!userId) {
setProfile(null)
setProfileLoading(false)
return
}
setProfileLoading(true)
fetch("/api/profile") fetch("/api/profile")
.then((r) => (r.ok ? r.json() : { profile: null })) .then((r) => (r.ok ? r.json() : { profile: null }))
.then((data) => { .then((data) => {
if (active) { if (active) setFetched({ userId, profile: data.profile ?? null })
setProfile(data.profile ?? null)
setProfileLoading(false)
}
}) })
.catch(() => { .catch(() => {
if (active) { if (active) setFetched({ userId, profile: null })
setProfile(null)
setProfileLoading(false)
}
}) })
return () => { return () => {
active = false active = false
} }
}, [userId]) }, [userId])
const isCurrent = !!userId && fetched?.userId === userId
return { return {
user: session?.user user: session?.user
? { id: session.user.id, email: session.user.email, name: session.user.name } ? { id: session.user.id, email: session.user.email, name: session.user.name }
: null, : null,
profile, profile: isCurrent ? (fetched?.profile ?? null) : null,
loading: isPending || profileLoading, // 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 { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema" import { profiles, properties, tenants } from "@/lib/db/schema"
import { PLAN_LIMITS } from "@/lib/stripe/plans" import { PLAN_LIMITS, checkLimit } from "@/lib/stripe/plans"
import { getUserStorageBytes } from "@/lib/storage" import { getUserStorageBytes } from "@/lib/storage"
import type { Plan } from "@/types" import type { Plan } from "@/types"
@@ -39,3 +39,43 @@ export async function checkStorageLimit(
} }
return null 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) .filter(Boolean)
export function isAdminUser( 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 { ): boolean {
if (!u) return false if (!u) return false
if (u.role === "admin") return true 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.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 return false
} }
@@ -52,7 +61,7 @@ export function isAdminUser(
export async function getAdminSession() { export async function getAdminSession() {
const session = await auth.api.getSession({ headers: await headers() }) const session = await auth.api.getSession({ headers: await headers() })
const user = session?.user ?? null 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) }) const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user!.id) })
return { user: user!, profile: profile ?? null } return { user: user!, profile: profile ?? null }
} }
@@ -65,7 +74,7 @@ export async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() }) const session = await auth.api.getSession({ headers: await headers() })
const user = session?.user ?? null const user = session?.user ?? null
if (!user) redirect("/login") 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) }) const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user.id) })
return { user, profile: profile ?? null } return { user, profile: profile ?? null }
} }
+14 -1
View File
@@ -13,7 +13,20 @@ export async function verifyTurnstile(
remoteIp?: string | null remoteIp?: string | null
): Promise<boolean> { ): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY 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 if (!token) return false
try { try {
+65 -11
View File
@@ -39,18 +39,72 @@ function isPrivateIPv4(ip: string): boolean {
return false 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 { function isPrivateIPv6(ip: string): boolean {
const addr = ip.toLowerCase().split("%")[0] // strip zone id const h = ipv6Hextets(ip)
if (addr === "::1" || addr === "::") return true if (!h) return true // unparseable → treat as unsafe
// 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})$/) // ::/96 (covers :: and ::1) and ::ffff:0:0/96 both carry an IPv4 address in
if (mapped) return isPrivateIPv4(mapped[1]) // the low 32 bits. Decode it and reuse the IPv4 rules.
const head = addr.replace(/^\[|\]$/g, "") const embedsIPv4 =
if (head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb")) h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 &&
return true // fe80::/10 link-local (h[5] === 0 || h[5] === 0xffff)
if (head.startsWith("fc") || head.startsWith("fd")) return true // fc00::/7 unique-local if (embedsIPv4) {
if (head.startsWith("ff")) return true // ff00::/8 multicast 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 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:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "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": { "dependencies": {
"@anthropic-ai/sdk": "^0.110.0", "@anthropic-ai/sdk": "^0.110.0",
@@ -25,7 +28,7 @@
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@sentry/nextjs": "^10.63.0", "@sentry/nextjs": "^10.63.0",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"better-auth": "^1.6.20", "better-auth": "^1.7.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
@@ -34,7 +37,7 @@
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"next": "16.2.2", "next": "^16.3.4",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"openai": "^6.33.0", "openai": "^6.33.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
@@ -58,7 +61,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.2", "eslint-config-next": "^16.3.4",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "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 // Build the Content-Security-Policy.
// 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 // `script-src` is NONCE-based on the app surface and 'unsafe-inline' elsewhere.
// therefore blocks those inline scripts and the app never hydrates (blank page). //
// `style-src` also keeps 'unsafe-inline' (Radix / Tailwind / framer-motion inject // The nonce is minted per request and set on the REQUEST headers, which is how
// inline styles). NOTE: to restore the stricter nonce-based script policy, build // Next discovers it and stamps it onto its inline hydration scripts; we echo the
// with webpack (`next build --webpack`) so Next applies the nonce to its scripts. // policy on the response. Browsers ignore 'unsafe-inline' once a nonce is
function buildCsp(): string { // 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 isDev = process.env.NODE_ENV !== "production"
const sentry = sentryIngestOrigin() const sentry = sentryIngestOrigin()
@@ -69,7 +87,7 @@ function buildCsp(): string {
// (added to connect-src below). // (added to connect-src below).
const umami = umamiOrigin() const umami = umamiOrigin()
const scriptSrc = [ const scriptSrc = [
"script-src 'self' 'unsafe-inline'", nonce ? `script-src 'self' 'nonce-${nonce}'` : "script-src 'self' 'unsafe-inline'",
isDev ? "'unsafe-eval'" : "", isDev ? "'unsafe-eval'" : "",
"https://challenges.cloudflare.com", "https://challenges.cloudflare.com",
umami, // load the Umami analytics script umami, // load the Umami analytics script
@@ -104,6 +122,21 @@ function buildCsp(): string {
].join("; ") ].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 // 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 // an auth page we confirm the session is actually alive — otherwise a stale
// cookie loops forever: /dashboard → /login (server sees no session) → // cookie loops forever: /dashboard → /login (server sees no session) →
@@ -155,10 +188,28 @@ export async function proxy(request: NextRequest) {
dropStaleSessionCookie = state === "invalid" 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() const csp = buildCsp(nonce)
// Set the CSP on the outgoing response so the browser enforces it.
// 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) response.headers.set("Content-Security-Policy", csp)
if (dropStaleSessionCookie) { 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)
})
})