Skip to content

Commit 36f8ee4

Browse files
committed
feat(webapp): add permission-gating primitives
Add checkPermissions(ability, checks) which maps a set of action/resource checks to a boolean record using the injected ability, so loaders can compute display-only permission flags server-side and pass them to the client. Add PermissionButton and PermissionLink wrappers that disable the underlying control and show an explanatory tooltip when a server-computed hasPermission flag is false. No permission logic ships to the client; the route builder authorization block remains the security boundary.
1 parent f48c897 commit 36f8ee4

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { forwardRef, type ReactNode } from "react";
2+
import { Button } from "./Buttons";
3+
4+
export const DEFAULT_NO_PERMISSION_TOOLTIP = "You don't have permission to do this";
5+
6+
type PermissionButtonProps = React.ComponentProps<typeof Button> & {
7+
/** Server-computed flag (see `checkPermissions`). When false the button is disabled with a tooltip. */
8+
hasPermission: boolean;
9+
noPermissionTooltip?: ReactNode;
10+
};
11+
12+
/**
13+
* A `Button` that disables itself and shows an explanatory tooltip when the
14+
* user lacks permission. Display only — the server route builder's
15+
* `authorization` block is the real gate. `Button` already renders its
16+
* `tooltip` while disabled (it wraps the disabled button in a hoverable span),
17+
* so we reuse that path.
18+
*/
19+
export const PermissionButton = forwardRef<HTMLButtonElement, PermissionButtonProps>(
20+
({ hasPermission, noPermissionTooltip, disabled, tooltip, ...props }, ref) => {
21+
if (hasPermission) {
22+
return <Button ref={ref} disabled={disabled} tooltip={tooltip} {...props} />;
23+
}
24+
25+
return (
26+
<Button
27+
ref={ref}
28+
{...props}
29+
disabled
30+
tooltip={noPermissionTooltip ?? DEFAULT_NO_PERMISSION_TOOLTIP}
31+
/>
32+
);
33+
}
34+
);
35+
36+
PermissionButton.displayName = "PermissionButton";
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { type ReactNode } from "react";
2+
import { cn } from "~/utils/cn";
3+
import { ButtonContent, type ButtonContentPropsType, LinkButton } from "./Buttons";
4+
import { SimpleTooltip } from "./Tooltip";
5+
import { DEFAULT_NO_PERMISSION_TOOLTIP } from "./PermissionButton";
6+
7+
type PermissionLinkProps = React.ComponentProps<typeof LinkButton> & {
8+
/** Server-computed flag (see `checkPermissions`). When false the link is disabled with a tooltip. */
9+
hasPermission: boolean;
10+
noPermissionTooltip?: ReactNode;
11+
};
12+
13+
/**
14+
* A `LinkButton` that disables itself and shows an explanatory tooltip when the
15+
* user lacks permission. Display only — the server route builder's
16+
* `authorization` block is the real gate. Unlike `Button`, `LinkButton` has no
17+
* tooltip support and renders a `pointer-events-none` element when disabled
18+
* (which can't be hovered), so the denied state renders a `SimpleTooltip`
19+
* around a non-interactive `ButtonContent` instead — the same pattern the team
20+
* settings page uses for its gated controls.
21+
*/
22+
export function PermissionLink({
23+
hasPermission,
24+
noPermissionTooltip,
25+
...props
26+
}: PermissionLinkProps) {
27+
if (hasPermission) {
28+
return <LinkButton {...props} />;
29+
}
30+
31+
return (
32+
<SimpleTooltip
33+
button={
34+
<ButtonContent
35+
{...(props as ButtonContentPropsType)}
36+
className={cn(props.className, "cursor-not-allowed opacity-50")}
37+
/>
38+
}
39+
content={noPermissionTooltip ?? DEFAULT_NO_PERMISSION_TOOLTIP}
40+
disableHoverableContent
41+
/>
42+
);
43+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
2+
3+
/**
4+
* A single permission check, mirroring the `authorization` option the
5+
* dashboard/api route builders accept: either a super-user check or an
6+
* action + resource(s) pair.
7+
*/
8+
export type PermissionCheck =
9+
| { requireSuper: true }
10+
| { action: string; resource: RbacResource | RbacResource[] };
11+
12+
/**
13+
* Evaluate a set of permission checks against an already-resolved `ability`
14+
* and return a plain boolean map for the client to gate UI on.
15+
*
16+
* The matching lives entirely in the injected ability — permissive by
17+
* default, and fully enforced when an RBAC plugin is installed — so this only
18+
* calls `can`/`canSuper` and no permission-model logic lives here. The
19+
* returned booleans are display-only: the route builder's `authorization`
20+
* block is the real security boundary.
21+
*/
22+
export function checkPermissions<K extends string>(
23+
ability: RbacAbility,
24+
checks: Record<K, PermissionCheck>
25+
): Record<K, boolean> {
26+
const result = {} as Record<K, boolean>;
27+
for (const key in checks) {
28+
const check = checks[key];
29+
result[key] =
30+
"requireSuper" in check ? ability.canSuper() : ability.can(check.action, check.resource);
31+
}
32+
return result;
33+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, it, expect } from "vitest";
2+
import type { RbacAbility } from "@trigger.dev/rbac";
3+
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
4+
5+
const permissive: RbacAbility = { can: () => true, canSuper: () => false };
6+
const denyAll: RbacAbility = { can: () => false, canSuper: () => false };
7+
8+
describe("checkPermissions", () => {
9+
it("returns true for every check under a permissive ability (OSS path)", () => {
10+
const result = checkPermissions(permissive, {
11+
canCancelRun: { action: "write", resource: { type: "runs" } },
12+
canManageMembers: { action: "manage", resource: { type: "members" } },
13+
});
14+
15+
expect(result).toEqual({ canCancelRun: true, canManageMembers: true });
16+
});
17+
18+
it("returns false for every check under a deny-all ability", () => {
19+
const result = checkPermissions(denyAll, {
20+
canCancelRun: { action: "write", resource: { type: "runs" } },
21+
});
22+
23+
expect(result).toEqual({ canCancelRun: false });
24+
});
25+
26+
it("evaluates each check independently against can()", () => {
27+
const ability: RbacAbility = {
28+
can: (action, resource) => {
29+
const r = Array.isArray(resource) ? resource[0] : resource;
30+
return action === "read" || r.type === "tasks";
31+
},
32+
canSuper: () => false,
33+
};
34+
35+
const result = checkPermissions(ability, {
36+
readRuns: { action: "read", resource: { type: "runs" } },
37+
writeRuns: { action: "write", resource: { type: "runs" } },
38+
writeTasks: { action: "write", resource: { type: "tasks" } },
39+
});
40+
41+
expect(result).toEqual({ readRuns: true, writeRuns: false, writeTasks: true });
42+
});
43+
44+
it("supports requireSuper checks via canSuper()", () => {
45+
const admin: RbacAbility = { can: () => false, canSuper: () => true };
46+
47+
expect(checkPermissions(admin, { adminOnly: { requireSuper: true } })).toEqual({
48+
adminOnly: true,
49+
});
50+
expect(checkPermissions(denyAll, { adminOnly: { requireSuper: true } })).toEqual({
51+
adminOnly: false,
52+
});
53+
});
54+
55+
it("passes resource arrays straight through to can()", () => {
56+
const seen: unknown[] = [];
57+
const ability: RbacAbility = {
58+
can: (_action, resource) => {
59+
seen.push(resource);
60+
return true;
61+
},
62+
canSuper: () => false,
63+
};
64+
65+
checkPermissions(ability, {
66+
x: { action: "read", resource: [{ type: "runs" }, { type: "tasks" }] },
67+
});
68+
69+
expect(seen[0]).toEqual([{ type: "runs" }, { type: "tasks" }]);
70+
});
71+
});

0 commit comments

Comments
 (0)