Skip to content

Commit 5636b9b

Browse files
committed
fix(webapp): gate TaskRunsTable row menu + runs-index/errors bulk controls on write:runs
Thread canCancelRuns/canReplayRuns (default true) through TaskRunsTable to RunActionsCell: disable + tooltip the Cancel/Replay popover items and hide the redundant hover icons when denied. The runs-index and errors loaders compute the flags from the injected ability; gate the index Bulk action button + r/c shortcuts and the errors Bulk replay link accordingly. Display only; the action routes enforce write:runs. Permissive in OSS.
1 parent 50b2ed9 commit 5636b9b

3 files changed

Lines changed: 190 additions & 70 deletions

File tree

  • apps/webapp/app
    • components/runs/v3
    • routes
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index

apps/webapp/app/components/runs/v3/TaskRunsTable.tsx

Lines changed: 102 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ type RunsTableProps = {
7676
disableAdjacentRows?: boolean;
7777
additionalTableState?: Record<string, string>;
7878
childrenStatusesBasePath?: string;
79+
/**
80+
* Display-only write:runs flags from the caller's loader. Default true so
81+
* callers that don't pass them (and OSS, where the ability is permissive)
82+
* keep the controls enabled. The cancel/replay action routes enforce
83+
* write:runs regardless.
84+
*/
85+
canCancelRuns?: boolean;
86+
canReplayRuns?: boolean;
7987
};
8088

