Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions crates/schema-forge-cli/src/commands/site/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
96 changes: 95 additions & 1 deletion crates/schema-forge-cli/templates/site/src/App.tsx.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<span className="topbar-tenant-chip topbar-tenant-admin">
Operating as: platform_admin
</span>
)
}

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 (
<label className="topbar-tenant">
<Building2 size={14} aria-hidden="true" />
<span className="sr-only">Active tenant</span>
<select
value={current}
onChange={(e) => switchTo(e.target.value)}
aria-label="Switch active tenant"
>
{current === "" ? <option value="">Select tenant…</option> : null}
{chain.map((t) => (
<option key={`${t.schema}:${t.entity_id}`} value={`${t.schema}:${t.entity_id}`}>
{t.schema}: {shortTenantId(t.entity_id)}
</option>
))}
</select>
</label>
)
}

if (active) {
return (
<span
className="topbar-tenant-chip"
title={`${active.tenant_type}:${active.tenant_id}`}
>
<Building2 size={14} aria-hidden="true" />
<span>
{active.tenant_type}: {shortTenantId(active.tenant_id)}
</span>
</span>
)
}

// 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()
Expand Down Expand Up @@ -324,6 +416,8 @@ function Topbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => v
</span>
</button>

<TenantControl />

<button
type="button"
className="btn btn-icon btn-ghost"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// and `FieldResponse`. Keep them in sync with that file when the backend
// changes — TODO(Phase 4) is to have the backend expose a pre-digested
// "view" shape so we don't parse the raw `FieldType` enum on the client.
import { refreshToken, tokenStore } from "@/lib/auth"
import { ACTIVE_TENANT_HEADER, activeTenantStore, refreshToken, tokenStore } from "@/lib/auth"

const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? ""
const FORGE_API_PREFIX = "/api/v1/forge"
Expand Down Expand Up @@ -374,6 +374,14 @@ async function sendRequest(path: string, init?: RequestInit): Promise<Response>
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
// Scope every entity request to the operator's chosen tenant. The server
// resolves this against the token's membership set and 403s a non-member
// value, so it is safe to send unconditionally. A caller-supplied header
// (via `init`) wins, letting one-off requests override the active scope.
const activeTenant = activeTenantStore.get()
if (activeTenant && !(ACTIVE_TENANT_HEADER in headers)) {
headers[ACTIVE_TENANT_HEADER] = activeTenant
}
return fetch(`${API_BASE}${path}`, { ...init, headers })
}

Expand Down
111 changes: 111 additions & 0 deletions crates/schema-forge-cli/templates/site/src/lib/auth.ts.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,21 @@ const TOKEN_KEY = "schemaforge.token"
const EXPIRES_KEY = "schemaforge.token_expires_at"
const ROLES_KEY = "schemaforge.roles"
const USERNAME_KEY = "schemaforge.username"
// The tenant the operator is currently acting within. Persisted so the
// choice survives reloads; sent verbatim as the `X-Active-Tenant` request
// header. Value shape is `<tenant_type>:<tenant_id>`, the same string the
// server's tenant_scope middleware parses.
const ACTIVE_TENANT_KEY = "schemaforge.active_tenant"

const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? ""
const LOGIN_PATH = "/api/v1/forge/auth/login"
const REFRESH_PATH = "/api/v1/forge/auth/refresh"
const ME_PATH = "/api/v1/forge/auth/me"

/** The header the backend reads to scope a request to one of the caller's
* tenant memberships. Mirrors `ACTIVE_TENANT_HEADER` in the Rust
* `tenant_scope` middleware; surfaced here so callers don't hard-code it. */
export const ACTIVE_TENANT_HEADER = "X-Active-Tenant"

// Fire the background refresh this many ms before the token's stated
// `expires_at`. Five minutes gives the network and the refresh handler
Expand Down Expand Up @@ -108,6 +119,9 @@ export const tokenStore = {
window.sessionStorage.removeItem(EXPIRES_KEY)
window.sessionStorage.removeItem(ROLES_KEY)
window.sessionStorage.removeItem(USERNAME_KEY)
// Clear the active tenant alongside the token. Losing the session must
// not strand a stale tenant selection that the next operator inherits.
window.sessionStorage.removeItem(ACTIVE_TENANT_KEY)
} catch {
// Ignore — clear is best-effort.
}
Expand All @@ -118,6 +132,46 @@ export const tokenStore = {
},
}

