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 (
+
+
+
+ {active.tenant_type}: {shortTenantId(active.tenant_id)}
+
+
+ )
+ }
+
+ // 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
+
+