Skip to content

Commit 5a4e754

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
fix(webapp): handle disabled Vercel installations and uninstall Vercel when deleting an organization
Deleting an organization now uninstalls its Vercel integration. A Vercel installation that was disabled on Vercel's side is reported as such instead of being treated as an expired connection: removing it no longer pretends to succeed, and the settings page explains that a Vercel team owner has to restore or remove it. Mono-RevId: dea49bc21ac7550b5a05fcf46cf792f89fa4a1a8
1 parent 480ca8e commit 5a4e754

13 files changed

Lines changed: 609 additions & 57 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Deleting an organization now uninstalls its Vercel integration. A Vercel installation disabled on Vercel's side is reported as such instead of as an expired connection, and removing it no longer reports success.

‎apps/webapp/app/components/integrations/VercelOnboardingModal.tsx‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export function VercelOnboardingModal({
136136
const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment;
137137

138138
const computeInitialState = useCallback((): OnboardingState => {
139-
if (!hasOrgIntegration || onboardingData?.authInvalid) {
139+
if (!hasOrgIntegration || onboardingData?.authInvalid || onboardingData?.disabled) {
140140
return "idle";
141141
}
142142
const projectSelected = onboardingData?.hasProjectSelected ?? false;
@@ -331,16 +331,21 @@ export function VercelOnboardingModal({
331331
const loadingStateRef = useRef<OnboardingState | null>(null);
332332

333333
useEffect(() => {
334-
if (!isOpen || state === "idle") {
334+
if (!isOpen) {
335335
loadingStateRef.current = null;
336336
return;
337337
}
338338

339-
if (onboardingData?.authInvalid) {
339+
if (onboardingData?.authInvalid || onboardingData?.disabled) {
340340
onClose();
341341
return;
342342
}
343343

344+
if (state === "idle") {
345+
loadingStateRef.current = null;
346+
return;
347+
}
348+
344349
if (loadingStateRef.current === state) {
345350
return;
346351
}
@@ -376,29 +381,49 @@ export function VercelOnboardingModal({
376381
loadingStateRef.current = null;
377382
break;
378383
}
379-
}, [isOpen, state, onboardingData?.authInvalid, vercelStagingEnvironment, onDataReload, onClose]);
384+
}, [
385+
isOpen,
386+
state,
387+
onboardingData?.authInvalid,
388+
onboardingData?.disabled,
389+
vercelStagingEnvironment,
390+
onDataReload,
391+
onClose,
392+
]);
380393

381394
useEffect(() => {
382395
if (
383396
!onboardingData?.authInvalid &&
397+
!onboardingData?.disabled &&
384398
state === "loading-projects" &&
385399
onboardingData?.availableProjects !== undefined
386400
) {
387401
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
388402
setState("project-selection");
389403
}
390-
}, [state, onboardingData?.availableProjects, onboardingData?.authInvalid]);
404+
}, [
405+
state,
406+
onboardingData?.availableProjects,
407+
onboardingData?.authInvalid,
408+
onboardingData?.disabled,
409+
]);
391410

392411
useEffect(() => {
393412
if (
394413
!onboardingData?.authInvalid &&
414+
!onboardingData?.disabled &&
395415
state === "loading-env-vars" &&
396416
onboardingData?.environmentVariables
397417
) {
398418
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
399419
setState("env-var-sync");
400420
}
401-
}, [state, onboardingData?.environmentVariables, onboardingData?.authInvalid]);
421+
}, [
422+
state,
423+
onboardingData?.environmentVariables,
424+
onboardingData?.authInvalid,
425+
onboardingData?.disabled,
426+
]);
402427

403428
useEffect(() => {
404429
if (
@@ -710,7 +735,7 @@ export function VercelOnboardingModal({
710735
}
711736
}, [state, availableProjects, selectedVercelProject]);
712737

713-
if (!isOpen || onboardingData?.authInvalid) {
738+
if (!isOpen || onboardingData?.authInvalid || onboardingData?.disabled) {
714739
return null;
715740
}
716741

‎apps/webapp/app/models/vercelIntegration.server.ts‎

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server"
3636
import { boundedIn } from "@trigger.dev/database";
3737
import { toVercelApiError, type VercelApiError } from "~/v3/vercel/vercelApiError";
3838
import { createVercelClient, type VercelClient as Vercel } from "~/v3/vercel/vercelClient.server";
39+
import { vercelErrorLogMetadata } from "~/v3/vercel/vercelErrorLogMetadata";
3940
import {
4041
callVercelWithRecovery,
4142
wrapVercelCallWithRecovery,
@@ -236,7 +237,11 @@ export class VercelIntegrationRepository {
236237

237238
if (!clientId || !clientSecret) {
238239
logger.error("Vercel integration not configured");
239-
return errAsync({ message: "Vercel integration not configured", authInvalid: false });
240+
return errAsync({
241+
message: "Vercel integration not configured",
242+
reason: "other",
243+
authInvalid: false,
244+
});
240245
}
241246

242247
return ResultAsync.fromPromise(
@@ -580,8 +585,9 @@ export class VercelIntegrationRepository {
580585
const body = await response.text().catch(() => "");
581586
const error = new Error(
582587
`Failed to fetch Vercel shared environment variables: ${response.status} ${response.statusText} — ${body}`
583-
) as Error & { status?: number };
588+
) as Error & { status?: number; body?: string };
584589
error.status = response.status;
590+
error.body = body;
585591
throw error;
586592
}
587593

@@ -2000,23 +2006,42 @@ export class VercelIntegrationRepository {
20002006

20012007
static uninstallVercelIntegration(
20022008
integration: OrganizationIntegration & { tokenReference: SecretReference }
2003-
): ResultAsync<{ authInvalid: boolean }, VercelApiError> {
2004-
return this.getVercelClient(integration).andThen((client) =>
2005-
ResultAsync.fromPromise(
2006-
(async () => {
2007-
const secret = await getSecretStore(integration.tokenReference.provider).getSecret(
2008-
VercelSecretSchema,
2009-
integration.tokenReference.key
2010-
);
2009+
): ResultAsync<{ authInvalid: boolean; sharedWith?: string }, VercelApiError> {
2010+
return ResultAsync.fromPromise(
2011+
(async () => {
2012+
const secret = await getSecretStore(integration.tokenReference.provider).getSecret(
2013+
VercelSecretSchema,
2014+
integration.tokenReference.key
2015+
);
20112016

2012-
if (!secret?.installationId) {
2013-
throw new Error("Installation ID not found in Vercel integration");
2014-
}
2017+
if (!secret?.installationId) {
2018+
throw new Error("Installation ID not found in Vercel integration");
2019+
}
20152020

2016-
return secret.installationId;
2017-
})(),
2018-
toVercelApiError
2019-
).andThen((installationId) =>
2021+
const sharedWith = await prisma.organizationIntegration.findFirst({
2022+
where: {
2023+
id: { not: integration.id },
2024+
service: "VERCEL",
2025+
deletedAt: null,
2026+
integrationData: { path: ["installationId"], equals: secret.installationId },
2027+
},
2028+
select: { id: true },
2029+
});
2030+
2031+
return { installationId: secret.installationId, sharedWith: sharedWith?.id };
2032+
})(),
2033+
toVercelApiError
2034+
).andThen(({ installationId, sharedWith }) => {
2035+
if (sharedWith) {
2036+
logger.info("Vercel installation still used by another integration, not uninstalling", {
2037+
installationId,
2038+
integrationId: integration.id,
2039+
sharedWith,
2040+
});
2041+
return okAsync({ authInvalid: false, sharedWith });
2042+
}
2043+
2044+
return this.getVercelClient(integration).andThen((client) =>
20202045
ResultAsync.fromPromise(
20212046
client.integrations.deleteConfiguration({
20222047
id: installationId,
@@ -2025,19 +2050,25 @@ export class VercelIntegrationRepository {
20252050
)
20262051
.map(() => ({ authInvalid: false }))
20272052
.orElse((error) => {
2028-
const isAuthError = toVercelApiError(error).authInvalid;
2053+
if (vercelErrorLogMetadata(error).status === 404) {
2054+
logger.info("Vercel configuration already removed", { installationId });
2055+
return okAsync({ authInvalid: false });
2056+
}
2057+
2058+
const apiError = toVercelApiError(error);
20292059
logger.error("Failed to uninstall Vercel integration", {
20302060
installationId,
20312061
error: error instanceof Error ? error.message : "Unknown error",
2032-
isAuthError,
2062+
isAuthError: apiError.authInvalid,
2063+
reason: apiError.reason,
20332064
});
2034-
// Auth errors (401/403): still clean up on our side, return flag for caller
2035-
if (isAuthError) {
2065+
// Revoked token: still clean up on our side, return flag for caller
2066+
if (apiError.authInvalid) {
20362067
return okAsync({ authInvalid: true });
20372068
}
2038-
return errAsync(toVercelApiError(error));
2069+
return errAsync(apiError);
20392070
})
2040-
)
2041-
);
2071+
);
2072+
});
20422073
}
20432074
}

‎apps/webapp/app/models/vercelSdkRecovery.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export function wrapVercelCallWithRecovery<T>(
124124
...(error.status === undefined ? {} : { status: error.status }),
125125
authInvalid: apiError.authInvalid,
126126
});
127-
return { message, authInvalid: apiError.authInvalid };
127+
return { message, reason: apiError.reason, authInvalid: apiError.authInvalid };
128128
});
129129
}
130130

‎apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts‎

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type VercelSettingsResult = {
2424
enabled: boolean;
2525
hasOrgIntegration: boolean;
2626
authInvalid?: boolean;
27+
disabled?: boolean;
2728
authError?: string;
2829
connectedProject?: {
2930
id: string;
@@ -61,6 +62,7 @@ export type VercelOnboardingData = {
6162
availableProjects: VercelAvailableProject[];
6263
hasProjectSelected: boolean;
6364
authInvalid?: boolean;
65+
disabled?: boolean;
6466
authError?: string;
6567
existingVariables: Record<string, { environments: string[] }>; // Environment slugs (non-archived only)
6668
gitHubAppInstallations: GitHubAppInstallation[];
@@ -131,10 +133,12 @@ export class VercelSettingsPresenter extends BasePresenter {
131133
if (hasOrgIntegration) {
132134
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
133135
if (tokenResult.isErr() || !tokenResult.value.isValid) {
136+
const disabled = tokenResult.isErr() && tokenResult.error.reason === "disabled";
134137
return ok({
135138
enabled: true,
136139
hasOrgIntegration: true,
137-
authInvalid: true,
140+
authInvalid: !disabled,
141+
disabled,
138142
authError: tokenResult.isErr() ? tokenResult.error.message : "Vercel token is invalid",
139143
connectedProject: undefined,
140144
isGitHubConnected: false,
@@ -267,6 +271,7 @@ export class VercelSettingsPresenter extends BasePresenter {
267271
connectedProject,
268272
]) => {
269273
const fetchVercelData = async (): Promise<{
274+
disabled?: boolean;
270275
customEnvironments: VercelCustomEnvironment[];
271276
autoAssignCustomDomains: boolean | null;
272277
vercelManageAccessUrl?: string;
@@ -363,7 +368,12 @@ export class VercelSettingsPresenter extends BasePresenter {
363368
);
364369
}
365370

371+
const disabled = [customEnvsResult, autoAssignResult, triggerVersionResult].some(
372+
(r) => r.isErr() && r.error.reason === "disabled"
373+
);
374+
366375
return {
376+
disabled,
367377
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
368378
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
369379
vercelManageAccessUrl,
@@ -377,26 +387,39 @@ export class VercelSettingsPresenter extends BasePresenter {
377387
cause: error,
378388
})).map(
379389
({
390+
disabled,
380391
customEnvironments,
381392
autoAssignCustomDomains,
382393
vercelManageAccessUrl,
383394
currentTriggerVersion,
384395
currentTriggerVersionFetchFailed,
385396
}) =>
386-
({
387-
enabled: true,
388-
hasOrgIntegration,
389-
authInvalid: false,
390-
connectedProject,
391-
isGitHubConnected,
392-
hasStagingEnvironment,
393-
hasPreviewEnvironment,
394-
customEnvironments,
395-
autoAssignCustomDomains,
396-
vercelManageAccessUrl,
397-
currentTriggerVersion,
398-
currentTriggerVersionFetchFailed,
399-
}) as VercelSettingsResult
397+
(disabled
398+
? {
399+
enabled: true,
400+
hasOrgIntegration,
401+
authInvalid: false,
402+
disabled: true,
403+
connectedProject: undefined,
404+
isGitHubConnected: false,
405+
hasStagingEnvironment: false,
406+
hasPreviewEnvironment: false,
407+
customEnvironments: [],
408+
}
409+
: {
410+
enabled: true,
411+
hasOrgIntegration,
412+
authInvalid: false,
413+
connectedProject,
414+
isGitHubConnected,
415+
hasStagingEnvironment,
416+
hasPreviewEnvironment,
417+
customEnvironments,
418+
autoAssignCustomDomains,
419+
vercelManageAccessUrl,
420+
currentTriggerVersion,
421+
currentTriggerVersionFetchFailed,
422+
}) as VercelSettingsResult
400423
);
401424
}
402425
)
@@ -497,12 +520,14 @@ export class VercelSettingsPresenter extends BasePresenter {
497520

498521
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
499522
if (tokenResult.isErr() || !tokenResult.value.isValid) {
523+
const disabled = tokenResult.isErr() && tokenResult.error.reason === "disabled";
500524
return {
501525
customEnvironments: [],
502526
environmentVariables: [],
503527
availableProjects: [],
504528
hasProjectSelected: false,
505-
authInvalid: true,
529+
authInvalid: !disabled,
530+
disabled,
506531
authError: tokenResult.isErr() ? tokenResult.error.message : "Vercel token is invalid",
507532
existingVariables: {},
508533
gitHubAppInstallations,
@@ -520,6 +545,7 @@ export class VercelSettingsPresenter extends BasePresenter {
520545
availableProjects: [],
521546
hasProjectSelected: false,
522547
authInvalid: clientResult.error.authInvalid,
548+
disabled: clientResult.error.reason === "disabled",
523549
authError: clientResult.error.authInvalid ? clientResult.error.message : undefined,
524550
existingVariables: {},
525551
gitHubAppInstallations,
@@ -555,6 +581,7 @@ export class VercelSettingsPresenter extends BasePresenter {
555581
availableProjects: [],
556582
hasProjectSelected: false,
557583
authInvalid: availableProjectsResult.error.authInvalid,
584+
disabled: availableProjectsResult.error.reason === "disabled",
558585
authError: availableProjectsResult.error.authInvalid
559586
? availableProjectsResult.error.message
560587
: undefined,
@@ -607,6 +634,26 @@ export class VercelSettingsPresenter extends BasePresenter {
607634
}>
608635
),
609636
]);
637+
const disabled =
638+
(customEnvironmentsResult.isErr() &&
639+
customEnvironmentsResult.error.reason === "disabled") ||
640+
(projectEnvVarsResult.isErr() && projectEnvVarsResult.error.reason === "disabled") ||
641+
(sharedEnvVarsResult.isErr() && sharedEnvVarsResult.error.reason === "disabled");
642+
643+
if (disabled) {
644+
return {
645+
customEnvironments: [],
646+
environmentVariables: [],
647+
availableProjects: availableProjectsResult.value,
648+
hasProjectSelected: true,
649+
disabled: true,
650+
existingVariables: {},
651+
gitHubAppInstallations,
652+
isGitHubConnected,
653+
isOnboardingComplete: false,
654+
};
655+
}
656+
610657
const authInvalid =
611658
(customEnvironmentsResult.isErr() && customEnvironmentsResult.error.authInvalid) ||
612659
(projectEnvVarsResult.isErr() && projectEnvVarsResult.error.authInvalid) ||

0 commit comments

Comments
 (0)