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

@@ -38,6 +38,8 @@ import {
Plug, Plug,
MessageSquare, MessageSquare,
CreditCard, CreditCard,
Flag,
Eye,
// CREMA:NAV-ICONS // CREMA:NAV-ICONS
} from "lucide-react" } from "lucide-react"
@@ -65,7 +67,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "~/components/ui/popover" } from "~/components/ui/popover"
import { profileInitials, useProfile } from "~/lib/profile" import { profileInitials, useProfile } from "~/lib/profile"
import { signOut, useSession } from "~/lib/session" import { endImpersonation, signOut, useSession } from "~/lib/session"
import { capabilityForPath, useCapabilities } from "~/lib/capabilities" import { capabilityForPath, useCapabilities } from "~/lib/capabilities"
import { import {
addNotification, addNotification,
@@ -160,6 +162,7 @@ const navGroups: NavGroup[] = [
label: "Automation", label: "Automation",
icon: Plug, icon: Plug,
items: [ items: [
{ to: "/feature-flags", icon: Flag, label: "Feature flags" },
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" }, { to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" }, { to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/integrations", icon: Plug, label: "Integrations" }, { to: "/integrations", icon: Plug, label: "Integrations" },
@@ -468,6 +471,7 @@ export function AppShell({
</aside> </aside>
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
<ImpersonationBanner />
{/* Mobile-only menu trigger, floating top-left of main */} {/* Mobile-only menu trigger, floating top-left of main */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}> <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger <SheetTrigger
@@ -787,6 +791,37 @@ function NotificationDispatcher() {
) )
} }
function ImpersonationBanner() {
const session = useSession()
const navigate = useNavigate()
if (!session?.impersonatedBy) return null
return (
<div
role="alert"
data-slot="impersonation-banner"
className="sticky top-0 z-40 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-amber-500/40 bg-amber-500/15 px-4 py-2 text-sm text-amber-900 backdrop-blur-sm dark:text-amber-200"
>
<span className="inline-flex items-center gap-1.5">
<Eye className="size-4" />
Viewing as <span className="font-semibold">{session.email}</span> actions you take
happen as this user.
</span>
<button
type="button"
data-action="impersonation-stop"
onClick={() => {
endImpersonation()
navigate("/users")
}}
className="rounded-md border border-amber-600/40 px-2 py-0.5 font-medium transition-colors hover:bg-amber-500/25"
>
Stop impersonating
</button>
</div>
)
}
function NotificationsBell() { function NotificationsBell() {
const items = useNotifications() const items = useNotifications()
const unread = unreadCount(items) const unread = unreadCount(items)

View File

@@ -0,0 +1,26 @@
// Billing — the plan catalogue (what plans tenants can be on). Per-tenant plan
// assignment lives on the tenant detail page; this is the platform-level view
// of what's on offer. Backend: /api/v1/billing/plans.
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface PlanMeter {
meter_key: string
included_units: number | null
overage_price_cents: number | null
}
export interface Plan {
name: string
slug: string
description: string | null
meters: PlanMeter[]
pricing: unknown[]
billing_track: string
trial_days: number
}
export async function listPlans(arcadia: ArcadiaClient): Promise<Plan[]> {
const res = await arcadia.GET<{ data: Plan[] }>("/api/v1/billing/plans")
return res.data
}

View File

@@ -0,0 +1,51 @@
// Platform-wide feature flags — the registry of flags that exist across the
// whole deployment. A tenant then overrides any of these under its detail page
// (see tenant-detail/feature-flags-tab). Backend: /api/v1/admin/feature-flags
// (platform_admin only).
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface PlatformFlag {
id: string
key: string
description: string | null
enabled_by_default: boolean
inserted_at?: string
updated_at?: string
}
export type PlatformFlagInput = {
key?: string
description?: string | null
enabled_by_default?: boolean
}
export async function listPlatformFlags(arcadia: ArcadiaClient): Promise<PlatformFlag[]> {
const res = await arcadia.GET<{ data: PlatformFlag[] }>("/api/v1/admin/feature-flags")
return res.data
}
export async function createPlatformFlag(
arcadia: ArcadiaClient,
input: PlatformFlagInput,
): Promise<PlatformFlag> {
const res = await arcadia.POST<{ data: PlatformFlag }>("/api/v1/admin/feature-flags", {
body: { flag: input },
})
return res.data
}
export async function updatePlatformFlag(
arcadia: ArcadiaClient,
id: string,
input: PlatformFlagInput,
): Promise<PlatformFlag> {
const res = await arcadia.PUT<{ data: PlatformFlag }>(`/api/v1/admin/feature-flags/${id}`, {
body: { flag: input },
})
return res.data
}
export async function deletePlatformFlag(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/feature-flags/${id}`)
}

View File

@@ -109,3 +109,41 @@ export async function setUserStatus(
): Promise<User> { ): Promise<User> {
return updateUser(arcadia, id, { status }) return updateUser(arcadia, id, { status })
} }
// --- Impersonation ---
// The operator (platform/tenant admin) can act as another user for support.
// `startImpersonation` returns a token scoped to the target; the client swaps
// to it (see session.beginImpersonation). Stopping is a client-side restore of
// the operator's parked session — the /stop endpoint can't be reached with the
// impersonation token (it lacks admin), so we don't rely on it.
export interface CanImpersonate {
can_impersonate: boolean
user: Pick<User, "id" | "email" | "status"> & { first_name?: string; last_name?: string }
}
export interface ImpersonationToken {
access_token: string
impersonated_by: string
expires_in: number
}
export async function canImpersonate(
arcadia: ArcadiaClient,
userId: string,
): Promise<CanImpersonate> {
const res = await arcadia.GET<{ data: CanImpersonate }>(
`/api/v1/admin/impersonate/${userId}/can-impersonate`,
)
return res.data
}
export async function startImpersonation(
arcadia: ArcadiaClient,
userId: string,
): Promise<ImpersonationToken> {
const res = await arcadia.POST<{ data: ImpersonationToken }>(
`/api/v1/admin/impersonate/${userId}`,
)
return res.data
}

View File

@@ -39,6 +39,7 @@ export type Capability =
| "platform.search" | "platform.search"
| "platform.ai" | "platform.ai"
| "platform.integrations" // external-API registry (keys/budgets) on the gateway | "platform.integrations" // external-API registry (keys/budgets) on the gateway
| "platform.feature_flags"
// Special — always-on; not gated. // Special — always-on; not gated.
| "always.assistant" | "always.assistant"
| "always.profile" | "always.profile"
@@ -84,6 +85,7 @@ const PLATFORM_ADMIN_CAPS: Capability[] = [
"platform.search", "platform.search",
"platform.ai", "platform.ai",
"platform.integrations", "platform.integrations",
"platform.feature_flags",
] ]
const ALWAYS_CAPS: Capability[] = ["always.assistant", "always.profile"] const ALWAYS_CAPS: Capability[] = ["always.assistant", "always.profile"]
@@ -135,6 +137,7 @@ export const ROUTE_CAPABILITY: Record<string, Capability> = {
"/search": "platform.search", "/search": "platform.search",
"/ai": "platform.ai", "/ai": "platform.ai",
"/integrations": "platform.integrations", "/integrations": "platform.integrations",
"/feature-flags": "platform.feature_flags",
"/assistant": "always.assistant", "/assistant": "always.assistant",
"/profile": "always.profile", "/profile": "always.profile",

View File

@@ -27,9 +27,16 @@ export type Session = {
tenantSlug?: string tenantSlug?: string
roles: string[] roles: string[]
availableTenants: AvailableTenant[] availableTenants: AvailableTenant[]
// Set (to the operator's user id) when this session is an impersonation —
// derived from the JWT's `impersonated_by` claim. Drives the "Viewing as"
// banner and gates the normal identity chrome.
impersonatedBy?: string
} }
const STORAGE_KEY = "crema.session" const STORAGE_KEY = "crema.session"
// Where the operator's real session is parked while they impersonate someone,
// so Stop can restore it without another round-trip.
const IMPERSONATION_BACKUP_KEY = "crema.session.impersonation-backup"
const CHANGE_EVENT = "crema:session-change" const CHANGE_EVENT = "crema:session-change"
function readFromStorage(): Session | null { function readFromStorage(): Session | null {
@@ -66,6 +73,8 @@ function readFromStorage(): Session | null {
!!t && typeof (t as AvailableTenant).id === "string", !!t && typeof (t as AvailableTenant).id === "string",
) as AvailableTenant[]) ) as AvailableTenant[])
: [], : [],
impersonatedBy:
typeof parsed.impersonatedBy === "string" ? parsed.impersonatedBy : undefined,
} }
} catch { } catch {
return null return null
@@ -157,6 +166,90 @@ export function hasSession(): boolean {
return !!readFromStorage() return !!readFromStorage()
} }
/** Build a Session from a bare access token (used for impersonation, where the
* server hands back a token but no user record — identity comes from the
* token's own claims). */
function sessionFromToken(token: string, fallbackEmail = "impersonated user"): Session {
const claims = decodeJwt(token) ?? {}
const email = typeof claims.email === "string" ? claims.email : fallbackEmail
// `sub` is "<user_id>:<tenant_id>" — take the user id.
const sub = typeof claims.sub === "string" ? claims.sub.split(":")[0] : ""
const availableTenants: AvailableTenant[] = Array.isArray(claims.available_tenants)
? (claims.available_tenants as AvailableTenantClaim[])
.filter((t) => t && typeof t.id === "string")
.map((t) => ({ id: t.id as string, slug: t.slug, name: t.name, roles: t.roles ?? [] }))
: []
return {
userId: (typeof claims.sub === "string" ? sub : "") || email,
name: email,
email,
token,
issuedAt: Date.now(),
tenantId: typeof claims.tenant_id === "string" ? claims.tenant_id : undefined,
tenantSlug: typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined,
roles: Array.isArray(claims.roles) ? (claims.roles as string[]) : [],
availableTenants,
impersonatedBy:
typeof claims.impersonated_by === "string" ? claims.impersonated_by : undefined,
}
}
/**
* Enter impersonation: park the operator's real session, then swap the active
* session + token to the impersonation token the server minted. Every
* subsequent request acts as the target user.
*/
export function beginImpersonation(impersonationToken: string): Session | null {
if (typeof window === "undefined") return null
const current = readFromStorage()
if (!current) return null
// Back up the operator's real session + API token so Stop can restore both.
const currentApiToken = sessionStorage.getItem("arcadia_access_token")
localStorage.setItem(
IMPERSONATION_BACKUP_KEY,
JSON.stringify({ session: current, apiToken: currentApiToken }),
)
const next = sessionFromToken(impersonationToken, current.email)
sessionStorage.setItem("arcadia_access_token", impersonationToken)
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
return next
}
/** True when an impersonation backup is parked (i.e. we can Stop). */
export function isImpersonating(): boolean {
if (typeof window === "undefined") return false
return !!localStorage.getItem(IMPERSONATION_BACKUP_KEY)
}
/**
* Leave impersonation: restore the operator's parked session + API token.
* Impersonation is stateless (just which token the client sends), so this is a
* pure client-side restore — no server round-trip needed.
*/
export function endImpersonation(): Session | null {
if (typeof window === "undefined") return null
const raw = localStorage.getItem(IMPERSONATION_BACKUP_KEY)
if (!raw) return null
try {
const { session, apiToken } = JSON.parse(raw) as {
session: Session
apiToken: string | null
}
if (apiToken) sessionStorage.setItem("arcadia_access_token", apiToken)
else sessionStorage.removeItem("arcadia_access_token")
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
localStorage.removeItem(IMPERSONATION_BACKUP_KEY)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
return session
} catch {
localStorage.removeItem(IMPERSONATION_BACKUP_KEY)
return null
}
}
// `useSyncExternalStore` demands a *referentially stable* snapshot: it must // `useSyncExternalStore` demands a *referentially stable* snapshot: it must
// return the identical object until the underlying value genuinely changes. // return the identical object until the underlying value genuinely changes.
// Keying the cache on the raw stored string gives us that for free — reparse // Keying the cache on the raw stored string gives us that for free — reparse

View File

@@ -29,6 +29,7 @@ export default [
route("status-page", "routes/status-page.tsx"), route("status-page", "routes/status-page.tsx"),
route("search", "routes/search.tsx"), route("search", "routes/search.tsx"),
route("billing", "routes/billing.tsx"), route("billing", "routes/billing.tsx"),
route("feature-flags", "routes/feature-flags.tsx"),
route("integrations", "routes/integrations.tsx"), route("integrations", "routes/integrations.tsx"),
// CREMA:ROUTES // CREMA:ROUTES
] satisfies RouteConfig ] satisfies RouteConfig

View File

@@ -1,15 +1,17 @@
// Billing — one honest home for the three surfaces that were previously three // Billing — the plan catalogue. Per-tenant plan assignment lives on each
// separate "Coming soon" nav items (Plan, Entitlements, Apps). None of them // tenant's detail page (Plan & quotas tab); this is the platform view of what
// has a live endpoint yet, so none earns its own nav weight; they collapse to // plans exist. Entitlements and Apps aren't wired to endpoints yet, so they're
// this single page until Phase 5 wires them. When Plan and Entitlements go // named honestly as still-to-come rather than given their own dead nav items.
// live they become their own items under the Billing group and this becomes
// the group overview.
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 { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header" 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 { import {
Card, Card,
CardContent, CardContent,
@@ -17,65 +19,156 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card" } 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") 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() { 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 ( return (
<AppShell> <AppShell>
<PageHeader <PageHeader
title="Billing" 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> <Card>
<CardHeader> <CardHeader>
<CardTitle>Not yet available on this deployment</CardTitle> <CardTitle>Plan catalogue</CardTitle>
<CardDescription> <CardDescription>
Billing isn't wired to a payment provider here. These are the {plans.length} plan{plans.length === 1 ? "" : "s"} defined.
surfaces that will land under Billing each becomes its own screen
once its endpoint exists.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<ul className="flex flex-col divide-y"> <DataState
{upcoming.map(({ icon: Icon, title, description }) => ( loading={loading}
<li key={title} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"> error={error}
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> isEmpty={plans.length === 0}
<Icon className="size-4" /> 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> </div>
<div className="min-w-0"> {plan.description ? (
<p className="font-medium">{title}</p> <p className="text-sm text-muted-foreground">{plan.description}</p>
<p className="text-sm text-muted-foreground">{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> </div>
) : null}
</li> </li>
))} ))}
</ul> </ul>
</DataState>
</CardContent> </CardContent>
</Card> </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> </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" 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, deleteUser,
listUsers, listUsers,
setUserStatus, setUserStatus,
startImpersonation,
updateUser, updateUser,
type User, type User,
type UserInput, type UserInput,
type UserStatus, type UserStatus,
} from "~/lib/arcadia/users" } from "~/lib/arcadia/users"
import { pageTitle } from "~/lib/page-meta" 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 { useRegisterContext } from "@crema/aifirst-ui/context"
import { UserDetailSheet } from "~/components/users/user-detail-sheet" import { UserDetailSheet } from "~/components/users/user-detail-sheet"
@@ -246,6 +248,21 @@ function UsersPanel({
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast() 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 [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all") const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all")
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; user: User } | null>(null) const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; user: User } | null>(null)
@@ -335,6 +352,7 @@ function UsersPanel({
setPendingDelete, setPendingDelete,
setDetailUser, setDetailUser,
toast, toast,
impersonate,
})} })}
triggerDataAction={`user-${u.id}-actions`} triggerDataAction={`user-${u.id}-actions`}
/> />
@@ -502,9 +520,11 @@ function userRowActions(
setPendingDelete: (u: User | null) => void setPendingDelete: (u: User | null) => void
setDetailUser: (u: User | null) => void setDetailUser: (u: User | null) => void
toast: ReturnType<typeof useToast> toast: ReturnType<typeof useToast>
impersonate: (u: User) => void
}, },
): ActionItem[] { ): ActionItem[] {
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, toast } = ctx const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, toast, impersonate } =
ctx
const items: ActionItem[] = [] const items: ActionItem[] = []
items.push({ items.push({
@@ -515,6 +535,18 @@ function userRowActions(
onSelect: () => setDetailUser(u), 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({ items.push({
id: "edit", id: "edit",
label: "Edit", label: "Edit",