11import { conform , useForm } from "@conform-to/react" ;
22import { parse } from "@conform-to/zod" ;
33import { Form , useActionData , useNavigation } from "@remix-run/react" ;
4- import { type ActionFunction , type LoaderFunctionArgs , json } from "@remix-run/server-runtime" ;
5- import { typedjson , useTypedLoaderData , useTypedFetcher } from "remix-typedjson" ;
4+ import { json } from "@remix-run/server-runtime" ;
5+ import {
6+ type UseDataFunctionReturn ,
7+ typedjson ,
8+ useTypedLoaderData ,
9+ useTypedFetcher ,
10+ } from "remix-typedjson" ;
611import { z } from "zod" ;
7- import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout" ;
12+ import {
13+ MainCenteredContainer ,
14+ MainHorizontallyCenteredContainer ,
15+ } from "~/components/layout/AppLayout" ;
16+ import { PermissionDenied } from "~/components/PermissionDenied" ;
17+ import { $replica } from "~/db.server" ;
18+ import { dashboardAction , dashboardLoader } from "~/services/routeBuilders/dashboardBuilder" ;
819import { Button } from "~/components/primitives/Buttons" ;
920import { CheckboxWithLabel } from "~/components/primitives/Checkbox" ;
1021import { Fieldset } from "~/components/primitives/Fieldset" ;
@@ -26,7 +37,6 @@ import {
2637import { ProjectSettingsService } from "~/services/projectSettings.server" ;
2738import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server" ;
2839import { logger } from "~/services/logger.server" ;
29- import { requireUserId } from "~/services/session.server" ;
3040import { EnvironmentParamSchema , v3BillingPath , vercelResourcePath } from "~/utils/pathBuilder" ;
3141import React , { useEffect , useState , useCallback , useRef } from "react" ;
3242import { useSearchParams } from "@remix-run/react" ;
@@ -39,48 +49,71 @@ import {
3949import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel" ;
4050import { OrgIntegrationRepository } from "~/models/orgIntegration.server" ;
4151
42- export const loader = async ( { request, params } : LoaderFunctionArgs ) => {
43- const userId = await requireUserId ( request ) ;
44- const { projectParam, organizationSlug } = EnvironmentParamSchema . parse ( params ) ;
52+ async function resolveOrgIdFromSlug ( slug : string ) : Promise < string | null > {
53+ const org = await $replica . organization . findFirst ( { where : { slug } , select : { id : true } } ) ;
54+ return org ?. id ?? null ;
55+ }
4556
46- const projectSettingsPresenter = new ProjectSettingsPresenter ( ) ;
47- const resultOrFail = await projectSettingsPresenter . getProjectSettings (
48- organizationSlug ,
49- projectParam ,
50- userId
51- ) ;
57+ export const loader = dashboardLoader (
58+ {
59+ params : EnvironmentParamSchema ,
60+ context : async ( params ) => {
61+ const organizationId = await resolveOrgIdFromSlug ( params . organizationSlug ) ;
62+ return organizationId ? { organizationId } : { } ;
63+ } ,
64+ // No hard authorization: the page renders a PermissionDenied panel for
65+ // roles that can't manage any integration (see canManageIntegrations).
66+ } ,
67+ async ( { params, user, ability } ) => {
68+ const { projectParam, organizationSlug } = params ;
5269
53- if ( resultOrFail . isErr ( ) ) {
54- switch ( resultOrFail . error . type ) {
55- case "project_not_found" : {
56- throw new Response ( undefined , {
57- status : 404 ,
58- statusText : "Project not found" ,
59- } ) ;
60- }
61- case "other" :
62- default : {
63- resultOrFail . error . type satisfies "other" ;
64-
65- logger . error ( "Failed loading project settings" , {
66- error : resultOrFail . error ,
67- } ) ;
68- throw new Response ( undefined , {
69- status : 400 ,
70- statusText : "Something went wrong, please try again!" ,
71- } ) ;
70+ const canManageIntegrations =
71+ ability . can ( "write" , { type : "github" } ) || ability . can ( "write" , { type : "vercel" } ) ;
72+
73+ if ( ! canManageIntegrations ) {
74+ return typedjson ( { canManageIntegrations : false as const } ) ;
75+ }
76+
77+ const projectSettingsPresenter = new ProjectSettingsPresenter ( ) ;
78+ const resultOrFail = await projectSettingsPresenter . getProjectSettings (
79+ organizationSlug ,
80+ projectParam ,
81+ user . id
82+ ) ;
83+
84+ if ( resultOrFail . isErr ( ) ) {
85+ switch ( resultOrFail . error . type ) {
86+ case "project_not_found" : {
87+ throw new Response ( undefined , {
88+ status : 404 ,
89+ statusText : "Project not found" ,
90+ } ) ;
91+ }
92+ case "other" :
93+ default : {
94+ resultOrFail . error . type satisfies "other" ;
95+
96+ logger . error ( "Failed loading project settings" , {
97+ error : resultOrFail . error ,
98+ } ) ;
99+ throw new Response ( undefined , {
100+ status : 400 ,
101+ statusText : "Something went wrong, please try again!" ,
102+ } ) ;
103+ }
72104 }
73105 }
74- }
75106
76- const { gitHubApp, buildSettings } = resultOrFail . value ;
107+ const { gitHubApp, buildSettings } = resultOrFail . value ;
77108
78- return typedjson ( {
79- githubAppEnabled : gitHubApp . enabled ,
80- buildSettings,
81- vercelIntegrationEnabled : OrgIntegrationRepository . isVercelSupported ,
82- } ) ;
83- } ;
109+ return typedjson ( {
110+ canManageIntegrations : true as const ,
111+ githubAppEnabled : gitHubApp . enabled ,
112+ buildSettings,
113+ vercelIntegrationEnabled : OrgIntegrationRepository . isVercelSupported ,
114+ } ) ;
115+ }
116+ ) ;
84117
85118const UpdateBuildSettingsFormSchema = z . object ( {
86119 action : z . literal ( "update-build-settings" ) ,
@@ -118,63 +151,89 @@ const UpdateBuildSettingsFormSchema = z.object({
118151 . transform ( ( val ) => val === "on" ) ,
119152} ) ;
120153
121- export const action : ActionFunction = async ( { request, params } ) => {
122- const userId = await requireUserId ( request ) ;
123- const { organizationSlug, projectParam } = params ;
124- if ( ! organizationSlug || ! projectParam ) {
125- return json ( { errors : { body : "organizationSlug and projectParam are required" } } , { status : 400 } ) ;
126- }
127-
128- const formData = await request . formData ( ) ;
129- const submission = parse ( formData , { schema : UpdateBuildSettingsFormSchema } ) ;
154+ export const action = dashboardAction (
155+ {
156+ params : EnvironmentParamSchema ,
157+ context : async ( params ) => {
158+ const organizationId = await resolveOrgIdFromSlug ( params . organizationSlug ) ;
159+ return organizationId ? { organizationId } : { } ;
160+ } ,
161+ // Build settings configure the Git-based deploy, so gate on write:github
162+ // (a restricted role can view neither this page nor mutate via a POST).
163+ authorization : { action : "write" , resource : { type : "github" } } ,
164+ } ,
165+ async ( { request, params, user } ) => {
166+ const { organizationSlug, projectParam } = params ;
167+
168+ const formData = await request . formData ( ) ;
169+ const submission = parse ( formData , { schema : UpdateBuildSettingsFormSchema } ) ;
170+
171+ if ( ! submission . value || submission . intent !== "submit" ) {
172+ return json ( submission ) ;
173+ }
130174
131- if ( ! submission . value || submission . intent !== "submit" ) {
132- return json ( submission ) ;
133- }
175+ const projectSettingsService = new ProjectSettingsService ( ) ;
176+ const membershipResultOrFail = await projectSettingsService . verifyProjectMembership (
177+ organizationSlug ,
178+ projectParam ,
179+ user . id
180+ ) ;
134181
135- const projectSettingsService = new ProjectSettingsService ( ) ;
136- const membershipResultOrFail = await projectSettingsService . verifyProjectMembership (
137- organizationSlug ,
138- projectParam ,
139- userId
140- ) ;
182+ if ( membershipResultOrFail . isErr ( ) ) {
183+ return json ( { errors : { body : membershipResultOrFail . error . type } } , { status : 404 } ) ;
184+ }
141185
142- if ( membershipResultOrFail . isErr ( ) ) {
143- return json ( { errors : { body : membershipResultOrFail . error . type } } , { status : 404 } ) ;
144- }
186+ const { projectId } = membershipResultOrFail . value ;
145187
146- const { projectId } = membershipResultOrFail . value ;
188+ const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
189+ submission . value ;
147190
148- const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
149- submission . value ;
191+ const resultOrFail = await projectSettingsService . updateBuildSettings ( projectId , {
192+ installCommand : installCommand || undefined ,
193+ preBuildCommand : preBuildCommand || undefined ,
194+ triggerConfigFilePath : triggerConfigFilePath || undefined ,
195+ useNativeBuildServer : useNativeBuildServer ,
196+ } ) ;
150197
151- const resultOrFail = await projectSettingsService . updateBuildSettings ( projectId , {
152- installCommand : installCommand || undefined ,
153- preBuildCommand : preBuildCommand || undefined ,
154- triggerConfigFilePath : triggerConfigFilePath || undefined ,
155- useNativeBuildServer : useNativeBuildServer ,
156- } ) ;
198+ if ( resultOrFail . isErr ( ) ) {
199+ switch ( resultOrFail . error . type ) {
200+ case "other" :
201+ default : {
202+ resultOrFail . error . type satisfies "other" ;
157203
158- if ( resultOrFail . isErr ( ) ) {
159- switch ( resultOrFail . error . type ) {
160- case "other" :
161- default : {
162- resultOrFail . error . type satisfies "other" ;
163-
164- logger . error ( "Failed to update build settings" , {
165- error : resultOrFail . error ,
166- } ) ;
167- return redirectBackWithErrorMessage ( request , "Failed to update build settings" ) ;
204+ logger . error ( "Failed to update build settings" , {
205+ error : resultOrFail . error ,
206+ } ) ;
207+ return redirectBackWithErrorMessage ( request , "Failed to update build settings" ) ;
208+ }
168209 }
169210 }
211+
212+ return redirectBackWithSuccessMessage ( request , "Build settings updated successfully" ) ;
170213 }
214+ ) ;
171215
172- return redirectBackWithSuccessMessage ( request , "Build settings updated successfully" ) ;
173- } ;
216+ type IntegrationsData = Extract <
217+ UseDataFunctionReturn < typeof loader > ,
218+ { canManageIntegrations : true }
219+ > ;
174220
175221export default function IntegrationsSettingsPage ( ) {
176- const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
177- useTypedLoaderData < typeof loader > ( ) ;
222+ const data = useTypedLoaderData < typeof loader > ( ) ;
223+
224+ if ( ! data . canManageIntegrations ) {
225+ return (
226+ < MainCenteredContainer >
227+ < PermissionDenied message = "With your current role, you can't manage integrations." />
228+ </ MainCenteredContainer >
229+ ) ;
230+ }
231+
232+ return < IntegrationsSettings data = { data } /> ;
233+ }
234+
235+ function IntegrationsSettings ( { data } : { data : IntegrationsData } ) {
236+ const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = data ;
178237 const project = useProject ( ) ;
179238 const organization = useOrganization ( ) ;
180239 const environment = useEnvironment ( ) ;
@@ -223,14 +282,28 @@ export default function IntegrationsSettingsPage() {
223282 } else if ( vercelFetcher . state === "idle" && vercelFetcher . data === undefined ) {
224283 // Load onboarding data
225284 vercelFetcher . load (
226- `${ vercelResourcePath ( organization . slug , project . slug , environment . slug ) } ?vercelOnboarding=true`
285+ `${ vercelResourcePath (
286+ organization . slug ,
287+ project . slug ,
288+ environment . slug
289+ ) } ?vercelOnboarding=true`
227290 ) ;
228291 }
229292 } else if ( ! hasQueryParam && isModalOpen ) {
230293 // Query param removed but modal is open, close modal
231294 setIsModalOpen ( false ) ;
232295 }
233- } , [ hasQueryParam , vercelIntegrationEnabled , organization . slug , project . slug , environment . slug , vercelFetcher . data , vercelFetcher . state , isModalOpen , openVercelOnboarding ] ) ;
296+ } , [
297+ hasQueryParam ,
298+ vercelIntegrationEnabled ,
299+ organization . slug ,
300+ project . slug ,
301+ environment . slug ,
302+ vercelFetcher . data ,
303+ vercelFetcher . state ,
304+ isModalOpen ,
305+ openVercelOnboarding ,
306+ ] ) ;
234307
235308 // Ensure modal stays open when query param is present (even after data reloads)
236309 // This is a safeguard to prevent the modal from closing during form submissions
@@ -272,14 +345,30 @@ export default function IntegrationsSettingsPage() {
272345 // Need to load data first, mark that we're waiting for button click
273346 waitingForButtonClickRef . current = true ;
274347 vercelFetcher . load (
275- `${ vercelResourcePath ( organization . slug , project . slug , environment . slug ) } ?vercelOnboarding=true`
348+ `${ vercelResourcePath (
349+ organization . slug ,
350+ project . slug ,
351+ environment . slug
352+ ) } ?vercelOnboarding=true`
276353 ) ;
277354 }
278- } , [ organization . slug , project . slug , environment . slug , vercelFetcher , setSearchParams , hasQueryParam , openVercelOnboarding ] ) ;
355+ } , [
356+ organization . slug ,
357+ project . slug ,
358+ environment . slug ,
359+ vercelFetcher ,
360+ setSearchParams ,
361+ hasQueryParam ,
362+ openVercelOnboarding ,
363+ ] ) ;
279364
280365 // When data loads from button click, open modal
281366 useEffect ( ( ) => {
282- if ( waitingForButtonClickRef . current && vercelFetcher . data ?. onboardingData && vercelFetcher . state === "idle" ) {
367+ if (
368+ waitingForButtonClickRef . current &&
369+ vercelFetcher . data ?. onboardingData &&
370+ vercelFetcher . state === "idle"
371+ ) {
283372 // Data loaded from button click, open modal and ensure query param is present
284373 waitingForButtonClickRef . current = false ;
285374 openVercelOnboarding ( ) ;
@@ -313,7 +402,9 @@ export default function IntegrationsSettingsPage() {
313402 projectSlug = { project . slug }
314403 environmentSlug = { environment . slug }
315404 onOpenVercelModal = { handleOpenVercelModal }
316- isLoadingVercelData = { vercelFetcher . state === "loading" || vercelFetcher . state === "submitting" }
405+ isLoadingVercelData = {
406+ vercelFetcher . state === "loading" || vercelFetcher . state === "submitting"
407+ }
317408 />
318409 </ div >
319410 </ div >
@@ -346,8 +437,14 @@ export default function IntegrationsSettingsPage() {
346437 vercelManageAccessUrl = { vercelFetcher . data ?. vercelManageAccessUrl }
347438 onDataReload = { ( vercelEnvironmentId ) => {
348439 vercelFetcher . load (
349- `${ vercelResourcePath ( organization . slug , project . slug , environment . slug ) } ?vercelOnboarding=true${
350- vercelEnvironmentId ? `&vercelEnvironmentId=${ encodeURIComponent ( vercelEnvironmentId ) } ` : ""
440+ `${ vercelResourcePath (
441+ organization . slug ,
442+ project . slug ,
443+ environment . slug
444+ ) } ?vercelOnboarding=true${
445+ vercelEnvironmentId
446+ ? `&vercelEnvironmentId=${ encodeURIComponent ( vercelEnvironmentId ) } `
447+ : ""
351448 } `
352449 ) ;
353450 } }
0 commit comments