Workspaces
The switcher, what switching actually does, and rendering a team.
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.
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.
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.
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.
ConnectWorkspaceSwitcher
Three workspaces, one of them outside this application’s audience.
import { ConnectWorkspaceSwitcher } from "@cosmoops/connect-sdk/client";
// `row` draws it as a sidebar row rather than a standalone control.
<ConnectWorkspaceSwitcher row />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
const next = await connect.workspaces.switch(
{ workspaceId: "ws_other" },
{ asUser: sessionToken },
);
// Persist next.sessionToken. The one you passed is now revoked.Persist before you navigate
The old token is revoked the moment the switch succeeds. Drop the new one and the user is signed out.
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.
<ConnectWorkspaceSwitcher onSwitched={(workspace) => router.replace("/")} />Pass onSwitched only if you are certain every mounted query re-runs.
The team
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.