multi-role-switcher-uirun.md01_define/output/spec.mdThe multi-role-principal feature (epic stub 4) made it possible for an internal user to hold and
switch between several granted roles in-session, but nothing surfaces or manages it: users can't see
or switch their granted roles, admins can't grant or revoke extra roles, and a user who holds two
roles (e.g. csm+sdm) is treated as two separate people — receiving duplicate notification emails and
appearing as two rows in People/directory views. This is stub 8 of the platform-access-permissions
epic under Build the Bridge / 2026-Q2 Objective 3 — Validate Technical Infrastructure & Payout Flow:
the multi-role principal is only validated once real users can switch roles and admins can grant them,
and the platform must not double-notify or double-list a multi-role person.
app-sidebar.tsx, render a role switcher in the user menu
only when the current user holds more than one granted role, plus an active-role badge showing
which role is active. Switching sets the active-role cookie/claim (plumbing from
multi-role-principal) and lands the user on the correct ROLE_HOME for the newly-active role.
Single-role users never see the switcher.app/(app)/users/[id]/). An admin can
grant or revoke a user's extra granted roles, written to the membership publicMetadata.roles
via the Clerk backend client. A newly-granted role is lazy-provisioned (its per-role Mongo doc
created on first switch, per the principal design). The UI cannot revoke a user's last remaining
role. This surface manages extra granted roles only; changing a user's primary org-membership
role is a separate feature (in-app-role-assignment) — the two controls sit coherently on the same
detail page without overlapping.packages/services/src/notifications dedupe recipients
by clerkUserId (falling back to email) so a notification targeting two roles a single person
holds sends exactly one copy.ROLE_HOME for the newly-active role.multi-role-principal.in-app-role-assignment (stub 12).admin-view-as (stub 9).02_build/output/notes.mdapps/web/components/sidebar/role-switcher.tsx (new): client RoleSwitcher — a
dropdown of the user's granted roles with an active-role badge. Selecting a role
calls the existing switchActiveRole action (sets the active-role cookie and
redirects to ROLE_HOME).apps/web/components/sidebar/app-sidebar.tsx: new grantedRoles prop; renders
RoleSwitcher in the footer only when grantedRoles.length > 1 — single-role
users never see it.apps/web/app/(app)/layout.tsx: fetch getGrantedRoles() alongside the active
role and pass it to the sidebar.apps/web/app/(app)/users/[id]/actions.ts: new admin-only actions
getManageableRoles, grantUserRole, revokeUserRole. Grant/revoke write the
Clerk org-membership publicMetadata.roles (the source of the org_roles claim)
via updateOrganizationMembershipMetadata. Guards: can't revoke the primary
role (that's in-app-role-assignment), and can't revoke a user's last remaining
role. A newly-granted role's Mongo doc is provisioned lazily on first switch (no DB
write on grant).apps/web/components/users/user-profile/manage-roles.tsx (new, server) +
manage-roles-panel.tsx (new, client): the panel on the detail page — current
roles (primary marked, extras revocable) + a grant picker. Hidden for non-admins
and for users with no Clerk identity yet.packages/services/src/notifications/core/fan-out.ts
(new) resolveEmailRecipients(tenantId, userIds) returns the one id per person (by
clerkUserId, falling back to email) that should send the email. Every
recipient still gets an in-app record + realtime publish (via createNotification's
new skipEmail flag) — so a multi-role user never misses the in-app copy under
whichever role they're viewing, while receiving a single email across their roles.
Applied at the three fan-out sites: leads-service create, workspace escalate and
raiseConcern. (This email-only dedupe replaced an earlier whole-notification dedupe
that could drop the in-app copy under the non-surviving role — raised in PR review.)notifications/core/preferences.ts —
updateNotificationPreference now propagates the change to every role doc the
principal holds in the tenant, so a multi-role user has one preference set.userService.findAllGroupedByPerson (new
aggregation) returns one row per person with all their roles; the /users table
and the admin People table (deriveAdminPeople) both render grouped rows with
multiple role badges.userService.syncSharedIdentityAcrossRoles (new);
updateTenantUser calls it after an edit to propagate name across the principal's
role docs (isActive stays per-role).ROLE_HOME — sidebar + switchActiveRole redirect.grantedRoles.length > 1 guard.grantUserRole writes publicMetadata.roles; existing switch flow provisions the doc.revokeUserRole.resolveEmailRecipients + skipEmail; in-app copy preserved per role so it's never missed.updateNotificationPreference propagates across role docs.findAllGroupedByPerson + grouped deriveAdminPeople.syncSharedIdentityAcrossRoles from updateTenantUser.user-management-card) is deliberately
left per-doc — it's a recency widget (sorted by updatedAt), not the directory;
grouping after a limit-5 window would break its "recent activity" semantics. The two
canonical directory/People surfaces (/users and the admin People table) are grouped.publicMetadata.roles); the
primary role is shown but changed elsewhere (in-app-role-assignment), keeping the
two surfaces coherent per the spec.org_roles claim refreshes on the user's next token refresh after a grant, so a
just-granted role appears in their switcher once their session token rolls over — the
same JWT-propagation the multi-role-principal plumbing already relies on.03_release/output/changelog.mdPublished as apps/help/app/changelog/2026-07-16-multi-role-switcher-ui/page.mdx.
title: Switch between your roles without logging out personas: [admin, csm, sdm, expert, vendor]
If you hold more than one role, you can now switch between them from the sidebar — no logging out and back in.
03_release/output/investor-update.mdWho it's for: Internal team — admin, CSM, SDM, expert, vendor What shipped: People who hold several roles switch between them in-session; admins grant or revoke a user's extra roles from the directory; a multi-role person now gets one notification and one directory row. Why it matters: One operator can cover several roles without separate logins — advancing Build the Bridge's Objective 3, validate technical infrastructure.
Dig deeper: <merged-PR URL> · <changelog entry URL>
03_release/output/release.mdskipEmail is an internal flag; person-grouping is a new query method — none alter documented architecture, apps, packages, routes, or env vars)resolveEmailRecipients + skipEmail. Verified across create.ts / fan-out.ts / all three call sites. Resolved.deriveAdminPeople groups People rows by person; deriveRolesReadiness().totalUsers still counts user records (seats). Accepted — the "Total users" stat lives on a separate roles-permissions card, not adjacent to the grouped table, and role coverage (rolesAssigned/missingRoles) correctly counts across docs.writeMembershipExtras sets publicMetadata: { roles } — relies on Clerk's top-level shallow-merge to preserve other keys. Accepted — membership publicMetadata only carries roles in this app.ROLE_LABEL map duplicated across role-switcher and manage-roles-panel — accepted (small, matches existing per-file role-label maps).RoleSwitcher when grantedRoles.length > 1; switchActiveRole redirects to ROLE_HOME.grantedRoles.length > 1 guard.grantUserRole writes membership publicMetadata.roles; per-role doc provisioned on first switch.revokeUserRole.resolveEmailRecipients + skipEmail; in-app copy preserved per role.updateNotificationPreference propagates across the person's role docs.findAllGroupedByPerson + grouped deriveAdminPeople.syncSharedIdentityAcrossRoles from updateTenantUser.