Prima Get an API key

Module kit / Identity and auth

Workspace switcher and bootstrap

identity-auth/ui/workspace-switcher-and-bootstraplive

Kind frontend
Surface SPA
Maturity prod
Reusable extract
Design system alchemy-ui
Stack react-19, react-router-7, tailwind-3.4, alchemy-ui, axios-1
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton, ui-design-system/ui/appshell-navigation
Renders identity-auth/multi-tenant-workspaces, identity-auth/surface-registry-and-gate
Entry alchemy_labs/frontend/src/contexts/WorkspaceContext.tsx
Reference implementation
  • alchemy_labs/frontend/src/contexts/WorkspaceContext.tsx
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/frontend/src/contexts/ConnectionContext.tsx
  • alchemy_labs/frontend/src/components/AppShell.tsx
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Version 1

Purpose

Three workspaces, two sharing a name, current ticked open server rendered, hydrated in view

The signed-in person sees which workspace every page is showing, can move to another one they belong to, and never lands in a page with no workspace at all: a fresh account gets a personal workspace made for it the first time a workspace-scoped call answers 409 no_workspace. The invariant that makes it trustworthy is that the browser's choice is a hint and the backend re-validates it on every request: X-Workspace-ID is read from localStorage.currentWorkspaceId, and the dependency behind it (S6) answers 403 when the caller is not a member of that id, so a stale or edited id shows an error, never another tenant's data.

It makes B15 visible (the list from GET /api/workspaces/, the bootstrap route, the roles the members route returns) and B4 (a desk requester's session is confined: the members call answers 403 desk_only_role and the switcher renders with no role).

When to use

Trigger phrases: "which workspace am I in", "switch workspace", "the dropdown shows nothing", "two workspaces with the same name", "X-Workspace-ID", "workspace context required", "no_workspace", "the new account has no workspace", "the role badge". Surface: the Labs SPA, because the switcher is a region of the shell (ui-design-system/ui/appshell-navigation, which this recipe depends on) and the context sits in S2's provider tree; every private page reads the same current workspace.

Not for the tenant resolution on the server and the 400/403/409 table (S6), the creation paths and the role matrix (B15), the admin's list of every workspace (identity-auth/ui/admin-users-console), or the chat's workspace picker, which is a different component on a different surface (identity-auth/ui/chat-signin-and-workspace-picker). ConnectionContext.tsx (49 lines) is listed on the inventory row and has nothing to do with workspaces: it holds a list of selected connection ids for the data pages and is not read by the switcher.

Stack assumptions

S1 tokens (paper, ink, ink-60, ink-40, line, primary-500, primary-50, primary-700, shadow-pop, warn, warn-bg, danger), S2 (the provider order: WorkspaceProvider inside AuthProvider, since it calls useAuth() at WorkspaceContext.tsx line 68), S3. Pinned per S2 at fd8558f (2026-09-12): react 19.2.0, react-router-dom ^7.8.2, tailwindcss 3.4.18, axios ^1. Component library: @prima-modules/alchemy-ui 0.1.0 pinned to fd8558f (Badge, Text); the shell around the in-shell state is the appshell-navigation demo's Shell, the page inside it the merchant-home-dashboard demo's HomePage, both imported relatively.

Data-fetch style: api (the intercepted axios instance in services/api.ts), which adds the Bearer and X-Workspace-ID to every request (32 to 45) and owns the 401 refresh-retry and the 409 bootstrap-retry (48 to 108). switchWorkspace reloads the page (window.location.reload(), 230) instead of re-fetching, so no page needs currentWorkspace.id in its effect dependencies; the comment at 218 to 227 gives the reason. Copy: the switcher's two words are English ("Workspace"), the empty message is German du-Form with a capital "Dich" (AppShell.tsx 269 to 270); the demo's additions are de-CH.

Data model

Client state (WorkspaceContext.tsx 16 to 54): Workspace { id, name, slug, description?, owner_id, is_active, created_at, updated_at } (the WorkspaceResponse model, workspace_router.py 44 to 55), WorkspaceMember { id, user_id, role, is_active, joined_at } with role one of owner | admin | member | viewer | guest | desk_requester (line 30; the members route sets only admin | member | viewer | desk_requester, _MEMBER_ROLES at workspace_router.py 74), and the context value { currentWorkspace, currentUserRole, workspaces, isLoadingWorkspaces, setCurrentWorkspace, switchWorkspace, refreshWorkspaces, createWorkspace, hasPermission, isWorkspaceAdmin, isWorkspaceOwner }. The demo's Props reduce that to { view, workspaces, currentId, role, open, phase, user, deskOnly } with phase one of ready | loading | bootstrapping | failed.

Browser storage:

KeyStoreWritten byCleared byLifetime
currentWorkspaceIdlocalStoragethe persist effect on every change of currentWorkspace (118); switchWorkspace synchronously before the reload (228); the 409 bootstrap branch after POST /bootstrap (96)clearAuthStorage at logout only (authSession.ts 102); never by the context (the comment at 125 to 145 explains the two regressions that rule closes)across reloads, until logout
accessTokenlocalStorageS2S2read here only as a boolean (hasAuthToken, 71, 95 to 96)
workspaceIdlocalStoragenothing at fd8558f (grep -rn "localStorage.setItem(.workspaceId" frontend/src prints nothing)nothingdead: a fallback key two services read and no code writes
X-Workspace-IDlocalStoragenothing at fd8558f (same grep for the literal string as a key name prints nothing)nothingdead, same as above

What is not stored: the list, the role, the members. All three are re-read on every load (refreshWorkspaces on mount when a token exists, fetchUserRole on every change of the current workspace, 121). A workspace id is not a credential: the backend checks membership on every request that carries it.

Two services outside WorkspaceContext and the axios api instance build their own X-Workspace-ID from a three-key fallback chain instead of reading the context: services/serviceDeskService.ts getWorkspaceId() (19 to 26) and services/commerceService.ts getWorkspaceId() (9 to 16) both try currentWorkspaceId, then workspaceId, then the literal string X-Workspace-ID as a localStorage key, and send nothing when all three are empty. currentWorkspaceId is the only one anything writes, so the chain is functionally a slower, duplicated read of the same context value with two dead branches kept for a migration that never happened; a fetch call through either service therefore tracks the switcher correctly today, but by coincidence of the first key winning, not by design, and a future rename of currentWorkspaceId breaks both silently. A new build reads the context (or its persisted key) once, in one place.

API surface

RouteCalled fromParsedOutcomes rendered
GET /api/workspaces/refreshWorkspaces (165 to 176) on mount and on AUTH_CHANGED_EVENTWorkspaceResponse[], the workspaces the caller owns or is an active member of (workspace_router.py 177 to 201)the list; [] on any failure (172), which the panel then reports as "no workspace" (Gotchas); isLoadingWorkspaces while in flight
GET /api/workspaces/{id}/membersfetchUserRole (178 to 210) after each change of the current workspaceWorkspaceMemberResponse[]; the caller's own row gives currentUserRolethe role (nowhere at fd8558f; the demo's badge); 403 with details.error === "desk_only_role" is expected for a desk requester and is not logged (203 to 205); 403 "You don't have permission to view workspace members" for a non-member; any failure sets the role to null
GET /api/auth/mefetchUserRole only when user.userId is not yet loaded (186 to 190)idnone
POST /api/workspaces/bootstrapthe response interceptor on a 409 no_workspace (api.ts 90 to 103)WorkspaceResponse; idempotent: the caller's first workspace, else a new personal one named <first_name>'s Workspace (workspace_router.py 102 to 136)the id is stored, the original request is retried once with the header; a second 409 is surfaced
POST /api/workspaces/createWorkspace (237 to 257), called once at fd8558f from pages/CalendarSettings.tsx 490 (the "Workspace einrichten" card that page shows when the list is empty, 511 to 540), always with the name Mein WorkspaceWorkspaceResponse (201)appended to the list and made current (no reload)
every workspace-scoped routethe request interceptorX-Workspace-ID: <currentWorkspaceId> when the key exists (39 to 42)S6's table: 400 "Invalid X-Workspace-ID header", 403 "You don't have access to this workspace" (api/dependencies.py 713 to 732), 400 "Workspace context required (user has multiple workspaces; pass X-Workspace-ID header)" when the key is missing and the caller has several (759 to 764), 409 {"error": "no_workspace", "message": ...} when the caller has none (770 to 777)

