Files
2026-07-13 13:32:57 +08:00

393 lines
14 KiB
TypeScript

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<typeof loader>;
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<typeof loader>();
const organization = useOrganization();
const showSelfServe = useShowSelfServe();
const rolesById = new Map<string, LoaderRole>(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 (
<PageContainer>
<NavBar>
<PageTitle title="Roles" />
{/* Hide on OSS self-host and managed customers (!showSelfServe). */}
{isUsingPlugin && showSelfServe ? <RequestCustomRoles /> : null}
</NavBar>
<PageBody scrollable={false}>
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="border-b border-grid-bright px-4 py-6">
<Paragraph variant="small">
Roles control what each team member can do in <strong>{organization.title}</strong>.
Compare what each role grants below; assign a role to a team member from the{" "}
<TextLink to={`/orgs/${organization.slug}/settings/team`}>Team page</TextLink>.
</Paragraph>
{currentRoleName ? (
<Paragraph variant="small" className="mt-2">
Your role is <strong className="text-text-bright">{currentRoleName}</strong>.
</Paragraph>
) : null}
</div>
<div className="min-h-0 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{columns.length === 0 ? (
<EmptyState isUsingPlugin={isUsingPlugin} showSelfServe={showSelfServe} />
) : (
<Table stickyHeader containerClassName="border-t-0">
<TableHeader>
<TableRow>
<TableHeaderCell>Permission</TableHeaderCell>
{columns.map(({ role }) => (
<TableHeaderCell key={role.id}>
<div className="flex items-center gap-1">
<span>{role.name}</span>
<PlanBadge
roleId={role.id}
assignable={assignable}
systemRoleIdSet={systemRoleIdSet}
/>
</div>
</TableHeaderCell>
))}
<TableHeaderCell>Description</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{grouped.length === 0 ? (
<TableBlankRow colSpan={columns.length + 2}>
<Paragraph variant="small" className="text-text-dimmed">
No permissions to display.
</Paragraph>
</TableBlankRow>
) : (
grouped.flatMap(({ group, permissions }) => [
<TableRow key={`${group}-header`}>
<TableCell colSpan={columns.length + 2} className="bg-background-bright">
<Header3 className="text-xs uppercase tracking-wide text-text-dimmed">
{group}
</Header3>
</TableCell>
</TableRow>,
...permissions.map((permission) => (
<TableRow key={permission.name}>
<TableCell>
<code className="text-xs">{permission.name}</code>
</TableCell>
{columns.map(({ role }) => (
<TableCell key={role.id}>
<RoleCell
permissionName={permission.name}
rolePermissions={role.permissions}
/>
</TableCell>
))}
<TableCell>
<Paragraph variant="small">
{permission.description || (
<span className="text-text-dimmed"></span>
)}
</Paragraph>
</TableCell>
</TableRow>
)),
])
)}
</TableBody>
</Table>
)}
</div>
</div>
</PageBody>
</PageContainer>
);
}
function EmptyState({
isUsingPlugin,
showSelfServe,
}: {
isUsingPlugin: boolean;
showSelfServe: boolean;
}) {
// OSS self-host vs plan-gated empty state.
if (!isUsingPlugin) {
return (
<div className="flex flex-col items-center gap-2 p-8 text-center">
<Header3>Roles aren't available in this self-hosted deployment.</Header3>
<Paragraph variant="small" className="text-text-dimmed">
All members have full access. Role-Based Access Controls are available in Trigger.dev
Cloud or with an enterprise self-hosted license.
</Paragraph>
</div>
);
}
return (
<div className="flex flex-col items-center gap-2 p-8 text-center">
<Header3>No roles available on this plan.</Header3>
<Paragraph variant="small" className="text-text-dimmed">
{showSelfServe
? "Upgrade to Pro to unlock RBAC."
: "Contact us to discuss RBAC for your organization."}
</Paragraph>
{!showSelfServe ? (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Contact us</Button>}
/>
) : null}
</div>
);
}
function PlanBadge({
roleId,
assignable,
systemRoleIdSet,
}: {
roleId: string;
assignable: ReadonlySet<string>;
systemRoleIdSet: ReadonlySet<string>;
}) {
if (assignable.has(roleId)) return null;
// Unassignable system roles → Pro; custom roles → Enterprise.
if (systemRoleIdSet.has(roleId)) {
return <Badge variant="extra-small">Pro</Badge>;
}
return <Badge variant="extra-small">Enterprise</Badge>;
}
function RoleCell({
permissionName,
rolePermissions,
}: {
permissionName: string;
rolePermissions: RolePermission[];
}) {
const matching = rolePermissions.filter((p) => p.name === permissionName);
if (matching.length === 0) {
return (
<span className="text-text-dimmed" aria-label="Not granted">
<XMarkIcon className="size-4" />
</span>
);
}
const allowed = matching.filter((p) => !p.inverted);
const denied = matching.filter((p) => p.inverted);
if (allowed.length === 0) {
return (
<span className="text-error" aria-label="Denied">
<XMarkIcon className="size-4" />
</span>
);
}
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 (
<div className="flex flex-col items-start gap-1">
{allowedEnvTypes.map((type) => (
<EnvironmentCombo key={type} environment={{ type }} className="text-xs" />
))}
</div>
);
}
// Conditions we can't map to environments fall back to a text label.
return (
<span className="text-xs text-text-dimmed">{conditionLabel(conditionalDeny.conditions)}</span>
);
}
return (
<span className="text-success" aria-label="Allowed">
<CheckIcon className="size-4" />
</span>
);
}
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<string, unknown>): 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, unknown>): 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<string, LoaderPermission[]>();
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary/small">Create role</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Custom roles are an Enterprise feature</DialogHeader>
<div className="flex flex-col gap-3 pt-2">
<Paragraph>
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.
</Paragraph>
<Paragraph variant="small" className="text-text-dimmed">
Get in touch and we'll walk you through the Enterprise plan and how custom roles fit
your team.
</Paragraph>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="secondary/medium" onClick={() => setOpen(false)}>
Maybe later
</Button>
<Button
variant="primary/medium"
onClick={() => {
window.open("https://trigger.dev/contact", "_blank");
setOpen(false);
}}
>
Contact us
</Button>
</div>
</DialogContent>
</Dialog>
);
}