Skip to content

Commit a29e8b7

Browse files
committed
RBAC: Roles page (TRI-8880)
Adds a Settings → Roles page that lists every role visible to the org, with each role's permissions rendered in a Table grouped by category (Runs, Tasks, Waitpoints, Realtime, Deployments, Prompts, Query, Tokens, Organisation, Wildcards). Each permission shows its name and description. Page surfaces: - Role name + description + System/Custom badge - "Not on this plan" badge for roles outside the current plan tier (system roles gated by PlansClient.isSystemRoleAssignable) - "Create role" button: - Free / Hobby / Pro: opens an "Upgrade to Enterprise" dialog with a Contact us CTA (deep-links to trigger.dev/contact) - Enterprise: hidden — the create-role UI is a follow-up after TRI-8747's controller-level CRUD already in place Plumbing: - apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles - organizationRolesPath helper in pathBuilder - Roles SideMenuItem next to Team in OrganizationSettingsSideMenu - "View all role permissions →" link on the Teams page next to the Active team members section so an Owner about to assign a role can audit the choice
1 parent 46c35c4 commit a29e8b7

4 files changed

Lines changed: 352 additions & 1 deletion

File tree

apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Cog8ToothIcon,
55
CreditCardIcon,
66
LockClosedIcon,
7+
ShieldCheckIcon,
78
UserGroupIcon,
89
} from "@heroicons/react/20/solid";
910
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
@@ -14,6 +15,7 @@ import { useFeatures } from "~/hooks/useFeatures";
1415
import { type MatchedOrganization } from "~/hooks/useOrganizations";
1516
import { cn } from "~/utils/cn";
1617
import {
18+
organizationRolesPath,
1719
organizationSettingsPath,
1820
organizationSlackIntegrationPath,
1921
organizationTeamPath,
@@ -128,6 +130,14 @@ export function OrganizationSettingsSideMenu({
128130
to={organizationTeamPath(organization)}
129131
data-action="team"
130132
/>
133+
<SideMenuItem
134+
name="Roles"
135+
icon={ShieldCheckIcon}
136+
activeIconColor="text-sky-500"
137+
inactiveIconColor="text-sky-500"
138+
to={organizationRolesPath(organization)}
139+
data-action="roles"
140+
/>
131141
<SideMenuItem
132142
name="Settings"
133143
icon={Cog8ToothIcon}
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
2+
import { type MetaFunction } from "@remix-run/react";
3+
import { useState } from "react";
4+
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
5+
import { z } from "zod";
6+
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
7+
import { Badge } from "~/components/primitives/Badge";
8+
import { Button } from "~/components/primitives/Buttons";
9+
import {
10+
Dialog,
11+
DialogContent,
12+
DialogHeader,
13+
DialogTrigger,
14+
} from "~/components/primitives/Dialog";
15+
import { Header2, Header3 } from "~/components/primitives/Headers";
16+
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
17+
import { Paragraph } from "~/components/primitives/Paragraph";
18+
import {
19+
Table,
20+
TableBlankRow,
21+
TableBody,
22+
TableCell,
23+
TableHeader,
24+
TableHeaderCell,
25+
TableRow,
26+
} from "~/components/primitives/Table";
27+
import { $replica } from "~/db.server";
28+
import { useOrganization } from "~/hooks/useOrganizations";
29+
import { rbac } from "~/services/rbac.server";
30+
import {
31+
dashboardLoader,
32+
} from "~/services/routeBuilders/dashboardBuilder";
33+
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
34+
35+
export const meta: MetaFunction = () => {
36+
return [
37+
{
38+
title: `Roles | Trigger.dev`,
39+
},
40+
];
41+
};
42+
43+
const Params = z.object({
44+
organizationSlug: z.string(),
45+
});
46+
47+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
48+
const org = await $replica.organization.findFirst({
49+
where: { slug },
50+
select: { id: true },
51+
});
52+
return org?.id ?? null;
53+
}
54+
55+
export const loader = dashboardLoader(
56+
{
57+
params: Params,
58+
context: async (params) => {
59+
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
60+
return orgId ? { organizationId: orgId } : {};
61+
},
62+
// Read-only page; same gating as the Teams page.
63+
authorization: { action: "read", resource: { type: "members" } },
64+
},
65+
async ({ params }) => {
66+
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
67+
if (!orgId) {
68+
throw new Response("Not Found", { status: 404 });
69+
}
70+
71+
const [roles, assignableRoleIds] = await Promise.all([
72+
rbac.allRoles(orgId),
73+
rbac.getAssignableRoleIds(orgId),
74+
]);
75+
76+
return typedjson({ roles, assignableRoleIds });
77+
}
78+
);
79+
80+
export default function Page() {
81+
const { roles, assignableRoleIds } = useTypedLoaderData<typeof loader>();
82+
const organization = useOrganization();
83+
const plan = useCurrentPlan();
84+
const planCode = plan?.v3Subscription?.plan?.code;
85+
const isEnterprise = planCode === "enterprise";
86+
87+
const assignable = new Set(assignableRoleIds);
88+
89+
return (
90+
<PageContainer>
91+
<NavBar>
92+
<PageTitle title="Roles" />
93+
{!isEnterprise ? <CreateRoleUpsell /> : null}
94+
</NavBar>
95+
<PageBody scrollable={false}>
96+
<div className="grid max-h-full min-h-full grid-rows-[1fr_auto]">
97+
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
98+
<div className="mx-auto max-w-3xl px-4 pb-8 pt-20">
99+
<Paragraph spacing>
100+
Roles control what each team member can do in{" "}
101+
<strong>{organization.title}</strong>. Each role bundles a set of
102+
permissions; assign a role to a team member from the{" "}
103+
<a
104+
className="text-text-link hover:underline"
105+
href={`/orgs/${organization.slug}/settings/team`}
106+
>
107+
Team page
108+
</a>
109+
.
110+
</Paragraph>
111+
112+
{roles.length === 0 ? (
113+
<EmptyState />
114+
) : (
115+
<div className="mt-6 flex flex-col gap-8">
116+
{roles.map((role) => (
117+
<RoleCard
118+
key={role.id}
119+
role={role}
120+
isAssignable={assignable.has(role.id)}
121+
/>
122+
))}
123+
</div>
124+
)}
125+
</div>
126+
</div>
127+
</div>
128+
</PageBody>
129+
</PageContainer>
130+
);
131+
}
132+
133+
function EmptyState() {
134+
return (
135+
<div className="mt-6 flex flex-col items-center gap-2 rounded-md border border-dashed border-grid-bright p-8 text-center">
136+
<ShieldCheckIcon className="size-8 text-text-dimmed" />
137+
<Header3>No roles available on this plan.</Header3>
138+
<Paragraph variant="small" className="text-text-dimmed">
139+
Upgrade to Pro to unlock RBAC and additional system roles.
140+
</Paragraph>
141+
</div>
142+
);
143+
}
144+
145+
type LoaderRole = UseDataFunctionReturn<typeof loader>["roles"][number];
146+
type LoaderPermission = LoaderRole["permissions"][number];
147+
148+
function RoleCard({
149+
role,
150+
isAssignable,
151+
}: {
152+
role: LoaderRole;
153+
isAssignable: boolean;
154+
}) {
155+
// Group permissions by their description metadata's `group`. The
156+
// controller populates `description` from PERMISSION_METADATA at the
157+
// boundary, but the wire type doesn't carry the group, so we infer
158+
// groups from the permission name's prefix as a fallback.
159+
const grouped = groupPermissions(role.permissions);
160+
161+
return (
162+
<div className="flex flex-col gap-3">
163+
<div className="flex items-baseline gap-3">
164+
<Header2>{role.name}</Header2>
165+
{role.isSystem ? (
166+
<Badge variant="extra-small">System role</Badge>
167+
) : (
168+
<Badge variant="extra-small">Custom role</Badge>
169+
)}
170+
{!isAssignable ? (
171+
<Badge variant="extra-small">Not on this plan</Badge>
172+
) : null}
173+
</div>
174+
{role.description ? (
175+
<Paragraph variant="small" className="text-text-dimmed">
176+
{role.description}
177+
</Paragraph>
178+
) : null}
179+
<Table containerClassName="border-t-0">
180+
<TableHeader>
181+
<TableRow>
182+
<TableHeaderCell>Permission</TableHeaderCell>
183+
<TableHeaderCell>Description</TableHeaderCell>
184+
</TableRow>
185+
</TableHeader>
186+
<TableBody>
187+
{role.permissions.length === 0 ? (
188+
<TableBlankRow colSpan={2}>
189+
<Paragraph variant="small" className="text-text-dimmed">
190+
This role has no permissions assigned.
191+
</Paragraph>
192+
</TableBlankRow>
193+
) : (
194+
grouped.flatMap(({ group, permissions }) => [
195+
<TableRow key={`${group}-header`}>
196+
<TableCell colSpan={2} className="bg-charcoal-800">
197+
<Header3 className="text-xs uppercase tracking-wide text-text-dimmed">
198+
{group}
199+
</Header3>
200+
</TableCell>
201+
</TableRow>,
202+
...permissions.map((permission) => (
203+
<TableRow key={`${role.id}-${permission.name}`}>
204+
<TableCell>
205+
<code className="text-xs">{permission.name}</code>
206+
</TableCell>
207+
<TableCell>
208+
<Paragraph variant="small">
209+
{permission.description || (
210+
<span className="text-text-dimmed"></span>
211+
)}
212+
</Paragraph>
213+
</TableCell>
214+
</TableRow>
215+
)),
216+
])
217+
)}
218+
</TableBody>
219+
</Table>
220+
</div>
221+
);
222+
}
223+
224+
// Permission name-prefix → display group. Mirrors PERMISSION_METADATA's
225+
// groupings server-side (cloud/enterprise/plugins/src/rbac/permissions.ts)
226+
// but lives client-side because the wire-format Permission only carries
227+
// `name` and `description`. Keep in lockstep.
228+
const PERMISSION_GROUP_BY_NAME: Record<string, string> = {
229+
"read:runs": "Runs",
230+
"write:runs": "Runs",
231+
"read:tags": "Runs",
232+
"read:batch": "Runs",
233+
"write:batch": "Runs",
234+
"read:tasks": "Tasks",
235+
"write:tasks": "Tasks",
236+
"trigger:tasks": "Tasks",
237+
"batchTrigger:tasks": "Tasks",
238+
"read:waitpoints": "Waitpoints",
239+
"write:waitpoints": "Waitpoints",
240+
"read:inputStreams": "Realtime",
241+
"write:inputStreams": "Realtime",
242+
"read:deployments": "Deployments",
243+
"read:prompts": "Prompts",
244+
"write:prompts": "Prompts",
245+
"update:prompts": "Prompts",
246+
"read:query": "Query",
247+
"read:tokens": "Tokens",
248+
"write:tokens": "Tokens",
249+
"read:members": "Organisation",
250+
"manage:members": "Organisation",
251+
"manage:billing": "Organisation",
252+
"read:all": "Wildcards",
253+
"write:all": "Wildcards",
254+
admin: "Wildcards",
255+
};
256+
257+
const GROUP_ORDER = [
258+
"Wildcards",
259+
"Runs",
260+
"Tasks",
261+
"Waitpoints",
262+
"Realtime",
263+
"Deployments",
264+
"Prompts",
265+
"Query",
266+
"Tokens",
267+
"Organisation",
268+
"Other",
269+
];
270+
271+
function groupPermissions(
272+
permissions: LoaderPermission[]
273+
): { group: string; permissions: LoaderPermission[] }[] {
274+
const buckets = new Map<string, LoaderPermission[]>();
275+
for (const permission of permissions) {
276+
const group = PERMISSION_GROUP_BY_NAME[permission.name] ?? "Other";
277+
const list = buckets.get(group) ?? [];
278+
list.push(permission);
279+
buckets.set(group, list);
280+
}
281+
return GROUP_ORDER.flatMap((group) =>
282+
buckets.has(group) ? [{ group, permissions: buckets.get(group)! }] : []
283+
);
284+
}
285+
286+
// "Create role" upsell shown to non-Enterprise plans. Enterprise sees
287+
// nothing here for now — the actual create-role UI is a follow-up
288+
// (depends on TRI-8747's controller-level CRUD already in place).
289+
function CreateRoleUpsell() {
290+
const [open, setOpen] = useState(false);
291+
return (
292+
<Dialog open={open} onOpenChange={setOpen}>
293+
<DialogTrigger asChild>
294+
<Button variant="primary/small">Create role</Button>
295+
</DialogTrigger>
296+
<DialogContent>
297+
<DialogHeader>Custom roles are an Enterprise feature</DialogHeader>
298+
<div className="flex flex-col gap-3 pt-2">
299+
<Paragraph>
300+
Define your own roles with bespoke permission sets — perfect for
301+
"Member, but no production deploys" or a vendor/contractor role.
302+
Available on the Enterprise plan.
303+
</Paragraph>
304+
<Paragraph variant="small" className="text-text-dimmed">
305+
Get in touch and we'll walk you through the Enterprise plan and how
306+
custom roles fit your team.
307+
</Paragraph>
308+
</div>
309+
<div className="mt-6 flex justify-end gap-2">
310+
<Button variant="secondary/medium" onClick={() => setOpen(false)}>
311+
Maybe later
312+
</Button>
313+
<Button
314+
variant="primary/medium"
315+
onClick={() => {
316+
window.open("https://trigger.dev/contact", "_blank");
317+
setOpen(false);
318+
}}
319+
>
320+
Contact us
321+
</Button>
322+
</div>
323+
</DialogContent>
324+
</Dialog>
325+
);
326+
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
} from "~/services/routeBuilders/dashboardBuilder";
5959
import {
6060
inviteTeamMemberPath,
61+
organizationRolesPath,
6162
organizationTeamPath,
6263
resendInvitePath,
6364
revokeInvitePath,
@@ -384,7 +385,17 @@ export default function Page() {
384385
</ul>
385386
</>
386387
)}
387-
<Header2>Active team members</Header2>
388+
<div className="mt-4 flex items-baseline justify-between">
389+
<Header2>Active team members</Header2>
390+
{roles.length > 0 ? (
391+
<a
392+
className="text-xs text-text-link hover:underline"
393+
href={organizationRolesPath(organization)}
394+
>
395+
View all role permissions →
396+
</a>
397+
) : null}
398+
</div>
388399
<ul className="divide-ui-border mb-8 mt-3 flex w-full flex-col divide-y border-y border-grid-bright">
389400
{members.map((member) => (
390401
<li key={member.user.id} className="flex items-center gap-x-4 py-4">

apps/webapp/app/utils/pathBuilder.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ export function organizationTeamPath(organization: OrgForPath) {
114114
return `${organizationPath(organization)}/settings/team`;
115115
}
116116

117+
export function organizationRolesPath(organization: OrgForPath) {
118+
return `${organizationPath(organization)}/settings/roles`;
119+
}
120+
117121
export function inviteTeamMemberPath(organization: OrgForPath) {
118122
return `${organizationPath(organization)}/invite`;
119123
}

0 commit comments

Comments
 (0)