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:
jules
2026-07-14 14:04:09 +10:00
parent 7415b40240
commit af2c8d6663
10 changed files with 788 additions and 51 deletions

View File

@@ -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"

View File

@@ -0,0 +1,365 @@
import { useCallback, useEffect, useState, type FormEvent } from "react"
import { Plus, RefreshCw, Trash2 } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { DataState, DialogError } from "~/components/data-state"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardHeader,
} from "~/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { Switch } from "~/components/ui/switch"
import {
createPlatformFlag,
deletePlatformFlag,
listPlatformFlags,
updatePlatformFlag,
type PlatformFlag,
} from "~/lib/arcadia/feature-flags"
import { errorMessage } from "~/lib/errors"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Feature flags")
export default function FeatureFlagsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [flags, setFlags] = useState<PlatformFlag[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<PlatformFlag | null>(null)
const [pendingDelete, setPendingDelete] = useState<PlatformFlag | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
setFlags(await listPlatformFlags(arcadia))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const toggleDefault = async (flag: PlatformFlag, next: boolean) => {
setBusy(flag.id)
setFlags((prev) =>
prev.map((f) => (f.id === flag.id ? { ...f, enabled_by_default: next } : f)),
)
try {
await updatePlatformFlag(arcadia, flag.id, { enabled_by_default: next })
toast.success(`${flag.key} defaults to ${next ? "on" : "off"}`)
} catch (err) {
setFlags((prev) => prev.map((f) => (f.id === flag.id ? flag : f)))
toast.error(errorMessage(err, `update ${flag.key}`))
} finally {
setBusy(null)
}
}
const remove = async () => {
if (!pendingDelete) return
const flag = pendingDelete
try {
await deletePlatformFlag(arcadia, flag.id)
setPendingDelete(null)
await refresh()
toast.success(`Deleted flag ${flag.key}`)
} catch (err) {
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${flag.key}`))
}
}
return (
<AppShell>
<PageHeader
title="Feature flags"
description="Platform-wide flags. Each defines a switch that every tenant inherits by default and can override from its own settings."
actions={
<>
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="feature-flags-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
size="sm"
onClick={() => {
setEditing(null)
setEditorOpen(true)
}}
data-action="feature-flags-create"
>
<Plus className="size-4" />
New flag
</Button>
</>
}
/>
<Card>
<CardHeader className="text-xs text-muted-foreground">
{flags.length} flag{flags.length === 1 ? "" : "s"}
</CardHeader>
<CardContent className="p-0">
<DataState
loading={loading}
error={error}
isEmpty={flags.length === 0}
onRetry={refresh}
loadingLabel="Loading feature flags…"
empty={
<EmptyState
title="No feature flags yet"
description="Create a flag to gate a feature across the platform. Tenants inherit its default and can override it per-tenant."
className="py-12"
/>
}
>
<ul className="divide-y">
{flags.map((flag) => (
<li key={flag.id} className="flex items-center gap-3 px-4 py-3">
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
setEditing(flag)
setEditorOpen(true)
}}
data-action={`feature-flag-${flag.key}-edit`}
>
<code className="font-mono text-sm">{flag.key}</code>
{flag.description ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{flag.description}
</p>
) : null}
</button>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Default</span>
<Switch
checked={flag.enabled_by_default}
onCheckedChange={(v) => toggleDefault(flag, v)}
disabled={busy === flag.id}
data-action={`feature-flag-${flag.key}-default`}
aria-label={`Default for ${flag.key}`}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPendingDelete(flag)}
aria-label={`Delete ${flag.key}`}
data-action={`feature-flag-${flag.key}-delete`}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
</DataState>
</CardContent>
</Card>
<FlagEditorDialog
open={editorOpen}
flag={editing}
onClose={() => setEditorOpen(false)}
onSaved={async (msg) => {
setEditorOpen(false)
await refresh()
toast.success(msg)
}}
/>
<ConfirmDialog
open={!!pendingDelete}
onOpenChange={(o) => !o && setPendingDelete(null)}
title="Delete feature flag?"
description={
pendingDelete
? `"${pendingDelete.key}" and every tenant's override of it are removed. Any code still reading this flag falls back to its built-in default.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={remove}
/>
</AppShell>
)
}
function FlagEditorDialog({
open,
flag,
onClose,
onSaved,
}: {
open: boolean
flag: PlatformFlag | null
onClose: () => void
onSaved: (message: string) => void
}) {
const arcadia = useArcadiaClient()
const isEdit = !!flag
const [key, setKey] = useState("")
const [description, setDescription] = useState("")
const [enabledByDefault, setEnabledByDefault] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (open) {
setKey(flag?.key ?? "")
setDescription(flag?.description ?? "")
setEnabledByDefault(flag?.enabled_by_default ?? false)
setError(null)
setSubmitting(false)
}
}, [open, flag])
const keyInvalid = key.length > 0 && !/^[a-z0-9_]+$/.test(key)
const canSubmit = !submitting && key.trim().length > 0 && !keyInvalid
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!canSubmit) return
setSubmitting(true)
setError(null)
try {
if (isEdit) {
await updatePlatformFlag(arcadia, flag!.id, {
description: description.trim() || null,
enabled_by_default: enabledByDefault,
})
onSaved(`Updated flag ${flag!.key}`)
} else {
await createPlatformFlag(arcadia, {
key: key.trim(),
description: description.trim() || null,
enabled_by_default: enabledByDefault,
})
onSaved(`Created flag ${key.trim()}`)
}
} catch (err) {
setError(err)
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{isEdit ? "Edit flag" : "New feature flag"}</DialogTitle>
<DialogDescription>
{isEdit
? "The key is fixed once created — code references it."
: "The key is how code references this flag; it can't change later."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="flag-key">Key</Label>
<Input
id="flag-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="new_dashboard"
autoFocus={!isEdit}
disabled={isEdit}
className="font-mono"
data-action="feature-flag-form-key"
/>
<p className="text-xs text-muted-foreground">
{keyInvalid
? "Lowercase letters, digits, and underscores only."
: "Lowercase letters, digits, and underscores."}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="flag-description">Description</Label>
<Input
id="flag-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this flag controls"
data-action="feature-flag-form-description"
/>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<div>
<div className="text-sm font-medium">Enabled by default</div>
<div className="text-xs text-muted-foreground">
Tenants inherit this unless they override it.
</div>
</div>
<Switch
checked={enabledByDefault}
onCheckedChange={setEnabledByDefault}
data-action="feature-flag-form-default"
/>
</div>
</div>
{error ? <DialogError error={error} context="save the flag" /> : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={submitting}
data-action="feature-flag-form-cancel"
>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit} data-action="feature-flag-form-save">
{submitting ? "Saving…" : isEdit ? "Save" : "Create flag"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -76,13 +76,15 @@ import {
deleteUser,
listUsers,
setUserStatus,
startImpersonation,
updateUser,
type User,
type UserInput,
type UserStatus,
} from "~/lib/arcadia/users"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { beginImpersonation, useSession } from "~/lib/session"
import { useNavigate } from "react-router"
import { useRegisterContext } from "@crema/aifirst-ui/context"
import { UserDetailSheet } from "~/components/users/user-detail-sheet"
@@ -246,6 +248,21 @@ function UsersPanel({
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const navigate = useNavigate()
const impersonate = async (u: User) => {
try {
const token = await startImpersonation(arcadia, u.id)
// Swap the session to the impersonation token, then land on the user's
// own home. The "Viewing as…" banner appears from here on.
beginImpersonation(token.access_token)
toast.info(`Now viewing as ${u.email}`)
navigate("/")
} catch (err) {
toast.error(errorMessage(err, `impersonate ${u.email}`))
}
}
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all")
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; user: User } | null>(null)
@@ -335,6 +352,7 @@ function UsersPanel({
setPendingDelete,
setDetailUser,
toast,
impersonate,
})}
triggerDataAction={`user-${u.id}-actions`}
/>
@@ -502,9 +520,11 @@ function userRowActions(
setPendingDelete: (u: User | null) => void
setDetailUser: (u: User | null) => void
toast: ReturnType<typeof useToast>
impersonate: (u: User) => void
},
): ActionItem[] {
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, toast } = ctx
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, toast, impersonate } =
ctx
const items: ActionItem[] = []
items.push({
@@ -515,6 +535,18 @@ function userRowActions(
onSelect: () => setDetailUser(u),
})
// Support-facing: act as this user. Only meaningful for a user who can sign
// in; the server re-checks eligibility and 403s if not allowed.
if (u.status === "active") {
items.push({
id: "impersonate",
label: "Impersonate",
icon: <Eye className="size-4" />,
dataAction: `user-${u.id}-impersonate`,
onSelect: () => impersonate(u),
})
}
items.push({
id: "edit",
label: "Edit",