@@ -104,6 +104,7 @@ import { getImpersonationId } from "~/services/impersonation.server";
104104import { logger } from "~/services/logger.server" ;
105105import { getResizableSnapshot } from "~/services/resizablePanel.server" ;
106106import { requireUserId } from "~/services/session.server" ;
107+ import { rbac } from "~/services/rbac.server" ;
107108import { cn } from "~/utils/cn" ;
108109import { lerp } from "~/utils/lerp" ;
109110import {
@@ -189,7 +190,10 @@ async function getRunsListFromTableState({
189190 return null ;
190191 }
191192
192- const clickhouse = await clickhouseFactory . getClickhouseForOrganization ( project . organizationId , "standard" ) ;
193+ const clickhouse = await clickhouseFactory . getClickhouseForOrganization (
194+ project . organizationId ,
195+ "standard"
196+ ) ;
193197 const runsListPresenter = new NextRunListPresenter ( $replica , clickhouse ) ;
194198 const currentPageResult = await runsListPresenter . call ( project . organizationId , environment . id , {
195199 userId,
@@ -253,6 +257,15 @@ async function getRunsListFromTableState({
253257 }
254258}
255259
260+ // Display-only write:runs flags for the Replay/Cancel controls. The cancel
261+ // and replay action routes enforce write:runs independently; this mirrors the
262+ // result so the buttons disable for roles that lack it. Permissive in OSS.
263+ async function runWritePermissions ( request : Request , userId : string , organizationId : string ) {
264+ const auth = await rbac . authenticateSession ( request , { userId, organizationId } ) ;
265+ const canWriteRun = auth . ok ? auth . ability . can ( "write" , { type : "runs" } ) : true ;
266+ return { canReplayRun : canWriteRun , canCancelRun : canWriteRun } ;
267+ }
268+
256269export const loader = async ( { request, params } : LoaderFunctionArgs ) => {
257270 const userId = await requireUserId ( request ) ;
258271 const impersonationId = await getImpersonationId ( request ) ;
@@ -318,11 +331,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
318331 // Skip on `_data` requests (Remix data fetches): they're
319332 // client-driven follow-ups and the client URL is what matters,
320333 // not the loader's view of it.
321- if (
322- ! url . searchParams . has ( "span" ) &&
323- ! url . searchParams . has ( "_data" ) &&
324- buffered . run . spanId
325- ) {
334+ if ( ! url . searchParams . has ( "span" ) && ! url . searchParams . has ( "_data" ) && buffered . run . spanId ) {
326335 url . searchParams . set ( "span" , buffered . run . spanId ) ;
327336 throw redirect ( url . pathname + "?" + url . searchParams . toString ( ) ) ;
328337 }
@@ -336,6 +345,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
336345 maximumLiveReloadingSetting : env . MAXIMUM_LIVE_RELOADING_EVENTS ,
337346 resizable : { parent, tree } ,
338347 runsList : null ,
348+ ...( await runWritePermissions ( request , userId , buffered . run . environment . organizationId ) ) ,
339349 } ) ;
340350 }
341351
@@ -347,11 +357,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
347357 // block in the buffered fallback above — the sibling redirect routes
348358 // do this, but direct navigation to the canonical project-scoped URL
349359 // never hits them, leaving the right detail panel collapsed.
350- if (
351- ! url . searchParams . has ( "span" ) &&
352- ! url . searchParams . has ( "_data" ) &&
353- result . run . spanId
354- ) {
360+ if ( ! url . searchParams . has ( "span" ) && ! url . searchParams . has ( "_data" ) && result . run . spanId ) {
355361 url . searchParams . set ( "span" , result . run . spanId ) ;
356362 throw redirect ( url . pathname + "?" + url . searchParams . toString ( ) ) ;
357363 }
@@ -378,6 +384,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
378384 tree,
379385 } ,
380386 runsList,
387+ ...( await runWritePermissions ( request , userId , result . run . environment . organizationId ) ) ,
381388 } ) ;
382389} ;
383390
@@ -417,8 +424,15 @@ async function tryMollifiedRunFallback(args: {
417424type LoaderData = SerializeFrom < typeof loader > ;
418425
419426export default function Page ( ) {
420- const { run, trace, maximumLiveReloadingSetting, runsList, resizable } =
421- useLoaderData < typeof loader > ( ) ;
427+ const {
428+ run,
429+ trace,
430+ maximumLiveReloadingSetting,
431+ runsList,
432+ resizable,
433+ canReplayRun,
434+ canCancelRun,
435+ } = useLoaderData < typeof loader > ( ) ;
422436 const organization = useOrganization ( ) ;
423437 const project = useProject ( ) ;
424438 const environment = useEnvironment ( ) ;
@@ -500,6 +514,8 @@ export default function Page() {
500514 LeadingIcon = { ArrowUturnLeftIcon }
501515 shortcut = { { key : "R" } }
502516 className = "pr-2"
517+ disabled = { ! canReplayRun }
518+ tooltip = { canReplayRun ? undefined : "You don't have permission to replay runs" }
503519 >
504520 Replay run
505521 </ Button >
@@ -518,6 +534,7 @@ export default function Page() {
518534 { run . isFinished ? null : (
519535 < ControlledCancelRunDialog
520536 key = { `cancel-${ run . friendlyId } ` }
537+ canCancel = { canCancelRun }
521538 runFriendlyId = { run . friendlyId }
522539 redirectPath = { v3RunSpanPath (
523540 organization ,
@@ -699,15 +716,23 @@ function TraceView({
699716function ControlledCancelRunDialog ( {
700717 runFriendlyId,
701718 redirectPath,
719+ canCancel,
702720} : {
703721 runFriendlyId : string ;
704722 redirectPath : string ;
723+ canCancel : boolean ;
705724} ) {
706725 const [ open , setOpen ] = useState ( false ) ;
707726 return (
708727 < Dialog open = { open } onOpenChange = { setOpen } >
709728 < DialogTrigger asChild >
710- < Button variant = "danger/small" LeadingIcon = { StopCircleIcon } shortcut = { { key : "C" } } >
729+ < Button
730+ variant = "danger/small"
731+ LeadingIcon = { StopCircleIcon }
732+ shortcut = { { key : "C" } }
733+ disabled = { ! canCancel }
734+ tooltip = { canCancel ? undefined : "You don't have permission to cancel runs" }
735+ >
711736 Cancel run…
712737 </ Button >
713738 </ DialogTrigger >
0 commit comments