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>
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
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"
|