> ## Documentation Index
> Fetch the complete documentation index at: https://doc.askmydocs.padosoft.com/llms.txt
> Use this file to discover all available pages before exploring further.

# The team switcher

> Per-team SPA routing (/app/{teamHash}/…), automatic X-Tenant-Id stamping, cache isolation on switch, and a membership-aware authorization gate — the front-end half of AskMyDocs multi-tenancy.

## Motivation

[Multi-tenant deployment](/multi-tenant-isolation) gives the **back end** a
per-request `tenant_id` that scopes every Eloquent query (R30/R31). But a user who
belongs to more than one team needs a **front-end** that makes the active team
explicit, switches between teams without leaking data, and proves to the server
that the team they ask for is one they actually belong to.

The **team switcher** is that front-end half. It turns the abstract
`X-Tenant-Id` header into a first-class part of the URL and the UI, so the active
tenant is always visible, bookmarkable, and impossible to confuse across a switch.

## Design

```mermaid theme={null}
flowchart TD
    A[GET /api/auth/me] -->|teams: groupBy tenant| B[team-store: teams + currentTeam]
    B --> C{currentTeam exists?}
    C -->|default membership| D[no X-Tenant-Id header — host fallback]
    C -->|other membership| E[axios interceptor stamps X-Tenant-Id: tenant]
    D --> F[AuthorizeTenantHeader]
    E --> F
    F -->|membership in resolved tenant| G[request proceeds, R30-scoped]
    F -->|no membership| H[403 tenant_forbidden]
    H --> I[interceptor → resetToFirstTeam → reload /app]
    B --> J[TeamSwitcher in topbar]
    J -->|switchTeam| K[queryClient.clear + cancelQueries]
    K --> L[AppShell remounts outlet on key=tenant_id]
    L --> M[navigate /app/{teamHash}/…]
```

**The pieces**

| Piece                   | Path                                             | Responsibility                                                                                                                |
| ----------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `team-store`            | `frontend/src/lib/team-store.ts`                 | Zustand store (persisted): `teams`, `currentTeam`, `userId`; `syncFromMe`, `switchTeam`, `resetToFirstTeam`                   |
| axios interceptor       | `frontend/src/lib/api.ts`                        | Stamps `X-Tenant-Id` on every non-exempt request when a real operational team is selected                                     |
| `TeamSwitcher`          | `frontend/src/components/shell/TeamSwitcher.tsx` | Topbar `menuitemradio` switcher; disabled single-team state; Escape returns focus to the trigger (R15)                        |
| `TeamGate` + routes     | `frontend/src/routes/index.tsx`                  | Hosts operational tenant screens under `/app/{teamHash}/…`; global system administration and company onboarding are hash-less |
| `TeamHash`              | `app/Support/TeamHash.php`                       | BE-computed routing segment per tenant (a non-secret namespace)                                                               |
| `/api/auth/me` `teams`  | `app/Services/Auth/UserTeamsResolver.php`        | Returns active tenants derived exclusively from the caller's memberships; may be empty                                        |
| `AuthorizeTenantHeader` | `app/Http/Middleware/AuthorizeTenantHeader.php`  | Requires a membership in the resolved tenant, even without an explicit header                                                 |

## Reserved fallbacks never become teams

The legacy literal `default` may still be used internally when no request
tenant has been resolved, but it is reserved and non-operational.
`UserTeamsResolver` filters it even when stale membership data exists, and
`AuthorizeTenantHeader` rejects it before any tenant-aware query. Real teams
(for example `acme`) always send `X-Tenant-Id` and remain membership-scoped.

## Cache isolation on switch

Switching team must never render one tenant's cached data under another. `switchTeam`
therefore `cancelQueries()` + `clear()`s the entire TanStack Query cache, and
`AppShell` keys the route outlet on `currentTeam` so all page-local state remounts.
A persisted selection is honoured only if it still belongs to the same user **and**
still exists in the fresh `/api/auth/me` `teams` list — otherwise it falls back to
the first team, so a revoked membership self-heals on the next bootstrap.

## Authorization — membership is mandatory

`AuthorizeTenantHeader` runs **after** `auth:sanctum` and **before** any
tenant-aware query. It requires a `project_membership` in the resolved tenant,
whether that tenant came from `X-Tenant-Id` or the header-less `default`
fallback. Reserved fallbacks are rejected regardless of membership. A
membership in tenant B never opens tenant A and another user's
membership never helps. Anything else returns `403 tenant_forbidden`,
which the front-end response interceptor turns into a snap-back to the first valid
team.

## Worked example

A user who is a member of `acme` and `globex` logs in:

1. `GET /api/auth/me` returns `teams: [{tenant_id:'acme',hash:…}, {tenant_id:'globex',hash:…}]`.
2. The SPA boots at the persisted membership or `/app/{acmeHash}/…`.
3. The user picks **acme** in the topbar. The cache is cleared, the outlet remounts, the URL becomes `/app/{acmeHash}/admin/dashboard`, and every call now carries `X-Tenant-Id: acme`.
4. A forged `X-Tenant-Id: stark` (no membership) → `403 tenant_forbidden` → the SPA resets to the first valid team and reloads.

## Gotchas

* **`teamHash` is a routing namespace, not a secret.** Authorization always stays on the server-validated `X-Tenant-Id`; guessing or forging a hash discloses and grants nothing.
* **Don't synthesize or expose `default`.** It is a compatibility storage
  fallback, not a company; no membership may put it in `/api/auth/me.teams`.
* **Zero teams is valid store state, but it is gated.** The store keeps
  `currentTeam = null`; System Admins land on the global tenant registry, while
  normal accounts are routed to resumable company onboarding and cannot enter a
  tenant dashboard until they create a company. The no-tenant page remains the
  compatibility fallback only when the backend explicitly reports onboarding
  as unavailable.
* **Live stale-tenant recovery is best-effort.** The interceptor auto-recovers only on the host's `tenant_forbidden` 403; package routes reject with their own statuses (404/410/423) and self-heal on the next `/api/auth/me` bootstrap.

See also: [Multi-tenant isolation](/multi-tenant-isolation), [The project registry](/projects-registry).