The 409 body, through the Labs envelope (app_fastapi.py 768 to 786): {error: "no_workspace", code: "RESOURCE_CONFLICT", request_id, timestamp, details: {error: "no_workspace", message}}. The machine code is at error and at details.error; there is no detail key. The 403 for a desk requester has the same shape with desk_only_role. isNoWorkspace and isDeskOnlyRefusal in helpers.ts read both positions. No postMessage or WebSocket contract.

Application logic

  1. Sign-in. setTokens fires AUTH_CHANGED_EVENT; the context flips hasAuthToken (95 to 96) and calls refreshWorkspaces unless the path is a public one (/embed/, /f/, /book/, /public/, 76 to 79). isLoadingWorkspaces is true; the switcher button reads "Workspace" with no name (AppShell.tsx 246).
  2. The list arrives. The restore effect (149 to 163) picks localStorage.currentWorkspaceId when it is still in the list, else the first entry (restoreWorkspace in helpers.ts). The persist effect writes the id back and calls fetchUserRole (115 to 122). The button now shows the name's initial, the name, and (in the demo) the role.
  3. A fresh account with no workspace. The list is [], the button says "Workspace", and the first workspace-scoped request (the dashboard's summary) answers 409. The interceptor posts /bootstrap, stores the returned id, retries the request with the header, and the page renders. The list in the context is still [] until the next load; the demo's bootstrapping and bootstrapped states show the two moments. (At fd8558f the branch never fires: Gotchas.)
  4. Open the switcher. The panel opens on every press (255 to 271): the options, or the empty message, never nothing. Two options with the same name show their slugs (showSlugs; the name that collides is Mein Workspace, the literal CalendarSettings.tsx 490 passes to createWorkspace, which is why the shell's comment at 295 to 298 calls it "the default name"). The current one is bold, purple and ticked.
  5. Pick another. switchWorkspace returns at once for the current id (216), else writes the id synchronously and reloads (228 to 230). Every page re-fetches under the new header; step 2 runs again with the saved id.
  6. A desk requester. Their memberships are desk_requester rows; GET /members answers 403 desk_only_role, the role stays null, and the portal renders without a role (197 to 209). Every route outside /api/desk/* and /api/auth/* answers 403 for them (B4), so the switcher is the only workspace control they see.
  7. Logout. clearAuthStorage removes currentWorkspaceId with the tokens; the event fires; the context clears the list and the current workspace (84 to 87).

Invariants

  • No mutation without an endpoint: the only writes are POST /bootstrap and POST /api/workspaces/; picking a workspace writes a browser key and reloads.
  • The header is a hint, membership is the check: X-Workspace-ID is re-validated by S6 on every request; an id that is not the caller's is 403, and the page shows that error rather than empty data.
  • currentWorkspaceId is removed at logout and at no other time (the two regressions at 125 to 145 are why); a saved id that is no longer listed falls back to the first workspace without touching the key.
  • The panel always opens and always says something: an empty list renders the empty sentence, a failed read renders its own sentence (the demo's failed state; Labs collapses both into "no workspace", Gotchas).
  • A 409 no_workspace is answered by exactly one bootstrap and one retry (_wsBootstrap on the request config, 90 to 91); a second 409 is surfaced.
  • Slugs are shown only when names collide (showSlugs); the ids never render.
  • The role is a label: hasPermission, isWorkspaceAdmin and isWorkspaceOwner decide what to show, never what the backend allows (B15 and S6 refuse regardless).
  • A desk requester's 403 on /members is expected and is neither logged nor shown as an error.

Frontend

The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.

One workspace, owner, panel closed closed server rendered, hydrated in view
A member of someone else’s workspace member server rendered, hydrated in view
GET /api/workspaces/ in flight loading server rendered, hydrated in view
409 no_workspace answered, POST /bootstrap in flight bootstrapping server rendered, hydrated in view
The personal workspace the bootstrap created bootstrapped server rendered, hydrated in view
No membership row: the panel says so instead of rendering nothing empty server rendered, hydrated in view
Where it lives: the contextual column of the shell, panel open in-shell server rendered, hydrated in view
Workspace

Guten Tag, Mira

Alles aus Praxis am See an einem Ort.

Schnellzugriff

Dashboard-Werte

Letzte Aktivitäten

Alle anzeigen
Lea Frei ist als Lead eingegangen
Kontaktformular · gerade eben
Noah Brunner ist als Lead eingegangen
Newsletter · vor 3 Std.
Sara Meier hat eine Bestellung aufgegeben
Store · vor 5 Std.
CHF 240
Tim Huber hat einen Termin gebucht
Buchungsseite · vor 1 Tagen
Besucher ist als Lead eingegangen
Kontaktformular · vor 2 Tagen
Anna Roth hat eine Bestellung aufgegeben
Store · vor 3 Tagen
CHF 1’180

Benötigt deine Aufmerksamkeit

Aus dem gesamten Workspace zusammengetragen.

Hilfe-Center

Erste Schritte im Workspace
24 MB · MP4
Ein Formular bauen und einbetten
41 MB · MP4
Das Insights-Snippet installieren
18 MB · MP4
identity-auth/ui/workspace-switcher-and-bootstrap/demo.tsx
/**
 * Live demo for identity-auth/ui/workspace-switcher-and-bootstrap.
 *
 * The workspace switcher at the top of the shell's contextual column
 * (alchemy_labs, branch d1a-token-source, fd8558f: AppShell.tsx lines 232 to
 * 300, the button, the listbox and the empty-list message) with the state
 * WorkspaceContext.tsx (384 lines) feeds it: the list from GET
 * /api/workspaces/, the restored current id, the role from GET
 * /api/workspaces/{id}/members, loading, and the bootstrap that runs when the
 * first workspace-scoped call answers 409 no_workspace.
 *
 * Two views. `panel` renders the switcher alone at the column's width, one
 * frame per state, so the states that are one line each in the shell
 * (loading, empty, bootstrapped, failed read, desk requester) are legible.
 * `shell` renders it where it lives, inside the appshell-navigation rebuild
 * around the dashboard page, at the app's viewport.
 *
 * What the rebuild adds to the Labs markup, on purpose and said here: the
 * role badge under the current workspace and in the button's second line
 * (the context computes currentUserRole and nothing renders it at fd8558f,
 * recipe Gotchas), a distinct sentence for a failed list read (Labs sets the
 * list to [] and the panel then claims the account has no workspace), a
 * "bootstrapping" line for the seconds between the 409 and the retry, and a
 * warn-toned line for the desk requester. Everything else is the shell's
 * markup: the same classes, the same copy, the same lucide-free inline
 * glyphs; the loading line is Labs' own (AppShell.tsx 264 to 266) and the
 * disc keeps its placeholder initial while loading, as the shell does (133).
 * The `shell` view uses the shell demo's own switcher, which has none of the
 * four additions.
 *
 * Kept as in Labs, on purpose: `bg-primary-500/12` on the two discs emits no
 * rule under Tailwind 3.4 (12 is not on the opacity scale), so the disc
 * ground is transparent here as in the app at fd8558f (recipe Gotchas, with
 * the fix). The demo is the reference as measured, not as intended.
 *
 * Sample data only: generic workspace names, ids of the form ws_n.
 */
import React from 'react';
import { defineStates } from '@prima-modules/demo';
import { Badge, Text } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';
import { HomePage, states as homeStates } from '../../../ui-design-system/ui/merchant-home-dashboard/demo';
import { ROLE_LABEL, type Role, type Workspace, showSlugs } from './helpers';

/* ---- props ----------------------------------------------------------- */

export type Phase = 'ready' | 'loading' | 'bootstrapping' | 'failed';

export interface Props {
  view: 'panel' | 'shell';
  workspaces: Workspace[];
  /** localStorage currentWorkspaceId after the restore effect; null while nothing is picked. */
  currentId: string | null;
  /** currentUserRole from the members call; null while loading, for a desk requester, or on a 403. */
  role: Role | null;
  open: boolean;
  phase: Phase;
  user: ShellUser;
  /** The members call answered 403 desk_only_role: the account is confined to the desk. */
  deskOnly?: boolean;
}

/* ---- sample data ------------------------------------------------------ */

const member: ShellUser = { name: 'Mira Keller', email: 'mira@example.com', isAdmin: false };
const requester: ShellUser = { name: 'Lena Frei', email: 'lena@example.com', isAdmin: false };
const one: Workspace[] = [{ id: 'ws_1', name: 'Praxis am See', slug: 'praxis-am-see' }];
const three: Workspace[] = [
  { id: 'ws_1', name: 'Praxis am See', slug: 'praxis-am-see' },
  { id: 'ws_2', name: 'Mein Workspace', slug: 'mein-workspace-2' },
  { id: 'ws_3', name: 'Mein Workspace', slug: 'mein-workspace-3' },
];
const fresh: Workspace[] = [{ id: 'ws_7', name: "Mira's Workspace", slug: 'miras-workspace' }];

const base: Props = { view: 'panel', workspaces: one, currentId: 'ws_1', role: 'owner', open: false, phase: 'ready', user: member };

export const states = defineStates<Props>({
  closed: { label: 'One workspace, owner, panel closed', props: base, height: 200 },
  open: { label: 'Three workspaces, two sharing a name, current ticked', props: { ...base, workspaces: three, currentId: 'ws_1', open: true }, height: 360 },
  member: { label: 'A member of someone else’s workspace', props: { ...base, workspaces: three, currentId: 'ws_2', role: 'member', open: true }, height: 360 },
  loading: { label: 'GET /api/workspaces/ in flight', props: { ...base, workspaces: [], currentId: null, role: null, phase: 'loading', open: true }, height: 260 },
  bootstrapping: { label: '409 no_workspace answered, POST /bootstrap in flight', props: { ...base, workspaces: [], currentId: null, role: null, phase: 'bootstrapping', open: true }, height: 260 },
  bootstrapped: { label: 'The personal workspace the bootstrap created', props: { ...base, workspaces: fresh, currentId: 'ws_7', role: 'owner', open: true }, height: 300 },
  empty: { label: 'No membership row: the panel says so instead of rendering nothing', props: { ...base, workspaces: [], currentId: null, role: null, open: true }, height: 280 },
  failed: { label: 'The list read failed: not the same as an empty list', props: { ...base, workspaces: [], currentId: null, role: null, phase: 'failed', open: true }, height: 280 },
  'desk-requester': { label: 'Desk requester: members answers 403 desk_only_role, no role, confined to /desk', props: { ...base, user: requester, role: null, deskOnly: true, open: true }, height: 320 },
  'in-shell': { label: 'Where it lives: the contextual column of the shell, panel open', props: { ...base, view: 'shell', workspaces: three, currentId: 'ws_1', open: true }, height: 720, viewport: 1440 },
});

/* ---- the switcher --------------------------------------------------------- */

function Chevron() {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-ink-40">
      <path d="m8 10 4 4 4-4" />
    </svg>
  );
}

function Tick() {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
      <path d="m5 12 5 5L20 7" />
    </svg>
  );
}

