From e5dd6e476738a650f57e6d3653f53277b6e26ba5 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 28 May 2026 14:58:32 -0500 Subject: [PATCH] feat(site): tenant switcher chrome in generated admin shell (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the operator's tenancy in the generated admin shell and let multi-tenant users switch the active tenant, building on the stateless header-driven model shipped in #67/#70 (no active_tenant token claim; the active tenant is chosen per-request via the X-Active-Tenant header). - auth.ts: activeTenantStore (sessionStorage), ACTIVE_TENANT_HEADER, fetchMe(), and MeResponse/TenantRef/ActiveTenant types mirroring the Rust shapes. The active tenant is cleared alongside the token so a lost session never strands a stale selection for the next operator. - api-client: attach X-Active-Tenant on every request (a caller-supplied header still wins). The server already 403s a non-member value, so this is convenience + display, never the security boundary. - App.tsx: TenantControl in the topbar — a picker for >=2 memberships, a chip for exactly one, and an "Operating as: platform_admin" badge for role holders (the server bypasses tenant scoping for them, so a picker would imply a constraint that does not exist). Switching stores the choice and hard-reloads to /admin so no prior-tenant data lingers in the React Query cache under the new scope. - vendor.rs: INDEX_CSS styles for the picker, chip, and badge. Frontend-only; no backend change. --- .../src/commands/site/vendor.rs | 37 ++++++ .../templates/site/src/App.tsx.jinja | 96 ++++++++++++++- .../site/src/admin/api-client.ts.jinja | 10 +- .../templates/site/src/lib/auth.ts.jinja | 111 ++++++++++++++++++ 4 files changed, 252 insertions(+), 2 deletions(-) diff --git a/crates/schema-forge-cli/src/commands/site/vendor.rs b/crates/schema-forge-cli/src/commands/site/vendor.rs index 6978cc6..8da89c5 100644 --- a/crates/schema-forge-cli/src/commands/site/vendor.rs +++ b/crates/schema-forge-cli/src/commands/site/vendor.rs @@ -1222,6 +1222,43 @@ body { border-radius: 2px; } +/* ---- Tenant switcher (topbar) ---- */ +.topbar-tenant { + display: inline-flex; align-items: center; gap: 6px; + padding: 0 4px 0 8px; + border: 1px solid var(--app-border); + border-radius: 2px; + background: var(--app-bg-2); + color: var(--app-fg-2); +} +.topbar-tenant:hover { border-color: var(--app-border-2); } +.topbar-tenant select { + border: 0; background: transparent; + color: var(--app-fg-1); + font-family: inherit; font-size: 13px; + padding: 5px 4px; cursor: pointer; + max-width: 220px; +} +.topbar-tenant select:focus-visible { + outline: 2px solid var(--app-fg-1); + outline-offset: 2px; border-radius: 2px; +} +.topbar-tenant-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 10px; + border: 1px solid var(--app-border); + border-radius: 2px; + background: var(--app-bg-2); + color: var(--app-fg-2); + font-size: 13px; +} +.topbar-tenant-admin { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.04em; + border-color: var(--accent); + color: var(--app-fg-1); +} + /* ---- Page wrapper ---- */ .page { padding: 24px 32px 64px; max-width: 1480px; } .page-narrow { padding: 24px 32px 64px; max-width: 920px; } diff --git a/crates/schema-forge-cli/templates/site/src/App.tsx.jinja b/crates/schema-forge-cli/templates/site/src/App.tsx.jinja index 3134424..ac73772 100644 --- a/crates/schema-forge-cli/templates/site/src/App.tsx.jinja +++ b/crates/schema-forge-cli/templates/site/src/App.tsx.jinja @@ -39,7 +39,14 @@ import { routeManifest } from "@/generated/route-manifest" import { LoginPage } from "@/pages/login" import { AdminLayout } from "@/admin/layout" import { RequireAuth } from "@/lib/require-auth" -import { getCurrentUsername, isAuthenticated, logout } from "@/lib/auth" +import { + activeTenantStore, + fetchMe, + getCurrentRoles, + getCurrentUsername, + isAuthenticated, + logout, +} from "@/lib/auth" import { getAdminPermissions, isSystemSchema, @@ -273,6 +280,91 @@ function humanizeCrumb(seg: string): string { return decoded } +// The role that transcends tenancy. A holder sees cross-tenant data because +// the server's tenant_scope middleware bypasses scoping for them entirely — +// so the switcher shows a standing badge rather than a picker. Mirrors +// `PLATFORM_ADMIN_ROLE` in the Rust backend. +const PLATFORM_ADMIN_ROLE = "platform_admin" + +// Tenant ids are TypeIDs (`organization_01k…`); show a compact tail so a +// screen reader and the eye both get something legible rather than 30 chars. +function shortTenantId(id: string): string { + const tail = id.includes("_") ? id.slice(id.lastIndexOf("_") + 1) : id + return tail.length > 10 ? tail.slice(0, 8) + "…" : tail +} + +// Tenant chrome: a picker when the operator belongs to several tenants, a +// static chip when they belong to exactly one, and a "platform_admin" badge +// when no tenant scope is in effect (so cross-tenant visibility is explained). +// Switching is stateless — it stores the choice and hard-reloads to /admin so +// no tenant-A data lingers in the React Query cache under tenant B. +function TenantControl() { + const { data: me } = useQuery({ + queryKey: ["auth", "me", activeTenantStore.get()], + queryFn: fetchMe, + }) + + const isPlatformAdmin = getCurrentRoles().includes(PLATFORM_ADMIN_ROLE) + const chain = me?.tenant_chain ?? [] + const active = me?.active_tenant ?? null + + // Platform admin first: their requests are never tenant-scoped server-side, + // so a picker would imply a constraint that does not exist. + if (isPlatformAdmin) { + return ( + + Operating as: platform_admin + + ) + } + + function switchTo(value: string) { + const sep = value.indexOf(":") + if (sep <= 0) return + activeTenantStore.set(value.slice(0, sep), value.slice(sep + 1)) + if (typeof window !== "undefined") window.location.assign("/admin") + } + + if (chain.length >= 2) { + const current = active ? `${active.tenant_type}:${active.tenant_id}` : "" + return ( + + ) + } + + if (active) { + return ( + + + ) + } + + // No tenancy in effect (single-tenant deployment or no memberships). + return null +} + function Topbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) { const location = useLocation() const navigate = useNavigate() @@ -324,6 +416,8 @@ function Topbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => v + +