Skip to content

Commit de70b04

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 58c22ba commit de70b04

3 files changed

Lines changed: 189 additions & 67 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: 101 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ type RunsTableProps = {
7979
showTopBorder?: boolean;
8080
stickyHeader?: boolean;
8181
childrenStatusesBasePath?: string;
82+
/**
83+
* Display-only write:runs flags from the caller's loader. Default true so
84+
* callers that don't pass them (and OSS, where the ability is permissive)
85+
* keep the controls enabled. The cancel/replay action routes enforce
86+
* write:runs regardless.
87+
*/
88+
canCancelRuns?: boolean;
89+
canReplayRuns?: boolean;
8290
};
8391

8492
export function TaskRunsTable({
@@ -95,6 +103,8 @@ export function TaskRunsTable({
95103
showTopBorder = true,
96104
stickyHeader = false,
97105
childrenStatusesBasePath,
106+
canCancelRuns = true,
107+
canReplayRuns = true,
98108
}: RunsTableProps) {
99109
const regions = useRegions();
100110
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
@@ -512,7 +522,12 @@ export function TaskRunsTable({
512522
{run.tags.map((tag) => <RunTag key={tag} tag={tag} />) || "–"}
513523
</div>
514524
</TableCell>
515-
<RunActionsCell run={run} path={path} />
525+
<RunActionsCell
526+
run={run}
527+
path={path}
528+
canCancelRuns={canCancelRuns}
529+
canReplayRuns={canReplayRuns}
530+
/>
516531
</TableRow>
517532
);
518533
})
@@ -530,7 +545,17 @@ export function TaskRunsTable({
530545
);
531546
}
532547

533-
function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
548+
function RunActionsCell({
549+
run,
550+
path,
551+
canCancelRuns,
552+
canReplayRuns,
553+
}: {
554+
run: NextRunListItem;
555+
path: string;
556+
canCancelRuns: boolean;
557+
canReplayRuns: boolean;
558+
}) {
534559
const location = useLocation();
535560

536561
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
@@ -546,57 +571,85 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
546571
leadingIconClassName="text-blue-500"
547572
title="View run"
548573
/>
549-
{run.isCancellable && (
550-
<Dialog>
551-
<DialogTrigger
552-
asChild
553-
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
554-
>
555-
<Button
556-
variant="small-menu-item"
557-
LeadingIcon={NoSymbolIcon}
558-
leadingIconClassName="text-error"
559-
fullWidth
560-
textAlignLeft
561-
className="w-full px-1.5 py-[0.9rem]"
574+
{run.isCancellable &&
575+
(canCancelRuns ? (
576+
<Dialog>
577+
<DialogTrigger
578+
asChild
579+
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
562580
>
563-
Cancel run
564-
</Button>
565-
</DialogTrigger>
566-
<CancelRunDialog
567-
runFriendlyId={run.friendlyId}
568-
redirectPath={`${location.pathname}${location.search}`}
569-
/>
570-
</Dialog>
571-
)}
572-
{run.isReplayable && (
573-
<Dialog>
574-
<DialogTrigger
575-
asChild
576-
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
581+
<Button
582+
variant="small-menu-item"
583+
LeadingIcon={NoSymbolIcon}
584+
leadingIconClassName="text-error"
585+
fullWidth
586+
textAlignLeft
587+
className="w-full px-1.5 py-[0.9rem]"
588+
>
589+
Cancel run
590+
</Button>
591+
</DialogTrigger>
592+
<CancelRunDialog
593+
runFriendlyId={run.friendlyId}
594+
redirectPath={`${location.pathname}${location.search}`}
595+
/>
596+
</Dialog>
597+
) : (
598+
<Button
599+
variant="small-menu-item"
600+
LeadingIcon={NoSymbolIcon}
601+
leadingIconClassName="text-error"
602+
fullWidth
603+
textAlignLeft
604+
className="w-full px-1.5 py-[0.9rem]"
605+
disabled
606+
tooltip="You don't have permission to cancel runs"
577607
>
578-
<Button
579-
variant="small-menu-item"
580-
LeadingIcon={ArrowPathIcon}
581-
leadingIconClassName="text-success"
582-
fullWidth
583-
textAlignLeft
584-
className="w-full px-1.5 py-[0.9rem]"
608+
Cancel run
609+
</Button>
610+
))}
611+
{run.isReplayable &&
612+
(canReplayRuns ? (
613+
<Dialog>
614+
<DialogTrigger
615+
asChild
616+
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
585617
>
586-
Replay run…
587-
</Button>
588-
</DialogTrigger>
589-
<ReplayRunDialog
590-
runFriendlyId={run.friendlyId}
591-
failedRedirect={`${location.pathname}${location.search}`}
592-
/>
593-
</Dialog>
594-
)}
618+
<Button
619+
variant="small-menu-item"
620+
LeadingIcon={ArrowPathIcon}
621+
leadingIconClassName="text-success"
622+
fullWidth
623+
textAlignLeft
624+
className="w-full px-1.5 py-[0.9rem]"
625+
>
626+
Replay run…
627+
</Button>
628+
</DialogTrigger>
629+
<ReplayRunDialog
630+
runFriendlyId={run.friendlyId}
631+
failedRedirect={`${location.pathname}${location.search}`}
632+
/>
633+
</Dialog>
634+
) : (
635+
<Button
636+
variant="small-menu-item"
637+
LeadingIcon={ArrowPathIcon}
638+
leadingIconClassName="text-success"
639+
fullWidth
640+
textAlignLeft
641+
className="w-full px-1.5 py-[0.9rem]"
642+
disabled
643+
tooltip="You don't have permission to replay runs"
644+
>
645+
Replay run…
646+
</Button>
647+
))}
595648
</>
596649
}
597650
hiddenButtons={
598651
<>
599-
{run.isCancellable && (
652+
{run.isCancellable && canCancelRuns && (
600653
<SimpleTooltip
601654
button={
602655
<Dialog>
@@ -617,10 +670,10 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
617670
disableHoverableContent
618671
/>
619672
)}
620-
{run.isCancellable && run.isReplayable && (
673+
{run.isCancellable && canCancelRuns && run.isReplayable && canReplayRuns && (
621674
<div className="mx-0.5 h-6 w-px bg-grid-dimmed" />
622675
)}
623-
{run.isReplayable && (
676+
{run.isReplayable && canReplayRuns && (
624677
<SimpleTooltip
625678
button={
626679
<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)