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:
365
app/routes/feature-flags.tsx
Normal file
365
app/routes/feature-flags.tsx
Normal 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"
|
||||
Reference in New Issue
Block a user