triggerdotdev--trigger.dev
1293 行
47 KiB
TypeScript
1293 行
47 KiB
TypeScript
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. You’ll 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>
|
|
);
|
|
}
|