Phase 5: platform feature-flags CRUD, impersonation, billing catalogue

Three new platform screens on top of the Phase 1-4 work.

Feature flags (/feature-flags) — platform-wide flag registry. New route +
lib/arcadia/feature-flags.ts, capability platform.feature_flags, nav under
Automation. List/create/edit/delete with a per-row default toggle; pairs with
the Phase-4 per-tenant override tab.

Impersonation — "Impersonate" action on active users. Entirely client-side
token swap in session.ts (beginImpersonation parks the operator's session +
API token and swaps to the impersonation token; endImpersonation restores it),
with a sticky "Viewing as <email> — Stop" banner in the shell driven by the
JWT's impersonated_by claim. Stop is client-side because the impersonation
token carries the target's roles and can't reach the admin-gated /stop
endpoint; impersonation is stateless JWT so restoring the parked token is
sufficient.

Billing (/billing) — replaced the coming-soon stub with the real plan
catalogue from GET /billing/plans (lib/arcadia/billing.ts). Per-tenant plan
assignment stays on the tenant detail page; Entitlements + Apps remain honestly
marked "Soon".

Verified in-browser with real backend; typecheck adds zero errors (36→36).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-14 14:04:09 +10:00
parent 7415b40240
commit af2c8d6663
10 changed files with 788 additions and 51 deletions

View File

@@ -38,6 +38,8 @@ import {
Plug,
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)