import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { Form, useActionData, useNavigation, useSearchParams } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectBackWithErrorMessage, redirectBackWithSuccessMessage, } from "~/models/message.server"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; import { logger } from "~/services/logger.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder"; import { throwPermissionDenied } from "~/utils/permissionDenied"; import { type BuildSettings } from "~/v3/buildSettings"; import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; import { VercelOnboardingModal, VercelSettingsPanel, } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; export const loader = dashboardLoader( { params: EnvironmentParamSchema, context: async (params) => { const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, // No hard authorization: the page renders a PermissionDenied panel for // roles that can't manage any integration (see canManageIntegrations). }, async ({ params, user, ability }) => { const { projectParam, organizationSlug } = params; const canManageIntegrations = ability.can("write", { type: "github" }) || ability.can("write", { type: "vercel" }); if (!canManageIntegrations) { throwPermissionDenied("With your current role, you can't manage integrations."); } const projectSettingsPresenter = new ProjectSettingsPresenter(); const resultOrFail = await projectSettingsPresenter.getProjectSettings( organizationSlug, projectParam, user.id ); if (resultOrFail.isErr()) { switch (resultOrFail.error.type) { case "project_not_found": { throw new Response(undefined, { status: 404, statusText: "Project not found", }); } case "other": default: { resultOrFail.error.type satisfies "other"; logger.error("Failed loading project settings", { error: resultOrFail.error, }); throw new Response(undefined, { status: 400, statusText: "Something went wrong, please try again!", }); } } } const { gitHubApp, buildSettings } = resultOrFail.value; return typedjson({ githubAppEnabled: gitHubApp.enabled, buildSettings, vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, }); } ); const UpdateBuildSettingsFormSchema = z.object({ action: z.literal("update-build-settings"), triggerConfigFilePath: z .string() .trim() .optional() .transform((val) => (val ? val.replace(/^\/+/, "") : val)) .refine((val) => !val || val.length <= 255, { message: "Config file path must not exceed 255 characters", }), installCommand: z .string() .trim() .optional() .refine((val) => !val || !val.includes("\n"), { message: "Install command must be a single line", }) .refine((val) => !val || val.length <= 500, { message: "Install command must not exceed 500 characters", }), preBuildCommand: z .string() .trim() .optional() .refine((val) => !val || !val.includes("\n"), { message: "Pre-build command must be a single line", }) .refine((val) => !val || val.length <= 500, { message: "Pre-build command must not exceed 500 characters", }), useNativeBuildServer: z .string() .optional() .transform((val) => val === "on"), }); export const action = dashboardAction( { params: EnvironmentParamSchema, context: async (params) => { const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, // Build settings configure the Git-based deploy, so gate on write:github // (a restricted role can view neither this page nor mutate via a POST). authorization: { action: "write", resource: { type: "github" } }, }, async ({ request, params, user }) => { const { organizationSlug, projectParam } = params; const formData = await request.formData(); const submission = parseWithZod(formData, { schema: UpdateBuildSettingsFormSchema }); if (submission.status !== "success") { return json(submission.reply()); } const projectSettingsService = new ProjectSettingsService(); const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( organizationSlug, projectParam, user.id ); if (membershipResultOrFail.isErr()) { return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); } const { projectId } = membershipResultOrFail.value; const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } = submission.value; const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { installCommand: installCommand || undefined, preBuildCommand: preBuildCommand || undefined, triggerConfigFilePath: triggerConfigFilePath || undefined, useNativeBuildServer: useNativeBuildServer, }); if (resultOrFail.isErr()) { switch (resultOrFail.error.type) { case "other": default: { resultOrFail.error.type satisfies "other"; logger.error("Failed to update build settings", { error: resultOrFail.error, }); return redirectBackWithErrorMessage(request, "Failed to update build settings"); } } } return redirectBackWithSuccessMessage(request, "Build settings updated successfully"); } ); export default function IntegrationsSettingsPage() { const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); const environment = useEnvironment(); const [searchParams, setSearchParams] = useSearchParams(); // Vercel onboarding modal state const hasQueryParam = searchParams.get("vercelOnboarding") === "true"; const nextUrl = searchParams.get("next"); const [isModalOpen, setIsModalOpen] = useState(false); const vercelFetcher = useTypedFetcher(); // Helper to open modal and ensure query param is present const openVercelOnboarding = useCallback(() => { setIsModalOpen(true); // Ensure query param is present to maintain state during form submissions if (!hasQueryParam) { setSearchParams((prev) => { prev.set("vercelOnboarding", "true"); return prev; }); } }, [hasQueryParam, setSearchParams]); const closeVercelOnboarding = useCallback(() => { // Remove query param if present if (hasQueryParam) { setSearchParams((prev) => { prev.delete("vercelOnboarding"); return prev; }); } // Close modal setIsModalOpen(false); }, [hasQueryParam, setSearchParams]); // When query param is present, handle modal opening // Note: We don't close the modal based on data state during onboarding - only when explicitly closed useEffect(() => { if (hasQueryParam && vercelIntegrationEnabled) { // Ensure query param is present and modal is open if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { // Data is loaded, ensure modal is open (query param takes precedence) if (!isModalOpen) { openVercelOnboarding(); } } else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) { // Load onboarding data vercelFetcher.load( `${vercelResourcePath( organization.slug, project.slug, environment.slug )}?vercelOnboarding=true` ); } } else if (!hasQueryParam && isModalOpen) { // Query param removed but modal is open, close modal setIsModalOpen(false); } }, [ hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding, ]); // Ensure modal stays open when query param is present (even after data reloads) // This is a safeguard to prevent the modal from closing during form submissions useEffect(() => { if (hasQueryParam && !isModalOpen) { // Query param is present but modal is closed, open it // This ensures the modal stays open during the onboarding flow openVercelOnboarding(); } }, [hasQueryParam, isModalOpen, openVercelOnboarding]); // When data finishes loading (from query param), ensure modal is open useEffect(() => { if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { // Data loaded and query param is present, ensure modal is open if (!isModalOpen) { openVercelOnboarding(); } } }, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); // Track if we're waiting for data from button click (not query param) const waitingForButtonClickRef = useRef(false); // Handle opening modal from button click (without query param) const handleOpenVercelModal = useCallback(() => { // Add query param to maintain state during form submissions if (!hasQueryParam) { setSearchParams((prev) => { prev.set("vercelOnboarding", "true"); return prev; }); } if (vercelFetcher.data && vercelFetcher.data.onboardingData) { // Data already loaded, open modal immediately openVercelOnboarding(); } else { // Need to load data first, mark that we're waiting for button click waitingForButtonClickRef.current = true; vercelFetcher.load( `${vercelResourcePath( organization.slug, project.slug, environment.slug )}?vercelOnboarding=true` ); } }, [ organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding, ]); // When data loads from button click, open modal useEffect(() => { if ( waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle" ) { // Data loaded from button click, open modal and ensure query param is present waitingForButtonClickRef.current = false; openVercelOnboarding(); } }, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]); return ( <>
{githubAppEnabled && (
Git settings
{vercelIntegrationEnabled && (
Vercel integration
)}
Build settings
)}
{/* Vercel Onboarding Modal */} {vercelIntegrationEnabled && ( { vercelFetcher.load( `${vercelResourcePath( organization.slug, project.slug, environment.slug )}?vercelOnboarding=true${ vercelEnvironmentId ? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}` : "" }` ); }} /> )} ); } function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false); const [buildSettingsValues, setBuildSettingsValues] = useState({ preBuildCommand: buildSettings?.preBuildCommand || "", installCommand: buildSettings?.installCommand || "", triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "", useNativeBuildServer: buildSettings?.useNativeBuildServer || false, }); useEffect(() => { const hasChanges = buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") || buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") || buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") || buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false); setHasBuildSettingsChanges(hasChanges); }, [buildSettingsValues, buildSettings]); const [buildSettingsForm, fields] = useForm({ id: "update-build-settings", lastResult: lastSubmission, shouldRevalidate: "onSubmit", onValidate({ formData }) { return parseWithZod(formData, { schema: UpdateBuildSettingsFormSchema, }); }, }); const isBuildSettingsLoading = navigation.formData?.get("action") === "update-build-settings" && (navigation.state === "submitting" || navigation.state === "loading"); return (
{ setBuildSettingsValues((prev) => ({ ...prev, triggerConfigFilePath: e.target.value, })); }} /> Path to your Trigger configuration file, relative to the root directory of your repo. {fields.triggerConfigFilePath.errors} { setBuildSettingsValues((prev) => ({ ...prev, installCommand: e.target.value, })); }} /> Command to install your project dependencies. This will be run from the root directory of your repo. Auto-detected by default. {fields.installCommand.errors?.join(", ")} { setBuildSettingsValues((prev) => ({ ...prev, preBuildCommand: e.target.value, })); }} /> Any command that needs to run before we build and deploy your project. This will be run from the root directory of your repo. {fields.preBuildCommand.errors?.join(", ")}
{ setBuildSettingsValues((prev) => ({ ...prev, useNativeBuildServer: isChecked, })); }} /> Native build server builds do not rely on external build providers and will become the default in the future. Version 4.2.0 or newer is required. {fields.useNativeBuildServer.errors}
{buildSettingsForm.errors} Save } />
); }