Skip to Content

← All archived runs

Run: multi-role-switcher-ui

run.md

Run: multi-role-switcher-ui

  • branch: claude/multi-role-switcher-ui-amw0ud
  • pr: #658

01_define/output/spec.md

Spec: Multi-role switcher UI & safety rails

  • slug: multi-role-switcher-ui
  • personas: Admin, CSM, SDM, Expert, Vendor
  • touches: apps/web/components/sidebar/app-sidebar.tsx, apps/web/app/(app)/users/[id]/, packages/services/src/notifications
  • complexity: complex

Problem

The 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.

Proposed change

  • Role switcher (sidebar user menu). In 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.
  • Admin "Manage roles" on the user-directory detail page (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.
  • Safety rails.
    • Notification dedupe: fan-out helpers in 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.
    • Notification preferences are per-person: a multi-role user has one notification-preference set keyed by person (not one set per role doc). Deduped fan-out reads that single preference set — there is no per-role preference conflict to resolve.
    • Directory grouping: People/directory views group by person (clerkUserId) rather than by role doc, showing one row per person with their held roles, instead of one row per (user, role).
    • Shared-identity sync: when a shared identity field (e.g. name, email, avatar) is edited on one of a user's role docs, the change is synced across that user's other role docs so they don't drift.

Acceptance criteria

  • A user who holds more than one granted role sees the role switcher and active-role badge in the sidebar user menu; after switching they land on the correct ROLE_HOME for the newly-active role.
  • A user who holds exactly one granted role never sees the switcher.
  • An admin can grant an extra role to a user from the user-directory detail page; the user can then switch into it (the per-role doc is lazy-provisioned on first switch).
  • An admin attempting to revoke a user's last remaining role is blocked with a clear message.
  • A csm+sdm dual-role user receives exactly one copy of a notification that targets both roles.
  • A multi-role user has a single notification-preference set; changing it affects notifications for all of that person's roles.
  • Directory/People views show one row per person (with their roles listed), not one row per role doc.
  • Editing a shared identity field on one of a user's role docs is reflected across that user's other role docs.

Out of scope

  • The multi-role plumbing — granted-roles claim, active-role cookie, per-(user, tenant, role) Mongo doc and index migration, principal resolution — delivered in multi-role-principal.
  • Changing a user's primary org-membership role — in-app-role-assignment (stub 12).
  • Admin View As / server-side emulation — admin-view-as (stub 9).
  • Making customer a switchable multi-role grant — internal roles only for this run (epic decision).
  • Any redesign of the notification-preference model beyond keying the existing set per person and having deduped fan-out read it.

Open questions

  • none — notification-preference granularity resolved at Define as per-person (one set).

02_build/output/notes.md

Build notes: multi-role-switcher-ui

  • commits: feat: multi-role-switcher-ui — sidebar switcher, admin grant/revoke, notification + directory safety rails

What changed

Role switcher (sidebar user menu) — AC1, AC2

  • apps/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.

Admin "Manage roles" (user detail page) — AC3, AC4

  • 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.

Safety rails — AC5, AC6, AC7, AC8

  • Notification dedupe (AC5): 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.)
  • Per-person preferences (AC6): notifications/core/preferences.tsupdateNotificationPreference now propagates the change to every role doc the principal holds in the tenant, so a multi-role user has one preference set.
  • Directory grouping (AC7): 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.
  • Shared-identity sync (AC8): userService.syncSharedIdentityAcrossRoles (new); updateTenantUser calls it after an edit to propagate name across the principal's role docs (isActive stays per-role).

Acceptance criteria status

  • Multi-role user sees switcher + active-role badge, lands on correct ROLE_HOME — sidebar + switchActiveRole redirect.
  • Single-role user never sees the switcher — grantedRoles.length > 1 guard.
  • Admin grants an extra role; user can switch into it (lazy-provisioned) — grantUserRole writes publicMetadata.roles; existing switch flow provisions the doc.
  • Revoking a user's last remaining role is blocked — primary-role + last-role guards in revokeUserRole.
  • csm+sdm dual-role user gets exactly one copy of a notification targeting both roles — email deduped to one per person via resolveEmailRecipients + skipEmail; in-app copy preserved per role so it's never missed.
  • Multi-role user has a single notification-preference set — updateNotificationPreference propagates across role docs.
  • Directory/People views show one row per person — findAllGroupedByPerson + grouped deriveAdminPeople.
  • Editing a shared identity field syncs across the person's role docs — syncSharedIdentityAcrossRoles from updateTenantUser.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here.

Notes for review

  • The admin dashboard's "recent 5 users" card (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.
  • Grant/revoke manages extra roles only (membership publicMetadata.roles); the primary role is shown but changed elsewhere (in-app-role-assignment), keeping the two surfaces coherent per the spec.
  • The 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.md

Changelog: multi-role-switcher-ui

Published 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.

  • A role switcher appears in the sidebar user menu with a badge showing your active role; pick another role and you land straight on that role's home. If you only have one role, nothing changes.
  • Admins can grant or revoke a teammate's extra roles from the user directory, so people can be given access to another role when they need it (and a person's last role can never be removed).
  • Someone who holds several roles now shows as a single row across your people and directory views, with all their roles listed — no more duplicate entries.
  • If a notification is meant for two roles you both hold, you'll get a single email instead of duplicates, and your notification preferences stay the same across all your roles.

03_release/output/investor-update.md

One operator, many roles — switch in a click

Who 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.md

Release: multi-role-switcher-ui

  • pr: https://github.com/sustentus/sustentus/pull/658 · merged: pending (squash on ticked "Ready to merge")
  • CI: Quality Project (format/lint/typecheck/build) green on the branch; Vercel web preview Ready; Vercel Agent Review resolved (email-only dedupe fix verified). "Review diff against CONVENTIONS.md" skipped (ENABLE_CLAUDE_REVIEW off) → ran /code-review high locally.
  • technical docs: no technical docs impact (notifyX still persist to Mongo + publish to Ably; skipEmail is an internal flag; person-grouping is a new query method — none alter documented architecture, apps, packages, routes, or env vars)
  • business docs: apps/docs/app/business/roles/page.mdx — added a "Multi-Role Users" section and a grant/revoke-of-extra-roles capability under Admin
  • release notes: both — changelog entry (apps/help/app/changelog/2026-07-16-multi-role-switcher-ui) + investor draft in this PR
  • sent: pending (investor update sent after merge)

Review summary

  • Email-only notification dedupe (PR-review driven) — in-app notification created for every recipient, email deduped per person via 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).

Acceptance check (vs spec)

  • Multi-role user sees switcher + active-role badge, lands on correct ROLE_HOME — sidebar renders RoleSwitcher when grantedRoles.length > 1; switchActiveRole redirects to ROLE_HOME.
  • Single-role user never sees the switcher — grantedRoles.length > 1 guard.
  • Admin grants an extra role; user can switch into it (lazy-provisioned) — grantUserRole writes membership publicMetadata.roles; per-role doc provisioned on first switch.
  • Revoking a user's last remaining role is blocked — primary-role guard + last-role guard in revokeUserRole.
  • csm+sdm dual-role user gets exactly one email — resolveEmailRecipients + skipEmail; in-app copy preserved per role.
  • Single notification-preference set per person — updateNotificationPreference propagates across the person's role docs.
  • Directory/People views show one row per person — findAllGroupedByPerson + grouped deriveAdminPeople.
  • Editing a shared identity field syncs across role docs — syncSharedIdentityAcrossRoles from updateTenantUser.