@@ -71,6 +71,9 @@ import {
7171 DeploymentListPresenter ,
7272} from "~/presenters/v3/DeploymentListPresenter.server" ;
7373import { requireUserId } from "~/services/session.server" ;
74+ import { $replica } from "~/db.server" ;
75+ import { rbac } from "~/services/rbac.server" ;
76+ import { checkPermissions } from "~/services/routeBuilders/permissions.server" ;
7477import { titleCase } from "~/utils" ;
7578import { cn } from "~/utils/cn" ;
7679import {
@@ -98,6 +101,11 @@ const SearchParams = z.object({
98101 version : z . string ( ) . optional ( ) ,
99102} ) ;
100103
104+ async function resolveOrgIdFromSlug ( slug : string ) : Promise < string | null > {
105+ const org = await $replica . organization . findFirst ( { where : { slug } , select : { id : true } } ) ;
106+ return org ?. id ?? null ;
107+ }
108+
101109export const loader = async ( { request, params } : LoaderFunctionArgs ) => {
102110 const userId = await requireUserId ( request ) ;
103111 const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema . parse ( params ) ;
@@ -141,7 +149,25 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
141149
142150 const autoReloadPollIntervalMs = env . DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS ;
143151
144- return typedjson ( { ...result , selectedDeployment, autoReloadPollIntervalMs } ) ;
152+ // Display flag for the rollback/promote/cancel controls — the action
153+ // routes enforce write:deployments independently. Permissive in OSS.
154+ const orgId = await resolveOrgIdFromSlug ( organizationSlug ) ;
155+ const deploymentAuth = orgId
156+ ? await rbac . authenticateSession ( request , { userId, organizationId : orgId } )
157+ : null ;
158+ const canWriteDeployments =
159+ deploymentAuth && deploymentAuth . ok
160+ ? checkPermissions ( deploymentAuth . ability , {
161+ canWriteDeployments : { action : "write" , resource : { type : "deployments" } } ,
162+ } ) . canWriteDeployments
163+ : true ;
164+
165+ return typedjson ( {
166+ ...result ,
167+ selectedDeployment,
168+ autoReloadPollIntervalMs,
169+ canWriteDeployments,
170+ } ) ;
145171 } catch ( error ) {
146172 console . error ( error ) ;
147173 throw new Response ( undefined , {
@@ -164,6 +190,7 @@ export default function Page() {
164190 environmentGitHubBranch,
165191 autoReloadPollIntervalMs,
166192 hasVercelIntegration,
193+ canWriteDeployments,
167194 } = useTypedLoaderData < typeof loader > ( ) ;
168195 const hasDeployments = totalPages > 0 ;
169196
@@ -257,7 +284,12 @@ export default function Page() {
257284 < TableRow key = { deployment . id } className = "group" isSelected = { isSelected } >
258285 < TableCell to = { path } isTabbableCell isSelected = { isSelected } >
259286 < div className = "flex items-center gap-2" >
260- < Paragraph variant = "extra-small" className = "group-hover/table-row:text-text-bright" > { deployment . shortCode } </ Paragraph >
287+ < Paragraph
288+ variant = "extra-small"
289+ className = "group-hover/table-row:text-text-bright"
290+ >
291+ { deployment . shortCode }
292+ </ Paragraph >
261293 { deployment . label && (
262294 < Badge variant = "extra-small" > { titleCase ( deployment . label ) } </ Badge >
263295 ) }
@@ -333,6 +365,7 @@ export default function Page() {
333365 path = { path }
334366 isSelected = { isSelected }
335367 currentDeployment = { currentDeployment }
368+ canWriteDeployments = { canWriteDeployments }
336369 />
337370 </ TableRow >
338371 ) ;
@@ -419,8 +452,14 @@ export default function Page() {
419452export function UserTag ( { name, avatarUrl } : { name : string ; avatarUrl ?: string } ) {
420453 return (
421454 < div className = "flex items-center gap-1" >
422- < UserAvatar avatarUrl = { avatarUrl } name = { name } className = "h-4 w-4 group-hover/table-row:text-text-bright" />
423- < Paragraph variant = "extra-small" className = "group-hover/table-row:text-text-bright" > { name } </ Paragraph >
455+ < UserAvatar
456+ avatarUrl = { avatarUrl }
457+ name = { name }
458+ className = "h-4 w-4 group-hover/table-row:text-text-bright"
459+ />
460+ < Paragraph variant = "extra-small" className = "group-hover/table-row:text-text-bright" >
461+ { name }
462+ </ Paragraph >
424463 </ div >
425464 ) ;
426465}
@@ -430,11 +469,13 @@ function DeploymentActionsCell({
430469 path,
431470 isSelected,
432471 currentDeployment,
472+ canWriteDeployments,
433473} : {
434474 deployment : DeploymentListItem ;
435475 path : string ;
436476 isSelected : boolean ;
437477 currentDeployment ?: DeploymentListItem ;
478+ canWriteDeployments : boolean ;
438479} ) {
439480 const location = useLocation ( ) ;
440481 const project = useProject ( ) ;
@@ -463,66 +504,105 @@ function DeploymentActionsCell({
463504 isSelected = { isSelected }
464505 popoverContent = {
465506 < >
466- { canBeRolledBack && (
467- < Dialog >
468- < DialogTrigger asChild >
469- < Button
470- variant = "small-menu-item"
471- LeadingIcon = { ArrowUturnLeftIcon }
472- leadingIconClassName = "text-blue-500"
473- fullWidth
474- textAlignLeft
475- >
476- Rollback
477- </ Button >
478- </ DialogTrigger >
479- < RollbackDeploymentDialog
480- projectId = { project . id }
481- deploymentShortCode = { deployment . shortCode }
482- redirectPath = { `${ location . pathname } ${ location . search } ` }
483- />
484- </ Dialog >
485- ) }
486- { canBePromoted && (
487- < Dialog >
488- < DialogTrigger asChild >
489- < Button
490- variant = "small-menu-item"
491- LeadingIcon = { PromoteIcon }
492- leadingIconClassName = "text-blue-500"
493- fullWidth
494- textAlignLeft
495- >
496- Promote
497- </ Button >
498- </ DialogTrigger >
499- < PromoteDeploymentDialog
500- projectId = { project . id }
501- deploymentShortCode = { deployment . shortCode }
502- redirectPath = { `${ location . pathname } ${ location . search } ` }
503- />
504- </ Dialog >
505- ) }
506- { canBeCanceled && (
507- < Dialog >
508- < DialogTrigger asChild >
509- < Button
510- variant = "small-menu-item"
511- LeadingIcon = { NoSymbolIcon }
512- leadingIconClassName = "text-error"
513- fullWidth
514- textAlignLeft
515- >
516- Cancel
517- </ Button >
518- </ DialogTrigger >
519- < CancelDeploymentDialog
520- projectId = { project . id }
521- deploymentShortCode = { deployment . shortCode }
522- redirectPath = { `${ location . pathname } ${ location . search } ` }
523- />
524- </ Dialog >
525- ) }
507+ { canBeRolledBack &&
508+ ( canWriteDeployments ? (
509+ < Dialog >
510+ < DialogTrigger asChild >
511+ < Button
512+ variant = "small-menu-item"
513+ LeadingIcon = { ArrowUturnLeftIcon }
514+ leadingIconClassName = "text-blue-500"
515+ fullWidth
516+ textAlignLeft
517+ >
518+ Rollback
519+ </ Button >
520+ </ DialogTrigger >
521+ < RollbackDeploymentDialog
522+ projectId = { project . id }
523+ deploymentShortCode = { deployment . shortCode }
524+ redirectPath = { `${ location . pathname } ${ location . search } ` }
525+ />
526+ </ Dialog >
527+ ) : (
528+ < Button
529+ variant = "small-menu-item"
530+ LeadingIcon = { ArrowUturnLeftIcon }
531+ leadingIconClassName = "text-blue-500"
532+ fullWidth
533+ textAlignLeft
534+ disabled
535+ tooltip = "You don't have permission to roll back deployments"
536+ >
537+ Rollback
538+ </ Button >
539+ ) ) }
540+ { canBePromoted &&
541+ ( canWriteDeployments ? (
542+ < Dialog >
543+ < DialogTrigger asChild >
544+ < Button
545+ variant = "small-menu-item"
546+ LeadingIcon = { PromoteIcon }
547+ leadingIconClassName = "text-blue-500"
548+ fullWidth
549+ textAlignLeft
550+ >
551+ Promote
552+ </ Button >
553+ </ DialogTrigger >
554+ < PromoteDeploymentDialog
555+ projectId = { project . id }
556+ deploymentShortCode = { deployment . shortCode }
557+ redirectPath = { `${ location . pathname } ${ location . search } ` }
558+ />
559+ </ Dialog >
560+ ) : (
561+ < Button
562+ variant = "small-menu-item"
563+ LeadingIcon = { PromoteIcon }
564+ leadingIconClassName = "text-blue-500"
565+ fullWidth
566+ textAlignLeft
567+ disabled
568+ tooltip = "You don't have permission to promote deployments"
569+ >
570+ Promote
571+ </ Button >
572+ ) ) }
573+ { canBeCanceled &&
574+ ( canWriteDeployments ? (
575+ < Dialog >
576+ < DialogTrigger asChild >
577+ < Button
578+ variant = "small-menu-item"
579+ LeadingIcon = { NoSymbolIcon }
580+ leadingIconClassName = "text-error"
581+ fullWidth
582+ textAlignLeft
583+ >
584+ Cancel
585+ </ Button >
586+ </ DialogTrigger >
587+ < CancelDeploymentDialog
588+ projectId = { project . id }
589+ deploymentShortCode = { deployment . shortCode }
590+ redirectPath = { `${ location . pathname } ${ location . search } ` }
591+ />
592+ </ Dialog >
593+ ) : (
594+ < Button
595+ variant = "small-menu-item"
596+ LeadingIcon = { NoSymbolIcon }
597+ leadingIconClassName = "text-error"
598+ fullWidth
599+ textAlignLeft
600+ disabled
601+ tooltip = "You don't have permission to cancel deployments"
602+ >
603+ Cancel
604+ </ Button >
605+ ) ) }
526606 </ >
527607 }
528608 />
0 commit comments