A Connect account can belong to more than one workspace. Your application sees
exactly one at a time — the one the session token is pinned to.

## The switcher

Already in the sidebar header, directly under the brand — which workspace you
are in scopes everything below it. It reads the caller's own session, so there is nothing
to wire and no `roles` prop to pass or forget.

```tsx
import { ConnectWorkspaceSwitcher } from "@cosmoops/connect-sdk/client";

// Only if you turned off the built-in one with workspaceSwitcher={false}
<ConnectWorkspaceSwitcher />
```

It renders **nothing** for an account with a single workspace, which is most of
them. That is deliberate: a switcher offering one option teaches the user
nothing and costs a row of chrome on every screen.

<Callout title="Authorization is the data, not a prop">
  `GET /workspaces` takes no user id — it resolves both the user and their
  memberships from the session token. A workspace the user cannot reach is
  simply not in the list, so there is no permission check for you to get wrong
  and no way to render a row you should not have.
</Callout>

### Rows you can see but cannot pick

A workspace the user belongs to but cannot use *here* — outside your audience,
or with no subscription — renders **disabled with the reason** rather than
hidden.

Hiding it reads as data loss to the person who owns that workspace. `switch`
refuses the same two cases, so the label and the outcome always agree.

<Preview name="workspace-switcher">

```tsx
import { ConnectWorkspaceSwitcher } from "@cosmoops/connect-sdk/client";

// `row` draws it as a sidebar row rather than a standalone control.
<ConnectWorkspaceSwitcher row />
```

</Preview>

## Switching is a new session

This is the part worth understanding before you build your own switcher.

A session token is pinned to a workspace when it is minted, and that pinning is
what every downstream check reads — the data store's tenant path, RBAC
resolution, entitlement gates. So a switch cannot edit the token in place. It
**re-runs sign-in's own authorization** against the target workspace and issues
a fresh credential:

- the user must be an **active member** of it
- it must be **in your audience**, when your application enforces that at sign-in
- **MFA must already be enrolled**, if that workspace requires it

```ts title="Server-side, if you are building your own"
const next = await connect.workspaces.switch(
  { workspaceId: "ws_other" },
  { asUser: sessionToken },
);
// Persist next.sessionToken. The one you passed is now revoked.
```

<Callout type="warn" title="Persist before you navigate">
  The old token is revoked the moment the switch succeeds. Drop the new one and
  the user is signed out.
</Callout>

Through the bridge you do not handle the token at all — `createConnectHandler`
writes it into your own session store and answers with the new workspace's
name. The credential never reaches the browser. That is also why the switch
route **does not exist** for a partner who supplied no `session.write`: rotating
a token with nowhere to persist it would sign the user out silently, so the
route is absent rather than half-working.

Switches are written to the audit trail. Changing which tenant a session can
reach is the same class of event as the sign-in before it.

## After a switch

`ConnectWorkspaceSwitcher` reloads the page by default, and that is almost
always right: every server-rendered screen was built for the *previous*
workspace, and a client-side refresh leaves stale data on screen next to the
new workspace's name.

```tsx
<ConnectWorkspaceSwitcher onSwitched={(workspace) => router.replace("/")} />
```

Pass `onSwitched` only if you are certain every mounted query re-runs.

## The team

```tsx
import { useConnectWorkspaceMembers } from "@cosmoops/connect-sdk/client";

function TeamTable() {
  const { data, isLoading } = useConnectWorkspaceMembers();
  if (isLoading) return <Skeleton />;

  return (
    <table>
      {data?.members.map((member) => (
        <tr key={member.userId}>
          <td>{member.displayName ?? member.email}</td>
          <td>{member.role ?? "Custom"}</td>
          <td>{member.granted.includes("pulse:campaign:manage") ? "Yes" : "No"}</td>
        </tr>
      ))}
    </table>
  );
}
```

Rows carry permissions **already resolved for your application** — permission
ids from your own catalog, plus an inferred role. Not `["admin"]`.

That means you never reimplement Connect's role resolution, and you are told
nothing about the customer's Connect account beyond what your application
needs. Branch on `granted`, not on `role` — `role` is `null` whenever someone's
permissions match no single named role, which is common once custom roles
exist.

## What this deliberately cannot do

There is no create-workspace and no invite-member call. Both involve billing
identity, seat counts and compliance an application has no way to reason about,
so both stay in Connect. Link users there — the account row already does, and
`ConnectWorkspaceTypeGate` is how you keep a team screen off an individual
account in the meantime.