import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { type MetaFunction } from "@remix-run/react"; import { useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; import { Button } from "~/components/primitives/Buttons"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Header3 } from "~/components/primitives/Headers"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Table, TableBlankRow, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, } from "~/components/primitives/Table"; import { TextLink } from "~/components/primitives/TextLink"; import { useOrganization } from "~/hooks/useOrganizations"; import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { rbac } from "~/services/rbac.server"; import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; export const meta: MetaFunction = () => { return [ { title: `Roles | Trigger.dev`, }, ]; }; const Params = z.object({ organizationSlug: z.string(), }); export const loader = dashboardLoader( { params: Params, context: async (params) => { const orgId = await resolveOrgIdFromSlug(params.organizationSlug); return orgId ? { organizationId: orgId } : {}; }, authorization: { action: "read", resource: { type: "members" } }, }, async ({ context, user }) => { const orgId = context.organizationId; if (!orgId) { throw new Response("Not Found", { status: 404 }); } const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRole] = await Promise.all([ rbac.allRoles(orgId), rbac.getAssignableRoleIds(orgId), rbac.allPermissions(orgId), rbac.systemRoles(orgId), // OSS self-host has no RBAC plugin. rbac.isUsingPlugin(), rbac.getUserRole({ userId: user.id, organizationId: orgId }), ]); return typedjson({ roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRoleName: currentRole?.name ?? null, }); } ); type LoaderData = UseDataFunctionReturn; type LoaderRole = LoaderData["roles"][number]; type LoaderPermission = LoaderData["allPermissions"][number]; type RolePermission = LoaderRole["permissions"][number]; // Ungrouped permissions fall into "Other". const FALLBACK_GROUP = "Other"; export default function Page() { const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRoleName } = useTypedLoaderData(); const organization = useOrganization(); const showSelfServe = useShowSelfServe(); const rolesById = new Map(roles.map((r) => [r.id, r])); const assignable = new Set(assignableRoleIds); // System roles first (plugin order), then custom roles. const systemRoleOrder = systemRoles ?? []; const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id)); const systemColumns = systemRoleOrder.flatMap((meta) => { const role = rolesById.get(meta.id); return role ? [{ role, fallbackName: meta.name }] : []; }); const customColumns = roles .filter((r) => !systemRoleIdSet.has(r.id)) .map((role) => ({ role, fallbackName: role.name })); const columns = [...systemColumns, ...customColumns]; const grouped = groupPermissions(allPermissions); return ( {/* Hide on OSS self-host and managed customers (!showSelfServe). */} {isUsingPlugin && showSelfServe ? : null}
Roles control what each team member can do in {organization.title}. Compare what each role grants below; assign a role to a team member from the{" "} Team page. {currentRoleName ? ( Your role is {currentRoleName}. ) : null}
{columns.length === 0 ? ( ) : ( Permission {columns.map(({ role }) => (
{role.name}
))} Description
{grouped.length === 0 ? ( No permissions to display. ) : ( grouped.flatMap(({ group, permissions }) => [ {group} , ...permissions.map((permission) => ( {permission.name} {columns.map(({ role }) => ( ))} {permission.description || ( )} )), ]) )}
)}
); } function EmptyState({ isUsingPlugin, showSelfServe, }: { isUsingPlugin: boolean; showSelfServe: boolean; }) { // OSS self-host vs plan-gated empty state. if (!isUsingPlugin) { return (
Roles aren't available in this self-hosted deployment. All members have full access. Role-Based Access Controls are available in Trigger.dev Cloud or with an enterprise self-hosted license.
); } return (
No roles available on this plan. {showSelfServe ? "Upgrade to Pro to unlock RBAC." : "Contact us to discuss RBAC for your organization."} {!showSelfServe ? ( Contact us} /> ) : null}
); } function PlanBadge({ roleId, assignable, systemRoleIdSet, }: { roleId: string; assignable: ReadonlySet; systemRoleIdSet: ReadonlySet; }) { if (assignable.has(roleId)) return null; // Unassignable system roles → Pro; custom roles → Enterprise. if (systemRoleIdSet.has(roleId)) { return Pro; } return Enterprise; } function RoleCell({ permissionName, rolePermissions, }: { permissionName: string; rolePermissions: RolePermission[]; }) { const matching = rolePermissions.filter((p) => p.name === permissionName); if (matching.length === 0) { return ( ); } const allowed = matching.filter((p) => !p.inverted); const denied = matching.filter((p) => p.inverted); if (allowed.length === 0) { return ( ); } const conditionalDeny = denied.find((p) => p.conditions); if (conditionalDeny?.conditions) { const allowedEnvTypes = allowedEnvTypesFromDeny(conditionalDeny.conditions); if (allowedEnvTypes) { // Conditional grant: show the environments the permission is allowed in. return (
{allowedEnvTypes.map((type) => ( ))}
); } // Conditions we can't map to environments fall back to a text label. return ( {conditionLabel(conditionalDeny.conditions)} ); } return ( ); } const ENV_TYPES = ["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"] as const; type EnvType = (typeof ENV_TYPES)[number]; // A conditional `cannot` rule denies the permission where the resource matches // its condition, so the permission stays allowed everywhere else. Translate the // envType condition into the set of environments where it's still allowed, or // null when we can't interpret it (caller falls back to a text label). function allowedEnvTypesFromDeny(conditions: Record): EnvType[] | null { const envType = conditions.envType; // Equality, e.g. { envType: "PRODUCTION" } → denied in prod, allowed elsewhere. if (typeof envType === "string") { return ENV_TYPES.includes(envType as EnvType) ? ENV_TYPES.filter((t) => t !== envType) : null; } // Negation, e.g. { envType: { $ne: "DEVELOPMENT" } } → denied everywhere except // DEVELOPMENT, so allowed only in DEVELOPMENT. if (envType && typeof envType === "object" && "$ne" in envType) { const ne = (envType as { $ne: unknown }).$ne; return typeof ne === "string" && ENV_TYPES.includes(ne as EnvType) ? [ne as EnvType] : null; } return null; } // Only `envType` is supported today. function conditionLabel(conditions: Record): string { if (typeof conditions.envType === "string") { if (conditions.envType === "PRODUCTION") return "Non-prod only"; return `Non-${conditions.envType.toLowerCase()} only`; } return JSON.stringify(conditions); } function groupPermissions( permissions: LoaderPermission[] ): { group: string; permissions: LoaderPermission[] }[] { const buckets = new Map(); for (const permission of permissions) { const group = permission.group ?? FALLBACK_GROUP; const list = buckets.get(group) ?? []; list.push(permission); buckets.set(group, list); } return Array.from(buckets, ([group, permissions]) => ({ group, permissions })); } function RequestCustomRoles() { const [open, setOpen] = useState(false); return ( Custom roles are an Enterprise feature
Define your own roles with bespoke permission sets — perfect for "Member, but no production deploys" or a vendor/contractor role. Available on the Enterprise plan. Get in touch and we'll walk you through the Enterprise plan and how custom roles fit your team.
); }