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
+215
View File
@@ -0,0 +1,215 @@
import { notFound } from "next/navigation"
import {
Building2,
Home,
Users as UsersIcon,
FileText,
CreditCard,
Wrench,
Receipt,
Sparkles,
ShieldAlert,
Ban,
Activity,
} from "lucide-react"
import { getUserDetail } from "@/lib/db/admin-queries"
import { requireAdmin } from "@/lib/session"
import { BackButton } from "@/components/ui/back-button"
import { CopyButton } from "@/components/shared/copy-button"
import { UserActions } from "@/components/admin/user-actions"
import { formatDate, initials, cn } from "@/lib/utils"
export const dynamic = "force-dynamic"
const PLAN_BADGE: Record<string, string> = {
starter: "border-white/15 bg-white/[0.04] text-white/40",
pro: "border-indigo-500/30 bg-indigo-500/10 text-indigo-300",
landlord: "border-violet-500/30 bg-violet-500/10 text-violet-300",
lifetime: "border-amber-500/30 bg-amber-500/10 text-amber-300",
}
const COUNT_META: { key: string; label: string; icon: typeof Building2 }[] = [
{ key: "propertyCount", label: "Properties", icon: Building2 },
{ key: "unitCount", label: "Units", icon: Home },
{ key: "tenantCount", label: "Tenants", icon: UsersIcon },
{ key: "leaseCount", label: "Leases", icon: FileText },
{ key: "paymentCount", label: "Payments", icon: CreditCard },
{ key: "maintenanceCount", label: "Maintenance", icon: Wrench },
{ key: "expenseCount", label: "Expenses", icon: Receipt },
{ key: "aiCount", label: "AI calls", icon: Sparkles },
]
export default async function AdminUserDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const [detail, { user: me }] = await Promise.all([getUserDetail(id), requireAdmin()])
if (!detail) notFound()
const { profile, account, counts, recentActivity } = detail
const isSelf = me.id === profile.id
const planKey = profile.plan ?? "starter"
return (
<div className="space-y-6">
<BackButton href="/admin/users" label="Back to users" />
{/* Header */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-rose-500/20 to-red-500/20 text-lg font-bold text-rose-300 ring-1 ring-inset ring-rose-500/20">
{initials(profile.full_name || profile.email)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold text-white">{profile.full_name || "Unnamed user"}</h1>
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize",
PLAN_BADGE[planKey] ?? PLAN_BADGE.starter
)}
>
{planKey}
</span>
{account?.role === "admin" && (
<span className="inline-flex items-center gap-1 rounded-full border border-rose-500/30 bg-rose-500/10 px-2 py-0.5 text-xs font-medium text-rose-300">
<ShieldAlert className="h-3 w-3" /> Admin
</span>
)}
{account?.banned && (
<span className="inline-flex items-center gap-1 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-xs font-medium text-red-400">
<Ban className="h-3 w-3" /> Banned
</span>
)}
{isSelf && (
<span className="inline-flex items-center rounded-full border border-white/15 bg-white/[0.04] px-2 py-0.5 text-xs font-medium text-white/50">
You
</span>
)}
</div>
<p className="mt-1 text-sm text-white/50">{profile.email}</p>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-white/30">
{profile.phone && <span>{profile.phone}</span>}
{profile.company_name && <span>{profile.company_name}</span>}
<span>Joined {formatDate(profile.created_at)}</span>
<span className="font-mono text-[11px]">{profile.id}</span>
</div>
{account?.banned && account.banReason && (
<p className="mt-2 text-xs text-red-400/80">Ban reason: {account.banReason}</p>
)}
</div>
</div>
</div>
</div>
{/* Counts grid */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{COUNT_META.map(({ key, label, icon: Icon }) => (
<div
key={key}
className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-4"
>
<div className="flex items-center gap-2 text-white/30">
<Icon className="h-4 w-4" />
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<p className="mt-2 text-2xl font-bold tabular-nums text-white">
{(counts[key as keyof typeof counts] ?? 0).toLocaleString()}
</p>
</div>
))}
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left: billing + activity */}
<div className="space-y-6 lg:col-span-2">
{/* Billing */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-6">
<h2 className="text-sm font-semibold text-white">Billing</h2>
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Plan</dt>
<dd className="mt-1 text-sm capitalize text-white/80">{planKey}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Status</dt>
<dd className="mt-1 text-sm capitalize text-white/80">
{profile.subscription_status || "—"}
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Plan expires</dt>
<dd className="mt-1 text-sm text-white/80">
{profile.plan_expires_at ? formatDate(profile.plan_expires_at) : "—"}
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Email verified</dt>
<dd className="mt-1 text-sm text-white/80">
{account?.emailVerified ? "Yes" : "No"}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Stripe customer ID</dt>
<dd className="mt-1 flex items-center gap-2">
<span className="truncate font-mono text-xs text-white/70">
{profile.stripe_customer_id || "—"}
</span>
{profile.stripe_customer_id && <CopyButton text={profile.stripe_customer_id} />}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Stripe subscription ID</dt>
<dd className="mt-1 flex items-center gap-2">
<span className="truncate font-mono text-xs text-white/70">
{profile.stripe_subscription_id || "—"}
</span>
{profile.stripe_subscription_id && <CopyButton text={profile.stripe_subscription_id} />}
</dd>
</div>
</dl>
</div>
{/* Recent activity */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-6">
<h2 className="text-sm font-semibold text-white">Recent activity</h2>
{recentActivity.length === 0 ? (
<p className="mt-4 text-sm text-white/30">No recent activity.</p>
) : (
<ul className="mt-4 space-y-3">
{recentActivity.map((a) => (
<li key={a.id} className="flex items-start gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-white/[0.06] bg-white/[0.02] text-white/40">
<Activity className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-white/80">{a.title}</p>
<p className="text-xs text-white/30">
{a.type} · {formatDate(a.created_at)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
</div>
{/* Right: actions */}
<div className="lg:col-span-1">
<UserActions
userId={profile.id}
email={profile.email}
currentPlan={planKey}
banned={!!account?.banned}
isSelf={isSelf}
/>
</div>
</div>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { getUsersPage } from "@/lib/db/admin-queries"
import { UsersTable } from "@/components/admin/users-table"
export const dynamic = "force-dynamic"
export default async function AdminUsersPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string; plan?: string; sort?: string; dir?: string }>
}) {
const { q, page, plan, sort, dir } = await searchParams
const result = await getUsersPage({
q,
page: Number(page) || 1,
plan,
sort,
dir: dir === "asc" ? "asc" : dir === "desc" ? "desc" : undefined,
})
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-bold text-white">Users</h1>
<p className="mt-1 text-sm text-white/40">
Manage accounts, plans and access across the platform.
</p>
</div>
<UsersTable data={result} query={{ q, plan, sort, dir }} />
</div>
)
}