568 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|