Skip to content

Commit bd84ee1

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix: enforce dashboard mutation permissions
Enforce the existing dashboard permissions on queue and environment mutations, prompt detail reads, idempotency key resets, paid add-on purchases, and organization changes. Controls you cannot use are disabled with an explanation rather than failing on submit, and allocation and quota-increase requests stay available. Billing notices now render from a fixed set of known message keys instead of caller-supplied copy, and the schedules add-on returns stable error messages. Permission checks scoped to a project now pass that project through, so a project-level role override applies wherever one is configured. Branch archival now respects the configured permissions for dashboard sessions and personal access tokens, with disabled controls explaining denied access. Existing API-key scope requirements are unchanged. Mono-RevId: 5a7c999c836e2b7d9a72f17f3ca48fefe5d46e6d
1 parent ff05824 commit bd84ee1

28 files changed

Lines changed: 947 additions & 374 deletions

File tree

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,7 @@ export function EnvironmentSelector({
184184
<div className="p-1">
185185
<PopoverMenuItem
186186
key="staging"
187-
to={v3BillingPath(
188-
organization,
189-
"Upgrade to unlock a Staging environment for your projects."
190-
)}
187+
to={v3BillingPath(organization, "stagingEnvironment")}
191188
title={
192189
<div className="flex w-full items-center justify-between">
193190
<EnvironmentCombo
@@ -202,10 +199,7 @@ export function EnvironmentSelector({
202199
/>
203200
<PopoverMenuItem
204201
key="preview"
205-
to={v3BillingPath(
206-
organization,
207-
"Upgrade to unlock Preview environments for your projects."
208-
)}
202+
to={v3BillingPath(organization, "previewEnvironments")}
209203
title={
210204
<div className="flex w-full items-center justify-between">
211205
<EnvironmentCombo

apps/webapp/app/components/queues/QueueControls.tsx

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function QueuePauseResumeButton({
3434
showTooltip = true,
3535
iconOnly = false,
3636
withQueueName = false,
37+
disabled = false,
3738
}: {
3839
/** The "id" here is a friendlyId */
3940
queue: { id: string; name: string; paused: boolean };
@@ -45,13 +46,16 @@ export function QueuePauseResumeButton({
4546
iconOnly?: boolean;
4647
/** Render the full "Pause/Resume {name} queue" label instead of the short "Pause"/"Resume". */
4748
withQueueName?: boolean;
49+
disabled?: boolean;
4850
}) {
4951
const [isOpen, setIsOpen] = useState(false);
5052

5153
const label = queue.paused
5254
? `Resumes the "${queue.name}" queue so its runs can be dequeued again.`
5355
: `Pauses all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`;
5456

57+
const tooltip = disabled ? "You don't have permission to manage queues" : label;
58+
5559
const trigger = showTooltip ? (
5660
<div>
5761
<TooltipProvider disableHoverableContent={true}>
@@ -80,6 +84,7 @@ export function QueuePauseResumeButton({
8084
fullWidth={fullWidth}
8185
textAlignLeft={fullWidth}
8286
aria-label={label}
87+
disabled={disabled}
8388
>
8489
{iconOnly
8590
? undefined
@@ -95,7 +100,7 @@ export function QueuePauseResumeButton({
95100
</div>
96101
</TooltipTrigger>
97102
<TooltipContent side="right" className={"text-xs"}>
98-
{label}
103+
{tooltip}
99104
</TooltipContent>
100105
</Tooltip>
101106
</TooltipProvider>
@@ -105,7 +110,14 @@ export function QueuePauseResumeButton({
105110
<PopoverMenuItem
106111
icon={queue.paused ? PlayIcon : PauseIcon}
107112
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
108-
title={queue.paused ? "Resume..." : "Pause..."}
113+
title={
114+
disabled
115+
? "You don't have permission to manage queues"
116+
: queue.paused
117+
? "Resume..."
118+
: "Pause..."
119+
}
120+
disabled={disabled}
109121
/>
110122
</DialogTrigger>
111123
);
@@ -158,13 +170,15 @@ export function QueueOverrideConcurrencyButton({
158170
queue,
159171
environmentConcurrencyLimit,
160172
trigger,
173+
disabled = false,
161174
}: {
162175
queue: QueueItem & { concurrencyLimitOverridePercent: number | null };
163176
environmentConcurrencyLimit: number;
164177
/** How to render the dialog trigger. "menu-item" (default) is a PopoverMenuItem for row menus;
165178
* "button" is a standalone labeled button; "icon" is an icon-only button with the label in a
166179
* hover tooltip, for compact placements like the detail-page live blocks. */
167180
trigger?: "menu-item" | "button" | "icon";
181+
disabled?: boolean;
168182
}) {
169183
const navigation = useNavigation();
170184
const [isOpen, setIsOpen] = useState(false);
@@ -208,7 +222,9 @@ export function QueueOverrideConcurrencyButton({
208222
const limitOverCap = Number.isFinite(limitNumber) && limitNumber > environmentConcurrencyLimit;
209223

210224
const submitDisabled =
211-
isLoading || (mode === "percent" ? !percentValid : !concurrencyLimit || limitOverCap);
225+
disabled ||
226+
isLoading ||
227+
(mode === "percent" ? !percentValid : !concurrencyLimit || limitOverCap);
212228

213229
const iconLabel = isOverridden ? "Edit override" : "Override limit";
214230

@@ -226,12 +242,13 @@ export function QueueOverrideConcurrencyButton({
226242
LeadingIcon={AdjustmentsHorizontalIcon}
227243
leadingIconClassName="text-text-dimmed"
228244
aria-label={iconLabel}
245+
disabled={disabled}
229246
/>
230247
</DialogTrigger>
231248
</div>
232249
</TooltipTrigger>
233250
<TooltipContent side="right" className="text-xs">
234-
{iconLabel}
251+
{disabled ? "You don't have permission to manage queues" : iconLabel}
235252
</TooltipContent>
236253
</Tooltip>
237254
</TooltipProvider>
@@ -249,23 +266,32 @@ export function QueueOverrideConcurrencyButton({
249266
aria-label={
250267
isOverridden ? "Edit concurrency override" : "Override concurrency limit"
251268
}
269+
disabled={disabled}
252270
>
253271
{isOverridden ? "Edit override" : "Override limit"}
254272
</Button>
255273
</DialogTrigger>
256274
</div>
257275
</TooltipTrigger>
258276
<TooltipContent side="bottom" className="max-w-[230px] text-xs">
259-
Give this queue its own concurrency limit instead of the environment default. Set it
260-
as a number or a percentage of the environment limit.
277+
{disabled
278+
? "You don't have permission to manage queues"
279+
: "Give this queue its own concurrency limit instead of the environment default. Set it as a number or a percentage of the environment limit."}
261280
</TooltipContent>
262281
</Tooltip>
263282
</TooltipProvider>
264283
) : (
265284
<DialogTrigger asChild>
266285
<PopoverMenuItem
267286
icon={AdjustmentsHorizontalIcon}
268-
title={isOverridden ? "Edit override…" : "Override limit…"}
287+
title={
288+
disabled
289+
? "You don't have permission to manage queues"
290+
: isOverridden
291+
? "Edit override…"
292+
: "Override limit…"
293+
}
294+
disabled={disabled}
269295
/>
270296
</DialogTrigger>
271297
)}
@@ -382,7 +408,7 @@ export function QueueOverrideConcurrencyButton({
382408
type="submit"
383409
name="action"
384410
value="queue-remove-override"
385-
disabled={isLoading}
411+
disabled={disabled || isLoading}
386412
variant="danger/medium"
387413
>
388414
Remove override

apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type Props = {
3434
usedSchedules: number;
3535
maxQuota: number;
3636
planScheduleLimit: number;
37+
canManageBilling: boolean;
3738
triggerButton?: ReactNode;
3839
};
3940

@@ -44,6 +45,7 @@ export function PurchaseSchedulesModal({
4445
usedSchedules,
4546
maxQuota,
4647
planScheduleLimit,
48+
canManageBilling,
4749
triggerButton,
4850
}: Props) {
4951
const showSelfServe = useShowSelfServe();
@@ -256,7 +258,10 @@ export function PurchaseSchedulesModal({
256258
<Button
257259
variant="danger/medium"
258260
type="submit"
259-
disabled={isLoading || state === "need_to_delete"}
261+
disabled={!canManageBilling || isLoading || state === "need_to_delete"}
262+
tooltip={
263+
canManageBilling ? undefined : "You don't have permission to manage billing"
264+
}
260265
LeadingIcon={isLoading ? SpinnerWhite : undefined}
261266
>
262267
<span className="tabular-nums">{`Remove ${formatNumber(
@@ -270,7 +275,10 @@ export function PurchaseSchedulesModal({
270275
<Button
271276
variant="primary/medium"
272277
type="submit"
273-
disabled={isLoading || state === "no_change"}
278+
disabled={!canManageBilling || isLoading || state === "no_change"}
279+
tooltip={
280+
canManageBilling ? undefined : "You don't have permission to manage billing"
281+
}
274282
LeadingIcon={isLoading ? SpinnerWhite : undefined}
275283
>
276284
<span className="tabular-nums">{`Purchase ${formatNumber(

apps/webapp/app/components/schedules/ScheduleLimitActions.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PurchaseSchedulesModal, type SchedulePricing } from "./PurchaseSchedule
99
type Props = {
1010
actionPath: string;
1111
canPurchaseSchedules: boolean;
12+
canManageBilling: boolean;
1213
schedulePricing: SchedulePricing | null;
1314
extraSchedules: number;
1415
limits: { used: number; limit: number };
@@ -22,6 +23,7 @@ type Props = {
2223
export function ScheduleLimitActions({
2324
actionPath,
2425
canPurchaseSchedules,
26+
canManageBilling,
2527
schedulePricing,
2628
extraSchedules,
2729
limits,
@@ -51,6 +53,7 @@ export function ScheduleLimitActions({
5153
usedSchedules={limits.used}
5254
maxQuota={maxScheduleQuota}
5355
planScheduleLimit={planScheduleLimit}
56+
canManageBilling={canManageBilling}
5457
triggerButton={
5558
variant === "dialog" ? <Button variant="primary/small">Purchase more…</Button> : undefined
5659
}

apps/webapp/app/components/schedules/SchedulesUsageBar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type Props = {
1212
/** True when the plan would let them upgrade (vs being already on the highest plan). */
1313
canUpgrade: boolean;
1414
canPurchaseSchedules: boolean;
15+
canManageBilling: boolean;
1516
extraSchedules: number;
1617
maxScheduleQuota: number;
1718
planScheduleLimit: number;
@@ -23,6 +24,7 @@ export function SchedulesUsageBar({
2324
requiresUpgrade,
2425
canUpgrade,
2526
canPurchaseSchedules,
27+
canManageBilling,
2628
extraSchedules,
2729
maxScheduleQuota,
2830
planScheduleLimit,
@@ -79,6 +81,7 @@ export function SchedulesUsageBar({
7981
<ScheduleLimitActions
8082
actionPath={actionPath}
8183
canPurchaseSchedules={canPurchaseSchedules}
84+
canManageBilling={canManageBilling}
8285
schedulePricing={schedulePricing}
8386
extraSchedules={extraSchedules}
8487
limits={limits}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ArrowUpCircleIcon, CheckIcon, EnvelopeIcon, PlusIcon } from "@heroicons
44
import { BookOpenIcon } from "@heroicons/react/24/solid";
55
import { DialogClose } from "@radix-ui/react-dialog";
66
import { useFetcher, useSearchParams } from "@remix-run/react";
7-
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
7+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
88
import { tryCatch } from "@trigger.dev/core/v3";
99
import { useCallback, useEffect, useState } from "react";
1010
import { SearchInput } from "~/components/primitives/SearchInput";
@@ -63,6 +63,8 @@ import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
6363
import { logger } from "~/services/logger.server";
6464
import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server";
6565
import { requireUserId } from "~/services/session.server";
66+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
67+
import { resolveProjectAuthScope } from "~/services/projectAuthScope.server";
6668
import { cn } from "~/utils/cn";
6769
import {
6870
branchesPath,
@@ -96,32 +98,41 @@ const PurchaseSchema = z.discriminatedUnion("action", [
9698
}),
9799
]);
98100

99-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
100-
const userId = await requireUserId(request);
101-
const { projectParam } = ProjectParamSchema.parse(params);
102-
103-
const searchParams = new URL(request.url).searchParams;
104-
const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams));
105-
const options = parsedSearchParams.success ? parsedSearchParams.data : {};
106-
107-
try {
108-
const presenter = new BranchesPresenter();
109-
const result = await presenter.call({
110-
userId,
111-
projectSlug: projectParam,
112-
env: "preview",
113-
...options,
114-
});
115-
116-
return typedjson(result);
117-
} catch (error) {
118-
logger.error("Error loading preview branches page", { error });
119-
throw new Response(undefined, {
120-
status: 400,
121-
statusText: "Something went wrong, if this problem persists please contact support.",
122-
});
101+
export const loader = dashboardLoader(
102+
{
103+
params: ProjectParamSchema,
104+
context: (params) => resolveProjectAuthScope(params.organizationSlug, params.projectParam),
105+
},
106+
async ({ request, params, user, ability }) => {
107+
const userId = user.id;
108+
const { projectParam } = params;
109+
110+
const searchParams = new URL(request.url).searchParams;
111+
const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams));
112+
const options = parsedSearchParams.success ? parsedSearchParams.data : {};
113+
114+
try {
115+
const presenter = new BranchesPresenter();
116+
const result = await presenter.call({
117+
userId,
118+
projectSlug: projectParam,
119+
env: "preview",
120+
...options,
121+
});
122+
123+
return typedjson({
124+
...result,
125+
canArchiveBranches: ability.can("write", { type: "deployments", envType: "PREVIEW" }),
126+
});
127+
} catch (error) {
128+
logger.error("Error loading preview branches page", { error });
129+
throw new Response(undefined, {
130+
status: 400,
131+
statusText: "Something went wrong, if this problem persists please contact support.",
132+
});
133+
}
123134
}
124-
};
135+
);
125136

126137
export async function action({ request, params }: ActionFunctionArgs) {
127138
const userId = await requireUserId(request);
@@ -206,6 +217,7 @@ export default function Page() {
206217
totalPages,
207218
hasBranches,
208219
canPurchaseBranches,
220+
canArchiveBranches,
209221
extraBranches,
210222
branchPricing,
211223
maxBranchQuota,
@@ -401,7 +413,10 @@ export default function Page() {
401413
/>
402414
)}
403415
{!branch.archivedAt ? (
404-
<ArchiveButton environment={branch} />
416+
<ArchiveButton
417+
environment={branch}
418+
canArchive={canArchiveBranches}
419+
/>
405420
) : null}
406421
</>
407422
) : null

0 commit comments

Comments
 (0)