Files
property-management-network/components/admin/user-actions.tsx
T
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
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>
2026-09-05 16:27:16 -04:00

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