Skip to content

fix(webapp): only offer assignable roles in the team role picker - #4888

Draft
claude[bot] wants to merge 2 commits into
mainfrom
rbac/team-role-picker-offerable-roles
Draft

fix(webapp): only offer assignable roles in the team role picker#4888
claude[bot] wants to merge 2 commits into
mainfrom
rbac/team-role-picker-offerable-roles

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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.

The team page's role dropdown listed every role the org's plan allows,
including roles above the viewer's own level. Picking one of those was
always rejected, so the dropdown advertised a change that could not
happen.

Narrow the list to the roles the viewer's own role lets them assign,
reusing the same ladder the invite flow already applies. Plan-locked
roles are kept in the list and still render as "Name (upgrade)" linking
to billing: being unable to assign a role and needing a plan upgrade to
assign it are different situations, and collapsing them would offer a
viewer an upgrade for a role their own role would still not let them
hand out. The member's current role stays listed so the dropdown keeps
showing what they actually hold.

TeamPresenter now returns `offerableRoleIds` (the ladder) alongside the
existing `assignableRoleIds` (the plan), and the invite loader reuses
both instead of re-querying — its dropdown has no upgrade affordance, so
it keeps intersecting the two.

Co-Authored-By: Claude <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: c7082a2

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

The previous commit narrowed the picker with `isAtOrBelow`, which only
answers "yes" for roles that have a position on the system-role ladder.
Org-defined custom roles have no position, so they were filtered out for
everyone, and a viewer holding a custom role — no position either — was
offered nothing at all, leaving them with a dropdown containing only the
role each member already had. Neither was asked for.

Narrow subtractively instead: drop a role only where the ladder places it
strictly above the viewer, which is the only case picking it would always
be rejected. A new `isAbove` expresses that, and it is false whenever
either side is off the ladder, so custom roles stay offerable exactly as
before and an off-ladder or roleless viewer is not narrowed at all —
being unable to manage members is worse than being offered a role the
server may refuse. Plan-locked roles still render as "Name (upgrade)":
the offerable set and `assignableRoleIds` remain separate.

`offerableRoleIds` is no longer the invite flow's rule, so the invite
loader goes back to applying `isAtOrBelow` itself — ladder-only, then
intersected with the plan — which is the set it computed before this
branch. Its action was already doing its own check and is untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant