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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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