Skip to content

Commit c7082a2

Browse files
committed
fix(webapp): keep custom roles in the team role picker
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>
1 parent e6e7e08 commit c7082a2

6 files changed

Lines changed: 142 additions & 58 deletions

File tree

‎.server-changes/team-role-picker-only-assignable-roles.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: improvement
44
---
55

6-
The Team page's role dropdown now only lists roles you are actually allowed to assign, instead of showing higher roles that were rejected when you picked them. Roles that need a plan upgrade still appear, with a link to upgrade.
6+
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.

‎apps/webapp/app/presenters/TeamPresenter.server.ts‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,23 +42,25 @@ export class TeamPresenter extends BasePresenter {
4242
organizationId
4343
),
4444
// The viewer's own role, plus the system-role ladder it sits on —
45-
// together these say how high this viewer is allowed to assign.
45+
// together these say which roles sit above this viewer.
4646
rbac.getUserRole({ userId, organizationId }),
4747
rbac.systemRoles(organizationId),
4848
]);
4949

50-
// Roles this viewer is allowed to hand out: at or below their own level
51-
// on the system-role ladder. Deliberately NOT intersected with
52-
// `assignableRoleIds` — the two answer different questions and the Team
53-
// page renders them differently. A role above the viewer's level is left
54-
// out of the picker altogether, while a role that is merely plan-locked
55-
// still needs to appear with an upgrade link. Merging them would offer a
56-
// viewer "Owner (upgrade)", inviting them to pay for something their own
57-
// role still would not let them assign.
50+
// Roles to offer this viewer in the picker: the catalogue minus whatever
51+
// the system-role ladder puts strictly above their own role, which is the
52+
// only part picking would always be rejected for. Roles the ladder can't
53+
// place — org-defined custom roles, and every role when the viewer's own
54+
// role is itself custom or missing — stay in, so custom roles keep
55+
// behaving as they always have rather than vanishing from the picker.
5856
//
59-
// Off-ladder roles (custom org roles, and any role held by a viewer who
60-
// is themselves on a custom role) are refused, the same way the invite
61-
// flow refuses them.
57+
// Deliberately NOT intersected with `assignableRoleIds` — the two answer
58+
// different questions and the Team page renders them differently. A role
59+
// above the viewer's level is left out of the picker altogether, while a
60+
// role that is merely plan-locked still needs to appear with an upgrade
61+
// link. Merging them would offer a viewer "Owner (upgrade)", inviting
62+
// them to pay for something their own role still would not let them
63+
// assign.
6264
const offerableRoleIds = computeOfferableRoleIds(roles, systemRoles, viewerRole?.id ?? null);
6365

