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

1293 lines
47 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
CheckCircleIcon,
ExclamationTriangleIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@heroicons/react/20/solid";
import { useFetcher, useNavigation, useSearchParams } from "@remix-run/react";
import { useTypedFetcher } from "remix-typedjson";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "~/components/primitives/Tooltip";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { ConnectGitHubRepoModal } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import {
type SyncEnvVarsMapping,
type EnvSlug,
ALL_ENV_SLUGS,
shouldSyncEnvVarForAnyEnvironment,
getAvailableEnvSlugs,
getAvailableEnvSlugsForBuildSettings,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server";
import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server";
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";
import { TextLink } from "../primitives/TextLink";
function safeRedirectUrl(url: string): string | null {
try {
const parsed = new URL(url, window.location.origin);
if (parsed.origin === window.location.origin) {
return parsed.toString();
}
if (parsed.protocol === "https:" && /^([a-z0-9-]+\.)*vercel\.com$/i.test(parsed.hostname)) {
return parsed.toString();
}
} catch {
// Invalid URL
}
return null;
}
function formatVercelTargets(targets: string[]): string {
const targetLabels: Record<string, string> = {
production: "Production",
preview: "Preview",
development: "Development",
staging: "Staging",
};
return targets.map((t) => targetLabels[t.toLowerCase()] || t).join(", ");
}
type OnboardingState =
| "idle"
| "installing"
| "loading-projects"
| "project-selection"
| "loading-env-mapping"
| "env-mapping"
| "loading-env-vars"
| "env-var-sync"
| "build-settings"
| "github-connection"
| "completed";
export function VercelOnboardingModal({
isOpen,
onClose,
onboardingData,
organizationSlug,
projectSlug,
environmentSlug,
hasStagingEnvironment,
hasPreviewEnvironment,
hasOrgIntegration,
nextUrl,
onDataReload,
vercelManageAccessUrl,
}: {
isOpen: boolean;
onClose: () => void;
onboardingData: VercelOnboardingData | null;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
hasStagingEnvironment: boolean;
hasPreviewEnvironment: boolean;
hasOrgIntegration: boolean;
nextUrl?: string;
onDataReload?: (vercelStagingEnvironment?: string) => void;
vercelManageAccessUrl?: string;
}) {
const { capture, startSessionRecording } = usePostHogTracking();
const navigation = useNavigation();
const fetcher = useTypedFetcher<typeof loader>();
const envMappingFetcher = useFetcher();
const completeOnboardingFetcher = useFetcher();
const { Form: _CompleteOnboardingForm } = completeOnboardingFetcher;
const [searchParams] = useSearchParams();
const origin = searchParams.get("origin");
const fromMarketplaceContext = origin === "marketplace";
const availableProjects = onboardingData?.availableProjects || [];
const _hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
const customEnvironments = onboardingData?.customEnvironments || [];
const envVars = onboardingData?.environmentVariables || [];
const existingVars = onboardingData?.existingVariables || {};
const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment;
const computeInitialState = useCallback((): OnboardingState => {
if (!hasOrgIntegration || onboardingData?.authInvalid) {
return "idle";
}
const projectSelected = onboardingData?.hasProjectSelected ?? false;
if (!projectSelected) {
if (!onboardingData?.availableProjects || onboardingData.availableProjects.length === 0) {
return "loading-projects";
}
return "project-selection";
}
// If onboarding was already completed but GitHub is not connected,
// go directly to the github-connection step (e.g., returning from GitHub App installation)
if (onboardingData?.isOnboardingComplete && !onboardingData?.isGitHubConnected) {
return "github-connection";
}
// For marketplace origin, skip env-mapping step and go directly to env-var-sync
if (!fromMarketplaceContext) {
const customEnvs =
(onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment;
if (customEnvs) {
return "env-mapping";
}
}
if (!onboardingData?.environmentVariables || onboardingData.environmentVariables.length === 0) {
return "loading-env-vars";
}
return "env-var-sync";
}, [hasOrgIntegration, onboardingData, hasStagingEnvironment, fromMarketplaceContext]);
const [state, setState] = useState<OnboardingState>(() => {
if (!isOpen) return "idle";
return computeInitialState();
});
const prevIsOpenRef = useRef(isOpen);
const hasSyncedStagingRef = useRef(false);
const hasSyncedPreviewRef = useRef(false);
useEffect(() => {
if (isOpen && !prevIsOpenRef.current) {
setState(computeInitialState());
hasSyncedStagingRef.current = false;
hasSyncedPreviewRef.current = false;
} else if (isOpen && state === "idle") {
setState(computeInitialState());
}
prevIsOpenRef.current = isOpen;
}, [isOpen, state, computeInitialState]);
const trackOnboarding = useCallback(
(eventName: string, extraProperties?: Record<string, unknown>) => {
capture(eventName, {
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
step: state,
organization_slug: organizationSlug,
project_slug: projectSlug,
...extraProperties,
});
},
[capture, fromMarketplaceContext, state, organizationSlug, projectSlug]
);
const hasTrackedStartRef = useRef(false);
const hasTrackedCompletionRef = useRef(false);
useEffect(() => {
if (isOpen && state === "project-selection" && !hasTrackedStartRef.current) {
hasTrackedStartRef.current = true;
startSessionRecording();
trackOnboarding("vercel onboarding started");
}
if (!isOpen) {
hasTrackedStartRef.current = false;
hasTrackedCompletionRef.current = false;
}
}, [isOpen, state, trackOnboarding, startSessionRecording]);
const [selectedVercelProject, setSelectedVercelProject] = useState<{
id: string;
name: string;
} | null>(null);
const [vercelStagingEnvironment, setVercelStagingEnvironment] = useState<{
environmentId: string;
displayName: string;
} | null>(null);
const _availableEnvSlugsForOnboarding = getAvailableEnvSlugs(
hasStagingEnvironment,
hasPreviewEnvironment
);
const availableEnvSlugsForOnboardingBuildSettings = getAvailableEnvSlugsForBuildSettings(
hasStagingEnvironment,
hasPreviewEnvironment
);
const [pullEnvVarsBeforeBuild, setPullEnvVarsBeforeBuild] = useState<EnvSlug[]>(
() => availableEnvSlugsForOnboardingBuildSettings
);
const [atomicBuilds, setAtomicBuilds] = useState<EnvSlug[]>(() => ["prod"]);
const [discoverEnvVars, setDiscoverEnvVars] = useState<EnvSlug[]>(
() => availableEnvSlugsForOnboardingBuildSettings
);
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true
// AND a custom Vercel environment is mapped (once)
useEffect(() => {
if (hasStagingEnvironment && vercelStagingEnvironment && !hasSyncedStagingRef.current) {
hasSyncedStagingRef.current = true;
setPullEnvVarsBeforeBuild((prev) => {
if (!prev.includes("stg")) {
return [...prev, "stg"];
}
return prev;
});
setDiscoverEnvVars((prev) => {
if (!prev.includes("stg")) {
return [...prev, "stg"];
}
return prev;
});
}
}, [hasStagingEnvironment, vercelStagingEnvironment]);
// Strip "stg" from build settings when the staging environment mapping is cleared
useEffect(() => {
if (!vercelStagingEnvironment) {
setPullEnvVarsBeforeBuild((prev) => prev.filter((s) => s !== "stg"));
setDiscoverEnvVars((prev) => prev.filter((s) => s !== "stg"));
}
}, [vercelStagingEnvironment]);
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasPreviewEnvironment becomes true (once)
useEffect(() => {
if (hasPreviewEnvironment && !hasSyncedPreviewRef.current) {
hasSyncedPreviewRef.current = true;
setPullEnvVarsBeforeBuild((prev) => {
if (!prev.includes("preview")) {
return [...prev, "preview"];
}
return prev;
});
setDiscoverEnvVars((prev) => {
if (!prev.includes("preview")) {
return [...prev, "preview"];
}
return prev;
});
}
}, [hasPreviewEnvironment]);
const [syncEnvVarsMapping, setSyncEnvVarsMapping] = useState<SyncEnvVarsMapping>({});
const [expandedEnvVars, setExpandedEnvVars] = useState(false);
const [expandedSecretEnvVars, setExpandedSecretEnvVars] = useState(false);
const [projectSelectionError, setProjectSelectionError] = useState<string | null>(null);
const [isRedirecting, setIsRedirecting] = useState(false);
const gitHubAppInstallations = onboardingData?.gitHubAppInstallations ?? [];
const isGitHubConnectedForOnboarding = onboardingData?.isGitHubConnected ?? false;
const isOnboardingComplete = onboardingData?.isOnboardingComplete ?? false;
const hasTriggeredMarketplaceRedirectRef = useRef(false);
// Auto-redirect for marketplace flow when returning from GitHub with everything complete
useEffect(() => {
if (hasTriggeredMarketplaceRedirectRef.current) {
return;
}
if (
isOpen &&
fromMarketplaceContext &&
nextUrl &&
isOnboardingComplete &&
isGitHubConnectedForOnboarding
) {
hasTriggeredMarketplaceRedirectRef.current = true;
const validUrl = safeRedirectUrl(nextUrl);
if (validUrl) {
setTimeout(() => {
window.location.href = validUrl;
}, 100);
}
}
}, [
isOpen,
fromMarketplaceContext,
nextUrl,
isOnboardingComplete,
isGitHubConnectedForOnboarding,
]);
useEffect(() => {
if (!isOpen) {
hasTriggeredMarketplaceRedirectRef.current = false;
setIsRedirecting(false);
}
}, [isOpen]);
const loadingStateRef = useRef<OnboardingState | null>(null);
useEffect(() => {
if (!isOpen || state === "idle") {
loadingStateRef.current = null;
return;
}
if (onboardingData?.authInvalid) {
onClose();
return;
}
if (loadingStateRef.current === state) {
return;
}
switch (state) {
case "loading-projects":
loadingStateRef.current = state;
if (onDataReload) {
onDataReload();
}
break;
case "loading-env-mapping":
loadingStateRef.current = state;
if (onDataReload) {
onDataReload();
}
break;
case "loading-env-vars":
loadingStateRef.current = state;
if (onDataReload) {
onDataReload(vercelStagingEnvironment?.environmentId || undefined);
}
break;
case "installing":
case "project-selection":
case "env-mapping":
case "env-var-sync":
case "completed":
case "build-settings":
case "github-connection":
loadingStateRef.current = null;
break;
}
}, [isOpen, state, onboardingData?.authInvalid, vercelStagingEnvironment, onDataReload, onClose]);
useEffect(() => {
if (
!onboardingData?.authInvalid &&
state === "loading-projects" &&
onboardingData?.availableProjects !== undefined
) {
setState("project-selection");
}
}, [state, onboardingData?.availableProjects, onboardingData?.authInvalid]);
useEffect(() => {
if (
!onboardingData?.authInvalid &&
state === "loading-env-vars" &&
onboardingData?.environmentVariables
) {
setState("env-var-sync");
}
}, [state, onboardingData?.environmentVariables, onboardingData?.authInvalid]);
useEffect(() => {
if (
state === "project-selection" &&
fetcher.data &&
"success" in fetcher.data &&
fetcher.data.success &&
fetcher.state === "idle"
) {
trackOnboarding("vercel onboarding project selected", {
vercel_project_name: selectedVercelProject?.name,
});
setState("loading-env-mapping");
if (onDataReload) {
onDataReload();
}
} else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") {
setProjectSelectionError(fetcher.data.error);
}
}, [
state,
fetcher.data,
fetcher.state,
onDataReload,
trackOnboarding,
selectedVercelProject?.name,
]);
// For marketplace origin, skip env-mapping step
useEffect(() => {
if (state === "loading-env-mapping" && onboardingData) {
const hasCustomEnvs =
(onboardingData.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment;
if (hasCustomEnvs && !fromMarketplaceContext) {
setState("env-mapping");
} else {
setState("loading-env-vars");
}
}
}, [state, onboardingData, hasStagingEnvironment, fromMarketplaceContext]);
const secretEnvVars = envVars.filter((v) => v.isSecret);
const syncableEnvVars = envVars.filter((v) => !v.isSecret);
const enabledEnvVars = syncableEnvVars.filter((v) =>
shouldSyncEnvVarForAnyEnvironment(syncEnvVarsMapping, v.key)
);
const overlappingEnvVarsCount = enabledEnvVars.filter((v) => existingVars[v.key]).length;
const _isSubmitting = navigation.state === "submitting" || navigation.state === "loading";
const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug);
const handleToggleEnvVar = useCallback((key: string, enabled: boolean) => {
setSyncEnvVarsMapping((prev) => {
const newMapping = { ...prev };
if (enabled) {
for (const envSlug of ALL_ENV_SLUGS) {
if (newMapping[envSlug]) {
const { [key]: _, ...rest } = newMapping[envSlug];
if (Object.keys(rest).length === 0) {
delete newMapping[envSlug];
} else {
newMapping[envSlug] = rest;
}
}
}
} else {
for (const envSlug of ALL_ENV_SLUGS) {
newMapping[envSlug] = {
...(newMapping[envSlug] || {}),
[key]: false,
};
}
}
return newMapping;
});
}, []);
const handleToggleAllEnvVars = useCallback(
(enabled: boolean, syncableVars: Array<{ key: string }>) => {
if (enabled) {
setSyncEnvVarsMapping({});
} else {
const newMapping: SyncEnvVarsMapping = {};
for (const envSlug of ALL_ENV_SLUGS) {
newMapping[envSlug] = {};
for (const v of syncableVars) {
newMapping[envSlug][v.key] = false;
}
}
setSyncEnvVarsMapping(newMapping);
}
},
[]
);
const handleProjectSelection = useCallback(async () => {
if (!selectedVercelProject) {
setProjectSelectionError("Please select a Vercel project");
return;
}
setProjectSelectionError(null);
const formData = new FormData();
formData.append("action", "select-vercel-project");
formData.append("vercelProjectId", selectedVercelProject.id);
formData.append("vercelProjectName", selectedVercelProject.name);
fetcher.submit(formData, {
method: "post",
action: actionUrl,
});
}, [selectedVercelProject, fetcher, actionUrl]);
const handleSkipOnboarding = useCallback(() => {
onClose();
if (fromMarketplaceContext) {
return window.close();
}
const formData = new FormData();
formData.append("action", "skip-onboarding");
fetcher.submit(formData, {
method: "post",
action: actionUrl,
});
}, [actionUrl, fetcher, onClose, fromMarketplaceContext]);
const handleSkipEnvMapping = useCallback(() => {
trackOnboarding("vercel onboarding env mapping completed", {
skipped: true,
staging_environment: null,
});
setVercelStagingEnvironment(null);
setState("loading-env-vars");
}, [trackOnboarding]);
const handleUpdateEnvMapping = useCallback(() => {
trackOnboarding("vercel onboarding env mapping completed", {
skipped: false,
staging_environment: vercelStagingEnvironment?.displayName ?? null,
});
if (!vercelStagingEnvironment) {
setState("loading-env-vars");
return;
}
const formData = new FormData();
formData.append("action", "update-env-mapping");
formData.append("vercelStagingEnvironment", JSON.stringify(vercelStagingEnvironment));
envMappingFetcher.submit(formData, {
method: "post",
action: actionUrl,
});
}, [vercelStagingEnvironment, envMappingFetcher, actionUrl, trackOnboarding]);
const handleBuildSettingsNext = useCallback(() => {
trackOnboarding("vercel onboarding build settings completed");
if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) {
setIsRedirecting(true);
}
const formData = new FormData();
formData.append("action", "complete-onboarding");
formData.append(
"vercelStagingEnvironment",
vercelStagingEnvironment ? JSON.stringify(vercelStagingEnvironment) : ""
);
formData.append("pullEnvVarsBeforeBuild", JSON.stringify(pullEnvVarsBeforeBuild));
formData.append("atomicBuilds", JSON.stringify(atomicBuilds));
formData.append("discoverEnvVars", JSON.stringify(discoverEnvVars));
formData.append("syncEnvVarsMapping", JSON.stringify(syncEnvVarsMapping));
if (fromMarketplaceContext) {
formData.append("origin", "marketplace");
}
if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) {
formData.append("next", nextUrl);
}
if (!isGitHubConnectedForOnboarding) {
formData.append("skipRedirect", "true");
}
completeOnboardingFetcher.submit(formData, {
method: "post",
action: actionUrl,
});
if (!isGitHubConnectedForOnboarding) {
setState("github-connection");
capture("vercel onboarding github step viewed", {
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
step: "github-connection",
organization_slug: organizationSlug,
project_slug: projectSlug,
github_app_installed: gitHubAppInstallations.length > 0,
});
}
}, [
vercelStagingEnvironment,
pullEnvVarsBeforeBuild,
atomicBuilds,
discoverEnvVars,
syncEnvVarsMapping,
nextUrl,
fromMarketplaceContext,
isGitHubConnectedForOnboarding,
completeOnboardingFetcher,
actionUrl,
trackOnboarding,
capture,
organizationSlug,
projectSlug,
gitHubAppInstallations.length,
]);
const _handleFinishOnboarding = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
completeOnboardingFetcher.submit(formData, {
method: "post",
action: actionUrl,
});
},
[completeOnboardingFetcher, actionUrl]
);
useEffect(() => {
if (
completeOnboardingFetcher.data &&
typeof completeOnboardingFetcher.data === "object" &&
"success" in completeOnboardingFetcher.data &&
completeOnboardingFetcher.data.success &&
completeOnboardingFetcher.state === "idle"
) {
if (state === "github-connection") {
return;
}
if (
"redirectTo" in completeOnboardingFetcher.data &&
typeof completeOnboardingFetcher.data.redirectTo === "string"
) {
const validRedirect = safeRedirectUrl(completeOnboardingFetcher.data.redirectTo);
if (validRedirect) {
window.location.href = validRedirect;
}
return;
}
setState("completed");
}
}, [completeOnboardingFetcher.data, completeOnboardingFetcher.state, state]);
useEffect(() => {
if (state === "github-connection" && isGitHubConnectedForOnboarding) {
trackOnboarding("vercel onboarding github completed");
if (fromMarketplaceContext && nextUrl) {
const validUrl = safeRedirectUrl(nextUrl);
if (validUrl) {
window.location.href = validUrl;
return;
}
}
setState("completed");
}
}, [state, isGitHubConnectedForOnboarding, fromMarketplaceContext, nextUrl, trackOnboarding]);
useEffect(() => {
if (state === "completed" && !hasTrackedCompletionRef.current) {
hasTrackedCompletionRef.current = true;
trackOnboarding("vercel onboarding completed", {
github_connected: isGitHubConnectedForOnboarding,
});
onClose();
}
}, [state, onClose, trackOnboarding, isGitHubConnectedForOnboarding]);
useEffect(() => {
if (state === "installing") {
const installUrl = vercelAppInstallPath(organizationSlug, projectSlug);
window.location.href = installUrl;
}
}, [state, organizationSlug, projectSlug]);
useEffect(() => {
if (
envMappingFetcher.data &&
typeof envMappingFetcher.data === "object" &&
"success" in envMappingFetcher.data &&
envMappingFetcher.data.success &&
envMappingFetcher.state === "idle"
) {
setState("loading-env-vars");
}
}, [envMappingFetcher.data, envMappingFetcher.state]);
useEffect(() => {
if (state === "env-mapping" && customEnvironments.length > 0 && !vercelStagingEnvironment) {
let selectedEnv: VercelCustomEnvironment;
if (customEnvironments.length === 1) {
selectedEnv = customEnvironments[0];
} else {
const stagingEnv = customEnvironments.find((env) => env.slug.toLowerCase() === "staging");
selectedEnv = stagingEnv ?? customEnvironments[0];
}
setVercelStagingEnvironment({ environmentId: selectedEnv.id, displayName: selectedEnv.slug });
}
}, [state, customEnvironments, vercelStagingEnvironment]);
useEffect(() => {
if (state === "project-selection" && availableProjects.length > 0 && !selectedVercelProject) {
setSelectedVercelProject(availableProjects[0]);
}
}, [state, availableProjects, selectedVercelProject]);
if (!isOpen || onboardingData?.authInvalid) {
return null;
}
const isLoadingState =
state === "loading-projects" ||
state === "loading-env-mapping" ||
state === "loading-env-vars" ||
state === "installing" ||
(state === "idle" && !onboardingData);
if (isLoadingState) {
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open && !fromMarketplaceContext) {
if ((state as string) !== "completed") {
trackOnboarding("vercel onboarding abandoned");
}
onClose();
}
}}
>
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2">
<VercelLogo className="size-5" />
<span>Set up Vercel Integration</span>
</div>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<SpinnerWhite className="size-6" />
</div>
</DialogContent>
</Dialog>
);
}
const showProjectSelection = state === "project-selection";
const showEnvMapping = state === "env-mapping";
const showEnvVarSync = state === "env-var-sync";
const showBuildSettings = state === "build-settings";
const showGitHubConnection = state === "github-connection";
const disabledEnvSlugsForBuildSettings =
hasStagingEnvironment && !vercelStagingEnvironment
? ({ stg: "Map a custom Vercel environment to Staging to enable this" } as Partial<
Record<EnvSlug, string>
>)
: undefined;
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open && !fromMarketplaceContext) {
if (state !== "completed") {
trackOnboarding("vercel onboarding abandoned");
}
onClose();
}
}}
>
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2">
<VercelLogo className="size-5" />
<span>Set up Vercel Integration</span>
</div>
</DialogHeader>
<div className="mt-4">
{showProjectSelection && (
<div className="flex flex-col gap-4">
<Header3>Select Vercel Project</Header3>
<Paragraph className="text-sm">
Choose which Vercel project to connect with this Trigger.dev project. Your API keys
will be automatically synced to Vercel.
</Paragraph>
{availableProjects.length === 0 ? (
<Callout variant="warning">
No Vercel projects found. Please create a project in Vercel first.
</Callout>
) : (
<Select
disabled={availableProjects.length === 1}
value={selectedVercelProject?.id || ""}
setValue={(value) => {
if (!Array.isArray(value)) {
const project = availableProjects.find((p) => p.id === value);
setSelectedVercelProject(project || null);
setProjectSelectionError(null);
}
}}
items={availableProjects}
filter={availableProjects.length > 5 ? { keys: ["name"] } : undefined}
variant="tertiary/medium"
placeholder="Select a Vercel project"
dropdownIcon
text={selectedVercelProject?.name || "Select a project"}
>
{availableProjects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</Select>
)}
{projectSelectionError && <FormError>{projectSelectionError}</FormError>}
<Hint>
Once connected, your{" "}
<code className="text-xs rounded bg-background-raised px-1 py-0.5 text-text-bright">
TRIGGER_SECRET_KEY
</code>{" "}
will be automatically synced to Vercel for each environment.
</Hint>
<FormButtons
confirmButton={
<div className="flex items-center gap-2">
{vercelManageAccessUrl && !origin && (
<LinkButton
to={vercelManageAccessUrl}
variant="tertiary/medium"
target="_self"
>
Manage access
</LinkButton>
)}
<Button
variant="primary/medium"
onClick={handleProjectSelection}
disabled={!selectedVercelProject || fetcher.state !== "idle"}
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
>
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
</Button>
</div>
}
cancelButton={
<Button variant="tertiary/medium" onClick={handleSkipOnboarding}>
Cancel
</Button>
}
/>
</div>
)}
{showEnvMapping && (
<div className="flex flex-col gap-4">
<Header3>Map Vercel Environment to Staging</Header3>
<Paragraph className="text-sm">
Select which custom Vercel environment should map to Trigger.dev's Staging
environment. Production and Preview environments are mapped automatically. If you
skip this step, the{" "}
<code className="rounded bg-background-raised px-1 py-0.5 text-text-bright">
TRIGGER_SECRET_KEY
</code>{" "}
will not be installed for the staging environment in Vercel. You can configure this
later in project settings.
</Paragraph>
<Select
value={vercelStagingEnvironment?.environmentId || ""}
setValue={(value) => {
if (!Array.isArray(value)) {
const env = customEnvironments.find((e) => e.id === value);
setVercelStagingEnvironment(
env ? { environmentId: env.id, displayName: env.slug } : null
);
}
}}
items={customEnvironments}
variant="tertiary/medium"
placeholder="Select environment"
dropdownIcon
text={vercelStagingEnvironment?.displayName || "Select environment"}
>
{customEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
{env.slug}
</SelectItem>
))}
</Select>
<Paragraph className="text-xs text-text-dimmed">
Make sure the staging branch in your Vercel project's Git settings matches the
staging branch configured in your GitHub integration.
</Paragraph>
<div className="flex items-center justify-between gap-2">
<Button variant="tertiary/medium" onClick={handleSkipOnboarding}>
Cancel
</Button>
<div className="flex items-center gap-2">
{!fromMarketplaceContext && (
<Button variant="tertiary/medium" onClick={handleSkipEnvMapping}>
Skip
</Button>
)}
<Button
variant="primary/medium"
onClick={handleUpdateEnvMapping}
disabled={envMappingFetcher.state !== "idle"}
LeadingIcon={envMappingFetcher.state !== "idle" ? SpinnerWhite : undefined}
>
Next
</Button>
</div>
</div>
</div>
)}
{showEnvVarSync && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Header3>Pull Environment Variables</Header3>
<Paragraph className="text-sm">
Choose which environment variables to import from Vercel. This runs as a one time
pull to prefill your project with the variables it needs. Youll be able to pull
again later, or enable automatic syncing before each build if you prefer. If you
are using Supabase or Neon branching,{" "}
<TextLink
href="https://trigger.dev/docs/vercel-integration#supabase-and-neon-database-branching"
target="_blank"
rel="noopener noreferrer"
>
read the docs
</TextLink>{" "}
for the recommended setup.
</Paragraph>
</div>
<div className="flex items-center justify-between rounded border bg-background-bright p-3">
<div>
<Label>Pull all environment variables now</Label>
<Hint>Select all variables to pull from Vercel.</Hint>
</div>
<Switch
variant="small"
checked={enabledEnvVars.length === syncableEnvVars.length}
onCheckedChange={(checked) => handleToggleAllEnvVars(checked, syncableEnvVars)}
/>
</div>
{syncableEnvVars.length > 0 && (
<div className="rounded border">
<button
type="button"
className="flex w-full items-center justify-between p-3 text-left"
onClick={() => setExpandedEnvVars(!expandedEnvVars)}
>
<span className="text-sm text-text-dimmed">
{enabledEnvVars.length} of {syncableEnvVars.length} variables will be pulled
</span>
{expandedEnvVars ? (
<ChevronUpIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</button>
{expandedEnvVars && (
<div className="max-h-64 overflow-y-auto border-t">
{syncableEnvVars.map((envVar) => (
<div
key={envVar.id}
className="flex items-center justify-between gap-2 border-b px-3 py-2 last:border-b-0"
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{existingVars[envVar.key] ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 max-w-full cursor-default text-left truncate font-mono text-xs underline decoration-yellow-500 decoration-dotted underline-offset-2">
{envVar.key}
</div>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 text-xs">
{`This variable is going to be replaced in: ${existingVars[
envVar.key
].environments.join(", ")}`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="truncate font-mono text-xs">{envVar.key}</span>
)}
{envVar.target && envVar.target.length > 0 && (
<span className="text-xs text-text-dimmed">
{formatVercelTargets(envVar.target)}
{envVar.isShared && " · Shared"}
</span>
)}
</div>
<Switch
variant="small"
checked={shouldSyncEnvVarForAnyEnvironment(
syncEnvVarsMapping,
envVar.key
)}
onCheckedChange={(checked) => handleToggleEnvVar(envVar.key, checked)}
/>
</div>
))}
</div>
)}
</div>
)}
{secretEnvVars.length > 0 && (
<div className="rounded border">
<button
type="button"
className="flex w-full items-center justify-between p-3 text-left"
onClick={() => setExpandedSecretEnvVars(!expandedSecretEnvVars)}
>
<span className="text-sm text-text-dimmed">
{secretEnvVars.length} secret{" "}
{secretEnvVars.length === 1 ? "variable" : "variables"} (cannot be pulled)
</span>
{expandedSecretEnvVars ? (
<ChevronUpIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</button>
{expandedSecretEnvVars && (
<div className="max-h-64 overflow-y-auto border-t">
{secretEnvVars.map((envVar) => (
<div
key={envVar.id}
className="flex items-center justify-between gap-2 border-b px-3 py-2 last:border-b-0"
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-mono text-xs">{envVar.key}</span>
{envVar.target && envVar.target.length > 0 && (
<span className="text-xs text-text-dimmed">
{formatVercelTargets(envVar.target)}
{envVar.isShared && " · Shared"}
</span>
)}
</div>
<span className="shrink-0 text-xs text-amber-400">Secret</span>
</div>
))}
</div>
)}
</div>
)}
{overlappingEnvVarsCount > 0 && enabledEnvVars.length > 0 && (
<div className="flex items-center gap-2">
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
<span className="text-xs text-text-dimmed">
{overlappingEnvVarsCount} env vars are going to be updated (marked with{" "}
<span className="underline decoration-yellow-500 decoration-dotted underline-offset-2">
underline
</span>
)
</span>
</div>
)}
<FormButtons
confirmButton={
<Button
variant="primary/medium"
onClick={() => {
trackOnboarding("vercel onboarding env vars configured", {
env_vars_enabled: enabledEnvVars.length,
env_vars_total: syncableEnvVars.length,
});
if (fromMarketplaceContext) {
handleBuildSettingsNext();
} else {
setState("build-settings");
}
}}
disabled={
fromMarketplaceContext &&
(completeOnboardingFetcher.state !== "idle" || isRedirecting)
}
LeadingIcon={
fromMarketplaceContext &&
(completeOnboardingFetcher.state !== "idle" || isRedirecting)
? SpinnerWhite
: undefined
}
>
{fromMarketplaceContext
? isGitHubConnectedForOnboarding
? "Finish"
: "Next"
: "Next"}
</Button>
}
cancelButton={
hasCustomEnvs && !fromMarketplaceContext ? (
<Button variant="tertiary/medium" onClick={() => setState("env-mapping")}>
Back
</Button>
) : (
<Button
variant="tertiary/medium"
onClick={handleSkipOnboarding}
disabled={fetcher.state !== "idle"}
>
Cancel
</Button>
)
}
/>
</div>
)}
{showBuildSettings && (
<div className="flex flex-col gap-4">
<Header3>Build Settings</Header3>
<Paragraph className="text-sm">
Configure how environment variables are pulled during builds and atomic deployments.
</Paragraph>
<BuildSettingsFields
availableEnvSlugs={availableEnvSlugsForOnboardingBuildSettings}
pullEnvVarsBeforeBuild={pullEnvVarsBeforeBuild}
onPullEnvVarsChange={setPullEnvVarsBeforeBuild}
discoverEnvVars={discoverEnvVars}
onDiscoverEnvVarsChange={setDiscoverEnvVars}
atomicBuilds={atomicBuilds}
onAtomicBuildsChange={setAtomicBuilds}
disabledEnvSlugs={disabledEnvSlugsForBuildSettings}
/>
<FormButtons
confirmButton={
<Button
variant="primary/medium"
onClick={handleBuildSettingsNext}
disabled={completeOnboardingFetcher.state !== "idle" || isRedirecting}
LeadingIcon={
completeOnboardingFetcher.state !== "idle" || isRedirecting
? SpinnerWhite
: undefined
}
>
{isGitHubConnectedForOnboarding ? "Finish" : "Next"}
</Button>
}
cancelButton={
<Button variant="tertiary/medium" onClick={() => setState("env-var-sync")}>
Back
</Button>
}
/>
</div>
)}
{showGitHubConnection && (
<div className="flex flex-col gap-4">
<Header3>Connect GitHub Repository</Header3>
<Paragraph className="text-sm">
To fully integrate with Vercel, Trigger.dev needs access to your source code. This
allows automatic deployments and build synchronization.
</Paragraph>
<Callout variant="info">
<p className="text-xs">
Connecting your GitHub repository enables Trigger.dev to read your source code and
automatically create deployments when you push changes to Vercel.
</p>
</Callout>
{(() => {
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
);
const redirectParams = new URLSearchParams();
redirectParams.set("vercelOnboarding", "true");
if (fromMarketplaceContext) {
redirectParams.set("origin", "marketplace");
}
if (nextUrl) {
redirectParams.set("next", nextUrl);
}
const redirectUrlWithContext = `${baseSettingsPath}?${redirectParams.toString()}`;
const nextDirectRedirect = nextUrl ? safeRedirectUrl(nextUrl) : null;
return gitHubAppInstallations.length === 0 ? (
<div className="flex flex-col gap-3">
<LinkButton
to={githubAppInstallPath(
organizationSlug,
`${redirectUrlWithContext}&openGithubRepoModal=1`
)}
variant="secondary/medium"
LeadingIcon={OctoKitty}
onClick={() =>
trackOnboarding("vercel onboarding github app install clicked")
}
>
Install GitHub app
</LinkButton>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<ConnectGitHubRepoModal
gitHubAppInstallations={gitHubAppInstallations}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={
nextDirectRedirect ??
(fromMarketplaceContext ? redirectUrlWithContext : baseSettingsPath)
}
preventDismiss={fromMarketplaceContext}
/>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
</span>
</div>
</div>
);
})()}
<FormButtons
confirmButton={
isGitHubConnectedForOnboarding && fromMarketplaceContext && nextUrl ? (
<Button
variant="primary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github completed");
setState("completed");
const validUrl = safeRedirectUrl(nextUrl);
if (validUrl) {
window.location.href = validUrl;
}
}}
>
Complete
</Button>
) : !fromMarketplaceContext ? (
<Button
variant="tertiary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github skipped");
setState("completed");
}}
>
Skip for now
</Button>
) : undefined
}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}