From af2c8d66634355d7286ff1e22ede32bd9b1e44cb Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 14 Jul 2026 14:04:09 +1000 Subject: [PATCH] Phase 5: platform feature-flags CRUD, impersonation, billing catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — 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 --- app/components/layout/app-shell.tsx | 37 ++- app/lib/arcadia/billing.ts | 26 ++ app/lib/arcadia/feature-flags.ts | 51 ++++ app/lib/arcadia/users.ts | 38 +++ app/lib/capabilities.ts | 3 + app/lib/session.ts | 93 +++++++ app/routes.ts | 1 + app/routes/billing.tsx | 189 ++++++++++---- app/routes/feature-flags.tsx | 365 ++++++++++++++++++++++++++++ app/routes/users.tsx | 36 ++- 10 files changed, 788 insertions(+), 51 deletions(-) create mode 100644 app/lib/arcadia/billing.ts create mode 100644 app/lib/arcadia/feature-flags.ts create mode 100644 app/routes/feature-flags.tsx diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index 5ab4c95..228e0eb 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -38,6 +38,8 @@ import { Plug, MessageSquare, CreditCard, + Flag, + Eye, // CREMA:NAV-ICONS } from "lucide-react" @@ -65,7 +67,7 @@ import { PopoverTrigger, } from "~/components/ui/popover" 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 { addNotification, @@ -160,6 +162,7 @@ const navGroups: NavGroup[] = [ label: "Automation", icon: Plug, items: [ + { to: "/feature-flags", icon: Flag, label: "Feature flags" }, { to: "/webhooks", icon: WebhookIcon, label: "Webhooks" }, { to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" }, { to: "/integrations", icon: Plug, label: "Integrations" }, @@ -468,6 +471,7 @@ export function AppShell({
+ {/* Mobile-only menu trigger, floating top-left of main */} + + + Viewing as {session.email} — actions you take + happen as this user. + + + + ) +} + function NotificationsBell() { const items = useNotifications() const unread = unreadCount(items) diff --git a/app/lib/arcadia/billing.ts b/app/lib/arcadia/billing.ts new file mode 100644 index 0000000..ef5e42e --- /dev/null +++ b/app/lib/arcadia/billing.ts @@ -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 { + const res = await arcadia.GET<{ data: Plan[] }>("/api/v1/billing/plans") + return res.data +} diff --git a/app/lib/arcadia/feature-flags.ts b/app/lib/arcadia/feature-flags.ts new file mode 100644 index 0000000..92133a3 --- /dev/null +++ b/app/lib/arcadia/feature-flags.ts @@ -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 { + const res = await arcadia.GET<{ data: PlatformFlag[] }>("/api/v1/admin/feature-flags") + return res.data +} + +export async function createPlatformFlag( + arcadia: ArcadiaClient, + input: PlatformFlagInput, +): Promise { + 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 { + 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 { + await arcadia.DELETE(`/api/v1/admin/feature-flags/${id}`) +} diff --git a/app/lib/arcadia/users.ts b/app/lib/arcadia/users.ts index 8bc17f4..9d78aa0 100644 --- a/app/lib/arcadia/users.ts +++ b/app/lib/arcadia/users.ts @@ -109,3 +109,41 @@ export async function setUserStatus( ): Promise { 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 & { 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 { + 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 { + const res = await arcadia.POST<{ data: ImpersonationToken }>( + `/api/v1/admin/impersonate/${userId}`, + ) + return res.data +} diff --git a/app/lib/capabilities.ts b/app/lib/capabilities.ts index 0b5b475..0dbeeb7 100644 --- a/app/lib/capabilities.ts +++ b/app/lib/capabilities.ts @@ -39,6 +39,7 @@ export type Capability = | "platform.search" | "platform.ai" | "platform.integrations" // external-API registry (keys/budgets) on the gateway + | "platform.feature_flags" // Special — always-on; not gated. | "always.assistant" | "always.profile" @@ -84,6 +85,7 @@ const PLATFORM_ADMIN_CAPS: Capability[] = [ "platform.search", "platform.ai", "platform.integrations", + "platform.feature_flags", ] const ALWAYS_CAPS: Capability[] = ["always.assistant", "always.profile"] @@ -135,6 +137,7 @@ export const ROUTE_CAPABILITY: Record = { "/search": "platform.search", "/ai": "platform.ai", "/integrations": "platform.integrations", + "/feature-flags": "platform.feature_flags", "/assistant": "always.assistant", "/profile": "always.profile", diff --git a/app/lib/session.ts b/app/lib/session.ts index 9f31c05..35aac93 100644 --- a/app/lib/session.ts +++ b/app/lib/session.ts @@ -27,9 +27,16 @@ export type Session = { tenantSlug?: string roles: string[] 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" +// 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" function readFromStorage(): Session | null { @@ -66,6 +73,8 @@ function readFromStorage(): Session | null { !!t && typeof (t as AvailableTenant).id === "string", ) as AvailableTenant[]) : [], + impersonatedBy: + typeof parsed.impersonatedBy === "string" ? parsed.impersonatedBy : undefined, } } catch { return null @@ -157,6 +166,90 @@ export function hasSession(): boolean { 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 ":" — 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 // return the identical object until the underlying value genuinely changes. // Keying the cache on the raw stored string gives us that for free — reparse diff --git a/app/routes.ts b/app/routes.ts index a374de1..c3050ce 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -29,6 +29,7 @@ export default [ route("status-page", "routes/status-page.tsx"), route("search", "routes/search.tsx"), route("billing", "routes/billing.tsx"), + route("feature-flags", "routes/feature-flags.tsx"), route("integrations", "routes/integrations.tsx"), // CREMA:ROUTES ] satisfies RouteConfig diff --git a/app/routes/billing.tsx b/app/routes/billing.tsx index 866da7e..f577c04 100644 --- a/app/routes/billing.tsx +++ b/app/routes/billing.tsx @@ -1,15 +1,17 @@ -// Billing — one honest home for the three surfaces that were previously three -// separate "Coming soon" nav items (Plan, Entitlements, Apps). None of them -// has a live endpoint yet, so none earns its own nav weight; they collapse to -// this single page until Phase 5 wires them. When Plan and Entitlements go -// live they become their own items under the Billing group and this becomes -// the group overview. +// Billing — the plan catalogue. Per-tenant plan assignment lives on each +// tenant's detail page (Plan & quotas tab); this is the platform view of what +// plans exist. Entitlements and Apps aren't wired to endpoints yet, so they're +// named honestly as still-to-come rather than given their own dead nav items. -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 { 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 { Card, CardContent, @@ -17,65 +19,156 @@ import { CardHeader, CardTitle, } 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") -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() { + const session = useSession() + const arcadia = useArcadiaClient() + const [plans, setPlans] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( + + Refresh + + } /> - Not yet available on this deployment + Plan catalogue - Billing isn't wired to a payment provider here. These are the - surfaces that will land under Billing — each becomes its own screen - once its endpoint exists. + {plans.length} plan{plans.length === 1 ? "" : "s"} defined. - -
    - {upcoming.map(({ icon: Icon, title, description }) => ( -
  • -
    - -
    -
    -

    {title}

    -

    {description}

    -
    -
  • - ))} -
+ + + } + > +
    + {plans.map((plan) => ( +
  • +
    + {plan.name} + + {plan.slug} + + {plan.billing_track} + {plan.trial_days > 0 ? ( + + · {plan.trial_days}-day trial + + ) : null} +
    + {plan.description ? ( +

    {plan.description}

    + ) : null} + {plan.meters.length > 0 ? ( +
    + {plan.meters.map((m) => ( + + {m.meter_key} + {m.included_units != null ? `: ${m.included_units} incl.` : ""} + + ))} +
    + ) : null} +
  • + ))} +
+
+ +
+ + +
) } +function ComingSoon({ + icon: Icon, + title, + description, +}: { + icon: React.ComponentType<{ className?: string }> + title: string + description: string +}) { + return ( +
+
+ +
+
+
+

{title}

+ + Soon + +
+

{description}

+
+
+ ) +} + export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error" diff --git a/app/routes/feature-flags.tsx b/app/routes/feature-flags.tsx new file mode 100644 index 0000000..e321951 --- /dev/null +++ b/app/routes/feature-flags.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [editorOpen, setEditorOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [pendingDelete, setPendingDelete] = useState(null) + const [busy, setBusy] = useState(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 ( + + + + + + } + /> + + + + {flags.length} flag{flags.length === 1 ? "" : "s"} + + + + } + > +
    + {flags.map((flag) => ( +
  • + + +
    + Default + toggleDefault(flag, v)} + disabled={busy === flag.id} + data-action={`feature-flag-${flag.key}-default`} + aria-label={`Default for ${flag.key}`} + /> +
    + + +
  • + ))} +
+
+
+
+ + setEditorOpen(false)} + onSaved={async (msg) => { + setEditorOpen(false) + await refresh() + toast.success(msg) + }} + /> + + !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} + /> +
+ ) +} + +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(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 ( + !o && onClose()}> + +
+ + {isEdit ? "Edit flag" : "New feature flag"} + + {isEdit + ? "The key is fixed once created — code references it." + : "The key is how code references this flag; it can't change later."} + + + +
+
+ + setKey(e.target.value)} + placeholder="new_dashboard" + autoFocus={!isEdit} + disabled={isEdit} + className="font-mono" + data-action="feature-flag-form-key" + /> +

+ {keyInvalid + ? "Lowercase letters, digits, and underscores only." + : "Lowercase letters, digits, and underscores."} +

+
+ +
+ + setDescription(e.target.value)} + placeholder="What this flag controls" + data-action="feature-flag-form-description" + /> +
+ +
+
+
Enabled by default
+
+ Tenants inherit this unless they override it. +
+
+ +
+
+ + {error ? : null} + + + + + + +
+
+ ) +} + +export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error" diff --git a/app/routes/users.tsx b/app/routes/users.tsx index 4459546..b007ded 100644 --- a/app/routes/users.tsx +++ b/app/routes/users.tsx @@ -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 + 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: , + dataAction: `user-${u.id}-impersonate`, + onSelect: () => impersonate(u), + }) + } + items.push({ id: "edit", label: "Edit",