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 + +