Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/webapp/app/components/integrations/VercelLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { VercelLogo } from "./VercelLogo";
import { LinkButton } from "~/components/primitives/Buttons";
import { SimpleTooltip } from "~/components/primitives/Tooltip";

export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) {
return (
<SimpleTooltip
button={
<LinkButton
variant="minimal/small"
LeadingIcon={<VercelLogo className="size-3.5" />}
iconSpacing="gap-x-1"
to={vercelDeploymentUrl}
className="pl-1"
>
Vercel
</LinkButton>
}
content="View on Vercel"
/>
);
}
52 changes: 39 additions & 13 deletions apps/webapp/app/components/integrations/VercelOnboardingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server";
import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server";
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
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";
Expand Down Expand Up @@ -102,6 +102,7 @@ export function VercelOnboardingModal({
hasOrgIntegration,
nextUrl,
onDataReload,
vercelManageAccessUrl,
}: {
isOpen: boolean;
onClose: () => void;
Expand All @@ -114,6 +115,7 @@ export function VercelOnboardingModal({
hasOrgIntegration: boolean;
nextUrl?: string;
onDataReload?: (vercelStagingEnvironment?: string) => void;
vercelManageAccessUrl?: string;
}) {
const { capture, startSessionRecording } = usePostHogTracking();
const navigation = useNavigation();
Expand All @@ -122,7 +124,8 @@ export function VercelOnboardingModal({
const completeOnboardingFetcher = useFetcher();
const { Form: CompleteOnboardingForm } = completeOnboardingFetcher;
const [searchParams] = useSearchParams();
const fromMarketplaceContext = searchParams.get("origin") === "marketplace";
const origin = searchParams.get("origin");
const fromMarketplaceContext = origin === "marketplace";

const availableProjects = onboardingData?.availableProjects || [];
const hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
Expand Down Expand Up @@ -543,8 +546,15 @@ export function VercelOnboardingModal({

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]);
}, [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();
Expand Down Expand Up @@ -639,7 +649,7 @@ export function VercelOnboardingModal({
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2">
<VercelLogo className="size-5" />
Expand Down Expand Up @@ -727,14 +737,25 @@ export function VercelOnboardingModal({

<FormButtons
confirmButton={
<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 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
Expand Down Expand Up @@ -813,6 +834,7 @@ export function VercelOnboardingModal({
<Header3>Pull Environment Variables</Header3>
<Paragraph className="text-sm">
Select which environment variables to pull from Vercel now. This is a one-time pull.
Later on environment variables can be pulled before each build.
</Paragraph>

<div className="flex gap-4 text-sm">
Expand Down Expand Up @@ -1057,7 +1079,7 @@ export function VercelOnboardingModal({
</Callout>

{(() => {
const baseSettingsPath = v3ProjectSettingsPath(
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
Expand All @@ -1081,6 +1103,7 @@ export function VercelOnboardingModal({
)}
variant="secondary/medium"
LeadingIcon={OctoKitty}
onClick={() => trackOnboarding("vercel onboarding github app install clicked")}
>
Install GitHub app
</LinkButton>
Expand Down Expand Up @@ -1110,6 +1133,7 @@ export function VercelOnboardingModal({
<Button
variant="primary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github completed");
setState("completed");
const validUrl = safeRedirectUrl(nextUrl);
if (validUrl) {
Expand All @@ -1123,6 +1147,7 @@ export function VercelOnboardingModal({
<Button
variant="tertiary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github skipped");
setState("completed");
if (fromMarketplaceContext && nextUrl) {
const validUrl = safeRedirectUrl(nextUrl);
Expand All @@ -1141,6 +1166,7 @@ export function VercelOnboardingModal({
<Button
variant="tertiary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github skipped");
setState("completed");
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import {
ChartBarIcon,
Cog8ToothIcon,
CreditCardIcon,
PuzzlePieceIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { SlackIcon } from "@trigger.dev/companyicons";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
Expand Down Expand Up @@ -115,13 +117,25 @@ export function OrganizationSettingsSideMenu({
to={organizationSettingsPath(organization)}
data-action="settings"
/>
</div>
<div className="flex flex-col">
<div className="mb-1">
<SideMenuHeader title="Integrations" />
</div>
<SideMenuItem
name="Integrations"
icon={PuzzlePieceIcon}
activeIconColor="text-blue-500"
name="Vercel"
icon={VercelLogo}
activeIconColor="text-white"
to={organizationVercelIntegrationPath(organization)}
data-action="integrations"
/>
<SideMenuItem
name="Slack"
icon={SlackIcon}
activeIconColor="text-white"
to={organizationSlackIntegrationPath(organization)}
data-action="integrations"
/>
</div>
<div className="flex flex-col gap-1">
<SideMenuHeader title="App version" />
Expand Down
31 changes: 27 additions & 4 deletions apps/webapp/app/components/navigation/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Cog8ToothIcon,
CogIcon,
ExclamationTriangleIcon,
PuzzlePieceIcon,
FolderIcon,
FolderOpenIcon,
GlobeAmericasIcon,
Expand Down Expand Up @@ -74,7 +75,8 @@ import {
v3LogsPath,
v3ProjectAlertsPath,
v3ProjectPath,
v3ProjectSettingsPath,
v3ProjectSettingsGeneralPath,
v3ProjectSettingsIntegrationsPath,
v3QueuesPath,
v3RunsPath,
v3SchedulesPath,
Expand Down Expand Up @@ -589,13 +591,34 @@ export function SideMenu({
data-action="limits"
isCollapsed={isCollapsed}
/>
</SideMenuSection>

<SideMenuSection
title="Project settings"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"project-settings"
)}
onCollapseToggle={handleSectionToggle("project-settings")}
>
<SideMenuItem
name="Project settings"
name="General"
icon={Cog8ToothIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
data-action="project-settings-general"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Integrations"
icon={PuzzlePieceIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
data-action="project-settings-integrations"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/app/components/navigation/sideMenuTypes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";

// Valid section IDs that can have their collapsed state toggled
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]);

// Inferred type from the schema
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
36 changes: 36 additions & 0 deletions apps/webapp/app/models/vercelIntegration.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,13 @@ export class VercelIntegrationRepository {
return { created: 0, updated: 0, errors: [] as string[] };
}

await this.removeAllVercelEnvVarsByKey({
client,
vercelProjectId: params.vercelProjectId,
teamId: params.teamId,
key: "TRIGGER_SECRET_KEY",
});
Comment on lines +978 to +983

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Non-atomic delete-then-create in syncApiKeysToVercel can permanently lose TRIGGER_SECRET_KEY env vars on partial failure

In syncApiKeysToVercel, the new code first deletes ALL TRIGGER_SECRET_KEY env vars from Vercel via removeAllVercelEnvVarsByKey, and then attempts to recreate them via batchUpsertVercelEnvVars. If the delete succeeds but the subsequent create/upsert fails (e.g., Vercel API outage, rate limiting, network error), all TRIGGER_SECRET_KEY environment variables are permanently lost from the Vercel project with no automatic recovery.

Root Cause and Impact

Before this change, syncApiKeysToVercel only called batchUpsertVercelEnvVars, which safely found existing env vars and updated them in place, or created new ones if missing. This was an atomic-per-variable approach.

The new code at apps/webapp/app/models/vercelIntegration.server.ts:978-983 adds a removeAllVercelEnvVarsByKey call before the upsert:

await this.removeAllVercelEnvVarsByKey({
  client,
  vercelProjectId: params.vercelProjectId,
  teamId: params.teamId,
  key: "TRIGGER_SECRET_KEY",
});
const result = await this.batchUpsertVercelEnvVars({ ... });

The batchUpsertVercelEnvVars at line 1419 makes another API call to filterProjectEnvs which will find no matching vars (since they were just deleted), then calls createProjectEnv to create them. If this create call fails, the secret keys are gone from Vercel.

Impact: Production, staging, and preview Vercel deployments would lose their TRIGGER_SECRET_KEY environment variable, causing Trigger.dev task execution to fail for all affected environments until the keys are manually re-synced.

Prompt for agents
In apps/webapp/app/models/vercelIntegration.server.ts, inside the syncApiKeysToVercel method (around lines 978-1001), refactor the delete-then-create approach to be more resilient. Instead of calling removeAllVercelEnvVarsByKey first (which permanently deletes before the replacement is guaranteed), either:

1. Perform the batch creation first, and only then remove any stale env vars that no longer match the current set of targets. This way, if the creation fails, the old keys are still in place.

2. Alternatively, wrap the operation so that if batchUpsertVercelEnvVars fails after removeAllVercelEnvVarsByKey, a retry/recovery path is attempted to re-create the env vars.

The goal is to never leave the Vercel project in a state where TRIGGER_SECRET_KEY env vars have been deleted but not replaced.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const result = await this.batchUpsertVercelEnvVars({
client,
vercelProjectId: params.vercelProjectId,
Expand Down Expand Up @@ -1526,6 +1533,35 @@ export class VercelIntegrationRepository {
return { created, updated, errors };
}

private static async removeAllVercelEnvVarsByKey(params: {
client: Vercel;
vercelProjectId: string;
teamId: string | null;
key: string;
}): Promise<void> {
const { client, vercelProjectId, teamId, key } = params;

const existingEnvs = await client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
});

const envs = extractVercelEnvs(existingEnvs);
const idsToRemove = envs
.filter((env) => env.key === key && env.id)
.map((env) => env.id!);

if (idsToRemove.length === 0) {
return;
}

await client.projects.batchRemoveProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: { ids: idsToRemove },
});
}

private static async upsertVercelEnvVar(params: {
client: Vercel;
vercelProjectId: string;
Expand Down
12 changes: 9 additions & 3 deletions apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { processGitMetadata } from "./BranchesPresenter.server";
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
import {
VercelProjectIntegrationDataSchema,
buildVercelDeploymentUrl,
} from "~/v3/vercel/vercelProjectIntegrationSchema";

const pageSize = 20;

Expand Down Expand Up @@ -232,8 +235,11 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;

let vercelDeploymentUrl: string | null = null;
if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) {
const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, "");
vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
vercelDeploymentUrl = buildVercelDeploymentUrl(
vercelTeamSlug,
vercelProjectName,
deployment.integrationDeploymentId
);
}

return {
Expand Down
Loading
Loading