Skip to content

Commit 727d74c

Browse files
committed
RBAC: Teams page UI — role dropdowns, plan-aware disabling, manage gating (TRI-8748)
Wire RBAC into the existing org Teams page (settings/team). OSS plugin - Adds RoleBaseAccessController.getAssignableRoleIds(orgId) — the subset of allRoles(orgId) that can be assigned right now. Returns [] in the OSS fallback (consistent with allRoles also returning [] there). Pure UI affordance: server-side enforcement remains setUserRole's lookupAssignableRole. Public package change with patch-level changeset. Enterprise plugin - Implements getAssignableRoleIds against PlansClient: system roles pass through isSystemRoleAssignable (Owner/Admin always; Member / Viewer require Pro+); custom roles require canCreateCustomRoles (Enterprise tier). Mirrors the gates in setUserRole so UI and server agree. Webapp - TeamPresenter now also returns rbac.allRoles(orgId), getAssignableRoleIds(orgId), and per-member role assignments. Per-member is N+1 today (low-traffic settings page); a batched lookup is filed as a future optimisation. - Route migrated from requireUserId to dashboardLoader / dashboardAction via the split builder (commit a2cdbfb). Loader gates on read:members; action stays permissive at the wrapper level so the existing remove/leave + purchase-seats flows keep working with their per-intent checks. New set-role intent gates on manage:members and calls rbac.setUserRole — surfaces the Result error inline next to the dropdown when the server rejects (system role rename, plan-gated assignment, foreign-org role). - UI: native select next to each member, defaults to that member's current role. Options not in assignableRoleIds render disabled with an (upgrade) suffix. Auto-submits on change via fetcher. Invite + Remove buttons hide/disable when canManageMembers is false (server-side ability check pre-computed in the loader). Self-leave is always allowed regardless of manage:members. Verification - Typecheck clean across @internal/rbac, webapp, enterprise/plugins, enterprise/db, packages/plans. - Browser smoke test deferred until webapp dev server is running.
1 parent 4d76bd5 commit 727d74c

6 files changed

Lines changed: 328 additions & 85 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/plugins": patch
3+
---
4+
5+
RBAC plugin: new `getAssignableRoleIds(organizationId)` method on `RoleBaseAccessController`. Returns the subset of `allRoles(organizationId)` IDs that may be assigned right now — used by the Teams page UI to disable role-dropdown options outside the org's plan tier. OSS fallback returns `[]` (permissive — `allRoles` already returns `[]` so there's nothing to gate); the enterprise plugin queries its plan client and returns the plan-allowed system roles plus all custom roles. Server-side enforcement (rejecting an actual `setUserRole` to a plan-gated role) is unchanged and remains the source of truth — this method is purely a UI affordance.

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

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getTeamMembersAndInvites } from "~/models/member.server";
2+
import { rbac } from "~/services/rbac.server";
23
import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server";
34
import { BasePresenter } from "./v3/basePresenter.server";
45

@@ -13,11 +14,33 @@ export class TeamPresenter extends BasePresenter {
1314
return;
1415
}
1516

16-
const [baseLimit, currentPlan, plans] = await Promise.all([
17-
getLimit(organizationId, "teamMembers", 100_000_000),
18-
getCurrentPlan(organizationId),
19-
getPlans(),
20-
]);
17+
const [baseLimit, currentPlan, plans, roles, assignableRoleIds, memberRoles] =
18+
await Promise.all([
19+
getLimit(organizationId, "teamMembers", 100_000_000),
20+
getCurrentPlan(organizationId),
21+
getPlans(),
22+
// RBAC role catalogue (system roles + any org-defined custom roles).
23+
// OSS fallback returns []; on cloud the enterprise plugin returns
24+
// the seeded system roles plus the org's custom roles.
25+
rbac.allRoles(organizationId),
26+
// Plan-gated subset — the Teams page disables dropdown options not
27+
// in this set. Server-side enforcement is independent (setUserRole
28+
// rejects a plan-gated assignment regardless of UI state).
29+
rbac.getAssignableRoleIds(organizationId),
30+
// Per-member current role. N+1 by design: this page is rendered
31+
// for admins on a low-traffic settings screen, and the rbac plugin
32+
// doesn't currently expose a batched lookup. Switching to a single
33+
// Drizzle query keyed on (orgId, userIds[]) is a future optimisation.
34+
Promise.all(
35+
result.members.map(async (m) => ({
36+
userId: m.user.id,
37+
role: await rbac.getUserRole({
38+
userId: m.user.id,
39+
organizationId,
40+
}),
41+
}))
42+
),
43+
]);
2144

2245
const canPurchaseSeats =
2346
currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true;
@@ -38,6 +61,9 @@ export class TeamPresenter extends BasePresenter {
3861
seatPricing,
3962
maxSeatQuota,
4063
planSeatLimit,
64+
roles,
65+
assignableRoleIds,
66+
memberRoles,
4167
};
4268
}
4369
}

0 commit comments

Comments
 (0)