Files
arcadia-admin/app/routes/billing.tsx
jules af2c8d6663 Phase 5: platform feature-flags CRUD, impersonation, billing catalogue
Three new platform screens on top of the Phase 1-4 work.

Feature flags (/feature-flags) — platform-wide flag registry. New route +
lib/arcadia/feature-flags.ts, capability platform.feature_flags, nav under
Automation. List/create/edit/delete with a per-row default toggle; pairs with
the Phase-4 per-tenant override tab.

Impersonation — "Impersonate" action on active users. Entirely client-side
token swap in session.ts (beginImpersonation parks the operator's session +
API token and swaps to the impersonation token; endImpersonation restores it),
with a sticky "Viewing as <email> — Stop" banner in the shell driven by the
JWT's impersonated_by claim. Stop is client-side because the impersonation
token carries the target's roles and can't reach the admin-gated /stop
endpoint; impersonation is stateless JWT so restoring the parked token is
sufficient.

Billing (/billing) — replaced the coming-soon stub with the real plan
catalogue from GET /billing/plans (lib/arcadia/billing.ts). Per-tenant plan
assignment stays on the tenant detail page; Entitlements + Apps remain honestly
marked "Soon".

Verified in-browser with real backend; typecheck adds zero errors (36→36).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:04:09 +10:00

175 lines
5.9 KiB
TypeScript

// Billing — the plan catalogue. Per-tenant plan assignment lives on each
// tenant's detail page (Plan & quotas tab); this is the platform view of what
// plans exist. Entitlements and Apps aren't wired to endpoints yet, so they're
// named honestly as still-to-come rather than given their own dead nav items.
import { useCallback, useEffect, useState } from "react"
import { Gauge, LayoutGrid, RefreshCw } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { DataState } from "~/components/data-state"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { EmptyState } from "@crema/feedback-ui"
import { listPlans, type Plan } from "~/lib/arcadia/billing"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Billing")
export default function BillingRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [plans, setPlans] = useState<Plan[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
setPlans(await listPlans(arcadia))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
return (
<AppShell>
<PageHeader
title="Billing"
description="The plans tenants can be placed on. Assign a plan to a tenant from its detail page."
actions={
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="billing-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
}
/>
<Card>
<CardHeader>
<CardTitle>Plan catalogue</CardTitle>
<CardDescription>
{plans.length} plan{plans.length === 1 ? "" : "s"} defined.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<DataState
loading={loading}
error={error}
isEmpty={plans.length === 0}
onRetry={refresh}
loadingLabel="Loading plans…"
empty={
<EmptyState
title="No plans defined"
description="Plans are created in arcadia-core. Once they exist, set a tenant's plan from its Plan & quotas tab."
className="py-12"
/>
}
>
<ul className="divide-y">
{plans.map((plan) => (
<li key={plan.slug} className="flex flex-col gap-1 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{plan.name}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{plan.slug}
</code>
<span className="text-xs text-muted-foreground">{plan.billing_track}</span>
{plan.trial_days > 0 ? (
<span className="text-xs text-muted-foreground">
· {plan.trial_days}-day trial
</span>
) : null}
</div>
{plan.description ? (
<p className="text-sm text-muted-foreground">{plan.description}</p>
) : null}
{plan.meters.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1.5">
{plan.meters.map((m) => (
<span
key={m.meter_key}
className="rounded-md border bg-card/40 px-2 py-0.5 text-xs text-muted-foreground"
>
{m.meter_key}
{m.included_units != null ? `: ${m.included_units} incl.` : ""}
</span>
))}
</div>
) : null}
</li>
))}
</ul>
</DataState>
</CardContent>
</Card>
<div className="grid gap-3 sm:grid-cols-2">
<ComingSoon
icon={Gauge}
title="Entitlements"
description="A tenant-rollup of metered allowances and usage. Per-tenant usage is on each tenant's Plan & quotas tab today; the platform rollup endpoint is pending."
/>
<ComingSoon
icon={LayoutGrid}
title="Apps"
description="Apps a tenant publishes and their per-app grants. Awaiting the catalog endpoint."
/>
</div>
</AppShell>
)
}
function ComingSoon({
icon: Icon,
title,
description,
}: {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
}) {
return (
<div className="flex items-start gap-3 rounded-lg border bg-card/40 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{title}</p>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
Soon
</span>
</div>
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
</div>
</div>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"