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>
175 lines
5.9 KiB
TypeScript
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"
|