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

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