Org switcher in console account menu
## Epic ask (verbatim)
> "Allow user to be a member of several orgs, including Operator. At any given moment user acts on behalf of the currently selected org, no permissions mix or leak. User can switch between orgs by clicking on his icon and selecting a switch icon next to the org. Same model as in Stripe and other multitenant systems"
## Requirements (PRD, verbatim)
- **R3 — Switcher in the account menu (A3).** Clicking the user's icon/badge opens the account menu. The menu lists every org the user belongs to: each row shows the org name, which must be readable and never truncated to an unidentifiable fragment; the active org is marked as current; every other org shows a switch icon (the icon also carries an accessible label, "Switch to <org>"); clicking it makes that org active and reloads the console into that org's home. This works on desktop (the sidebar footer menu), on the collapsed rail, and on phone (the drawer account section). A user with ONE org sees their org name and no switch control. The org list also surfaces pending invitations (R5) and "Create organization" (R6).
- **R4** "A switch applies to the whole browser session: every open tab of that session moves to the new org on its next request or refresh. No tab keeps acting as the old org after a switch."
- **R12** "This ships ON by default on the dev control plane (`dev.skynet.gmind.dev`), with no flag, and is reachable from the real console."
## Acceptance (PRD, verbatim)
- AC5: "Clicking the avatar/badge lists all orgs. The active one is marked; each other has a switch icon with an accessible 'Switch to <org>' label; clicking it switches. Works on the desktop expanded rail, the collapsed rail and phone width."
- AC6 (quality): "Org names in the switcher stay readable and identifiable. A long name (40 chars) shows at least its first ~20 characters plus an ellipsis, with the full name on hover. Never 1–4 characters. The menu does not overflow the viewport at phone width, and rows are clear tap targets (≥40px)."
- AC7: "A single-org user sees their org name and no switch control."
- AC8 (e2e): "After a switch in tab 1, tab 2's next request acts as the new org. No request is ever served as the old org afterwards."
- AC9/AC10 (e2e): "the invitation appears in the account menu and Accept works"; "'Create organization' from the menu creates an org in the `pending` state ... It shows as pending and cannot be switched to until an operator approves it; after approval it can be switched to."
- AC16: "On `dev.skynet.gmind.dev` in a browser: as a user holding Operator + a tenant org, open the avatar menu, switch to the tenant, observe tenant-only data and no operator nav, then switch back and observe operator surfaces." (the epic's Finish stage re-verifies on dev after deploy)
## Approach (architecture §3.3 web half, §3.11; consumes the pinned contracts)
- In `apps/web/src/components/Sidebar.tsx`, ONE `OrgSwitcherList` component rendered in all three account surfaces — desktop footer Menu (`:996-1105`), collapsed rail (same Menu), phone drawer account section (`:962-975`) — ABOVE the existing Support / My Profile / Sign out (untouched).
- Data: `GET /api/auth/me` `memberships` (`org_id, org_slug, org_name, state, is_current, is_operator_plane`, current first) and `pending_invitations` (`id, org_name, invited_by_name, created_at`).
- One row per membership; current marked (check + "Current"); others get a switch `IconButton` `aria-label="Switch to <org>"`; `pending_approval` rows labeled "Pending approval", not switchable; `disabled` rows not switchable. Pending invitations with Accept (`POST /api/auth/invitations/{id}/accept`) / Decline (`…/decline`). "Create organization" item opens a dialog posting `POST /api/orgs/self-create` (sign-up Application fields minus the person; 201 `{org_id, state:"pending_approval"}`; 409 `signup_pending` shown readably).
- Quality bar: org name single-line with `text-overflow: ellipsis` and a min text width that always shows ≥20 characters, full name in a tooltip; menu `max-width: min(360px, 100vw - 32px)`; rows ≥40px tall. Never shrink-to-fit font or clip to a few characters (the epic #482 failure).
- Switch: `POST /api/auth/switch {org_id}` → `session` → post on `BroadcastChannel('jaah-session')` + `window.location.assign('/')`; `reauth_required` + `otp` → inline TOTP prompt, retry with `code`; IdP door → navigate to `start_url`; 404/409 → readable error.
- `apps/web/src/api.ts::api` sends `X-Jaah-Org: <active org id the tab loaded with>` on every `/api` request; the EventSource adds `?org=<id>`. On `409 {"detail":"org_switched","active_org":{id,name}}` (or a `BroadcastChannel` message) show a BLOCKING dialog "You switched to <B> in another tab" with Reload; page underneath stays visible (text copyable) but inert.
- Playwright e2e on the supported viewports per `docs/standards/product-testing-requirements.md`.
Out of scope: Do NOT redesign or reorder Support / My Profile / Sign out. No separate org-management page (D-D). No deep-link interstitial (separate child). No API changes beyond what the consumed contracts already provide (if a contract gap is found, name it, don't widen silently). Epic out-of-scope (PRD §5, verbatim): "No per-tab simultaneous orgs. Org is per session. Do NOT put the org in the URL path as an addressing scheme (a deep-link interstitial is allowed)." "No audit event for a switch." "No self-service 'leave org'." "No mobile-native screens. `apps/mobile` is the wrapper; the web console change covers it." "No redesign of the account menu's existing items (Support / My Profile / Sign out stay)."
Done means:
- AC5 — Playwright on desktop-expanded, collapsed-rail and phone widths: open the menu, assert N org rows, assert the current marker, assert `Switch to <B>` accessible name, click it, land on B's home with B shown as current.
- AC6 — e2e screenshots at desktop and phone width with a 40-char org name: visible text ≥20 chars + ellipsis, full name in tooltip, no horizontal overflow of the viewport, each row's bounding box ≥40px tall. A rendering that shows ≤4 characters FAILS.
- AC7 — component test + e2e: single-membership user sees org name, zero switch buttons.
- AC8 — e2e with two tabs: switch in tab 1; tab 2 shows the blocking dialog (via BroadcastChannel, and via 409 when the channel is absent); tab 2 issues no successful request as the old org.
- AC3 browser half — e2e: a superadmin with Operator + T memberships sees operator-only nav items and the org indicator in Operator, and none of them after switching to T; they return after switching back.
- AC9/AC10 e2e — pending invitation appears in the menu, Accept adds the org to the list; Create organization yields a "Pending approval" row with no switch control; after operator approval it becomes switchable.
- Re-auth UX: switching into an MFA-required org from an `mfa_skip` session shows the TOTP prompt and succeeds with a valid code — e2e or component test.
- AC16 — no feature flag or env var gates the switcher (grep); Finish verifies on dev.
- AC17 — `./dc check full` green, including the tenancy bypass/scope censuses and the authz policy suite.
Required checks: gate
Outcome
Org switcher in account menu (rail, collapsed, phone) + stale-tab guard (X-Jaah-Org, broadcast, reload dialog), invites, create org. Full gate green, visual check good; operator-approved; merged #4513. Deferred: Google inline re-auth.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Awaiting response
- Awaiting response
- Runner resumed
- Runner ended (done)