Initial import: property management SaaS + security hardening + admin dashboard

Property Management Network — Next.js 16 (App Router), Better Auth,
Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend.

Includes:
- Security hardening: access-control/IDOR fixes, TLS-by-default DB layer,
  constant-time cron auth, strict security headers, atomic AI quota gating,
  HTML/email output encoding, demo-backdoor disabled in production.
- Superadmin dashboard at /admin (overview/MRR, server-paginated users with
  ban/impersonate/plan/delete, billing, platform activity + admin audit log,
  AI usage, system health) via the Better Auth admin plugin.
- Seed/migration utility scripts under scripts/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-06-23 20:36:07 -04:00
co-authored by Claude Opus 4.8
commit 857b9a7811
291 changed files with 38996 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
import { redirect } from "next/navigation"
import { and, eq, sql } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles, properties, tenants } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { CheckoutButton } from "@/components/forms/checkout-button"
import { PortalButton } from "@/components/forms/portal-button"
import { getPlanLabel, PLAN_LIMITS } from "@/lib/stripe/plans"
import { Check } from "lucide-react"
import type { Plan } from "@/types"
export const metadata = { title: "Billing" }
const PLANS = [
{
key: "starter" as Plan,
name: "Starter",
price: "$0",
interval: "forever",
description: "For landlords just getting started",
features: ["1 property", "3 tenants", "Maintenance tracking", "Rent tracker"],
cta: "Current plan",
highlight: false,
},
{
key: "pro" as Plan,
name: "Pro",
price: "$29",
interval: "/month",
description: "For active landlords growing their portfolio",
features: ["10 properties", "Unlimited tenants", "50 AI calls/month", "5GB storage", "Email notifications", "Stripe rent collection"],
cta: "Upgrade to Pro",
highlight: true,
},
{
key: "landlord" as Plan,
name: "Landlord",
price: "$59",
interval: "/month",
description: "For property managers at scale",
features: ["Unlimited properties", "Unlimited tenants", "200 AI calls/month", "25GB storage", "Team access", "White-label"],
cta: "Upgrade to Landlord",
highlight: false,
},
{
key: "lifetime" as Plan,
name: "Lifetime",
price: "$199",
interval: "one-time",
description: "Everything in Landlord, forever",
features: ["Everything in Landlord", "Lifetime updates", "Priority support", "Flippa-ready asset"],
cta: "Get Lifetime Deal",
highlight: false,
},
]
export default async function BillingPage({
searchParams,
}: {
searchParams: Promise<{ success?: string; canceled?: string }>
}) {
const user = await getSessionUser()
if (!user) redirect("/login")
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: {
plan: true,
subscription_status: true,
plan_expires_at: true,
stripe_customer_id: true,
stripe_subscription_id: true,
},
})
const params = await searchParams
const currentPlan = (profile?.plan ?? "starter") as Plan
const hasStripeAccount = !!profile?.stripe_customer_id
const limits = PLAN_LIMITS[currentPlan]
const [[{ count: propertiesUsed }], [{ count: tenantsUsed }]] = await Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(properties)
.where(eq(properties.user_id, user.id)),
db
.select({ count: sql<number>`count(*)::int` })
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
])
return (
<div className="max-w-5xl space-y-8">
<div>
<h2 className="text-lg font-semibold text-white">Billing</h2>
<p className="text-sm text-white/40">Manage your subscription and plan</p>
</div>
{params.success && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-5 py-4 text-sm text-emerald-400">
Payment successful! Your plan has been upgraded.
</div>
)}
{params.canceled && (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-5 py-4 text-sm text-amber-400">
Checkout canceled no charge was made.
</div>
)}
{/* Current plan */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-white/40">Current Plan</p>
<p className="mt-1 text-xl font-bold text-white">{getPlanLabel(currentPlan)}</p>
{profile?.subscription_status && (
<p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
)}
</div>
{hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
<PortalButton />
)}
</div>
</div>
{/* Plans grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{PLANS.map((plan) => {
const isCurrent = currentPlan === plan.key
const isDowngrade = (
currentPlan === "landlord" && (plan.key === "pro" || plan.key === "starter") ||
currentPlan === "lifetime"
)
return (
<div
key={plan.key}
className={`relative flex flex-col rounded-xl border p-5 ${
plan.highlight
? "border-indigo-500/40 bg-indigo-600/5"
: "border-white/[0.06] bg-[#16161f]"
}`}
>
{plan.highlight && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="rounded-full bg-indigo-600 px-3 py-0.5 text-xs font-semibold text-white">
Most Popular
</span>
</div>
)}
<div>
<p className="text-sm font-semibold text-white">{plan.name}</p>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-2xl font-bold text-white">{plan.price}</span>
<span className="text-xs text-white/40">{plan.interval}</span>
</div>
<p className="mt-1 text-xs text-white/40">{plan.description}</p>
</div>
<ul className="mt-4 flex-1 space-y-2">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-xs text-white/60">
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
{f}
</li>
))}
</ul>
<div className="mt-5">
{isCurrent ? (
<div className="w-full rounded-lg border border-white/10 py-2 text-center text-xs font-medium text-white/40">
Current Plan
</div>
) : plan.key === "starter" || isDowngrade ? (
<div className="w-full rounded-lg border border-white/10 py-2 text-center text-xs font-medium text-white/30">
{plan.key === "starter" ? "Free" : "Downgrade via portal"}
</div>
) : (
<CheckoutButton plan={plan.key} label={plan.cta} highlight={plan.highlight} />
)}
</div>
</div>
)
})}
</div>
{/* Usage overview */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-5">
<h3 className="text-sm font-semibold text-white">Usage</h3>
{[
{
label: "Properties",
used: propertiesUsed ?? 0,
max: limits.maxProperties,
},
{
label: "Active Tenants",
used: tenantsUsed ?? 0,
max: limits.maxTenants,
},
].map(({ label, used, max }) => {
const unlimited = max === Infinity
const pct = unlimited ? 0 : Math.min(100, Math.round((used / max) * 100))
const nearLimit = !unlimited && pct >= 80
return (
<div key={label}>
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className="text-white/60">{label}</span>
<span className={nearLimit ? "text-amber-400 font-medium" : "text-white/40"}>
{used} / {unlimited ? "Unlimited" : max}
</span>
</div>
{!unlimited && (
<div className="h-1.5 w-full rounded-full bg-white/[0.06]">
<div
className={`h-full rounded-full transition-all ${
pct >= 100 ? "bg-red-500" : pct >= 80 ? "bg-amber-500" : "bg-indigo-500"
}`}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
)
})}
<div className="grid grid-cols-2 gap-4 pt-1 sm:grid-cols-2 border-t border-white/[0.06]">
<div>
<p className="text-xs text-white/40">AI Calls / mo</p>
<p className="mt-1 text-sm font-semibold text-white">{limits.maxAiCalls === 0 ? "Not included" : limits.maxAiCalls}</p>
</div>
<div>
<p className="text-xs text-white/40">Storage</p>
<p className="mt-1 text-sm font-semibold text-white">{limits.maxStorageMB >= 1024 ? `${limits.maxStorageMB / 1024}GB` : `${limits.maxStorageMB}MB`}</p>
</div>
</div>
</div>
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { seedDemoData, clearDemoData, setTestPlan } from "@/app/actions/seed-demo"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { redirect, notFound } from "next/navigation"
import {
Building2, Users, CreditCard, Wrench,
FileText, Receipt, Sparkles, Trash2, CheckCircle2, Zap, Crown, Infinity
} from "lucide-react"
const DEMO_CONTENTS = [
{ icon: Building2, color: "text-indigo-400 bg-indigo-500/10", label: "3 Properties", detail: "Maple Court, Riverdale Flats, Crestwood Villa" },
{ icon: Building2, color: "text-violet-400 bg-violet-500/10", label: "7 Units", detail: "Mix of 1, 2 & 3-bedroom units across all properties" },
{ icon: Users, color: "text-blue-400 bg-blue-500/10", label: "6 Tenants", detail: "Sarah, Marcus, Priya, David, Emily & James — with contacts" },
{ icon: FileText, color: "text-emerald-400 bg-emerald-500/10", label: "6 Leases", detail: "Active leases, 2 expiring soon to trigger alerts" },
{ icon: CreditCard, color: "text-teal-400 bg-teal-500/10", label: "30+ Payments", detail: "6 months of history — paid, pending & overdue statuses" },
{ icon: Wrench, color: "text-amber-400 bg-amber-500/10", label: "6 Maintenance Requests", detail: "Open, in-progress & resolved — with priorities" },
{ icon: Receipt, color: "text-rose-400 bg-rose-500/10", label: "8 Expenses", detail: "Repairs, insurance, utilities, taxes — with vendors" },
]
const PLANS = [
{ value: "starter", label: "Starter", icon: Zap, color: "text-white/60 border-white/10 hover:border-white/20", desc: "Free — no AI" },
{ value: "pro", label: "Pro", icon: Sparkles, color: "text-indigo-300 border-indigo-500/30 hover:border-indigo-500/60 bg-indigo-500/5", desc: "50 AI calls/mo" },
{ value: "landlord", label: "Landlord", icon: Crown, color: "text-violet-300 border-violet-500/30 hover:border-violet-500/60 bg-violet-500/5", desc: "200 AI calls/mo" },
{ value: "lifetime", label: "Lifetime", icon: Infinity, color: "text-amber-300 border-amber-500/30 hover:border-amber-500/60 bg-amber-500/5", desc: "Unlimited — all features" },
] as const
export default async function DemoDataPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
if (process.env.NODE_ENV === "production") notFound()
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { plan: true },
})
const currentPlan = profile?.plan ?? "starter"
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Plan switcher — most prominent */}
<div className="rounded-2xl border border-indigo-500/20 bg-gradient-to-br from-indigo-600/10 via-[#16161f] to-violet-600/5 overflow-hidden">
<div className="px-5 py-4 border-b border-white/[0.06]">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-indigo-400" />
<p className="text-sm font-semibold text-white">Test Plan</p>
</div>
<p className="text-xs text-white/40 mt-0.5">
Switch plans instantly to test different features current: <span className="text-indigo-300 font-semibold capitalize">{currentPlan}</span>
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 p-4">
{PLANS.map((plan) => {
const isActive = currentPlan === plan.value
return (
<form key={plan.value} action={setTestPlan.bind(null, plan.value)}>
<button
type="submit"
className={`w-full flex flex-col items-center gap-1.5 rounded-xl border px-3 py-3 transition-all ${plan.color} ${isActive ? "ring-2 ring-indigo-500/50 ring-offset-1 ring-offset-[#16161f]" : ""}`}
>
<plan.icon className="h-4 w-4" />
<span className="text-xs font-semibold">{plan.label}</span>
<span className="text-[10px] opacity-60">{plan.desc}</span>
{isActive && <span className="text-[9px] font-bold text-emerald-400 uppercase tracking-wider">Active</span>}
</button>
</form>
)
})}
</div>
<div className="px-5 pb-4">
<p className="text-[10px] text-white/25 text-center">
For testing only does not affect real billing
</p>
</div>
</div>
{/* Demo data section */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-4 border-b border-white/[0.06]">
<p className="text-sm font-semibold text-white">Demo Data</p>
<p className="text-xs text-white/40 mt-0.5">Populate your account with realistic sample data</p>
</div>
<div className="divide-y divide-white/[0.04]">
{DEMO_CONTENTS.map((item) => (
<div key={item.label} className="flex items-center gap-4 px-5 py-3">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${item.color}`}>
<item.icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white">{item.label}</p>
<p className="text-xs text-white/40 truncate">{item.detail}</p>
</div>
<CheckCircle2 className="h-4 w-4 text-emerald-400/40 shrink-0 ml-auto" />
</div>
))}
</div>
</div>
{/* Warning */}
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3">
<p className="text-xs text-amber-400/80 leading-relaxed">
<span className="font-semibold text-amber-400">Note:</span> "Load demo data" adds records to your account. Use "Clear all data" to wipe everything when done testing.
</p>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3">
<form action={seedDemoData} className="flex-1">
<button
type="submit"
className="w-full flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-lg hover:shadow-indigo-500/25"
>
<Sparkles className="h-4 w-4" />
Load demo data
</button>
</form>
<form action={clearDemoData}>
<button
type="submit"
className="w-full sm:w-auto flex items-center justify-center gap-2 rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-3 text-sm font-semibold text-red-400 hover:bg-red-500/10 hover:border-red-500/40 transition-all"
>
<Trash2 className="h-4 w-4" />
Clear all data
</button>
</form>
</div>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { Skeleton } from "@/components/shared/skeleton"
export default function Loading() {
return (
<div className="max-w-2xl mx-auto space-y-6">
<Skeleton className="h-5 w-24" />
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-6 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
))}
<Skeleton className="h-10 w-32 rounded-xl mt-2" />
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function SettingsPage() {
redirect("/settings/profile")
}
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { ProfileForm } from "@/components/forms/profile-form"
export const metadata = { title: "Profile Settings" }
export default async function ProfileSettingsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
})
return (
<div className="max-w-xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Profile Settings</h2>
<p className="text-sm text-white/40">Update your personal information</p>
</div>
<ProfileForm profile={profile} />
</div>
)
}