8189
export function TaskRunsTable({
@@ -90,6 +98,8 @@ export function TaskRunsTable({
9098
variant = "dimmed",
9199
additionalTableState,
92100
childrenStatusesBasePath,
101+
canCancelRuns = true,
102+
canReplayRuns = true,
93103
}: RunsTableProps) {
94104
const regions = useRegions();
95105
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
@@ -464,9 +474,7 @@ export function TaskRunsTable({
464474
<TableCell to={path}>
465475
{run.region ? (
466476
<RegionLabel
467-
region={
468-
regionByMasterQueue.get(run.region) ?? { name: run.region }
469-
}
477+
region={regionByMasterQueue.get(run.region) ?? { name: run.region }}
470478
iconClassName="size-4"
471479
/>
472480
) : (
@@ -493,7 +501,12 @@ export function TaskRunsTable({
493501
{run.tags.map((tag) => <RunTag key={tag} tag={tag} />) || "–"}
494502
</div>
495503
</TableCell>
496-
<RunActionsCell run={run} path={path} />
504+
<RunActionsCell
505+
run={run}
506+
path={path}
507+
canCancelRuns={canCancelRuns}
508+
canReplayRuns={canReplayRuns}
509+
/>
497510
</TableRow>
498511
);
499512
})
@@ -511,7 +524,17 @@ export function TaskRunsTable({
511524
);
512525
}
513526

514-
function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
527+
function RunActionsCell({
528+
run,
529+
path,
530+
canCancelRuns,
531+
canReplayRuns,
532+
}: {
533+
run: NextRunListItem;
534+
path: string;
535+
canCancelRuns: boolean;
536+
canReplayRuns: boolean;
537+
}) {
515538
const location = useLocation();
516539

517540
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
@@ -527,57 +550,85 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
527550
leadingIconClassName="text-blue-500"
528551
title="View run"
529552
/>
530-
{run.isCancellable && (
531-
<Dialog>
532-
<DialogTrigger
533-
asChild
534-
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
535-
>
536-
<Button
537-
variant="small-menu-item"
538-
LeadingIcon={NoSymbolIcon}
539-
leadingIconClassName="text-error"
540-
fullWidth
541-
textAlignLeft
542-
className="w-full px-1.5 py-[0.9rem]"
553+
{run.isCancellable &&
554+
(canCancelRuns ? (
555+
<Dialog>
556+
<DialogTrigger
557+
asChild
558+
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
543559
>
544-
Cancel run
545-
</Button>
546-
</DialogTrigger>
547-
<CancelRunDialog
548-
runFriendlyId={run.friendlyId}
549-
redirectPath={`${location.pathname}${location.search}`}
550-
/>
551-
</Dialog>
552-
)}
553-
{run.isReplayable && (
554-
<Dialog>
555-
<DialogTrigger
556-
asChild
557-
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
560+
<Button
561+
variant="small-menu-item"
562+
LeadingIcon={NoSymbolIcon}
563+
leadingIconClassName="text-error"
564+
fullWidth
565+
textAlignLeft
566+
className="w-full px-1.5 py-[0.9rem]"
567+
>
568+
Cancel run
569+
</Button>
570+
</DialogTrigger>
571+
<CancelRunDialog
572+
runFriendlyId={run.friendlyId}
573+
redirectPath={`${location.pathname}${location.search}`}
574+
/>
575+
</Dialog>
576+
) : (
577+
<Button
578+
variant="small-menu-item"
579+
LeadingIcon={NoSymbolIcon}
580+
leadingIconClassName="text-error"
581+
fullWidth
582+
textAlignLeft
583+
className="w-full px-1.5 py-[0.9rem]"
584+
disabled
585+
tooltip="You don't have permission to cancel runs"
558586
>
559-
<Button
560-
variant="small-menu-item"
561-
LeadingIcon={ArrowPathIcon}
562-
leadingIconClassName="text-success"
563-
fullWidth
564-
textAlignLeft
565-
className="w-full px-1.5 py-[0.9rem]"
587+
Cancel run
588+
</Button>
589+
))}
590+
{run.isReplayable &&
591+
(canReplayRuns ? (
592+
<Dialog>
593+
<DialogTrigger
594+
asChild
595+
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
566596
>
567-
Replay run…
568-
</Button>
569-
</DialogTrigger>
570-
<ReplayRunDialog
571-
runFriendlyId={run.friendlyId}
572-
failedRedirect={`${location.pathname}${location.search}`}
573-
/>
574-
</Dialog>
575-
)}
597+
<Button
598+
variant="small-menu-item"
599+
LeadingIcon={ArrowPathIcon}
600+
leadingIconClassName="text-success"
601+
fullWidth
602+
textAlignLeft
603+
className="w-full px-1.5 py-[0.9rem]"
604+
>
605+
Replay run…
606+
</Button>
607+
</DialogTrigger>
608+
<ReplayRunDialog
609+
runFriendlyId={run.friendlyId}
610+
failedRedirect={`${location.pathname}${location.search}`}
611+
/>
612+
</Dialog>
613+
) : (
614+
<Button
615+
variant="small-menu-item"
616+
LeadingIcon={ArrowPathIcon}
617+
leadingIconClassName="text-success"
618+
fullWidth
619+
textAlignLeft
620+
className="w-full px-1.5 py-[0.9rem]"
621+
disabled
622+
tooltip="You don't have permission to replay runs"
623+
>
624+
Replay run…
625+
</Button>
626+
))}
576627
</>
577628
}
578629
hiddenButtons={
579630
<>
580-
{run.isCancellable && (
631+
{run.isCancellable && canCancelRuns && (
581632
<SimpleTooltip
582633
button={
583634
<Dialog>
@@ -598,10 +649,10 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
598649
disableHoverableContent
599650
/>
600651
)}
601-
{run.isCancellable && run.isReplayable && (
652+
{run.isCancellable && canCancelRuns && run.isReplayable && canReplayRuns && (
602653
<div className="mx-0.5 h-6 w-px bg-grid-dimmed" />
603654
)}
604-
{run.isReplayable && (
655+
{run.isReplayable && canReplayRuns && (
605656
<SimpleTooltip
606657
button={
607658
<Dialog>

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { PageBody } from "~/components/layout/AppLayout";
3636
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
3737
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
3838
import { LinkButton } from "~/components/primitives/Buttons";
39+
import { PermissionLink } from "~/components/primitives/PermissionLink";
3940
import { Callout } from "~/components/primitives/Callout";
4041
import { CopyableText } from "~/components/primitives/CopyableText";
4142
import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime";
@@ -74,6 +75,8 @@ import {
7475
import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server";
7576
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
7677
import { requireUser, requireUserId } from "~/services/session.server";
78+
import { rbac } from "~/services/rbac.server";
79+
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
7780
import { cn } from "~/utils/cn";
7881
import {
7982
EnvironmentParamSchema,
@@ -282,19 +285,41 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
282285
)
283286
.catch(() => ({ data: [] as ErrorGroupActivity, versions: [] as string[] }));
284287

288+
// Display flags for the row-menu and bulk-replay controls — the cancel/
289+
// replay action routes enforce write:runs independently. Permissive in OSS.
290+
const runAuth = await rbac.authenticateSession(request, {
291+
userId,
292+
organizationId: project.organizationId,
293+
});
294+
const runPermissions = runAuth.ok
295+
? checkPermissions(runAuth.ability, {
296+
canCancelRuns: { action: "write", resource: { type: "runs" } },
297+
canReplayRuns: { action: "write", resource: { type: "runs" } },
298+
})
299+
: { canCancelRuns: true, canReplayRuns: true };
300+
285301
return typeddefer({
286302
data: detailPromise,
287303
activity: activityPromise,
288304
organizationSlug,
289305
projectParam,
290306
envParam,
291307
fingerprint,
308+
...runPermissions,
292309
});
293310
};
294311

295312
export default function Page() {
296-
const { data, activity, organizationSlug, projectParam, envParam, fingerprint } =
297-
useTypedLoaderData<typeof loader>();
313+
const {
314+
data,
315+
activity,
316+
organizationSlug,
317+
projectParam,
318+
envParam,
319+
fingerprint,
320+
canCancelRuns,
321+
canReplayRuns,
322+
} = useTypedLoaderData<typeof loader>();
298323

299324
const location = useOptimisticLocation();
300325
const searchParams = new URLSearchParams(location.search);
@@ -387,6 +412,8 @@ export default function Page() {
387412
projectParam={projectParam}
388413
envParam={envParam}
389414
fingerprint={fingerprint}
415+
canCancelRuns={canCancelRuns}
416+
canReplayRuns={canReplayRuns}
390417
/>
391418
);
392419
}}
@@ -405,6 +432,8 @@ function ErrorGroupDetail({
405432
projectParam,
406433
envParam,
407434
fingerprint,
435+
canCancelRuns,
436+
canReplayRuns,
408437
}: {
409438
errorGroup: ErrorGroupSummary | undefined;
410439
runList: NextRunList | undefined;
@@ -413,6 +442,8 @@ function ErrorGroupDetail({
413442
projectParam: string;
414443
envParam: string;
415444
fingerprint: string;
445+
canCancelRuns: boolean;
446+
canReplayRuns: boolean;
416447
}) {
417448
const { value, values } = useSearchParams();
418449
const organization = useOrganization();
@@ -482,7 +513,9 @@ function ErrorGroupDetail({
482513
>
483514
View all runs
484515
</LinkButton>
485-
<LinkButton
516+
<PermissionLink
517+
hasPermission={canReplayRuns}
518+
noPermissionTooltip="You don't have permission to replay runs"
486519
variant="secondary/small"
487520
to={v3CreateBulkActionPath(
488521
organization,
@@ -495,7 +528,7 @@ function ErrorGroupDetail({
495528
LeadingIcon={ListCheckedIcon}
496529
>
497530
Bulk replay…
498-
</LinkButton>
531+
</PermissionLink>
499532
<ListPagination list={runList} />
500533
</div>
501534
)}
@@ -515,6 +548,8 @@ function ErrorGroupDetail({
515548
isLoading={false}
516549
variant="dimmed"
517550
additionalTableState={{ errorId: ErrorId.toFriendlyId(fingerprint) }}
551+
canCancelRuns={canCancelRuns}
552+
canReplayRuns={canReplayRuns}
518553
/>
519554
) : (
520555
<div className="flex flex-1 flex-col items-center justify-center gap-3">

0 commit comments

Comments
 (0)