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
+172
View File
@@ -0,0 +1,172 @@
import { getPlatformActivity, getAdminAuditLog } from "@/lib/db/admin-queries"
import { EmptyState } from "@/components/shared/empty-state"
import { formatDate } from "@/lib/utils"
import {
Activity, Shield, Ban, CreditCard, UserCog, Trash2, History,
} from "lucide-react"
export const dynamic = "force-dynamic"
// Type → small accent dot color for the platform activity list.
const typeDotColor: Record<string, string> = {
rent_paid: "bg-emerald-400",
rent_overdue: "bg-red-400",
tenant_added: "bg-blue-400",
tenant_removed: "bg-orange-400",
maintenance_opened: "bg-yellow-400",
maintenance_resolved: "bg-emerald-400",
lease_created: "bg-indigo-400",
lease_expiring: "bg-amber-400",
expense_added: "bg-purple-400",
property_added: "bg-cyan-400",
inspection_completed: "bg-teal-400",
vendor_added: "bg-pink-400",
ai_action: "bg-violet-400",
}
// Audit action → colored pill classes.
const actionPill: Record<string, string> = {
ban: "border-red-500/20 bg-red-500/10 text-red-400",
plan_change: "border-indigo-500/20 bg-indigo-500/10 text-indigo-400",
impersonate: "border-amber-500/20 bg-amber-500/10 text-amber-400",
delete_user: "border-red-500/20 bg-red-500/10 text-red-400",
}
const DEFAULT_PILL = "border-white/[0.08] bg-white/[0.04] text-white/40"
// Audit action → icon.
const actionIcon: Record<string, React.ElementType> = {
ban: Ban,
plan_change: CreditCard,
impersonate: UserCog,
delete_user: Trash2,
}
function compactJson(value: unknown): string {
if (value === null || value === undefined) return ""
try {
return typeof value === "string" ? value : JSON.stringify(value)
} catch {
return ""
}
}
export default async function AdminActivityPage() {
const [activity, audit] = await Promise.all([
getPlatformActivity({ limit: 60 }),
getAdminAuditLog({ limit: 40 }),
])
return (
<div className="min-h-full">
{/* Heading */}
<div className="mb-6">
<h1 className="text-xl font-bold text-white">Activity & Audit</h1>
<p className="text-sm text-white/40 mt-0.5">
Platform-wide events and administrative actions
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
{/* ── Platform Activity ───────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Activity className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">Platform Activity</h2>
<span className="ml-auto text-xs text-white/30">{activity.length} events</span>
</div>
{activity.length === 0 ? (
<EmptyState
icon={Activity}
title="No activity yet"
description="Platform-wide events from all accounts will appear here as users take actions."
/>
) : (
<div className="divide-y divide-white/[0.04]">
{activity.map((a) => (
<div
key={a.id}
className="flex items-start gap-3 px-5 py-3.5 hover:bg-white/[0.02] transition-colors"
>
<span
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${
typeDotColor[a.type] ?? "bg-white/30"
}`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{a.title}</p>
<p className="text-xs text-white/40 truncate mt-0.5">
{a.user_email ?? "Unknown user"}
</p>
</div>
<p className="shrink-0 text-xs text-white/25 mt-0.5">
{formatDate(a.created_at)}
</p>
</div>
))}
</div>
)}
</div>
{/* ── Admin Audit Log ─────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Shield className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">Admin Audit Log</h2>
<span className="ml-auto text-xs text-white/30">{audit.length} entries</span>
</div>
{audit.length === 0 ? (
<EmptyState
icon={History}
title="No audit entries"
description="Sensitive admin actions like bans, plan changes, and impersonation are recorded here."
/>
) : (
<div className="divide-y divide-white/[0.04]">
{audit.map((entry) => {
const Icon = actionIcon[entry.action] ?? Shield
const meta = compactJson(entry.metadata)
return (
<div
key={entry.id}
className="px-5 py-3.5 hover:bg-white/[0.02] transition-colors"
>
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-semibold ${
actionPill[entry.action] ?? DEFAULT_PILL
}`}
>
<Icon className="h-3 w-3" />
{entry.action}
</span>
{entry.target_user_id && (
<span className="font-mono text-[11px] text-white/40 truncate">
{entry.target_user_id}
</span>
)}
<span className="ml-auto shrink-0 text-xs text-white/25">
{formatDate(entry.created_at)}
</span>
</div>
{meta && (
<p className="mt-1.5 font-mono text-[11px] text-white/40 break-all line-clamp-2">
{meta}
</p>
)}
{entry.ip_address && (
<p className="mt-1 font-mono text-[11px] text-white/30">
{entry.ip_address}
</p>
)}
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { getAiUsageAggregates } from "@/lib/db/admin-queries"
import { StatsCard } from "@/components/dashboard/stats-card"
import { EmptyState } from "@/components/shared/empty-state"
import { Brain, BarChart3, Users } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function AdminAiUsagePage() {
const { byType, totalThisMonth, topUsers } = await getAiUsageAggregates()
const sortedByType = [...byType].sort((a, b) => b.count - a.count)
const maxTypeCount = sortedByType[0]?.count ?? 0
return (
<div className="min-h-full">
{/* Heading */}
<div className="mb-6">
<h1 className="text-xl font-bold text-white">AI Usage</h1>
<p className="text-sm text-white/40 mt-0.5">
AI event volume across the platform
</p>
</div>
{/* KPI */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 mb-6">
<StatsCard
label="AI Calls This Month"
value={totalThisMonth}
sub="Across all accounts"
icon={Brain}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
{/* ── Usage by type ───────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<BarChart3 className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">Usage by type</h2>
</div>
{sortedByType.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No AI usage yet"
description="AI event volume grouped by type will appear here once usage is recorded."
/>
) : (
<div className="divide-y divide-white/[0.04]">
{sortedByType.map((row) => (
<div key={row.event_type} className="px-5 py-3.5">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-white truncate">
{row.event_type}
</span>
<span className="shrink-0 text-sm font-semibold tabular-nums text-white/70">
{row.count.toLocaleString()}
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/[0.06]">
<div
className="h-full rounded-full bg-gradient-to-r from-rose-500 to-pink-500"
style={{
width: `${maxTypeCount ? Math.max(2, (row.count / maxTypeCount) * 100) : 0}%`,
}}
/>
</div>
</div>
))}
</div>
)}
</div>
{/* ── Top consumers ───────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Users className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">Top consumers</h2>
<span className="ml-auto text-xs text-white/30">This month</span>
</div>
{topUsers.length === 0 ? (
<EmptyState
icon={Users}
title="No consumers yet"
description="The accounts driving the most AI usage this month will be listed here."
/>
) : (
<div className="divide-y divide-white/[0.04]">
{topUsers.map((u, i) => (
<div
key={u.user_id}
className="flex items-center gap-3 px-5 py-3.5 hover:bg-white/[0.02] transition-colors"
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-white/[0.04] text-[11px] font-semibold tabular-nums text-white/40">
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-white">
{u.email}
</span>
<span className="shrink-0 text-sm font-semibold tabular-nums text-white/70">
{u.count.toLocaleString()}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
import {
getPlanDistribution,
computeMrr,
getAtRiskSubscriptions,
} from "@/lib/db/admin-queries"
import { StatsCard } from "@/components/dashboard/stats-card"
import { EmptyState } from "@/components/shared/empty-state"
import { PLAN_PRICES, getPlanLabel } from "@/lib/stripe/plans"
import { formatCurrency, formatDate } from "@/lib/utils"
import type { Plan } from "@/types"
import { DollarSign, TrendingUp, Gem, CreditCard, Download, ShieldCheck } from "lucide-react"
export const dynamic = "force-dynamic"
const STATUS_LABELS: Record<string, string> = {
past_due: "Past due",
unpaid: "Unpaid",
incomplete: "Incomplete",
}
// Plan rows for the distribution table (in display order)
const PLAN_ROWS: { plan: Plan; amount: number; oneTime: boolean }[] = [
{ plan: "starter", amount: 0, oneTime: false },
{ plan: "pro", amount: PLAN_PRICES.pro?.amount ?? 29, oneTime: false },
{ plan: "landlord", amount: PLAN_PRICES.landlord?.amount ?? 59, oneTime: false },
{ plan: "lifetime", amount: PLAN_PRICES.lifetime?.amount ?? 199, oneTime: true },
]
export default async function AdminBillingPage() {
const dist = await getPlanDistribution()
const { mrr, arr, lifetimeRevenue } = computeMrr(dist)
const atRisk = await getAtRiskSubscriptions()
const paidCustomers = dist.pro + dist.landlord + dist.lifetime
return (
<div className="min-h-full">
{/* Heading */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div>
<h1 className="text-xl font-bold text-white">Billing</h1>
<p className="text-sm text-white/40 mt-0.5">Revenue, plan mix, and subscription health</p>
</div>
<a
href="/api/admin/export/billing"
className="inline-flex items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white/80 transition hover:border-white/[0.16] hover:bg-white/[0.08] hover:text-white"
>
<Download className="h-4 w-4" />
Export CSV
</a>
</div>
{/* KPI cards */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 sm:gap-4 mb-6">
<StatsCard label="MRR" value={formatCurrency(mrr)} sub="Recurring monthly" icon={DollarSign} variant="success" />
<StatsCard label="ARR" value={formatCurrency(arr)} sub="Annualized run-rate" icon={TrendingUp} variant="success" />
<StatsCard
label="Lifetime Revenue"
value={formatCurrency(lifetimeRevenue)}
sub="One-time payments"
icon={Gem}
variant="warning"
/>
<StatsCard label="Paid Customers" value={paidCustomers} sub="Active paid plans" icon={CreditCard} />
</div>
{/* Plan distribution table */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden mb-6">
<div className="border-b border-white/[0.06] px-5 py-4">
<h2 className="text-sm font-semibold text-white">Plan Distribution</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-[11px] uppercase tracking-wider text-white/40">
<th className="px-5 py-3 font-medium">Plan</th>
<th className="px-5 py-3 font-medium text-right">Subscribers</th>
<th className="px-5 py-3 font-medium text-right">Unit Price</th>
<th className="px-5 py-3 font-medium text-right">Monthly Contribution</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{PLAN_ROWS.map(({ plan, amount, oneTime }) => {
const count = dist[plan] ?? 0
const isStarter = plan === "starter"
return (
<tr key={plan} className="hover:bg-white/[0.02] transition-colors">
<td className="px-5 py-3.5">
<span className="font-medium text-white">{getPlanLabel(plan)}</span>
{oneTime && (
<span className="ml-2 rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold text-amber-400">
one-time
</span>
)}
</td>
<td className="px-5 py-3.5 text-right tabular-nums text-white/80">{count.toLocaleString()}</td>
<td className="px-5 py-3.5 text-right tabular-nums text-white/60">
{isStarter ? "—" : formatCurrency(amount)}
{oneTime && <span className="text-white/30"> /once</span>}
</td>
<td className="px-5 py-3.5 text-right tabular-nums">
{isStarter ? (
<span className="text-white/30"></span>
) : oneTime ? (
<span className="text-amber-400">
{formatCurrency(count * amount)}
<span className="text-white/30 text-xs"> one-time</span>
</span>
) : (
<span className="font-semibold text-emerald-400">{formatCurrency(count * amount)}</span>
)}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr className="border-t border-white/[0.08] bg-white/[0.02]">
<td className="px-5 py-3.5 font-semibold text-white">MRR Total</td>
<td className="px-5 py-3.5 text-right tabular-nums text-white/60">{paidCustomers.toLocaleString()}</td>
<td className="px-5 py-3.5" />
<td className="px-5 py-3.5 text-right font-bold text-emerald-400 tabular-nums">{formatCurrency(mrr)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
{/* At-risk subscriptions table */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<h2 className="text-sm font-semibold text-white">At-risk Subscriptions</h2>
{atRisk.length > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-rose-500/20 px-1.5 text-[10px] font-bold text-rose-400">
{atRisk.length}
</span>
)}
</div>
{atRisk.length === 0 ? (
<EmptyState
icon={ShieldCheck}
title="No at-risk subscriptions"
description="All paid subscriptions are in good standing."
className="py-14"
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-[11px] uppercase tracking-wider text-white/40">
<th className="px-5 py-3 font-medium">Email</th>
<th className="px-5 py-3 font-medium">Plan</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium text-right">Expires</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{atRisk.map((s) => (
<tr key={s.id} className="hover:bg-white/[0.02] transition-colors">
<td className="px-5 py-3.5">
<span className="font-medium text-white">{s.email}</span>
{s.full_name && <span className="ml-2 text-xs text-white/40">{s.full_name}</span>}
</td>
<td className="px-5 py-3.5 text-white/70">{getPlanLabel(s.plan as Plan)}</td>
<td className="px-5 py-3.5">
<span className="rounded-full border border-rose-500/20 bg-rose-500/10 px-2.5 py-1 text-[10px] font-semibold text-rose-400">
{STATUS_LABELS[s.subscription_status ?? ""] ?? s.subscription_status ?? "Unknown"}
</span>
</td>
<td className="px-5 py-3.5 text-right tabular-nums text-white/60">
{s.plan_expires_at ? formatDate(s.plan_expires_at) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { requireAdmin } from "@/lib/session"
import { AdminSidebar } from "@/components/admin/admin-sidebar"
import { AdminHeader } from "@/components/admin/admin-header"
import { Breadcrumbs } from "@/components/dashboard/breadcrumbs"
import { PageTransition } from "@/components/dashboard/page-transition"
import { ScrollToTop } from "@/components/ui/scroll-to-top"
export const dynamic = "force-dynamic"
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
// Gate #2 (after proxy.ts edge check): redirects non-admins. Every
// /api/admin route handler re-checks via getAdminSession() — gate #3.
const { user, profile } = await requireAdmin()
return (
<div className="flex h-screen overflow-hidden bg-[#09090b]">
<AdminSidebar email={profile?.email ?? user.email} name={profile?.full_name ?? user.name ?? ""} />
<div className="flex flex-1 flex-col overflow-hidden">
<AdminHeader />
<main id="main-scroll" className="flex-1 overflow-y-auto p-4 sm:p-6">
<Breadcrumbs />
<PageTransition>{children}</PageTransition>
</main>
</div>
<ScrollToTop />
</div>
)
}
+142
View File
@@ -0,0 +1,142 @@
import {
getAdminOverviewStats,
getSignupsTrend,
getAtRiskSubscriptions,
} from "@/lib/db/admin-queries"
import { StatsCard } from "@/components/dashboard/stats-card"
import { PlanDonut, SignupsBars } from "@/components/admin/admin-charts"
import { getPlanLabel } from "@/lib/stripe/plans"
import { formatCurrency } from "@/lib/utils"
import type { Plan } from "@/types"
import {
DollarSign, TrendingUp, Users, Activity, CreditCard,
UserPlus, Building2, Home, Banknote, Brain, AlertTriangle,
} from "lucide-react"
export const dynamic = "force-dynamic"
const STATUS_LABELS: Record<string, string> = {
past_due: "Past due",
unpaid: "Unpaid",
incomplete: "Incomplete",
}
export default async function AdminOverviewPage() {
const [stats, signupsTrend, atRisk] = await Promise.all([
getAdminOverviewStats(),
getSignupsTrend(6),
getAtRiskSubscriptions(),
])
return (
<div className="min-h-full">
{/* Heading */}
<div className="mb-6">
<h1 className="text-xl font-bold text-white">Platform Overview</h1>
<p className="text-sm text-white/40 mt-0.5">Key metrics across all accounts</p>
</div>
{/* KPI grid */}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3 sm:gap-4 mb-6">
<StatsCard
label="MRR"
value={formatCurrency(stats.mrr)}
sub="Recurring monthly"
icon={DollarSign}
variant="success"
/>
<StatsCard
label="ARR"
value={formatCurrency(stats.arr)}
sub="Annualized run-rate"
icon={TrendingUp}
variant="success"
/>
<StatsCard
label="Total Users"
value={stats.totalUsers}
sub={`${stats.paidUsers} paid · ${stats.freeUsers} free`}
icon={Users}
/>
<StatsCard
label="Active (30d)"
value={stats.activeUsers30d}
sub="Recently active"
icon={Activity}
/>
<StatsCard
label="Paid Users"
value={stats.paidUsers}
sub={`${stats.freeUsers} free`}
icon={CreditCard}
variant="success"
/>
<StatsCard
label="New Signups"
value={stats.newSignupsThisMonth}
sub="This month"
icon={UserPlus}
/>
<StatsCard
label="Total Properties"
value={stats.totalProperties}
sub={`${stats.totalUnits} unit${stats.totalUnits !== 1 ? "s" : ""}`}
icon={Building2}
/>
<StatsCard
label="Total Tenants"
value={stats.totalTenants}
sub="Across platform"
icon={Home}
/>
<StatsCard
label="Rent Collected"
value={formatCurrency(stats.rentCollectedThisMonth)}
sub="This month"
icon={Banknote}
variant="success"
/>
<StatsCard
label="AI Calls"
value={stats.aiCallsThisMonth}
sub="This month"
icon={Brain}
/>
</div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-6">
<PlanDonut data={stats.planDistribution} />
<SignupsBars data={signupsTrend} />
</div>
{/* At-risk subscriptions */}
{atRisk.length > 0 && (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<AlertTriangle className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">At-risk subscriptions</h2>
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-rose-500/20 px-1.5 text-[10px] font-bold text-rose-400">
{atRisk.length}
</span>
</div>
<div className="divide-y divide-white/[0.04]">
{atRisk.slice(0, 8).map((s) => (
<div key={s.id} className="flex items-center justify-between px-5 py-3 hover:bg-white/[0.02] transition-colors">
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate">{s.email}</p>
<p className="text-xs text-white/40 truncate">
{s.full_name || "—"} · {getPlanLabel(s.plan as Plan)}
</p>
</div>
<span className="ml-3 shrink-0 rounded-full border border-rose-500/20 bg-rose-500/10 px-2.5 py-1 text-[10px] font-semibold text-rose-400">
{STATUS_LABELS[s.subscription_status ?? ""] ?? s.subscription_status ?? "Unknown"}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries"
import { formatDate } from "@/lib/utils"
import { Settings, Database, Table2 } from "lucide-react"
export const dynamic = "force-dynamic"
function humanize(name: string): string {
return name
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())
}
export default async function AdminSystemPage() {
const [{ counts, cronLastRun }, env] = await Promise.all([
getSystemCounts(),
Promise.resolve(getEnvHealth()),
])
return (
<div className="min-h-full">
{/* Heading */}
<div className="mb-6">
<h1 className="text-xl font-bold text-white">System Health</h1>
<p className="text-sm text-white/40 mt-0.5">
Configuration, database, and table statistics
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-5">
{/* ── Environment configuration ───────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Settings className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">Environment configuration</h2>
</div>
<div className="divide-y divide-white/[0.04]">
{env.map(({ key, present }) => (
<div
key={key}
className="flex items-center justify-between gap-3 px-5 py-3"
>
<span className="font-mono text-xs text-white/60 truncate">{key}</span>
<span className="flex shrink-0 items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
present ? "bg-emerald-400" : "bg-amber-400"
}`}
/>
<span
className={`text-xs font-medium ${
present ? "text-emerald-400" : "text-amber-400"
}`}
>
{present ? "Configured" : "Missing"}
</span>
</span>
</div>
))}
</div>
</div>
{/* ── Database ────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Database className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">Database</h2>
</div>
<div className="divide-y divide-white/[0.04]">
<div className="flex items-center justify-between gap-3 px-5 py-3">
<span className="text-sm text-white/60">Connection</span>
<span className="flex shrink-0 items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
<span className="text-xs font-medium text-emerald-400">Connected</span>
</span>
</div>
<div className="flex items-center justify-between gap-3 px-5 py-3">
<span className="text-sm text-white/60">Cron last run</span>
<span className="shrink-0 text-xs text-white/40">
{cronLastRun ? formatDate(cronLastRun) : "Never run"}
</span>
</div>
</div>
</div>
</div>
{/* ── Table row counts ──────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Table2 className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">Table row counts</h2>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-px bg-white/[0.04]">
{Object.entries(counts).map(([name, value]) => (
<div key={name} className="bg-[#16161f] px-5 py-4">
<p className="text-xs font-medium uppercase tracking-wider text-white/40">
{humanize(name)}
</p>
<p className="mt-1.5 text-2xl font-bold tabular-nums text-white">
{value.toLocaleString()}
</p>
</div>
))}
</div>
</div>
</div>
)
}
+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>
)
}