ui: confirming an alert dialog should close it #6
@@ -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({
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<ImpersonationBanner />
|
||||
{/* Mobile-only menu trigger, floating top-left of main */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<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() {
|
||||
const items = useNotifications()
|
||||
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> {
|
||||
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.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<string, Capability> = {
|
||||
"/search": "platform.search",
|
||||
"/ai": "platform.ai",
|
||||
"/integrations": "platform.integrations",
|
||||
"/feature-flags": "platform.feature_flags",
|
||||
|
||||
"/assistant": "always.assistant",
|
||||
"/profile": "always.profile",
|
||||
|
||||
@@ -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 "<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
|
||||
// return the identical object until the underlying value genuinely changes.
|
||||
// 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("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
|
||||
|
||||
@@ -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<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 (
|
||||
<AppShell>
|
||||
<PageHeader
|
||||
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>
|
||||
<CardHeader>
|
||||
<CardTitle>Not yet available on this deployment</CardTitle>
|
||||
<CardTitle>Plan catalogue</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col divide-y">
|
||||
{upcoming.map(({ icon: Icon, title, description }) => (
|
||||
<li key={title} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<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">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<CardContent className="p-0">
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={plans.length === 0}
|
||||
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>
|
||||
{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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
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,
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user