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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user