6466
const memberRoles = result.members.map((m) => ({

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,18 +77,28 @@ export const loader = dashboardLoader(
7777
throw new Response("Not Found", { status: 404 });
7878
}
7979

80+
// Inviter's own role drives the "below their level" filter on the
81+
// dropdown. Plus assignable role IDs already encode the org's plan
82+
// tier — the intersection is what we offer.
83+
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
84+
rbac.getUserRole({ userId, organizationId }),
85+
rbac.getAssignableRoleIds(organizationId),
86+
rbac.systemRoles(organizationId),
87+
]);
88+
8089
// Build the dropdown's offerable set server-side: roles that are
81-
// (a) at or below the inviter's own level AND (b) assignable on the
82-
// current plan. The client just renders these — it doesn't need to know
83-
// about the system-role catalogue or the ladder.
84-
//
85-
// The presenter already applies the ladder; intersecting with the plan is
86-
// right here, because the invite dropdown has no upgrade affordance and a
87-
// plan-locked role is simply not offered. The Team page keeps the two
88-
// sets apart instead, since it still shows plan-locked roles as an
89-
// upgrade link — so it uses the presenter's `offerableRoleIds` unmerged.
90-
const assignableSet = new Set(result.assignableRoleIds);
91-
const offerableRoleIds = result.offerableRoleIds.filter((id) => assignableSet.has(id));
90+
// (a) assignable on the current plan AND (b) at or below the
91+
// inviter's own level. The client just renders these — it doesn't
92+
// need to know about the system-role catalogue or the ladder.
93+
const assignableSet = new Set(assignableRoleIds);
94+
const offerableRoleIds = systemRoles
95+
? result.roles
96+
.filter(
97+
(r) =>
98+
assignableSet.has(r.id) && isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
99+
)
100+
.map((r) => r.id)
101+
: [];
92102

93103
// Buying seats is a billing operation: surface whether this user can, so
94104
// the purchase modal disables its trigger (the team action enforces it).

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -697,8 +697,10 @@ function LeaveRemoveButton({
697697
// UI-affordance layer only.
698698
//
699699
// Two different sets narrow the list, and they must stay separate:
700-
// offerableRoleIds — roles the viewer's own role lets them assign.
701-
// Anything else is left out of the list entirely.
700+
// offerableRoleIds — the catalogue minus the roles that sit above the
701+
// viewer on the system-role ladder. Those are left out
702+
// of the list entirely; custom roles, which aren't on
703+
// the ladder, are always in it.
702704
// assignableRoleIds — roles the org's plan allows. A role that is
703705
// offerable but not plan-assignable still shows,
704706
// as "Name (upgrade)" linking to billing.
@@ -721,13 +723,12 @@ function RolePicker({
721723
const fetcher = useFetcher<{ ok: boolean; error?: string } | { ok: true }>();
722724
const assignable = new Set(assignableRoleIds);
723725
const offerable = new Set(offerableRoleIds);
724-
// The member's current role stays in the list even when the viewer could
725-
// not assign it, so the controlled `value` below still resolves to a row
726-
// and the dropdown shows the role the member actually holds.
726+
// The member's current role stays in the list even when it sits above the
727+
// viewer, so the controlled `value` below still resolves to a row and the
728+
// dropdown shows the role the member actually holds.
727729
const visibleRoles = roles.filter((r) => offerable.has(r.id) || r.id === currentRoleId);
728-
// With no RBAC plugin installed the loader returns no roles, and a viewer
729-
// with nothing to offer would get an empty dropdown — render nothing
730-
// rather than a dead control.
730+
// With no RBAC plugin installed the loader returns no roles at all —
731+
// render nothing rather than an empty dropdown.
731732
if (visibleRoles.length === 0) return null;
732733

733734
const isSubmitting = fetcher.state === "submitting";
@@ -748,8 +749,8 @@ function RolePicker({
748749
text={(v) => visibleRoles.find((r) => r.id === v)?.name ?? "No role"}
749750
setValue={(next) => {
750751
if (typeof next !== "string" || next === (currentRoleId ?? "")) return;
751-
// The member's current role is listed even when it isn't offerable,
752-
// so re-check before submitting.
752+
// The member's current role is listed even when it sits above the
753+
// viewer, so re-check before submitting.
753754
if (!offerable.has(next)) return;
754755
// Upgrade-link rows have a value too (Ariakit needs one to
755756
// make the row interactive — without it the Link inside

‎apps/webapp/app/utils/inviteRoleLadder.ts‎

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
// A user can only assign a role at or below their own — on invite, and on the
2-
// Team page's role picker. The systemRoles array is in canonical order
3-
// (highest authority first), so array index drives the ladder. Custom roles
4-
// aren't in the table and are refused. Dependency-free so the rule can be
5-
// unit-tested directly.
1+
// The system roles form a ladder: the systemRoles array is in canonical order
2+
// (highest authority first), so array index gives each role a level. Roles the
3+
// org defined itself are not in that array and have no level at all.
4+
//
5+
// Two callers read the ladder, and they treat a role with no level
6+
// differently. The invite flow (`isAtOrBelow`) requires a level on both sides
7+
// and refuses anything else. The Team page's role picker (`offerableRoleIds`)
8+
// only removes what the ladder positively places above the viewer, so custom
9+
// roles stay offerable. Dependency-free so both rules can be unit-tested
10+
// directly.
611

712
export type LadderRole = { id: string };
813

@@ -34,20 +39,49 @@ export function isAtOrBelow(
3439
}
3540

3641
/**
37-
* The subset of `roles` that a user holding `viewerRoleId` may assign, by the
38-
* ladder above. Knows nothing about plan gating: a role the org's plan does
39-
* not allow is still returned, so a caller that wants to surface it as an
40-
* upgrade affordance can. Callers that have no upgrade affordance intersect
41-
* with their plan-assignable set themselves.
42+
* Whether the ladder places `candidateRoleId` strictly above `viewerRoleId`.
43+
* Only ever true when both roles have a level: a custom role on either side
44+
* is not comparable, so it is never "above", and nothing is above a viewer
45+
* whose own role has no level.
46+
*/
47+
function isAbove(
48+
roles: ReadonlyArray<LadderRole>,
49+
viewerRoleId: string | null,
50+
candidateRoleId: string
51+
): boolean {
52+
if (!viewerRoleId) return false;
53+
const level = buildRoleLevel(roles);
54+
const viewer = level[viewerRoleId];
55+
const candidate = level[candidateRoleId];
56+
if (viewer === undefined || candidate === undefined) return false;
57+
return candidate > viewer;
58+
}
59+
60+
/**
61+
* The subset of `roles` to offer a user holding `viewerRoleId` in a role
62+
* picker. The narrowing is subtractive: a role is dropped only where the
63+
* ladder puts it strictly above the viewer — the case where picking it would
64+
* always be rejected. Everything the ladder can't place stays offerable:
65+
*
66+
* - org-defined custom roles, which have no level, are always offered;
67+
* - a viewer whose own role has no level (a custom role, or no role at all)
68+
* is offered the whole catalogue, since narrowing to nothing would take
69+
* away their ability to manage members entirely — a worse outcome than
70+
* offering a role the server may go on to refuse.
71+
*
72+
* Knows nothing about plan gating: a role the org's plan does not allow is
73+
* still returned, so a caller that wants to surface it as an upgrade
74+
* affordance can. Callers with no upgrade affordance intersect with their
75+
* plan-assignable set themselves.
4276
*
43-
* `systemRoles` is null when no RBAC plugin is installed — there is no ladder
44-
* to check against, so nothing is offerable.
77+
* `systemRoles` is null when no RBAC plugin is installed — with no ladder
78+
* there is nothing to narrow by, so `roles` comes back as-is.
4579
*/
4680
export function offerableRoleIds(
4781
roles: ReadonlyArray<LadderRole>,
4882
systemRoles: ReadonlyArray<LadderRole> | null,
4983
viewerRoleId: string | null
5084
): string[] {
51-
if (!systemRoles) return [];
52-
return roles.filter((r) => isAtOrBelow(systemRoles, viewerRoleId, r.id)).map((r) => r.id);
85+
if (!systemRoles) return roles.map((r) => r.id);
86+
return roles.filter((r) => !isAbove(systemRoles, viewerRoleId, r.id)).map((r) => r.id);
5387
}

‎apps/webapp/test/inviteRoleLadder.test.ts‎

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,33 @@ describe("isAtOrBelow", () => {
3232
});
3333
});
3434

35-
// Property under test: the picker/dropdown set is the ladder alone. Plan
36-
// gating is a separate concern the caller layers on, so a plan-locked role
37-
// must still come back here — the Team page renders it as an upgrade link.
35+
// Property under test: the picker set is the catalogue minus the roles the
36+
// ladder puts strictly above the viewer. Nothing else is removed — roles with
37+
// no ladder position (org-defined custom roles) stay offerable, and so does
38+
// the whole catalogue when the viewer's own role has no position either.
39+
// Plan gating is a separate concern the caller layers on, so a plan-locked
40+
// role must still come back here — the Team page renders it as an upgrade link.
3841
describe("offerableRoleIds", () => {
3942
const catalogue = [{ id: "owner" }, { id: "admin" }, { id: "member" }, { id: "custom-1" }];
4043

4144
it("offers the viewer's own level and below", () => {
42-
expect(offerableRoleIds(catalogue, roles, "admin")).toEqual(["admin", "member"]);
43-
expect(offerableRoleIds(catalogue, roles, "member")).toEqual(["member"]);
45+
expect(offerableRoleIds(catalogue, roles, "admin")).toEqual(["admin", "member", "custom-1"]);
46+
expect(offerableRoleIds(catalogue, roles, "member")).toEqual(["member", "custom-1"]);
4447
});
4548

4649
it("leaves roles above the viewer out entirely", () => {
4750
expect(offerableRoleIds(catalogue, roles, "admin")).not.toContain("owner");
51+
expect(offerableRoleIds(catalogue, roles, "member")).not.toContain("owner");
52+
expect(offerableRoleIds(catalogue, roles, "member")).not.toContain("admin");
53+
});
54+
55+
it("offers the whole catalogue to a viewer at the top of the ladder", () => {
56+
expect(offerableRoleIds(catalogue, roles, "owner")).toEqual([
57+
"owner",
58+
"admin",
59+
"member",
60+
"custom-1",
61+
]);
4862
});
4963

5064
it("does not filter on plan gating — a plan-locked role is still offerable", () => {
@@ -53,12 +67,35 @@ describe("offerableRoleIds", () => {
5367
expect(offerableRoleIds(catalogue, roles, "owner")).toContain("owner");
5468
});
5569

56-
it("drops custom roles, which are not on the ladder", () => {
57-
expect(offerableRoleIds(catalogue, roles, "owner")).not.toContain("custom-1");
70+
it("keeps custom roles, which the ladder can't place above anyone", () => {
71+
expect(offerableRoleIds(catalogue, roles, "owner")).toContain("custom-1");
72+
expect(offerableRoleIds(catalogue, roles, "admin")).toContain("custom-1");
73+
expect(offerableRoleIds(catalogue, roles, "member")).toContain("custom-1");
74+
});
75+
76+
it("does not narrow at all for a viewer holding a custom role", () => {
77+
// The ladder can't say what is above a role it doesn't list, so leave the
78+
// picker as it was rather than emptying it and stranding the viewer.
79+
expect(offerableRoleIds(catalogue, roles, "custom-1")).toEqual([
80+
"owner",
81+
"admin",
82+
"member",
83+
"custom-1",
84+
]);
5885
});
5986

60-
it("offers nothing to a roleless viewer or with no ladder at all", () => {
61-
expect(offerableRoleIds(catalogue, roles, null)).toEqual([]);
62-
expect(offerableRoleIds(catalogue, null, "owner")).toEqual([]);
87+
it("does not narrow at all for a roleless viewer or with no ladder", () => {
88+
expect(offerableRoleIds(catalogue, roles, null)).toEqual([
89+
"owner",
90+
"admin",
91+
"member",
92+
"custom-1",
93+
]);
94+
expect(offerableRoleIds(catalogue, null, "owner")).toEqual([
95+
"owner",
96+
"admin",
97+
"member",
98+
"custom-1",
99+
]);
63100
});
64101
});

0 commit comments

Comments
 (0)