/** AppShell.tsx lines 232 to 300 with the four additions named in the header. */
export function WorkspaceSwitcher({ workspaces, currentId, role, open, phase, deskOnly }: Omit<Props, 'view' | 'user'>) {
  const current = workspaces.find((w) => w.id === currentId) ?? null;
  const initial = (current?.name || 'A').trim().charAt(0).toUpperCase();
  const slugs = showSlugs(workspaces);
  const busy = phase === 'loading' || phase === 'bootstrapping';

  return (
    <div className="relative">
      <button
        type="button"
        data-onboard="workspace"
        aria-haspopup="listbox"
        aria-expanded={open}
        className="w-full flex items-center gap-2.5 rounded-xl px-2.5 py-2 hover:bg-primary-50 transition-colors text-left"
      >
        <span className="h-7 w-7 rounded-lg bg-primary-500/12 text-primary-500 grid place-items-center text-[13px] font-bold">{initial}</span>
        <span className="min-w-0 flex-1 leading-tight">
          <span className="block text-[13.5px] font-bold text-ink truncate">{current?.name || 'Workspace'}</span>
          <span className="block text-[11px] text-ink-60">{role ? ROLE_LABEL[role] : 'Workspace'}</span>
        </span>
        <Chevron />
      </button>

      {open && (
        <div role="listbox" aria-busy={busy} className="absolute left-0 right-0 mt-1 z-30 rounded-xl border border-line bg-white shadow-pop overflow-hidden py-1">
          {phase === 'loading' && (
            <div className="px-3 py-2 text-[12.5px] text-ink-40">Workspaces werden geladen …</div>
          )}
          {phase === 'bootstrapping' && (
            <div className="px-3 py-2.5 text-[12.5px] text-ink-60 leading-snug">
              Dein Workspace wird eingerichtet …
              <span className="block text-ink-40 mt-0.5">Das dauert nur einen Moment.</span>
            </div>
          )}
          {phase === 'failed' && (
            <div role="alert" className="px-3 py-2.5 text-[12.5px] text-danger leading-snug">
              Die Workspaces konnten nicht geladen werden.
              <span className="block text-ink-40 mt-0.5">Bitte lade die Seite neu.</span>
            </div>
          )}
          {phase === 'ready' && workspaces.length === 0 && (
            <div className="px-3 py-2.5 text-[12.5px] text-ink-60 leading-snug">
              Diesem Konto ist kein Workspace zugeordnet.
              <span className="block text-ink-40 mt-0.5">Bitte wende Dich an Deine Administratorin.</span>
            </div>
          )}
          {phase === 'ready' &&
            workspaces.map((w) => {
              const isCurrent = w.id === currentId;
              return (
                <button
                  key={w.id}
                  type="button"
                  role="option"
                  aria-selected={isCurrent}
                  className={`w-full flex items-center gap-2 px-3 py-2 text-left text-[13.5px] hover:bg-primary-50 transition-colors ${isCurrent ? 'text-primary-700 font-semibold' : 'text-ink'}`}
                >
                  <span className="h-5 w-5 shrink-0 rounded bg-primary-500/12 text-primary-500 grid place-items-center text-[11px] font-bold">{w.name.charAt(0).toUpperCase()}</span>
                  <span className="min-w-0 flex-1 leading-tight">
                    <span className="block truncate">{w.name}</span>
                    {slugs && <span className="block text-[10.5px] text-ink-40 truncate font-mono">{w.slug}</span>}
                  </span>
                  {isCurrent && role && (
                    <Badge size="sm" variant={role === 'owner' || role === 'admin' ? 'primary' : 'default'}>
                      {ROLE_LABEL[role]}
                    </Badge>
                  )}
                  {isCurrent && <Tick />}
                </button>
              );
            })}
          {phase === 'ready' && deskOnly && (
            <div className="mx-2 mt-1 rounded-lg bg-warn-bg px-2.5 py-2 text-[11.5px] text-warn leading-snug">
              Dieses Konto ist auf den Service Desk beschränkt. Andere Bereiche antworten mit 403.
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* ---- the frames ----------------------------------------------------------- */

/** The column the switcher sits in, at its width, on the paper ground. */
function PanelFrame(props: Props) {
  return (
    <div className="min-h-[180px] bg-paper p-6 font-sans text-ink">
      <aside className="w-[224px] rounded-xl border border-line bg-white/60 backdrop-blur-sm px-3 py-4" data-gallery-focus="true">
        <WorkspaceSwitcher {...props} />
        <div className="px-2.5 mt-4">
          <Text size="xs" muted>
            {props.phase === 'ready' && props.workspaces.length > 0
              ? `${props.workspaces.length} ${props.workspaces.length === 1 ? 'Workspace' : 'Workspaces'}`
              : ' '}
          </Text>
        </div>
      </aside>
    </div>
  );
}

export default function Demo(props: Props) {
  if (props.view === 'shell') {
    const current = props.workspaces.find((w) => w.id === props.currentId) ?? props.workspaces[0];
    const home = homeStates.default.props;
    return (
      <Shell domains={DOMAINS} user={props.user} workspaces={props.workspaces} currentWorkspaceId={current?.id ?? ''} activePath="/dashboard" wsOpen={props.open} hasTour>
        <HomePage {...home} user={props.user} workspace={current ?? home.workspace} />
      </Shell>
    );
  }
  return <PanelFrame {...props} />;
}
Component tree, from the recipe
WorkspaceProvider (contexts/WorkspaceContext.tsx)         under AuthProvider, above the router (S2)
  state: workspaces[], currentWorkspace, currentUserRole, isLoadingWorkspaces, hasAuthToken
  effects: load on token (74 to 89) · storage + AUTH_CHANGED_EVENT (94 to 111) · persist id + fetch role (115 to 146) · restore from localStorage (149 to 163)
  actions: switchWorkspace (212 to 235: setItem, setState, location.reload) · refreshWorkspaces · createWorkspace (one caller: pages/CalendarSettings.tsx 490)
  derived: hasPermission (role matrix 264 to 331) · isWorkspaceAdmin · isWorkspaceOwner   (zero consumers outside the context)
services/api.ts request interceptor                        X-Workspace-ID from localStorage.currentWorkspaceId (39 to 42)
services/api.ts response interceptor                       409 no_workspace -> POST /api/workspaces/bootstrap -> retry once (82 to 103; dead, Gotchas)
AppShell > aside.contextual > div.relative                (AppShell.tsx 232 to 300; the shell is appshell-navigation)
  button[aria-haspopup=listbox][data-onboard=workspace]   initial disc · name | "Workspace" · "Workspace" · chevron
  div[role=listbox]?                                      on every press (255 to 271)
    div.empty "Diesem Konto ist kein Workspace zugeordnet." (workspaces.length === 0)
    button[role=option] x workspaces                       initial · name · slug (only when two names collide, 128 to 131) · tick on current
(role badge: not rendered anywhere at fd8558f; the demo adds it, see Frontend)
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild workspace-switcher-and-bootstrap/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state (RENDERING.md section 3). WorkspaceSwitcher is the shell's markup for the button and the listbox (AppShell.tsx 232 to 300: the same classes, the same copy, the same inline chevron and tick) as a presentational component over { workspaces, currentId, role, open, phase, deskOnly }; helpers.ts holds the pure decisions (showSlugs, restoreWorkspace, isNoWorkspace, isDeskOnlyRefusal, bootstrapName, isWorkspaceAdmin, ROLE_LABEL) and helpers.test.ts pins them.

Two views. panel renders the switcher alone inside a 224 px column on the paper ground, one frame per state, so the states that are one line in the shell are legible: closed (one workspace, owner, panel shut), open (three workspaces, two named "Mein Workspace" so the slug line shows, the current one ticked with an owner badge), member (the current one is somebody else's, the badge reads "Mitglied"), loading (the list read in flight: the shell's one-line message, AppShell.tsx 264 to 266, and the disc keeps the placeholder initial A as the shell does at 133), bootstrapping (after the 409, before the retry), bootstrapped (the personal workspace the bootstrap named, Mira's Workspace), empty (no membership row: the shell's own sentence), failed (the list read failed: a red sentence and "Bitte lade die Seite neu"), desk-requester (no role, a warn-toned line about the confinement). shell renders the switcher where it lives: the appshell-navigation rebuild around the dashboard page at viewport: 1440, panel open with the three workspaces (in-shell); this frame uses the shell demo's own switcher.

What the rebuild adds to the Labs markup, on purpose and named: the role badge under the current option and in the button's second line (the context computes currentUserRole and nothing renders it at fd8558f; the inventory's F2 row names the badge, so the demo shows the intended screen); the failed sentence; the bootstrapping line; the desk-requester line. Nothing else departs: the loading line carries the shell's classes, the disc never changes, and the desk requester's second line reads "Workspace" as it does in Labs (no role, step 6). The in-shell state carries none of the four because it is the shell demo's switcher.

Tokens: paper ground, white/60 column with backdrop-blur-sm, line hairlines, bg-primary-500/12 and text-primary-500 on the initial discs, of which only the text paints: 12 is not on Tailwind 3.4's opacity scale, the class emits no rule, and the disc ground is transparent at fd8558f and in the demo (Gotchas, with the fix), primary-50 hover, primary-700 for the current option, ink, ink-60, ink-40, shadow-pop on the panel, warn-bg/warn for the confinement line, danger for the failed read, Badge primary for owner and admin, default for the rest. Type sizes are the shell's pixel literals (text-[13.5px], text-[11px], text-[12.5px], text-[10.5px]; appshell-navigation Gotchas). Responsive: the column is hidden md:flex in the shell, so below 768 px the switcher does not exist and there is no other workspace control (Gotchas); the panel frames are fixed at 224 px and do not reflow.

What the demo deliberately does not render: the reload after a pick (a browser event, not a state), createWorkspace (one caller at fd8558f, pages/CalendarSettings.tsx 124 and 490: a settings-page card, not the switcher; grep -rn createWorkspace src/pages src/components prints those two lines and nothing else), the permission matrix (no consumer), and the header on the wire (the acceptance checks observe it).

Security & tenancy

Desk requester: members answers 403 desk_only_role, no role, confined to /desk desk-requester server rendered, hydrated in view

What the browser may hold: currentWorkspaceId (an opaque id of a workspace the person belongs to) and the S2 tokens. Never a key, never another member's row beyond what GET /members returns to a member. The id is not a capability: the request interceptor sends it, and S6's dependency answers 403 when the caller is not an active member or the owner (_user_has_workspace_access, api/dependencies.py 725 to 732); the middleware ignores the header on anonymous requests (middleware/workspace_middleware.py 95 to 101).

Origin and CSRF: the header rides on the intercepted axios instance with the Bearer; in token mode nothing is sent automatically. Surface gating from /me.surfaces: the switcher renders inside the shell, which renders only inside PrivateRoute (identity-auth/ui/auth-screens); a desk-only session sees the switcher and is refused everywhere else by the containment fence (B4, _refuse_desk_only_workspace_context at api/dependencies.py 710). Hidden by CSS versus enforced: the role badge and hasPermission are labels; nothing in this recipe hides a control that the backend would otherwise allow, and nothing it shows is allowed by the showing.

Attacker-controlled inputs: localStorage.currentWorkspaceId (editable in devtools; the backend re-validates), the workspace names and slugs (rendered as text through React, never as HTML).

Tests

  • Colocated: workspace-switcher-and-bootstrap/helpers.test.ts (vitest, 10 cases: slug collisions, the restore rule with a stale id, the 409 reader on the envelope and on the raw shape, another 409 code refused, the bootstrap name, the admin predicate, the desk refusal in both positions). Run npx vitest run --config modules/identity-auth/ui/workspace-switcher-and-bootstrap/vitest.config.mjs: 10 passed (2026-09-12). The root vitest.config.mjs (1350915) does not include modules/; see auth-screens Tests for the one-line change.
  • Render check: node tooling/verify/render-smoke.mjs identity-auth/ui/workspace-switcher-and-bootstrap renders the ten states with renderToString (in-shell includes the router-wrapped shell); 0 failures on 2026-09-12.
  • Labs at fd8558f: no test imports WorkspaceContext (grep -rln WorkspaceContext src/**/__tests__ src/**/*.test.* prints nothing); the shell's nav.test.ts covers the rail, not the switcher; no Playwright spec opens the panel. What a new app adds: a jsdom test of the context with a mocked api asserting (a) the saved id wins over the first entry, (b) a stale saved id falls back without removing the key, (c) a 409 with the envelope shape triggers exactly one POST /bootstrap and one retry, (d) a 403 desk_only_role on /members sets no role and logs nothing; and a Playwright walk: open the panel, pick the second workspace, observe the reload and the new name.
  • node --test: not needed; the helpers are covered by the vitest file.

Deploy & ops

Ships in the Labs bundle (S2 Deploy & ops); no env of its own. The backend side it needs: the workspace router mounted on LABS_SURFACES (app_fastapi.py 823), POST /api/workspaces/bootstrap reachable to a signed-in user with no workspace, and the CORS allow-list exposing nothing extra (the header is a request header on the API origin). The middleware echoes X-Workspace-ID on responses (workspace_middleware.py 135), which is harmless and is not read by the SPA. Rollback is the bundle's. Argus: POST /api/workspaces/bootstrap with the seeded owner's token returns the same id twice (B15).

Gotchas

The list read failed: not the same as an empty list failed server rendered, hydrated in view
  • 2026-09-12 (measured at fd8558f): the 409 bootstrap branch in services/api.ts (82 to 103) reads error.response?.data?.detail (singular). The Labs exception handler never emits detail: a dict detail becomes error (its error key) plus details (app_fastapi.py 768 to 786; the test helper tests/desk_support.py err() at 308 to 313 documents the same shape). noWorkspace is therefore always false and a fresh account with no workspace sees the 409 as an error on every page until a login runs _ensure_workspace (B15). The backend's own docstring still says "Frontend calls this ... when the workspace dependency returns 409" (workspace_router.py 115 to 116). Fix: read data.error and data.details?.error (isNoWorkspace in helpers.ts). The context's own desk-only check at WorkspaceContext.tsx 204 reads detail.error then error; the first is never present and the second matches only because the envelope copies the dict's error key to the top level (app_fastapi.py 775 to 776), so it works by the envelope's choice of message. A build to this recipe reads error and details.error as isDeskOnlyRefusal does.
  • 2026-09-12 (measured at fd8558f): currentUserRole, hasPermission, isWorkspaceAdmin and isWorkspaceOwner have zero consumers outside WorkspaceContext.tsx (grep -rln "currentUserRole\|isWorkspaceOwner\|isWorkspaceAdmin\|hasPermission(" src prints the context and nothing else), so GET /members runs on every switch for a value nobody renders, and the inventory's "role badge" does not exist on the screen. The demo renders it; a new app either renders it or drops the call.
  • 2026-09-12 (measured at fd8558f): refreshWorkspaces catches every failure into setWorkspaces([]) (170 to 173), so a network or 5xx failure renders the panel's "Diesem Konto ist kein Workspace zugeordnet", which is false and sends the person to their administrator. Fix: a failed phase (the demo's state).
  • 2026-09-12 (measured at fd8558f, rendering trap): switchWorkspace hard-reloads (230). Inside a demo stage or a verifier run there is nothing to reload; the demo's options are inert and the recipe's walk-through observes the reload in the app. The comment at 218 to 227 records why a state change alone was not enough (pages fetch on mount without the workspace in their deps); a new app that wants no reload keys every page's data hook on currentWorkspace.id.
  • 2026-09-12 (measured at fd8558f): the comment at WorkspaceContext.tsx 125 to 132 names a real customer's mailbox as the case that exposed the restore regression. It is the kind of line the wiki's client pages hold; a copy into a new app drops it.
  • 2026-09-12 (measured at fd8558f, rendering trap): the switcher lives in the contextual column, which is hidden md:flex, and the whole sidebar is hidden sm:flex (appshell-navigation), so below 768 px there is no way to switch workspaces at all; the page keeps the saved id. A mobile rendering of this row is the empty set.
  • 2026-09-12 (measured at fd8558f): AppShell.tsx reads useWorkspace() as any (50) and types the options as any (275), so the Workspace type the context exports is not what the shell renders against; a renamed field would fail at runtime, not at build.
  • 2026-09-12 (measured at fd8558f): ConnectionContext.tsx is on the inventory row and is unrelated (a selected-ids list for the data pages); the row's entryPoints list is corrected here, and nothing of it is rendered.
  • 2026-09-12 (measured at fd8558f): the empty sentence says "Bitte wende Dich an Deine Administratorin" with the formal capitals inside a du-Form app (269 to 270). A new app writes "dich" and "deine".
  • 2026-09-12 (measured at fd8558f, tailwindcss 3.4.18): the two initial discs carry bg-primary-500/12 (AppShell.tsx 240 and 290). Tailwind 3.4's opacity scale has 10 and 15 and no 12, and an unbracketed value off the scale generates nothing, so the class is dead and the discs paint no ground: the initial is purple text on the button's own background. Measured on the rebuilt page: getComputedStyle of the disc gives backgroundColor: rgba(0, 0, 0, 0), and grep -c 'primary-500\\/12' packages/alchemy-ui/dist/alchemy-ui.css prints 0 on a CSS that npm run check reports fresh. grep -rn "primary-500/12" src in Labs prints only the two shell lines; no safelist and no hand-written rule in src/index.css or tailwind.config.js rescues it. Fix in Labs: bg-primary-500/[0.12] (or /10) on both lines, then re-extract into the appshell-navigation demo (lines 342 and 373) and this one; both demos keep the dead class on purpose until then (D8: no code change in this wave). Same defect, same fix, for ui-design-system/ui/appshell-navigation.
  • 2026-09-12 (measured at prima.li 86fe9d1, docs host, rendering trap): the viewport stage is not scaled without overflow. src/index-site/demo-frame.css line 79 sets .demo-frame .demo-stage--viewport { padding: 0; overflow: hidden; } and lines 81 to 85 set .demo-frame .demo-stage { padding: var(--space-lg); overflow-x: auto; }; both are specificity (0,2,0) and the generic rule comes later, so it wins and the in-shell stage computes padding: 24px; overflow-x: auto (--space-lg: 1.5rem, src/styles/tokens.css 127). DemoFrame.astro 49 to 50 sets zoom = stage.clientWidth / 1440, and clientWidth includes the padding, so the scaled shell is 48 px wider than the content box and the stage scrolls sideways by 48 px at every column width. Measured on the served dist with Playwright 1.56.1, viewport 1440, 1024 and 400: scrollWidth / clientWidth of the in-shell stage 1038/990, 702/654, 398/350; the nine panel stages report scrollWidth == clientWidth. Fix in the host: move the --viewport rule below the generic block, or write it as .demo-frame .demo-stage.demo-stage--viewport; then re-measure check 5 here and in every recipe with a viewport state (appshell-navigation, merchant-home-dashboard, auth-screens). No host change in this wave.

Reference implementation

Branch: alchemy_labs d1a-token-source at fd8558f (2026-09-12), forked from feat/service-desk b1089b6. Paths relative to frontend/:

  • src/contexts/WorkspaceContext.tsx (384 lines): types (16 to 54), hasAuthToken (71), the load effect (74 to 89), the event listeners (94 to 111), the persist effect and its two-regression comment (115 to 146), the restore effect (149 to 163), refreshWorkspaces (165 to 176), fetchUserRole (178 to 210), switchWorkspace (212 to 235), createWorkspace (237 to 257), hasPermission and the matrix (260 to 340), isWorkspaceAdmin, isWorkspaceOwner (342 to 348), useWorkspace (376 to 382).
  • src/services/api.ts: the request interceptor (32 to 45), the response interceptor (48 to 108: 401 refresh 56 to 80, 409 bootstrap 82 to 103).
  • src/components/AppShell.tsx: useWorkspace read (50), wsOpen (53), outside-click and Escape (111 to 124), showWsSlug (128 to 131), the button (232 to 253), the dead-control comment (255 to 259), the listbox (260 to 300 onward).
  • src/services/authSession.ts: clearAuthStorage removing currentWorkspaceId (102).
  • src/pages/CalendarSettings.tsx: the one createWorkspace caller (124, 490) and the "Workspace einrichten" card that offers it (511 to 540).
  • src/contexts/ConnectionContext.tsx (49): unrelated, see Gotchas.
  • src/services/serviceDeskService.ts (getWorkspaceId 19 to 26) and src/services/commerceService.ts (getWorkspaceId 9 to 16): the two services outside the axios instance, each with its own three-key fallback read (Data model).

Backend anchors: backend/api/routers/workspace_router.py (WorkspaceResponse 44 to 55, WorkspaceMemberResponse 58 to 66, _MEMBER_ROLES 74, bootstrap_workspace 102 to 136, list_workspaces 177 to 201, list_workspace_members 350 to 387), backend/api/dependencies.py (_get_workspace_id 700 to 777), backend/middleware/workspace_middleware.py (49, 95 to 101, 135), backend/app_fastapi.py (768 to 786, 823), backend/tests/desk_support.py (err, 308 to 313).

Deviations of the reference from this recipe, dated 2026-09-12: the dead 409 branch; the unrendered role; the failed read reported as an empty list; the hard reload; the any casts; the formal capitals; a customer mailbox in a comment; the dead /12 tint on the discs. Not a deviation: createWorkspace is reachable from one settings page (CalendarSettings.tsx 490) and the switcher does not offer it, which is what this recipe describes.

Acceptance checks

From alchemy_labs/frontend at fd8558f, or a new app built to this recipe:

  1. grep -n "data?.detail" src/services/api.ts prints line 84 at fd8558f; on a build to this recipe the 409 reader matches error and details.error (the isNoWorkspace test is the authoritative form).
  2. grep -rln "currentUserRole" src | grep -v contexts/WorkspaceContext.tsx | wc -l prints 0 at fd8558f and at least 1 on a build that renders the badge.
  3. Browser walk-through, signed in as the seeded owner, tour closed: (a) open /dashboard, observe the workspace name and its initial in the column; (b) click it, observe the panel with the current workspace bold and ticked; (c) in devtools set localStorage.currentWorkspaceId to x and reload, observe the page's requests answer 403 "You don't have access to this workspace" and the switcher falls back to the first listed workspace on the next load; (d) as a second account that is a member of two workspaces sharing a name, open the panel, observe the slug line under both; pick the second, observe a full reload and the new name; (e) as a fresh account created without a workspace (DELETE its workspace_users row and its owned workspace in the scratch database), open /dashboard, observe on a build to this recipe one POST /api/workspaces/bootstrap in the network panel followed by the retried request, and at fd8558f the 409 surfacing as an error; (f) as a desk requester, open /desk, observe the switcher with no role and no console error from /members.
  4. Demo gate: npm run check prints no line for this recipe; node tooling/verify/render-smoke.mjs identity-auth/ui/workspace-switcher-and-bootstrap prints ok for the ten states and 0 failures; npx vitest run --config modules/identity-auth/ui/workspace-switcher-and-bootstrap/vitest.config.mjs prints 10 passed.
  5. Docs host: build the index site as in auth-screens check 5 and open /docs/modules/identity-auth/ui/workspace-switcher-and-bootstrap/: ten frames, zero console and page errors, and the in-shell frame's inner box zoomed to the column (.demo-viewport style.zoom 0.6875 at a 1440 window). At prima.li 86fe9d1 the stage still scrolls sideways by 48 px (scrollWidth - clientWidth of the in-shell .demo-stage is 48 at 1440, 1024 and 400; the host cascade Gotcha); on a host with that rule fixed the expected reading is scrollWidth == clientWidth on all ten stages. Measured 2026-09-12 at 86fe9d1 with Playwright 1.56.1 on the served dist: 10 frames, 0 errors, 1038/990 on in-shell, equal on the other nine.
  6. Verifier: node tooling/verify/compare.mjs identity-auth/ui/workspace-switcher-and-bootstrap --state in-shell --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json (tour closed through verify.storage; the harness's owner has one workspace, so the app side shows one option where the frame shows three) writes a report with page_errors: []; the reader judges the column's top 120 px only and excludes by name the option count, the greeting and the dashboard numbers.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.