ui: confirming an alert dialog should close it #6
@@ -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)
|
||||||
|
|||||||
26
app/lib/arcadia/billing.ts
Normal file
26
app/lib/arcadia/billing.ts
Normal 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
|
||||||
|
}
|
||||||
51
app/lib/arcadia/feature-flags.ts
Normal file
51
app/lib/arcadia/feature-flags.ts
Normal 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}`)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
</div>
|
loadingLabel="Loading plans…"
|
||||||
<div className="min-w-0">
|
empty={
|
||||||
<p className="font-medium">{title}</p>
|
<EmptyState
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
title="No plans defined"
|
||||||
</div>
|
description="Plans are created in arcadia-core. Once they exist, set a tenant's plan from its Plan & quotas tab."
|
||||||
</li>
|
className="py-12"
|
||||||
))}
|
/>
|
||||||
</ul>
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{plan.description ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{plan.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>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</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"
|
||||||
|
|||||||
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"
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user