From 3cecb9d1e0240f2c84fa9429a3f28a2f730ddcf0 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Thu, 19 Feb 2026 14:39:58 +0100 Subject: [PATCH 1/2] feat(settings): add project settings subpaths and general settings route Add dedicated subpaths for project settings: /general and /integrations. Introduce v3ProjectSettingsGeneralPath and v3ProjectSettingsIntegrationsPath in pathBuilder to allow linking to specific settings sections. Update GitHub integration routes and redirect logic to use the integrations subpath so installation/redirect targets point to the correct settings tab. Add a new nested route for project settings general: _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx. Implement form schema, actions, and UI scaffolding for renaming and deleting projects (with slug validation), plus server-side checks, messages, and service hooks. This prepares the app for per-tab settings and improves navigation/redirect accuracy for integrations. --- .../integrations/VercelOnboardingModal.tsx | 4 +- .../app/components/navigation/SideMenu.tsx | 31 +- .../components/navigation/sideMenuTypes.ts | 2 +- .../route.tsx | 4 +- .../route.tsx | 293 +++++++ .../route.tsx | 499 ++++++++++++ .../route.tsx | 751 +----------------- ...ationSlug.settings.integrations.vercel.tsx | 4 +- ...ionSlug.projects.$projectParam.settings.ts | 4 +- ...cts.$projectParam.env.$envParam.github.tsx | 6 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 4 +- apps/webapp/app/routes/vercel.connect.tsx | 4 +- apps/webapp/app/utils/pathBuilder.ts | 16 + 13 files changed, 872 insertions(+), 750 deletions(-) create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index dc6996dab14..283147d707f 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -44,7 +44,7 @@ import { } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server"; import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server"; -import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; +import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; import { useEffect, useState, useCallback, useRef } from "react"; import { usePostHogTracking } from "~/hooks/usePostHog"; @@ -1057,7 +1057,7 @@ export function VercelOnboardingModal({ {(() => { - const baseSettingsPath = v3ProjectSettingsPath( + const baseSettingsPath = v3ProjectSettingsIntegrationsPath( { slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug } diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index db86425b67b..d7ac89573be 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -11,6 +11,7 @@ import { Cog8ToothIcon, CogIcon, ExclamationTriangleIcon, + PuzzlePieceIcon, FolderIcon, FolderOpenIcon, GlobeAmericasIcon, @@ -74,7 +75,8 @@ import { v3LogsPath, v3ProjectAlertsPath, v3ProjectPath, - v3ProjectSettingsPath, + v3ProjectSettingsGeneralPath, + v3ProjectSettingsIntegrationsPath, v3QueuesPath, v3RunsPath, v3SchedulesPath, @@ -589,13 +591,34 @@ export function SideMenu({ data-action="limits" isCollapsed={isCollapsed} /> + + + + diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index 64afdf58e65..8dc722a37d1 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -1,7 +1,7 @@ import { z } from "zod"; // Valid section IDs that can have their collapsed state toggled -export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]); +export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]); // Inferred type from the schema export type SideMenuSectionId = z.infer; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 61e789e138b..d1ad05417ea 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -76,7 +76,7 @@ import { EnvironmentParamSchema, docsPath, v3DeploymentPath, - v3ProjectSettingsPath, + v3ProjectSettingsIntegrationsPath, } from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; @@ -377,7 +377,7 @@ export default function Page() { )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx new file mode 100644 index 00000000000..371ddd52fe8 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -0,0 +1,293 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { InlineCode } from "~/components/code/InlineCode"; +import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +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 { useProject } from "~/hooks/useProject"; +import { + redirectWithErrorMessage, + redirectWithSuccessMessage, +} from "~/models/message.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; +import { useState } from "react"; + +function createSchema( + constraints: { + getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; + } = {} +) { + return z.discriminatedUnion("action", [ + z.object({ + action: z.literal("rename"), + projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), + }), + z.object({ + action: z.literal("delete"), + projectSlug: z.string().superRefine((slug, ctx) => { + if (constraints.getSlugMatch === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: conform.VALIDATION_UNDEFINED, + }); + } else { + const { isMatch, projectSlug } = constraints.getSlugMatch(slug); + if (isMatch) { + return; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The slug must match ${projectSlug}`, + }); + } + }), + }), + ]); +} + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = params; + if (!organizationSlug || !projectParam) { + return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); + } + + const formData = await request.formData(); + + const schema = createSchema({ + getSlugMatch: (slug) => { + return { isMatch: slug === projectParam, projectSlug: projectParam }; + }, + }); + const submission = parse(formData, { schema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); + + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } + + const { projectId } = membershipResultOrFail.value; + + switch (submission.value.action) { + case "rename": { + const resultOrFail = await projectSettingsService.renameProject( + projectId, + submission.value.projectName + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to rename project", { + error: resultOrFail.error, + }); + return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); + } + } + } + + return redirectWithSuccessMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project renamed to ${submission.value.projectName}` + ); + } + case "delete": { + const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to delete project", { + error: resultOrFail.error, + }); + return redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project ${projectParam} could not be deleted` + ); + } + } + } + + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + "Project deleted" + ); + } + } +}; + +export default function GeneralSettingsPage() { + const project = useProject(); + const lastSubmission = useActionData(); + const navigation = useNavigation(); + + const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); + + const [renameForm, { projectName }] = useForm({ + id: "rename-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema(), + }); + }, + }); + + const isRenameLoading = + navigation.formData?.get("action") === "rename" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const [deleteForm, { projectSlug }] = useForm({ + id: "delete-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldValidate: "onInput", + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema({ + getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), + }), + }); + }, + }); + + const isDeleteLoading = + navigation.formData?.get("action") === "delete" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const [deleteInputValue, setDeleteInputValue] = useState(""); + + return ( + +
+
+ General +
+
+ + + + + This goes in your{" "} + trigger.config file. + + +
+
+
+ + + { + setHasRenameFormChanges(e.target.value !== project.name); + }} + /> + {projectName.error} + + + Save + + } + /> +
+
+
+
+ +
+ Danger zone +
+
+
+ + + setDeleteInputValue(e.target.value)} + /> + {projectSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Project slug + {project.slug} and then press + Delete. + + + + Delete + + } + /> +
+
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx new file mode 100644 index 00000000000..763c727e056 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx @@ -0,0 +1,499 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData, useTypedFetcher } 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 { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { + redirectBackWithErrorMessage, + redirectBackWithSuccessMessage, +} from "~/models/message.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; +import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder"; +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { useSearchParams } from "@remix-run/react"; +import { type BuildSettings } from "~/v3/buildSettings"; +import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; +import { + VercelSettingsPanel, + VercelOnboardingModal, +} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); + + const projectSettingsPresenter = new ProjectSettingsPresenter(); + const resultOrFail = await projectSettingsPresenter.getProjectSettings( + organizationSlug, + projectParam, + userId + ); + + 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: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = params; + if (!organizationSlug || !projectParam) { + return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); + } + + const formData = await request.formData(); + const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); + + 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=${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", + lastSubmission: lastSubmission, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(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.error} + + + + + + { + 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.error} + + + + { + 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.error} + +
+ + { + 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.error} + + +
+ {buildSettingsForm.error} + + Save + + } + /> +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index a5a70c39af6..8a9e36ae5c2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -1,57 +1,13 @@ -import { conform, useForm } from "@conform-to/react"; -import { parse } from "@conform-to/zod"; -import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; -import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { z } from "zod"; -import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { InlineCode } from "~/components/code/InlineCode"; -import { - MainHorizontallyCenteredContainer, - PageBody, - PageContainer, -} from "~/components/layout/AppLayout"; -import { Button } from "~/components/primitives/Buttons"; -import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; -import { ClipboardField } from "~/components/primitives/ClipboardField"; -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 { Outlet, type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { useOrganization } from "~/hooks/useOrganizations"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { useProject } from "~/hooks/useProject"; -import { - redirectBackWithErrorMessage, - redirectBackWithSuccessMessage, - redirectWithErrorMessage, - redirectWithSuccessMessage, -} from "~/models/message.server"; -import { ProjectSettingsService } from "~/services/projectSettings.server"; -import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder"; -import React, { useEffect, useState, useCallback, useRef } from "react"; -import { useSearchParams } from "@remix-run/react"; -import { useEnvironment } from "~/hooks/useEnvironment"; -import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; -import { type BuildSettings } from "~/v3/buildSettings"; -import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; -import { - VercelSettingsPanel, - VercelOnboardingModal, -} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; -import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; -import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; -import { useTypedFetcher } from "remix-typedjson"; +import { EnvironmentParamSchema, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -62,397 +18,26 @@ export const meta: MetaFunction = () => { }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); - - const projectSettingsPresenter = new ProjectSettingsPresenter(); - const resultOrFail = await projectSettingsPresenter.getProjectSettings( - organizationSlug, - projectParam, - userId - ); - - 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!", - }); - } - } + await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + // Redirect /settings to /settings/general + const url = new URL(request.url); + if (url.pathname.endsWith("/settings") || url.pathname.endsWith("/settings/")) { + return redirect( + v3ProjectSettingsGeneralPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ) + ); } - 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"), -}); - -type UpdateBuildSettingsFormSchema = z.infer; - -export function createSchema( - constraints: { - getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; - } = {} -) { - return z.discriminatedUnion("action", [ - z.object({ - action: z.literal("rename"), - projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), - }), - z.object({ - action: z.literal("delete"), - projectSlug: z.string().superRefine((slug, ctx) => { - if (constraints.getSlugMatch === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: conform.VALIDATION_UNDEFINED, - }); - } else { - const { isMatch, projectSlug } = constraints.getSlugMatch(slug); - if (isMatch) { - return; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `The slug must match ${projectSlug}`, - }); - } - }), - }), - UpdateBuildSettingsFormSchema, - ]); -} - -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam } = params; - if (!organizationSlug || !projectParam) { - return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); - } - - const formData = await request.formData(); - - const schema = createSchema({ - getSlugMatch: (slug) => { - return { isMatch: slug === projectParam, projectSlug: projectParam }; - }, - }); - const submission = parse(formData, { schema }); - - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } - - const projectSettingsService = new ProjectSettingsService(); - const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( - organizationSlug, - projectParam, - userId - ); - - if (membershipResultOrFail.isErr()) { - return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); - } - - const { projectId } = membershipResultOrFail.value; - - switch (submission.value.action) { - case "rename": { - const resultOrFail = await projectSettingsService.renameProject( - projectId, - submission.value.projectName - ); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed to rename project", { - error: resultOrFail.error, - }); - return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); - } - } - } - - return redirectWithSuccessMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project renamed to ${submission.value.projectName}` - ); - } - case "delete": { - const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed to delete project", { - error: resultOrFail.error, - }); - return redirectWithErrorMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project ${projectParam} could not be deleted` - ); - } - } - } - - return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), - request, - "Project deleted" - ); - } - case "update-build-settings": { - 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"); - } - default: { - submission.value satisfies never; - return redirectBackWithErrorMessage(request, "Failed to process request"); - } - } + return null; }; -export default function Page() { - const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = - useTypedLoaderData(); +export default function SettingsLayout() { const project = useProject(); - const organization = useOrganization(); - const environment = useEnvironment(); - const lastSubmission = useActionData(); - const navigation = useNavigation(); - 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]); - - const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); - - const [renameForm, { projectName }] = useForm({ - id: "rename-project", - // TODO: type this - lastSubmission: lastSubmission as any, - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: createSchema(), - }); - }, - }); - - const isRenameLoading = - navigation.formData?.get("action") === "rename" && - (navigation.state === "submitting" || navigation.state === "loading"); - - const [deleteForm, { projectSlug }] = useForm({ - id: "delete-project", - // TODO: type this - lastSubmission: lastSubmission as any, - shouldValidate: "onInput", - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: createSchema({ - getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), - }), - }); - }, - }); - - const isDeleteLoading = - navigation.formData?.get("action") === "delete" && - (navigation.state === "submitting" || navigation.state === "loading"); - - const [deleteInputValue, setDeleteInputValue] = useState(""); return ( @@ -479,302 +64,8 @@ export default function Page() { - -
-
- General -
-
- - - - - This goes in your{" "} - trigger.config file. - - -
-
-
- - - { - setHasRenameFormChanges(e.target.value !== project.name); - }} - /> - {projectName.error} - - - Save - - } - /> -
-
-
-
- - {githubAppEnabled && ( - -
- Git settings -
- -
-
- - {vercelIntegrationEnabled && ( -
- Vercel integration -
- -
-
- )} - -
- Build settings -
- -
-
-
- )} - -
- Danger zone -
-
-
- - - setDeleteInputValue(e.target.value)} - /> - {projectSlug.error} - {deleteForm.error} - - This change is irreversible, so please be certain. Type in the Project slug - {project.slug} and then press - Delete. - - - - Delete - - } - /> -
-
-
-
-
-
+
- - {/* Vercel Onboarding Modal */} - {vercelIntegrationEnabled && ( - { - vercelFetcher.load( - `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${ - vercelEnvironmentId ? `&vercelEnvironmentId=${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", - lastSubmission: lastSubmission, - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(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.error} - - - - - - { - 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.error} - - - - { - 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.error} - -
- - { - 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.error} - - -
- {buildSettingsForm.error} - - Save - - } - /> -
-
- ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index 10b3f2283ce..df6f5b9859a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -28,7 +28,7 @@ import { requireOrganization } from "~/services/org.server"; import { OrganizationParamsSchema } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; import { TrashIcon } from "@heroicons/react/20/solid"; -import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; import { LinkButton } from "~/components/primitives/Buttons"; function formatDate(date: Date): string { @@ -354,7 +354,7 @@ export default function VercelIntegrationPage() { { const user = await requireUser(request); @@ -39,5 +39,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const selector = new SelectBestEnvironmentPresenter(); const environment = await selector.selectBestEnvironment(project.id, user, project.environments); - return redirect(v3ProjectSettingsPath({ slug: organizationSlug }, project, environment)); + return redirect(v3ProjectSettingsGeneralPath({ slug: organizationSlug }, project, environment)); }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index afd89f33577..38ef50126cd 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -43,7 +43,7 @@ import { requireUserId } from "~/services/session.server"; import { githubAppInstallPath, EnvironmentParamSchema, - v3ProjectSettingsPath, + v3ProjectSettingsIntegrationsPath, } from "~/utils/pathBuilder"; import { cn } from "~/utils/cn"; import { type BranchTrackingConfig } from "~/v3/github"; @@ -459,7 +459,7 @@ export function ConnectGitHubRepoModal({ navigate( githubAppInstallPath( organizationSlug, - `${v3ProjectSettingsPath( + `${v3ProjectSettingsIntegrationsPath( { slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug } @@ -567,7 +567,7 @@ export function GitHubConnectionPrompt({ redirectUrl?: string; }) { - const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug }); + const githubInstallationRedirect = redirectUrl || v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug }); return (
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index bb0fca6d745..26e9ad5b3be 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -44,7 +44,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server"; -import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3ProjectSettingsIntegrationsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; import { VercelSettingsPresenter, type VercelOnboardingData, @@ -224,7 +224,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json(submission); } - const settingsPath = v3ProjectSettingsPath( + const settingsPath = v3ProjectSettingsIntegrationsPath( { slug: organizationSlug }, { slug: projectParam }, { slug: envParam } diff --git a/apps/webapp/app/routes/vercel.connect.tsx b/apps/webapp/app/routes/vercel.connect.tsx index 7c0701edfe3..f1be58fe977 100644 --- a/apps/webapp/app/routes/vercel.connect.tsx +++ b/apps/webapp/app/routes/vercel.connect.tsx @@ -7,7 +7,7 @@ import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercel import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { requestUrl } from "~/utils/requestUrl.server"; -import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; const VercelConnectSchema = z.object({ @@ -139,7 +139,7 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Response("Environment not found", { status: 404 }); } - const settingsPath = v3ProjectSettingsPath( + const settingsPath = v3ProjectSettingsIntegrationsPath( { slug: stateData.organizationSlug }, { slug: stateData.projectSlug }, { slug: environment.slug } diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 4f1c03d8d66..4230474804c 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -499,6 +499,22 @@ export function v3ProjectSettingsPath( return `${v3EnvironmentPath(organization, project, environment)}/settings`; } +export function v3ProjectSettingsGeneralPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3ProjectSettingsPath(organization, project, environment)}/general`; +} + +export function v3ProjectSettingsIntegrationsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3ProjectSettingsPath(organization, project, environment)}/integrations`; +} + export function v3LogsPath( organization: OrgForPath, project: ProjectForPath, From 5527e04710bcfe33301044c1001b0f5bca4a325b Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Thu, 19 Feb 2026 17:01:10 +0100 Subject: [PATCH 2/2] feat(settings): redirect to integrations when vercelOnboarding Redirect /settings to /settings/general by default, but detect the vercelOnboarding query parameter and redirect to /settings/integrations when present. Factor org/project/env slug objects and preserve original search params on redirect. This ensures users entering the settings route during Vercel onboarding are taken directly to the integrations page. --- .../route.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 8a9e36ae5c2..cc85dbb4acc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -7,7 +7,7 @@ import * as Property from "~/components/primitives/PropertyTable"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { useProject } from "~/hooks/useProject"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -21,16 +21,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - // Redirect /settings to /settings/general + // Redirect /settings to /settings/general (or /settings/integrations for Vercel onboarding) const url = new URL(request.url); if (url.pathname.endsWith("/settings") || url.pathname.endsWith("/settings/")) { - return redirect( - v3ProjectSettingsGeneralPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ) - ); + const org = { slug: organizationSlug }; + const project = { slug: projectParam }; + const env = { slug: envParam }; + + const basePath = url.searchParams.has("vercelOnboarding") + ? v3ProjectSettingsIntegrationsPath(org, project, env) + : v3ProjectSettingsGeneralPath(org, project, env); + + return redirect(`${basePath}${url.search}`); } return null;