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

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