Snapshot of uncommitted work that had accumulated in the tree alongside the Turnstile changes: - marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and structured data - admin billing actions and a per-user portfolio view, plus an admin error boundary - rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface - CSP and proxy adjustments, accounting/webhook lib updates - Playwright config and an e2e/unit test suite - next bumped to ^16.3.4 with the lockfile regenerated - generated AGENTS.md / CLAUDE.md Authored by other sessions working in this tree; committed here so the Turnstile work could be pushed without leaving the tree dirty. Typecheck passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
509 lines
19 KiB
TypeScript
509 lines
19 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useTransition } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { toast } from "sonner"
|
|
import {
|
|
Ban,
|
|
ShieldCheck,
|
|
UserCog,
|
|
MailCheck,
|
|
Trash2,
|
|
Crown,
|
|
CreditCard,
|
|
PlayCircle,
|
|
ShieldAlert,
|
|
} from "lucide-react"
|
|
import {
|
|
changeUserPlan,
|
|
banUser,
|
|
unbanUser,
|
|
impersonateUser,
|
|
markEmailVerified,
|
|
deleteUser,
|
|
cancelUserSubscription,
|
|
resumeUserSubscription,
|
|
setUserRole,
|
|
} from "@/app/actions/admin"
|
|
import { Select } from "@/components/ui/select"
|
|
import { ConfirmModal } from "@/components/ui/confirm-modal"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export type SubscriptionInfo = {
|
|
id: string
|
|
status: string
|
|
cancelAtPeriodEnd: boolean
|
|
currentPeriodEnd: number | null
|
|
amount: number | null
|
|
interval: string | null
|
|
} | null
|
|
|
|
interface UserActionsProps {
|
|
userId: string
|
|
email: string
|
|
currentPlan: string
|
|
banned: boolean
|
|
isSelf: boolean
|
|
isAdminRole: boolean
|
|
subscription: SubscriptionInfo
|
|
}
|
|
|
|
const PLAN_OPTIONS = [
|
|
{ value: "starter", label: "Starter" },
|
|
{ value: "pro", label: "Pro" },
|
|
{ value: "landlord", label: "Landlord" },
|
|
{ value: "lifetime", label: "Lifetime" },
|
|
]
|
|
|
|
// "comp" writes the entitlement only; "stripe" moves real billing. Keeping these
|
|
// as an explicit choice is the whole point — see changeUserPlan in
|
|
// app/actions/admin.ts.
|
|
const MODE_OPTIONS = [
|
|
{ value: "comp", label: "Comp — entitlement only, no billing change" },
|
|
{ value: "stripe", label: "Sync to Stripe — charges/credits the customer" },
|
|
]
|
|
|
|
const INTERVAL_OPTIONS = [
|
|
{ value: "month", label: "Monthly" },
|
|
{ value: "year", label: "Yearly" },
|
|
]
|
|
|
|
function errMessage(e: unknown) {
|
|
return e instanceof Error ? e.message : "Something went wrong"
|
|
}
|
|
|
|
/** Server actions here return either a thrown Error or `{ ok: false, error }`. */
|
|
type ActionResult = { ok?: boolean; error?: string; detail?: string } | void | unknown
|
|
|
|
export function UserActions({
|
|
userId,
|
|
email,
|
|
currentPlan,
|
|
banned,
|
|
isSelf,
|
|
isAdminRole,
|
|
subscription,
|
|
}: UserActionsProps) {
|
|
const router = useRouter()
|
|
const [isPending, startTransition] = useTransition()
|
|
const [plan, setPlan] = useState(currentPlan)
|
|
const [planMode, setPlanMode] = useState<"comp" | "stripe">("comp")
|
|
const [interval, setInterval] = useState<"month" | "year">("month")
|
|
const [showBan, setShowBan] = useState(false)
|
|
const [banReason, setBanReason] = useState("")
|
|
const [showImpersonate, setShowImpersonate] = useState(false)
|
|
const [showDelete, setShowDelete] = useState(false)
|
|
const [showStripePlan, setShowStripePlan] = useState(false)
|
|
const [showCancelNow, setShowCancelNow] = useState(false)
|
|
const [showRole, setShowRole] = useState(false)
|
|
|
|
// Run a server action inside a transition. Handles BOTH failure shapes: a
|
|
// thrown Error (guard/validation) and a returned { ok: false, error } (an
|
|
// expected Stripe condition, e.g. "no subscription to modify").
|
|
function run(fn: () => Promise<ActionResult>, fallbackMsg: string, after?: () => void) {
|
|
startTransition(async () => {
|
|
try {
|
|
const res = (await fn()) as { ok?: boolean; error?: string; detail?: string } | undefined
|
|
if (res && res.ok === false) {
|
|
toast.error(res.error ?? "Action failed")
|
|
return
|
|
}
|
|
toast.success(res?.detail ?? fallbackMsg)
|
|
after?.()
|
|
router.refresh()
|
|
} catch (e) {
|
|
toast.error(errMessage(e))
|
|
}
|
|
})
|
|
}
|
|
|
|
function applyPlan(mode: "comp" | "stripe") {
|
|
if (plan === currentPlan && mode === "comp") {
|
|
toast.message("Plan unchanged")
|
|
return
|
|
}
|
|
run(
|
|
() => changeUserPlan(userId, plan, mode, interval),
|
|
mode === "stripe" ? "Stripe subscription updated" : "Plan comped",
|
|
() => setShowStripePlan(false)
|
|
)
|
|
}
|
|
|
|
function onApplyPlan() {
|
|
// Moving real money always gets a confirmation step.
|
|
if (planMode === "stripe") {
|
|
setShowStripePlan(true)
|
|
return
|
|
}
|
|
applyPlan("comp")
|
|
}
|
|
|
|
const btnBase =
|
|
"w-full inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
|
|
const btnGhost =
|
|
"border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white"
|
|
|
|
const periodEnd = subscription?.currentPeriodEnd
|
|
? new Date(subscription.currentPeriodEnd * 1000).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})
|
|
: null
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* ── Plan ───────────────────────────────────────────────────────────── */}
|
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
|
<div className="flex items-center gap-2 text-white">
|
|
<Crown className="h-4 w-4 text-amber-400" />
|
|
<h3 className="text-sm font-semibold">Change plan</h3>
|
|
</div>
|
|
<p className="mt-1 text-xs text-white/40">
|
|
A comp grants access without touching billing. Syncing to Stripe changes what the
|
|
customer actually pays.
|
|
</p>
|
|
<div className="mt-4 space-y-3">
|
|
<Select value={plan} onChange={setPlan} options={PLAN_OPTIONS} />
|
|
<Select
|
|
value={planMode}
|
|
onChange={(v) => setPlanMode(v as "comp" | "stripe")}
|
|
options={MODE_OPTIONS}
|
|
/>
|
|
{planMode === "stripe" && (
|
|
<Select
|
|
value={interval}
|
|
onChange={(v) => setInterval(v as "month" | "year")}
|
|
options={INTERVAL_OPTIONS}
|
|
/>
|
|
)}
|
|
{planMode === "stripe" && (
|
|
<p className="rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-xs text-amber-300/90">
|
|
This charges or credits the customer immediately via proration.
|
|
</p>
|
|
)}
|
|
<button
|
|
onClick={onApplyPlan}
|
|
disabled={isPending}
|
|
className={cn(btnBase, "bg-indigo-600 text-white hover:bg-indigo-500")}
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Subscription ───────────────────────────────────────────────────── */}
|
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
|
<div className="flex items-center gap-2 text-white">
|
|
<CreditCard className="h-4 w-4 text-sky-400" />
|
|
<h3 className="text-sm font-semibold">Subscription</h3>
|
|
</div>
|
|
|
|
{subscription ? (
|
|
<>
|
|
<div className="mt-3 space-y-1 text-xs text-white/50">
|
|
<div>
|
|
Status:{" "}
|
|
<span
|
|
className={cn(
|
|
"font-medium",
|
|
subscription.status === "active" ? "text-emerald-300" : "text-amber-300"
|
|
)}
|
|
>
|
|
{subscription.status}
|
|
</span>
|
|
</div>
|
|
{subscription.amount !== null && (
|
|
<div>
|
|
${(subscription.amount / 100).toFixed(2)}
|
|
{subscription.interval ? ` / ${subscription.interval}` : ""}
|
|
</div>
|
|
)}
|
|
{periodEnd && (
|
|
<div>
|
|
{subscription.cancelAtPeriodEnd ? "Cancels" : "Renews"} {periodEnd}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-2.5">
|
|
{subscription.cancelAtPeriodEnd ? (
|
|
<button
|
|
onClick={() => run(() => resumeUserSubscription(userId), "Subscription resumed")}
|
|
disabled={isPending}
|
|
className={cn(
|
|
btnBase,
|
|
"border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20"
|
|
)}
|
|
>
|
|
<PlayCircle className="h-4 w-4" /> Resume subscription
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() =>
|
|
run(() => cancelUserSubscription(userId, false), "Cancellation scheduled")
|
|
}
|
|
disabled={isPending}
|
|
className={cn(btnBase, btnGhost)}
|
|
>
|
|
Cancel at period end
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setShowCancelNow(true)}
|
|
disabled={isPending}
|
|
className={cn(
|
|
btnBase,
|
|
"border border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20"
|
|
)}
|
|
>
|
|
Cancel immediately
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="mt-3 text-xs text-white/40">
|
|
No active Stripe subscription. Plan changes for this user must be comps.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Admin role ─────────────────────────────────────────────────────── */}
|
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
|
<div className="flex items-center gap-2 text-white">
|
|
<ShieldAlert className="h-4 w-4 text-violet-400" />
|
|
<h3 className="text-sm font-semibold">Admin access</h3>
|
|
</div>
|
|
<p className="mt-1 text-xs text-white/40">
|
|
{isAdminRole
|
|
? "This user has full admin access to the platform."
|
|
: "Grant full access to the admin dashboard and every account."}
|
|
</p>
|
|
<button
|
|
onClick={() => setShowRole(true)}
|
|
disabled={isPending || isSelf}
|
|
title={isSelf ? "You cannot change your own role" : undefined}
|
|
className={cn(
|
|
btnBase,
|
|
"mt-4",
|
|
isAdminRole
|
|
? "border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
|
|
: "border border-violet-500/30 bg-violet-500/10 text-violet-300 hover:bg-violet-500/20"
|
|
)}
|
|
>
|
|
<ShieldAlert className="h-4 w-4" />
|
|
{isAdminRole ? "Revoke admin access" : "Make admin"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Account actions ────────────────────────────────────────────────── */}
|
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
|
<div className="flex items-center gap-2 text-white">
|
|
<UserCog className="h-4 w-4 text-rose-400" />
|
|
<h3 className="text-sm font-semibold">Account</h3>
|
|
</div>
|
|
<div className="mt-4 space-y-2.5">
|
|
{banned ? (
|
|
<button
|
|
onClick={() => run(() => unbanUser(userId), "User unbanned")}
|
|
disabled={isPending}
|
|
className={cn(
|
|
btnBase,
|
|
"border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20"
|
|
)}
|
|
>
|
|
<ShieldCheck className="h-4 w-4" /> Unban user
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowBan(true)}
|
|
disabled={isPending || isSelf}
|
|
title={isSelf ? "You cannot ban yourself" : undefined}
|
|
className={cn(
|
|
btnBase,
|
|
"border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
|
|
)}
|
|
>
|
|
<Ban className="h-4 w-4" /> Ban user
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => setShowImpersonate(true)}
|
|
disabled={isPending || isSelf}
|
|
title={isSelf ? "You cannot impersonate yourself" : undefined}
|
|
className={cn(btnBase, btnGhost)}
|
|
>
|
|
<UserCog className="h-4 w-4" /> Impersonate
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => run(() => markEmailVerified(userId), "Email marked as verified")}
|
|
disabled={isPending}
|
|
className={cn(btnBase, btnGhost)}
|
|
>
|
|
<MailCheck className="h-4 w-4" /> Mark email verified
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Danger zone ────────────────────────────────────────────────────── */}
|
|
<div className="rounded-2xl border border-red-500/20 bg-red-500/[0.03] p-5">
|
|
<div className="flex items-center gap-2 text-red-400">
|
|
<Trash2 className="h-4 w-4" />
|
|
<h3 className="text-sm font-semibold">Danger zone</h3>
|
|
</div>
|
|
<p className="mt-1 text-xs text-white/40">
|
|
Permanently deletes the user and ALL their data. This cannot be undone.
|
|
</p>
|
|
<button
|
|
onClick={() => setShowDelete(true)}
|
|
disabled={isPending || isSelf}
|
|
title={isSelf ? "You cannot delete yourself" : undefined}
|
|
className={cn(btnBase, "mt-4 bg-red-600 text-white hover:bg-red-500")}
|
|
>
|
|
<Trash2 className="h-4 w-4" /> Delete user
|
|
</button>
|
|
</div>
|
|
|
|
{/* Ban modal (with reason input) */}
|
|
{showBan && (
|
|
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4">
|
|
<div
|
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
onClick={() => !isPending && setShowBan(false)}
|
|
/>
|
|
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] p-6 shadow-2xl shadow-black/80">
|
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-amber-500/20 bg-amber-500/10 text-amber-400">
|
|
<Ban className="h-6 w-6" />
|
|
</div>
|
|
<h2 className="text-center text-base font-bold text-white">Ban {email}?</h2>
|
|
<p className="mt-2 text-center text-sm text-white/50">
|
|
The user will be signed out and blocked from signing in until unbanned.
|
|
</p>
|
|
<div className="mt-4">
|
|
<label className="mb-1.5 block text-xs font-medium text-white/40">
|
|
Reason (optional)
|
|
</label>
|
|
<input
|
|
value={banReason}
|
|
onChange={(e) => setBanReason(e.target.value)}
|
|
placeholder="Banned by admin"
|
|
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2.5 text-sm text-white placeholder-white/25 outline-none transition focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/30"
|
|
/>
|
|
</div>
|
|
<div className="mt-6 flex gap-3">
|
|
<button
|
|
onClick={() => setShowBan(false)}
|
|
disabled={isPending}
|
|
className="flex-1 rounded-xl border border-white/10 py-2.5 text-sm font-medium text-white/50 transition hover:border-white/20 hover:text-white disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => {
|
|
setShowBan(false)
|
|
setBanReason("")
|
|
})
|
|
}
|
|
disabled={isPending}
|
|
className="flex-1 rounded-xl bg-amber-600 py-2.5 text-sm font-semibold text-white transition hover:bg-amber-500 disabled:opacity-50"
|
|
>
|
|
{isPending ? "Banning…" : "Ban user"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmModal
|
|
open={showStripePlan}
|
|
variant="warning"
|
|
title={`Move ${email} to ${plan} in Stripe?`}
|
|
description="This updates their live Stripe subscription with proration — the customer will be charged or credited the difference immediately. Choose the Comp option instead to grant access without billing them."
|
|
confirmLabel="Update billing"
|
|
loading={isPending}
|
|
onConfirm={() => applyPlan("stripe")}
|
|
onCancel={() => setShowStripePlan(false)}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
open={showCancelNow}
|
|
variant="danger"
|
|
title="Cancel immediately?"
|
|
description="Access ends right now and the plan resets to Starter. No refund is issued automatically — refund the charge separately if that's intended. To let them keep what they paid for, cancel at period end instead."
|
|
confirmLabel="Cancel now"
|
|
loading={isPending}
|
|
onConfirm={() =>
|
|
run(() => cancelUserSubscription(userId, true), "Subscription canceled", () =>
|
|
setShowCancelNow(false)
|
|
)
|
|
}
|
|
onCancel={() => setShowCancelNow(false)}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
open={showRole}
|
|
variant={isAdminRole ? "warning" : "danger"}
|
|
title={isAdminRole ? `Revoke admin from ${email}?` : `Make ${email} an admin?`}
|
|
description={
|
|
isAdminRole
|
|
? "They will lose access to the admin dashboard immediately."
|
|
: "They will gain full access to every account on the platform, including billing actions and user deletion. Grant this only to staff you trust completely."
|
|
}
|
|
confirmLabel={isAdminRole ? "Revoke access" : "Make admin"}
|
|
loading={isPending}
|
|
onConfirm={() =>
|
|
run(
|
|
() => setUserRole(userId, isAdminRole ? "user" : "admin"),
|
|
isAdminRole ? "Admin access revoked" : "User promoted to admin",
|
|
() => setShowRole(false)
|
|
)
|
|
}
|
|
onCancel={() => setShowRole(false)}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
open={showImpersonate}
|
|
variant="warning"
|
|
title={`Impersonate ${email}?`}
|
|
description="You will be signed in as this user and redirected to their dashboard. Your admin session can be restored from the impersonation banner."
|
|
confirmLabel="Impersonate"
|
|
loading={isPending}
|
|
onConfirm={() => {
|
|
startTransition(async () => {
|
|
try {
|
|
await impersonateUser(userId)
|
|
} catch (e) {
|
|
toast.error(errMessage(e))
|
|
setShowImpersonate(false)
|
|
}
|
|
})
|
|
}}
|
|
onCancel={() => setShowImpersonate(false)}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
open={showDelete}
|
|
variant="danger"
|
|
title={`Delete ${email}?`}
|
|
description="This permanently deletes the user and ALL their data — properties, units, tenants, leases, payments and more. This action cannot be undone."
|
|
confirmLabel="Delete user"
|
|
loading={isPending}
|
|
onConfirm={() => {
|
|
startTransition(async () => {
|
|
try {
|
|
await deleteUser(userId)
|
|
} catch (e) {
|
|
toast.error(errMessage(e))
|
|
setShowDelete(false)
|
|
}
|
|
})
|
|
}}
|
|
onCancel={() => setShowDelete(false)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|