Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx
2026-07-13 13:32:57 +08:00

1221 lines
44 KiB
TypeScript

import { getFormProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { CheckCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { Result, fromPromise } from "neverthrow";
import { useEffect, useRef, useState } from "react";
import { typedjson, useTypedFetcher } from "remix-typedjson";
import { z } from "zod";
import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PermissionLink } from "~/components/primitives/PermissionLink";
import { Select, SelectItem } from "~/components/primitives/Select";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import {
redirectBackWithErrorMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
import {
type VercelOnboardingData,
VercelSettingsPresenter,
} from "~/presenters/v3/VercelSettingsPresenter.server";
import { logger } from "~/services/logger.server";
import { rbac } from "~/services/rbac.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { requireUserId } from "~/services/session.server";
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
import {
EnvironmentParamSchema,
v3ProjectSettingsIntegrationsPath,
vercelAppInstallPath,
vercelResourcePath,
} from "~/utils/pathBuilder";
import {
type EnvSlug,
type SyncEnvVarsMapping,
type VercelProjectIntegrationData,
envSlugArrayField,
getAvailableEnvSlugs,
getAvailableEnvSlugsForBuildSettings,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
export type ConnectedVercelProject = {
id: string;
vercelProjectId: string;
vercelProjectName: string;
vercelTeamId: string | null;
integrationData: VercelProjectIntegrationData;
createdAt: Date;
};
const safeJsonParse = Result.fromThrowable(
(val: string) => JSON.parse(val) as Record<string, unknown>,
() => null
);
function parseVercelStagingEnvironment(
value: string | null | undefined
): { environmentId: string; displayName: string } | null {
if (!value) return null;
return safeJsonParse(value).match(
(parsed) => {
if (typeof parsed?.environmentId === "string" && typeof parsed?.displayName === "string") {
return { environmentId: parsed.environmentId, displayName: parsed.displayName };
}
return null;
},
() => null
);
}
// Sentinel values for the clearTriggerVersion hidden input. Used by the schema transform,
// the input's defaultValue, and the modal's submit helper — keep all three reading the same
// constants so they cannot drift.
const CLEAR_TRIGGER_VERSION_YES = "true";
const CLEAR_TRIGGER_VERSION_NO = "false";
const UpdateVercelConfigFormSchema = z.object({
action: z.literal("update-config"),
atomicBuilds: envSlugArrayField,
pullEnvVarsBeforeBuild: envSlugArrayField,
discoverEnvVars: envSlugArrayField,
vercelStagingEnvironment: z.string().nullable().optional(),
autoPromote: z
.string()
.optional()
.transform((val) => val !== "false"),
clearTriggerVersion: z
.string()
.optional()
.transform((val) => val === CLEAR_TRIGGER_VERSION_YES),
});
const DisconnectVercelFormSchema = z.object({
action: z.literal("disconnect"),
});
const CompleteOnboardingFormSchema = z.object({
action: z.literal("complete-onboarding"),
vercelStagingEnvironment: z.string().nullable().optional(),
pullEnvVarsBeforeBuild: envSlugArrayField,
atomicBuilds: envSlugArrayField,
discoverEnvVars: envSlugArrayField,
syncEnvVarsMapping: z.string().optional(),
next: z.string().optional(),
skipRedirect: z
.string()
.optional()
.transform((val) => val === "true"),
origin: z.string().optional(),
});
const SkipOnboardingFormSchema = z.object({
action: z.literal("skip-onboarding"),
});
const SelectVercelProjectFormSchema = z.object({
action: z.literal("select-vercel-project"),
vercelProjectId: z.string().min(1, "Please select a Vercel project"),
vercelProjectName: z.string().min(1),
});
const UpdateEnvMappingFormSchema = z.object({
action: z.literal("update-env-mapping"),
vercelStagingEnvironment: z.string().nullable().optional(),
});
const DisableAutoAssignFormSchema = z.object({
action: z.literal("disable-auto-assign"),
});
const VercelActionSchema = z.discriminatedUnion("action", [
UpdateVercelConfigFormSchema,
DisconnectVercelFormSchema,
CompleteOnboardingFormSchema,
SkipOnboardingFormSchema,
SelectVercelProjectFormSchema,
UpdateEnvMappingFormSchema,
DisableAutoAssignFormSchema,
]);
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new VercelSettingsPresenter();
const resultOrFail = await presenter.call({
projectId: project.id,
organizationId: project.organizationId,
});
if (resultOrFail.isErr()) {
logger.error("Failed to load Vercel settings", {
url: request.url,
params,
error: resultOrFail.error,
});
throw new Response("Failed to load Vercel settings", { status: 500 });
}
const result = resultOrFail.value;
const url = new URL(request.url);
const needsOnboarding = url.searchParams.get("vercelOnboarding") === "true";
const vercelEnvironmentId = url.searchParams.get("vercelEnvironmentId") || undefined;
let onboardingData: VercelOnboardingData | null = null;
if (needsOnboarding) {
onboardingData = await presenter.getOnboardingData(
project.id,
project.organizationId,
vercelEnvironmentId
);
}
const authInvalid = onboardingData?.authInvalid || result.authInvalid || false;
const authError = onboardingData?.authError || result.authError;
// Display flag for the connect/disconnect/configure controls — the action
// enforces write:vercel independently. Permissive in OSS.
const sessionAuth = await rbac.authenticateSession(request, {
userId,
organizationId: project.organizationId,
});
const canManageVercel = sessionAuth.ok
? sessionAuth.ability.can("write", { type: "vercel" })
: true;
return typedjson({
...result,
authInvalid,
authError,
onboardingData,
organizationSlug,
projectSlug: projectParam,
environmentSlug: envParam,
projectId: project.id,
organizationId: project.organizationId,
canManageVercel,
});
}
export const action = dashboardAction(
{
params: EnvironmentParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "write", resource: { type: "vercel" } },
},
async ({ request, params, user }) => {
const userId = user.id;
const { organizationSlug, projectParam, envParam } = params;
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const formData = await request.formData();
const submission = parseWithZod(formData, { schema: VercelActionSchema });
if (submission.status !== "success") {
return json(submission.reply());
}
const settingsPath = v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
const vercelService = new VercelIntegrationService();
const { action: actionType } = submission.value;
switch (actionType) {
case "update-config": {
const {
atomicBuilds,
pullEnvVarsBeforeBuild,
discoverEnvVars,
vercelStagingEnvironment,
autoPromote,
clearTriggerVersion,
} = submission.value;
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
// Get the previous staging environment before updating
const previousIntegration = await vercelService.getVercelProjectIntegration(project.id);
const previousStagingEnvId =
previousIntegration?.parsedIntegrationData.config?.vercelStagingEnvironment
?.environmentId ?? null;
const newStagingEnvId = parsedStagingEnv?.environmentId ?? null;
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
atomicBuilds,
pullEnvVarsBeforeBuild,
discoverEnvVars,
vercelStagingEnvironment: parsedStagingEnv,
autoPromote,
});
if (result) {
// Sync staging TRIGGER_SECRET_KEY if the custom environment changed
if (previousStagingEnvId !== newStagingEnvId) {
await vercelService.syncStagingKeyForCustomEnvironment(
project.id,
previousStagingEnvId,
newStagingEnvId
);
}
// When atomic deployments are being disabled and the user confirmed clearing the pin,
// remove TRIGGER_VERSION from Vercel production so future deploys don't stay pinned.
// If the Vercel API call fails we still consider the settings save itself successful,
// but tell the user so they can clear the env var manually from the Vercel dashboard.
if (clearTriggerVersion && !atomicBuilds?.includes("prod")) {
const cleared = await vercelService.clearTriggerVersionFromVercelProduction(project.id);
if (!cleared) {
return redirectWithErrorMessage(
settingsPath,
request,
"Vercel settings saved, but failed to clear TRIGGER_VERSION on Vercel — please remove it manually from your Vercel project settings."
);
}
}
return redirectWithSuccessMessage(
settingsPath,
request,
"Vercel settings updated successfully"
);
}
return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings");
}
case "disconnect": {
const success = await vercelService.disconnectVercelProject(project.id);
if (success) {
return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected");
}
return redirectWithErrorMessage(
settingsPath,
request,
"Failed to disconnect Vercel project"
);
}
case "complete-onboarding": {
const {
vercelStagingEnvironment,
pullEnvVarsBeforeBuild,
atomicBuilds,
discoverEnvVars,
syncEnvVarsMapping,
next,
skipRedirect,
origin,
} = submission.value;
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
const parsedSyncEnvVarsMapping = syncEnvVarsMapping
? (safeJsonParse(syncEnvVarsMapping).unwrapOr(undefined) as
| SyncEnvVarsMapping
| undefined)
: undefined;
const result = await vercelService.completeOnboarding(project.id, {
vercelStagingEnvironment: parsedStagingEnv,
pullEnvVarsBeforeBuild,
atomicBuilds,
discoverEnvVars,
syncEnvVarsMapping: parsedSyncEnvVarsMapping,
origin: origin === "marketplace" ? "marketplace" : "dashboard",
});
if (result) {
if (skipRedirect) {
return json({ success: true });
}
if (next) {
const sanitizedNext = sanitizeVercelNextUrl(next);
if (sanitizedNext) {
return json({ success: true, redirectTo: sanitizedNext });
}
logger.warn("Rejected next URL - not same-origin or vercel.com", { next });
}
return json({ success: true, redirectTo: settingsPath });
}
return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup");
}
case "update-env-mapping": {
const { vercelStagingEnvironment } = submission.value;
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
vercelStagingEnvironment: parsedStagingEnv,
});
if (result) {
// During onboarding there's no previous custom environment — just upsert
await vercelService.syncStagingKeyForCustomEnvironment(
project.id,
null,
parsedStagingEnv?.environmentId ?? null
);
return json({ success: true });
}
return json(
{ success: false, error: "Failed to update environment mapping" },
{ status: 400 }
);
}
case "skip-onboarding": {
return redirectWithSuccessMessage(
settingsPath,
request,
"Vercel integration setup skipped"
);
}
case "select-vercel-project": {
const { vercelProjectId, vercelProjectName } = submission.value;
const selectResult = await fromPromise(
vercelService.selectVercelProject({
organizationId: project.organizationId,
projectId: project.id,
vercelProjectId,
vercelProjectName,
userId,
}),
(error) => error
);
if (selectResult.isErr()) {
logger.error("Failed to select Vercel project", { error: selectResult.error });
return json({
error: "Failed to connect Vercel project. Please try again.",
});
}
const { integration, syncResult } = selectResult.value;
if (!syncResult.success && syncResult.errors.length > 0) {
logger.warn("Failed to send trigger secrets to Vercel", {
projectId: project.id,
vercelProjectId,
errors: syncResult.errors,
});
}
return json({
success: true,
integrationId: integration.id,
syncErrors: syncResult.errors,
});
}
case "disable-auto-assign": {
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(
project.id
);
if (!orgIntegration) {
return redirectWithErrorMessage(settingsPath, request, "No Vercel integration found");
}
const projectIntegration = await vercelService.getVercelProjectIntegration(project.id);
if (!projectIntegration) {
return redirectWithErrorMessage(settingsPath, request, "No Vercel project connected");
}
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
const disableResult = await VercelIntegrationRepository.getVercelClient(
orgIntegration
).andThen((client) =>
VercelIntegrationRepository.disableAutoAssignCustomDomains(
client,
projectIntegration.parsedIntegrationData.vercelProjectId,
teamId
)
);
if (disableResult.isErr()) {
logger.error("Failed to disable auto-assign custom domains", {
error: disableResult.error,
});
return redirectWithErrorMessage(
settingsPath,
request,
"Failed to disable auto-assign custom domains"
);
}
return redirectWithSuccessMessage(
settingsPath,
request,
"Auto-assign custom domains disabled"
);
}
default: {
submission.value satisfies never;
return redirectBackWithErrorMessage(request, "Failed to process request");
}
}
}
);
function VercelConnectionPrompt({
organizationSlug,
projectSlug,
environmentSlug,
hasOrgIntegration,
isGitHubConnected,
onOpenModal,
isLoading,
canManageVercel = true,
}: {
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
hasOrgIntegration: boolean;
isGitHubConnected: boolean;
onOpenModal?: () => void;
isLoading?: boolean;
canManageVercel?: boolean;
}) {
const installPath = vercelAppInstallPath(organizationSlug, projectSlug);
const handleConnectProject = () => {
if (onOpenModal) {
onOpenModal();
}
};
const isLoadingProjects = isLoading ?? false;
const isDisabled = isLoadingProjects || !onOpenModal;
return (
<Fieldset>
<InputGroup fullWidth>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
{hasOrgIntegration ? (
<>
<Button
variant="secondary/medium"
onClick={handleConnectProject}
disabled={isDisabled || !canManageVercel}
tooltip={
canManageVercel
? undefined
: "You don't have permission to manage the Vercel integration"
}
LeadingIcon={
isLoadingProjects
? () => <SpinnerWhite className="size-4" />
: () => <VercelLogo className="-mx-1 size-4" />
}
>
{isLoadingProjects ? "Loading projects..." : "Connect Vercel project"}
</Button>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> Vercel app is installed
</span>
{!onOpenModal && (
<span className="text-xs text-amber-400">
Please reconnect Vercel to continue
</span>
)}
</>
) : (
<>
<PermissionLink
hasPermission={canManageVercel}
noPermissionTooltip="You don't have permission to manage the Vercel integration"
to={installPath}
variant="secondary/medium"
LeadingIcon={() => <VercelLogo className="-mx-1 size-4" />}
>
Install Vercel app
</PermissionLink>
</>
)}
</div>
</div>
</InputGroup>
</Fieldset>
);
}
function VercelAuthInvalidBanner({
organizationSlug,
projectSlug,
canManageVercel = true,
}: {
organizationSlug: string;
projectSlug: string;
canManageVercel?: boolean;
}) {
const installUrl = vercelAppInstallPath(organizationSlug, projectSlug);
return (
<Callout variant="error" className="mb-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<p className="mb-2 font-sans text-sm font-medium text-text-bright">
Vercel connection expired
</p>
<p className="mb-3 font-sans text-xs text-text-dimmed">
Your Vercel access token has expired or been revoked. Please reconnect to restore
functionality.
</p>
<PermissionLink
hasPermission={canManageVercel}
noPermissionTooltip="You don't have permission to manage the Vercel integration"
to={installUrl}
variant="minimal/small"
className="border-error/20 bg-error/10 text-error hover:bg-error/20"
>
Reconnect Vercel
</PermissionLink>
</div>
</div>
</Callout>
);
}
function VercelGitHubWarning() {
return (
<Callout variant="warning" className="mb-4">
<p className="font-sans text-xs font-normal text-text-dimmed">
GitHub integration is not connected. Vercel integration cannot sync environment variables
and link deployments without a properly installed GitHub integration.
</p>
</Callout>
);
}
function envSlugLabel(slug: EnvSlug): string {
switch (slug) {
case "prod":
return "Production";
case "stg":
return "Staging";
case "preview":
return "Preview";
case "dev":
return "Development";
}
}
function ConnectedVercelProjectForm({
connectedProject,
hasStagingEnvironment,
hasPreviewEnvironment,
customEnvironments,
autoAssignCustomDomains,
currentTriggerVersion,
currentTriggerVersionFetchFailed,
organizationSlug,
projectSlug,
environmentSlug,
canManageVercel = true,
}: {
connectedProject: ConnectedVercelProject;
hasStagingEnvironment: boolean;
hasPreviewEnvironment: boolean;
customEnvironments: Array<{ id: string; slug: string }>;
autoAssignCustomDomains: boolean | null;
currentTriggerVersion: string | null;
currentTriggerVersionFetchFailed: boolean;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
canManageVercel?: boolean;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasConfigChanges, setHasConfigChanges] = useState(false);
const [configValues, setConfigValues] = useState({
atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [],
pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [],
discoverEnvVars: connectedProject.integrationData.config.discoverEnvVars ?? [],
vercelStagingEnvironment:
connectedProject.integrationData.config.vercelStagingEnvironment ?? null,
autoPromote: connectedProject.integrationData.config.autoPromote ?? true,
});
const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? [];
const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [];
const originalDiscoverEnvVars = connectedProject.integrationData.config.discoverEnvVars ?? [];
const originalStagingEnv =
connectedProject.integrationData.config.vercelStagingEnvironment ?? null;
const originalAutoPromote = connectedProject.integrationData.config.autoPromote ?? true;
useEffect(() => {
const atomicBuildsChanged =
JSON.stringify([...configValues.atomicBuilds].sort()) !==
JSON.stringify([...originalAtomicBuilds].sort());
const pullEnvVarsChanged =
JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !==
JSON.stringify([...originalPullEnvVars].sort());
const discoverEnvVarsChanged =
JSON.stringify([...configValues.discoverEnvVars].sort()) !==
JSON.stringify([...originalDiscoverEnvVars].sort());
const stagingEnvChanged =
configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId;
const autoPromoteChanged = configValues.autoPromote !== originalAutoPromote;
setHasConfigChanges(
atomicBuildsChanged ||
pullEnvVarsChanged ||
discoverEnvVarsChanged ||
stagingEnvChanged ||
autoPromoteChanged
);
}, [
configValues,
originalAtomicBuilds,
originalPullEnvVars,
originalDiscoverEnvVars,
originalStagingEnv,
originalAutoPromote,
]);
const [configForm, _fields] = useForm({
id: "update-vercel-config",
lastResult: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parseWithZod(formData, {
schema: UpdateVercelConfigFormSchema,
});
},
});
const saveButtonRef = useRef<HTMLButtonElement>(null);
const clearTriggerVersionInputRef = useRef<HTMLInputElement>(null);
const [showClearDialog, setShowClearDialog] = useState(false);
// Modal trigger uses the page-load state of atomicBuilds, not whatever changed in-session,
// because clearing TRIGGER_VERSION only makes sense when atomic was actually on at load time.
// If the Vercel lookup failed we still prompt — we don't know whether a pin exists, so the
// user needs to make the call explicitly rather than silently leaving prod pinned.
const wasAtomicEnabledAtLoad = originalAtomicBuilds.includes("prod");
const isAtomicNowDisabled = !configValues.atomicBuilds.includes("prod");
const shouldPromptClearOnSave =
wasAtomicEnabledAtLoad &&
isAtomicNowDisabled &&
(Boolean(currentTriggerVersion) || currentTriggerVersionFetchFailed);
const submitWithClearChoice = (clear: boolean) => {
if (clearTriggerVersionInputRef.current) {
clearTriggerVersionInputRef.current.value = clear
? CLEAR_TRIGGER_VERSION_YES
: CLEAR_TRIGGER_VERSION_NO;
}
setShowClearDialog(false);
// Conform owns the form's React ref via {...configForm.props}, so look it up by id
// (set via useForm({ id: "update-vercel-config" })) rather than fighting for the ref.
const form = document.getElementById("update-vercel-config") as HTMLFormElement | null;
form?.requestSubmit(saveButtonRef.current ?? undefined);
};
const isConfigLoading =
navigation.formData?.get("action") === "update-config" &&
(navigation.state === "submitting" || navigation.state === "loading");
const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug);
const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment);
const availableEnvSlugsForBuildSettings = getAvailableEnvSlugsForBuildSettings(
hasStagingEnvironment,
hasPreviewEnvironment
);
const disabledEnvSlugsForBuildSettings: Partial<Record<EnvSlug, string>> | undefined =
hasStagingEnvironment && !configValues.vercelStagingEnvironment
? { stg: "Map a custom Vercel environment to Staging to enable this" }
: undefined;
const _formatSelectedEnvs = (
selected: EnvSlug[],
availableSlugs: EnvSlug[] = availableEnvSlugs
): string => {
if (selected.length === 0) return "None selected";
if (selected.length === availableSlugs.length) return "All environments";
return selected.map(envSlugLabel).join(", ");
};
return (
<>
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
<div className="flex items-center gap-2">
<VercelLogo className="size-4" />
<span className="max-w-52 truncate text-sm text-text-bright">
{connectedProject.vercelProjectName}
</span>
<span className="text-xs text-text-dimmed">
<DateTime
date={connectedProject.createdAt}
includeTime={false}
includeSeconds={false}
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="minimal/small"
disabled={!canManageVercel}
tooltip={
canManageVercel
? undefined
: "You don't have permission to manage the Vercel integration"
}
>
Disconnect
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect Vercel project</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedProject.vercelProjectName}</span>? This
will stop pulling environment variables and disable atomic deployments.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disconnect" />
<Button type="submit" variant="danger/medium">
Disconnect project
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</div>
{/* Configuration form */}
<Form method="post" action={actionUrl} {...getFormProps(configForm)}>
<input
type="hidden"
name="atomicBuilds"
value={JSON.stringify(configValues.atomicBuilds)}
/>
<input
type="hidden"
name="pullEnvVarsBeforeBuild"
value={JSON.stringify(configValues.pullEnvVarsBeforeBuild)}
/>
<input
type="hidden"
name="discoverEnvVars"
value={JSON.stringify(configValues.discoverEnvVars)}
/>
<input
type="hidden"
name="vercelStagingEnvironment"
value={
configValues.vercelStagingEnvironment
? JSON.stringify(configValues.vercelStagingEnvironment)
: ""
}
/>
<input type="hidden" name="autoPromote" value={String(configValues.autoPromote)} />
{/* Flipped to CLEAR_TRIGGER_VERSION_YES by the clear-pinned-version modal on submit. */}
<input
type="hidden"
name="clearTriggerVersion"
defaultValue={CLEAR_TRIGGER_VERSION_NO}
ref={clearTriggerVersionInputRef}
/>
<Fieldset>
<InputGroup fullWidth>
<div className="flex flex-col gap-4">
{/* Staging environment mapping */}
{hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && (
<div>
<Label>Map Vercel environment to Staging</Label>
<Hint className="mb-2">
Select which custom Vercel environment should map to Trigger.dev's Staging
environment.
</Hint>
<Select
value={configValues.vercelStagingEnvironment?.environmentId || ""}
setValue={(value) => {
if (!Array.isArray(value)) {
const env = customEnvironments?.find((e) => e.id === value);
setConfigValues((prev) => {
const next = {
...prev,
vercelStagingEnvironment: env
? { environmentId: env.id, displayName: env.slug }
: null,
};
// When clearing the staging mapping, strip "stg" from build settings
if (!env) {
next.pullEnvVarsBeforeBuild = prev.pullEnvVarsBeforeBuild.filter(
(s) => s !== "stg"
);
next.discoverEnvVars = prev.discoverEnvVars.filter((s) => s !== "stg");
}
return next;
});
}
}}
items={[{ id: "", slug: "None" }, ...customEnvironments]}
variant="tertiary/small"
placeholder="Select environment"
dropdownIcon
text={configValues.vercelStagingEnvironment?.displayName || "None"}
>
{[
<SelectItem key="" value="">
None
</SelectItem>,
...customEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
{env.slug}
</SelectItem>
)),
]}
</Select>
</div>
)}
<BuildSettingsFields
availableEnvSlugs={availableEnvSlugsForBuildSettings}
pullEnvVarsBeforeBuild={configValues.pullEnvVarsBeforeBuild}
onPullEnvVarsChange={(slugs) =>
setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs }))
}
discoverEnvVars={configValues.discoverEnvVars}
onDiscoverEnvVarsChange={(slugs) =>
setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs }))
}
atomicBuilds={configValues.atomicBuilds}
onAtomicBuildsChange={(slugs) =>
setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs }))
}
envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`}
disabledEnvSlugs={disabledEnvSlugsForBuildSettings}
autoPromote={configValues.autoPromote}
onAutoPromoteChange={(value) =>
setConfigValues((prev) => ({ ...prev, autoPromote: value }))
}
currentTriggerVersion={currentTriggerVersion}
currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed}
hideSectionToggles
/>
{/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */}
{autoAssignCustomDomains !== false && configValues.atomicBuilds.includes("prod") && (
<Callout variant="warning">
<div className="flex flex-col gap-2">
<p className="font-sans text-xs font-normal text-text-dimmed">
Atomic deployments require the "Auto-assign Custom Domains" setting to be
disabled on your Vercel project. Without this, Vercel will promote deployments
before Trigger.dev is ready.
</p>
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disable-auto-assign" />
<Button
type="submit"
variant="tertiary/small"
disabled={
navigation.formData?.get("action") === "disable-auto-assign" &&
(navigation.state === "submitting" || navigation.state === "loading")
}
LeadingIcon={
navigation.formData?.get("action") === "disable-auto-assign" &&
(navigation.state === "submitting" || navigation.state === "loading")
? SpinnerWhite
: undefined
}
>
Disable auto-assign custom domains
</Button>
</Form>
</div>
</Callout>
)}
</div>
<FormError>{configForm.errors}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
ref={saveButtonRef}
type="submit"
name="action"
value="update-config"
variant="secondary/small"
disabled={isConfigLoading || !hasConfigChanges || !canManageVercel}
tooltip={
canManageVercel
? undefined
: "You don't have permission to manage the Vercel integration"
}
LeadingIcon={isConfigLoading ? SpinnerWhite : undefined}
onClick={(event) => {
if (shouldPromptClearOnSave) {
event.preventDefault();
setShowClearDialog(true);
}
}}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
<DialogContent className="max-w-md">
<DialogHeader>Clear TRIGGER_VERSION from Vercel?</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
{currentTriggerVersion ? (
<Paragraph className="mb-1">
Atomic deployments are being turned off. The{" "}
<span className="font-mono text-text-bright">TRIGGER_VERSION</span> env var on your
Vercel production environment is currently set to{" "}
<span className="font-mono text-text-bright">{currentTriggerVersion}</span>.
</Paragraph>
) : (
<Paragraph className="mb-1">
Atomic deployments are being turned off. We couldn't reach Vercel to confirm whether{" "}
<span className="font-mono text-text-bright">TRIGGER_VERSION</span> is currently set
on your Vercel production environment, so please verify in the Vercel dashboard.
</Paragraph>
)}
<Paragraph className="mb-1">
If you leave it, your Vercel project will stay pinned to this version. Since atomic
deployments will be off, Trigger.dev will no longer update this variable, and future
Vercel deploys will continue using this pinned version. We recommend clearing it.
</Paragraph>
<FormButtons
confirmButton={
<div className="flex gap-2">
<Button variant="secondary/medium" onClick={() => submitWithClearChoice(false)}>
Keep pinned
</Button>
<Button variant="primary/medium" onClick={() => submitWithClearChoice(true)}>
Clear and disable
</Button>
</div>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
function VercelSettingsPanel({
organizationSlug,
projectSlug,
environmentSlug,
onOpenVercelModal,
isLoadingVercelData,
}: {
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
onOpenVercelModal?: () => void;
isLoadingVercelData?: boolean;
}) {
const fetcher = useTypedFetcher<typeof loader>();
const _location = useLocation();
const data = fetcher.data;
const [hasError, _setHasError] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
useEffect(() => {
if (!data?.authInvalid && !hasError && !data && !hasFetched) {
fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug));
setHasFetched(true);
}
}, [
organizationSlug,
projectSlug,
environmentSlug,
data?.authInvalid,
hasError,
data,
hasFetched,
]);
if (hasError) {
return (
<div className="rounded-sm border border-rose-500/40 bg-rose-500/10 p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 shrink-0 text-rose-500" />
<div>
<p className="font-medium text-rose-400">Failed to load Vercel settings</p>
<p className="mt-1 text-sm text-rose-300">
There was an error loading the Vercel integration settings. Please refresh the page to
try again.
</p>
</div>
</div>
</div>
);
}
if (fetcher.state === "loading" && !data) {
return (
<div className="flex items-center gap-2 text-text-dimmed">
<SpinnerWhite className="size-4" />
<span className="text-sm">Loading Vercel settings...</span>
</div>
);
}
if (!data || !data.enabled) {
return null;
}
const showGitHubWarning = data.connectedProject && !data.isGitHubConnected;
const showAuthInvalid = data.authInvalid || data.onboardingData?.authInvalid;
if (data.connectedProject) {
return (
<>
{showAuthInvalid && (
<VercelAuthInvalidBanner
organizationSlug={organizationSlug}
projectSlug={projectSlug}
canManageVercel={data.canManageVercel}
/>
)}
{showGitHubWarning && <VercelGitHubWarning />}
{!showAuthInvalid && (
<ConnectedVercelProjectForm
connectedProject={data.connectedProject}
hasStagingEnvironment={data.hasStagingEnvironment}
hasPreviewEnvironment={data.hasPreviewEnvironment}
customEnvironments={data.customEnvironments}
autoAssignCustomDomains={data.autoAssignCustomDomains ?? null}
currentTriggerVersion={data.currentTriggerVersion ?? null}
currentTriggerVersionFetchFailed={data.currentTriggerVersionFetchFailed ?? false}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
canManageVercel={data.canManageVercel}
/>
)}
</>
);
}
return (
<div className="flex flex-col gap-2">
{showAuthInvalid && (
<VercelAuthInvalidBanner organizationSlug={organizationSlug} projectSlug={projectSlug} />
)}
{!showAuthInvalid && (
<>
<VercelConnectionPrompt
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
hasOrgIntegration={data.hasOrgIntegration}
isGitHubConnected={data.isGitHubConnected}
onOpenModal={showAuthInvalid ? undefined : onOpenVercelModal}
isLoading={isLoadingVercelData}
canManageVercel={data.canManageVercel}
/>
<Hint>
{data.hasOrgIntegration
? "Connect your Vercel project to pull environment variables and trigger builds automatically."
: "Install the Vercel app to connect your projects and pull environment variables."}
</Hint>
{!data.isGitHubConnected && (
<Hint>
GitHub integration is not connected. Vercel integration cannot sync environment
variables and link deployments without a properly installed GitHub integration.
</Hint>
)}
</>
)}
</div>
);
}
import { VercelOnboardingModal } from "~/components/integrations/VercelOnboardingModal";
export { VercelOnboardingModal, VercelSettingsPanel };