@@ -7,15 +7,21 @@ import {
77 useForm ,
88} from "@conform-to/react" ;
99import { parse } from "@conform-to/zod" ;
10- import { LockClosedIcon , LockOpenIcon , PlusIcon , XMarkIcon } from "@heroicons/react/20/solid" ;
10+ import {
11+ LockClosedIcon ,
12+ LockOpenIcon ,
13+ NoSymbolIcon ,
14+ PlusIcon ,
15+ XMarkIcon ,
16+ } from "@heroicons/react/20/solid" ;
1117import { Form , useActionData , useNavigate , useNavigation } from "@remix-run/react" ;
12- import { type ActionFunctionArgs , json } from "@remix-run/server-runtime" ;
18+ import { json } from "@remix-run/server-runtime" ;
1319import dotenv from "dotenv" ;
1420import { type RefObject , useCallback , useRef , useState } from "react" ;
1521import { redirect } from "remix-typedjson" ;
1622import invariant from "tiny-invariant" ;
1723import { z } from "zod" ;
18- import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel" ;
24+ import { EnvironmentLabel , environmentFullTitle } from "~/components/environments/EnvironmentLabel" ;
1925import { Button , LinkButton } from "~/components/primitives/Buttons" ;
2026import { CheckboxWithLabel } from "~/components/primitives/Checkbox" ;
2127import { Dialog , DialogContent , DialogHeader } from "~/components/primitives/Dialog" ;
@@ -41,7 +47,7 @@ import { useList } from "~/hooks/useList";
4147import { useOrganization } from "~/hooks/useOrganizations" ;
4248import { useProject } from "~/hooks/useProject" ;
4349import { useTypedMatchesData } from "~/hooks/useTypedMatchData" ;
44- import { requireUserId } from "~/services/session.server " ;
50+ import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder " ;
4551import { cn } from "~/utils/cn" ;
4652import {
4753 environmentVariablesRouteId ,
@@ -95,74 +101,107 @@ const schema = z.object({
95101 } , Variable . array ( ) . nonempty ( "At least one variable is required" ) ) ,
96102} ) ;
97103
98- export const action = async ( { request, params } : ActionFunctionArgs ) => {
99- const userId = await requireUserId ( request ) ;
100- const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema . parse ( params ) ;
104+ async function resolveOrgIdFromSlug ( slug : string ) : Promise < string | null > {
105+ const org = await prisma . organization . findFirst ( { where : { slug } , select : { id : true } } ) ;
106+ return org ?. id ?? null ;
107+ }
101108
102- if ( request . method . toUpperCase ( ) !== "POST" ) {
103- return { status : 405 , body : "Method Not Allowed" } ;
104- }
109+ export const action = dashboardAction (
110+ {
111+ params : EnvironmentParamSchema ,
112+ context : async ( params ) => {
113+ const organizationId = await resolveOrgIdFromSlug ( params . organizationSlug ) ;
114+ return organizationId ? { organizationId } : { } ;
115+ } ,
116+ // Per-environment write:envvars is enforced in the handler — the target
117+ // environments come from the submission, not the route params.
118+ } ,
119+ async ( { request, params, user, ability } ) => {
120+ const userId = user . id ;
121+ const { organizationSlug, projectParam, envParam } = params ;
122+
123+ if ( request . method . toUpperCase ( ) !== "POST" ) {
124+ throw new Response ( "Method Not Allowed" , { status : 405 } ) ;
125+ }
105126
106- const formData = await request . formData ( ) ;
107- const submission = parse ( formData , { schema } ) ;
127+ const formData = await request . formData ( ) ;
128+ const submission = parse ( formData , { schema } ) ;
108129
109- if ( ! submission . value ) {
110- return json ( submission ) ;
111- }
130+ if ( ! submission . value ) {
131+ return json ( submission ) ;
132+ }
133+
134+ // Enforce env-tier write:envvars for every targeted environment, so a role
135+ // that can't write a deployed tier can't create vars there via a direct
136+ // POST (the disabled checkboxes are not the boundary).
137+ const targetEnvironments = await prisma . runtimeEnvironment . findMany ( {
138+ where : { id : { in : submission . value . environmentIds } } ,
139+ select : { type : true } ,
140+ } ) ;
141+ const hasDeniedEnvironment = targetEnvironments . some (
142+ ( env ) => ! ability . can ( "write" , { type : "envvars" , envType : env . type } )
143+ ) ;
144+ if ( hasDeniedEnvironment ) {
145+ submission . error . environmentIds = [
146+ "You don't have permission to manage environment variables in one of the selected environments." ,
147+ ] ;
148+ return json ( submission ) ;
149+ }
112150
113- const project = await prisma . project . findUnique ( {
114- where : {
115- slug : params . projectParam ,
116- organization : {
117- members : {
118- some : {
119- userId,
151+ const project = await prisma . project . findUnique ( {
152+ where : {
153+ slug : params . projectParam ,
154+ organization : {
155+ members : {
156+ some : {
157+ userId,
158+ } ,
120159 } ,
121160 } ,
122161 } ,
123- } ,
124- select : {
125- id : true ,
126- } ,
127- } ) ;
128- if ( ! project ) {
129- submission . error . key = [ "Project not found" ] ;
130- return json ( submission ) ;
131- }
162+ select : {
163+ id : true ,
164+ } ,
165+ } ) ;
166+ if ( ! project ) {
167+ submission . error . key = [ "Project not found" ] ;
168+ return json ( submission ) ;
169+ }
132170
133- const repository = new EnvironmentVariablesRepository ( prisma ) ;
134- const result = await repository . create ( project . id , {
135- ...submission . value ,
136- lastUpdatedBy : {
137- type : "user" ,
138- userId,
139- } ,
140- } ) ;
171+ const repository = new EnvironmentVariablesRepository ( prisma ) ;
172+ const result = await repository . create ( project . id , {
173+ ...submission . value ,
174+ lastUpdatedBy : {
175+ type : "user" ,
176+ userId,
177+ } ,
178+ } ) ;
141179
142- if ( ! result . success ) {
143- if ( result . variableErrors ) {
144- for ( const { key, error } of result . variableErrors ) {
145- const index = submission . value . variables . findIndex ( ( v ) => v . key === key ) ;
180+ if ( ! result . success ) {
181+ if ( result . variableErrors ) {
182+ for ( const { key, error } of result . variableErrors ) {
183+ const index = submission . value . variables . findIndex ( ( v ) => v . key === key ) ;
146184
147- if ( index !== - 1 ) {
148- submission . error [ `variables[${ index } ].key` ] = [ error ] ;
185+ if ( index !== - 1 ) {
186+ submission . error [ `variables[${ index } ].key` ] = [ error ] ;
187+ }
149188 }
189+ } else {
190+ submission . error . variables = [ result . error ] ;
150191 }
151- } else {
152- submission . error . variables = [ result . error ] ;
192+
193+ return json ( submission ) ;
153194 }
154195
155- return json ( submission ) ;
196+ return redirect (
197+ v3EnvironmentVariablesPath (
198+ { slug : organizationSlug } ,
199+ { slug : projectParam } ,
200+ { slug : envParam }
201+ )
202+ ) ;
156203 }
157-
158- return redirect (
159- v3EnvironmentVariablesPath (
160- { slug : organizationSlug } ,
161- { slug : projectParam } ,
162- { slug : envParam }
163- )
164- ) ;
165- } ;
204+ ) ;
166205
167206export default function Page ( ) {
168207 const [ isOpen , setIsOpen ] = useState ( true ) ;
@@ -173,7 +212,8 @@ export default function Page() {
173212 parentData ,
174213 "Environment variables page loader data must be defined when rendering the create dialog"
175214 ) ;
176- const { environments, hasStaging } = parentData ;
215+ const { environments, hasStaging, accessibleEnvironmentIds } = parentData ;
216+ const accessibleEnvironmentIdSet = new Set ( accessibleEnvironmentIds ) ;
177217 const lastSubmission = useActionData ( ) ;
178218 const navigation = useNavigation ( ) ;
179219 const navigate = useNavigate ( ) ;
@@ -269,19 +309,45 @@ export default function Page() {
269309 ) )
270310 ) }
271311 < div className = "flex items-center gap-2" >
272- { nonBranchEnvironments . map ( ( environment ) => (
273- < CheckboxWithLabel
274- key = { environment . id }
275- id = { environment . id }
276- value = { environment . id }
277- defaultChecked = { selectedEnvironmentIds . has ( environment . id ) }
278- onChange = { ( isChecked ) =>
279- handleEnvironmentChange ( environment . id , isChecked , environment . type )
280- }
281- label = { < EnvironmentLabel environment = { environment } className = "text-sm" /> }
282- variant = "button"
283- />
284- ) ) }
312+ { nonBranchEnvironments . map ( ( environment ) =>
313+ accessibleEnvironmentIdSet . has ( environment . id ) ? (
314+ < CheckboxWithLabel
315+ key = { environment . id }
316+ id = { environment . id }
317+ value = { environment . id }
318+ defaultChecked = { selectedEnvironmentIds . has ( environment . id ) }
319+ onChange = { ( isChecked ) =>
320+ handleEnvironmentChange ( environment . id , isChecked , environment . type )
321+ }
322+ label = { < EnvironmentLabel environment = { environment } className = "text-sm" /> }
323+ variant = "button"
324+ />
325+ ) : (
326+ < TooltipProvider key = { environment . id } >
327+ < Tooltip >
328+ < TooltipTrigger asChild >
329+ < div >
330+ < CheckboxWithLabel
331+ id = { environment . id }
332+ value = { environment . id }
333+ disabled
334+ defaultChecked = { false }
335+ label = {
336+ < EnvironmentLabel environment = { environment } className = "text-sm" />
337+ }
338+ variant = "button"
339+ />
340+ </ div >
341+ </ TooltipTrigger >
342+ < TooltipContent className = "flex items-center gap-2" >
343+ < NoSymbolIcon className = "size-4 text-text-dimmed" />
344+ With your current role, you can't manage{ " " }
345+ { environmentFullTitle ( environment ) } environment variables.
346+ </ TooltipContent >
347+ </ Tooltip >
348+ </ TooltipProvider >
349+ )
350+ ) }
285351 { ! hasStaging && (
286352 < >
287353 < TooltipProvider >
0 commit comments