/**
* The tenant the operator is currently acting within.
*
* Switching tenants is a stateless, client-driven concern: the PASETO carries
* the user's *full* membership set (`tenant_chain`); which membership scopes a
* given request is chosen per-request by sending the `X-Active-Tenant` header.
* This store holds that choice. The server independently rejects a header
* naming a tenant the caller is not a member of (403), so this is convenience
* + display, never the security boundary.
*/
export const activeTenantStore = {
/** Current `<tenant_type>:<tenant_id>` header value, or `null` if unset. */
get(): string | null {
if (typeof window === "undefined") return null
try {
return window.sessionStorage.getItem(ACTIVE_TENANT_KEY)
} catch {
return null
}
},
set(tenantType: string, tenantId: string): void {
if (typeof window === "undefined") return
try {
window.sessionStorage.setItem(ACTIVE_TENANT_KEY, `${tenantType}:${tenantId}`)
} catch {
// sessionStorage quota / private mode: silently drop. Requests will
// fall back to no header; a multi-membership user then gets the
// server's 400 ACTIVE_TENANT_REQUIRED until the next successful set.
}
},
clear(): void {
if (typeof window === "undefined") return
try {
window.sessionStorage.removeItem(ACTIVE_TENANT_KEY)
} catch {
// Ignore — clear is best-effort.
}
},
}

function storeAndSchedule(body: LoginResponse): LoginResponse {
tokenStore.set(body.token, body.expires_at, body.roles ?? [])
scheduleRefresh(body.expires_at)
Expand Down Expand Up @@ -238,3 +292,60 @@ export function logout(): void {
export function isAuthenticated(): boolean {
return tokenStore.get() !== null
}

// ---------------------------------------------------------------------------
// Principal / tenancy (`GET /auth/me`)
// ---------------------------------------------------------------------------

/** One of the caller's tenant memberships. Mirrors the Rust `TenantRef`
* (`{schema, entity_id}`); every entry is directly usable to build an
* `X-Active-Tenant: <schema>:<entity_id>` value. */
export type TenantRef = {
schema: string
entity_id: string
}

/** The tenant scoping the current session. Mirrors the Rust `ActiveTenant`
* (`{tenant_type, tenant_id}`). `tenant_type` equals the membership's
* `schema`; `tenant_id` equals its `entity_id`. */
export type ActiveTenant = {
tenant_type: string
tenant_id: string
}

/** Response of `GET /auth/me` — the browser cannot decrypt the PASETO, so
* this is the client-side anchor for "signed in as / current tenant" chrome
* and the tenant switcher. Mirrors the Rust `MeResponse`. */
export type MeResponse = {
user_id: string
email: string
display_name: string | null
roles: string[]
/** The user's full membership set (every tenant they may operate in). */
tenant_chain: TenantRef[]
/** The tenant scoping this request: the sent `X-Active-Tenant` if a member,
* else the sole membership, else `null` (the client must choose). */
active_tenant: ActiveTenant | null
/** The header name to send to set/switch the active tenant. */
active_tenant_header: string
}

/**
* Fetch the authenticated principal and tenancy context. Sends the stored
* `X-Active-Tenant` so the returned `active_tenant` reflects the operator's
* current choice (the endpoint is exempt from tenant scoping, so it always
* returns the *full* `tenant_chain` regardless of the header). Throws on a
* missing token or non-200 so React Query surfaces the error state.
*/
export async function fetchMe(): Promise<MeResponse> {
const token = tokenStore.get()
if (!token) throw new Error("no stored token")
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
}
const active = activeTenantStore.get()
if (active) headers[ACTIVE_TENANT_HEADER] = active
const res = await fetch(`${API_BASE}${ME_PATH}`, { headers })
if (!res.ok) throw new Error(`auth/me failed: ${res.status}`)
return (await res.json()) as MeResponse
}
Loading