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>
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
// Billing — one honest home for the three surfaces that were previously three
|
||||
// separate "Coming soon" nav items (Plan, Entitlements, Apps). None of them
|
||||
// has a live endpoint yet, so none earns its own nav weight; they collapse to
|
||||
// this single page until Phase 5 wires them. When Plan and Entitlements go
|
||||
// live they become their own items under the Billing group and this becomes
|
||||
// the group overview.
|
||||
// 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 { CreditCard, Gauge, LayoutGrid } from "lucide-react"
|
||||
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 { pageTitle } from "~/lib/page-meta"
|
||||
import { DataState } from "~/components/data-state"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -17,65 +19,156 @@ import {
|
||||
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")
|
||||
|
||||
const upcoming = [
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: "Plan",
|
||||
description:
|
||||
"The tenant's subscription, renewal date, payment method, and invoice history.",
|
||||
},
|
||||
{
|
||||
icon: Gauge,
|
||||
title: "Entitlements",
|
||||
description:
|
||||
"Metered allowances — included units and usage to date per meter (AI tokens, storage, and so on).",
|
||||
},
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
title: "Apps",
|
||||
description:
|
||||
"Apps this tenant publishes, and the users who've granted them access to their personal clouds.",
|
||||
},
|
||||
]
|
||||
|
||||
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="Subscription, metered usage, and published apps for this tenant."
|
||||
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>Not yet available on this deployment</CardTitle>
|
||||
<CardTitle>Plan catalogue</CardTitle>
|
||||
<CardDescription>
|
||||
Billing isn't wired to a payment provider here. These are the
|
||||
surfaces that will land under Billing — each becomes its own screen
|
||||
once its endpoint exists.
|
||||
{plans.length} plan{plans.length === 1 ? "" : "s"} defined.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col divide-y">
|
||||
{upcoming.map(({ icon: Icon, title, description }) => (
|
||||
<li key={title} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<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">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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"
|
||||
|
||||
Reference in New Issue
Block a user