Requested by Matt Aitken · Slack thread
Before / After
Before. The role dropdown next to each member on the Team page listed every role the organization's plan allows — including roles above the viewer's own. A viewer who is not allowed to hand out the Owner role was still shown "Owner", and selecting it came back as a permission error. The dropdown advertised a change that could never happen.
After. Roles above the viewer's own level on the system-role ladder are not in the list at all, so there is nothing to click and nothing to be rejected for. Every other role is listed exactly as before: organization-defined custom roles are untouched, and a role the viewer could assign but the organization's plan does not include still appears as Name (upgrade), linking to the plan page. Each member's current role also stays in the list, so the dropdown keeps showing the role they hold even when the viewer could not have assigned it themselves.
How
Two separate sets now drive the picker, and keeping them separate is the point:
offerableRoleIds — the role catalogue minus the roles the system-role ladder (apps/webapp/app/utils/inviteRoleLadder.ts) places strictly above the viewer. Anything in that set is filtered out of the rendered list.
- The narrowing is subtractive, so it only removes what the ladder can positively rank: a role above the viewer on the ladder is hidden, while custom roles — which have no ladder position — are unaffected and stay offerable exactly as they are today. A viewer whose own role has no ladder position (a custom role, or no role) is not narrowed at all, since taking away their ability to manage members would be worse than offering a role the server may go on to refuse.
assignableRoleIds — the roles the organization's plan allows, unchanged. Inside the offerable list, a role that is not plan-assignable still renders as the (upgrade) row linking to billing.
Merging the two would show a viewer "Owner (upgrade)" — an invitation to pay for something their own role still would not let them do — so TeamPresenter returns the offerable set as its own field rather than intersecting it into the plan set.
The invite flow keeps its own, stricter rule: isAtOrBelow in the same file, which requires a ladder position on both sides and refuses anything else, intersected with the plan set. That loader and action are unchanged by this PR.
✅ Checklist
- I have followed every step in the contributing guide
- The PR title follows the convention.
- I ran and tested the code works
Testing
pnpm run format — clean, all 4322 matched files already correctly formatted.
pnpm run lint (oxlint) — clean.
pnpm run knip — clean (only the pre-existing lefthook configuration hint).
pnpm run typecheck --filter webapp — 20 tasks successful, 20 total.
pnpm exec vitest run test/inviteRoleLadder.test.ts (in apps/webapp) — 12 tests passed. The offerableRoleIds cases cover: the viewer's own level and below is offered, roles above the viewer are left out entirely, a top-of-ladder viewer gets the whole catalogue, plan-locked roles are still returned so the caller can render the upgrade row, custom roles are kept for viewers at every ladder level, and a viewer holding a custom role — or no role, or an org with no ladder at all — is not narrowed at all. The isAtOrBelow cases for the invite flow's stricter rule are unchanged.
The webapp's vitest setup has no DOM environment configured, so RolePicker itself is not covered by a rendering test; the filtering rule it applies is unit-tested through the extracted helper instead.
Changelog
The Team page's role dropdown no longer lists roles above your own, which were rejected when you picked them. Every other role you could pick before is still there, and roles that need a plan upgrade still appear with a link to upgrade.
Screenshots
Not captured — the change is to which rows the existing dropdown lists; its appearance is unchanged.