Files
2026-07-13 13:32:57 +08:00

568 lines
21 KiB
TypeScript

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<typeof loader>();
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<typeof vercelLoader>();
// 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 (
<>
<MainHorizontallyCenteredContainer className="md:mt-6">
<div className="flex flex-col gap-6">
{githubAppEnabled && (
<React.Fragment>
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
{vercelIntegrationEnabled && (
<div>
<Header2 spacing>Vercel integration</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<VercelSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
onOpenVercelModal={handleOpenVercelModal}
isLoadingVercelData={
vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"
}
/>
</div>
</div>
)}
<div>
<Header2 spacing>Build settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
</div>
</div>
</React.Fragment>
)}
</div>
</MainHorizontallyCenteredContainer>
{/* Vercel Onboarding Modal */}
{vercelIntegrationEnabled && (
<VercelOnboardingModal
isOpen={isModalOpen}
onClose={closeVercelOnboarding}
onboardingData={vercelFetcher.data?.onboardingData ?? null}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
nextUrl={nextUrl ?? undefined}
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
onDataReload={(vercelEnvironmentId) => {
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 (
<Form method="post" {...getFormProps(buildSettingsForm)}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
<Input
{...getInputProps(fields.triggerConfigFilePath, { type: "text" })}
defaultValue={buildSettings?.triggerConfigFilePath || ""}
placeholder="trigger.config.ts"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
triggerConfigFilePath: e.target.value,
}));
}}
/>
<Hint>
Path to your Trigger configuration file, relative to the root directory of your repo.
</Hint>
<FormError id={fields.triggerConfigFilePath.errorId}>
{fields.triggerConfigFilePath.errors}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.installCommand.id}>Install command</Label>
<Input
{...getInputProps(fields.installCommand, { type: "text" })}
defaultValue={buildSettings?.installCommand || ""}
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
installCommand: e.target.value,
}));
}}
/>
<Hint>
Command to install your project dependencies. This will be run from the root directory
of your repo. Auto-detected by default.
</Hint>
<FormError id={fields.installCommand.errorId}>
{fields.installCommand.errors?.join(", ")}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
<Input
{...getInputProps(fields.preBuildCommand, { type: "text" })}
defaultValue={buildSettings?.preBuildCommand || ""}
placeholder="e.g., `npm run prisma:generate`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
preBuildCommand: e.target.value,
}));
}}
/>
<Hint>
Any command that needs to run before we build and deploy your project. This will be run
from the root directory of your repo.
</Hint>
<FormError id={fields.preBuildCommand.errorId}>
{fields.preBuildCommand.errors?.join(", ")}
</FormError>
</InputGroup>
<div className="border-t border-grid-dimmed pt-4">
<InputGroup>
<CheckboxWithLabel
{...getInputProps(fields.useNativeBuildServer, { type: "checkbox" })}
label="Use native build server"
variant="simple/small"
defaultChecked={buildSettings?.useNativeBuildServer || false}
onChange={(isChecked) => {
setBuildSettingsValues((prev) => ({
...prev,
useNativeBuildServer: isChecked,
}));
}}
/>
<Hint>
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.
</Hint>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.errors}
</FormError>
</InputGroup>
</div>
<FormError>{buildSettingsForm.errors}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-build-settings"
variant="secondary/small"
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
);
}