From c9ed51c99ea088b9cafbddcf4d0881445ffc7985 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:14:01 -0400 Subject: [PATCH 1/4] fix(studio): add return to Vercel escape hatch (#48311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix / UX improvement for the Vercel Deploy Button create-project interstitial. ## What is the current behavior? On the Vercel create-project step, the organization picker is locked (correct — the integration is bound to that org) and Cancel is hidden. If the org can't create a free project (member free-project limits), users hit a dead end: Upgrade may not help, and there's no way out of the popup. Also includes a small capitalisation nit on the Vercel install page. | Before | | --- | | Create Vercel Project Supabase | ## What is the new behavior? - Replaces `hideCancelButton` with `cancelAction: 'studio' | 'vercel' | 'hidden'` - Vercel create flow shows **Return to Vercel**, which redirects to the install `next` URL (closing the popup cleanly) - Free-project-limit admonition adds a Vercel-only hint pointing at that button: “Or return to Vercel and restart with a different organization.” - Main `/new` Cancel behaviour is unchanged - Org picker stays disabled ## Additional context Org switching mid-create is intentionally not allowed. That would orphan the Vercel install. Returning to Vercel is the safe escape hatch so users can restart Deploy Button with another org, or free a project slot / upgrade and try again. ## To test As far as I can tell, this is impossible to test on prod. Shortly after merge though, you could test the following: - [ ] Happy path: create still works; Return to Vercel is secondary and does not block submit - [ ] Free-limit blocked org: Create disabled, Return to Vercel visible and redirects to `next` - [ ] Main `/new`: Cancel still goes to last org / organizations ## Summary by CodeRabbit - **New Features** - Enhanced project creation flow for Vercel: when a valid return destination is available, users can choose **“Return to Vercel”**. - Added additional messaging in the free-project-limit warning to guide users back to Vercel and restart with a different organization (when applicable). - **Bug Fixes** - Improved cancel behavior and routing consistency by only enabling Vercel return when the destination is valid. - **Style** - Updated the Vercel integration interstitial title capitalization for consistency. --------- Co-authored-by: Joshen Lim --- .../Vercel/VercelIntegration.utils.test.ts | 32 ++++++++++++++++++ .../Vercel/VercelIntegration.utils.ts | 6 ++++ .../FreeProjectLimitWarning.tsx | 11 ++++++- .../ProjectCreation/ProjectCreationFooter.tsx | 33 ++++++++++++++----- .../ProjectCreation/ProjectCreationForm.tsx | 13 +++++--- .../pages/integrations/vercel/install.tsx | 2 +- 6 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts diff --git a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts new file mode 100644 index 0000000000000..00cd23ecbf713 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'vitest' + +import { + getValidVercelReturnUrl, + isVercelUrl, +} from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils' + +describe('isVercelUrl', () => { + test('accepts https vercel.com urls', () => { + expect(isVercelUrl('https://vercel.com/callback')).toBe(true) + }) + + test('rejects non-vercel and invalid urls', () => { + expect(isVercelUrl('https://example.com')).toBe(false) + expect(isVercelUrl('http://vercel.com')).toBe(false) + expect(isVercelUrl('not-a-url')).toBe(false) + }) +}) + +describe('getValidVercelReturnUrl', () => { + test('returns the url when it is a valid vercel return url', () => { + expect(getValidVercelReturnUrl('https://vercel.com/callback')).toBe( + 'https://vercel.com/callback' + ) + }) + + test('returns undefined for missing or invalid next values', () => { + expect(getValidVercelReturnUrl(undefined)).toBeUndefined() + expect(getValidVercelReturnUrl('https://example.com')).toBeUndefined() + expect(getValidVercelReturnUrl('not-a-url')).toBeUndefined() + }) +}) diff --git a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts index a6802341ab751..05db949804dd3 100644 --- a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts +++ b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts @@ -10,6 +10,12 @@ export function isVercelUrl(url: string): boolean { } } +/** Returns `next` when it is a safe Vercel return URL; otherwise undefined. */ +export function getValidVercelReturnUrl(next: string | undefined): string | undefined { + if (typeof next === 'string' && isVercelUrl(next)) return next + return undefined +} + export function findVercelIntegrationByConfigurationId( integrations: Integration[] | undefined, configurationId: string | undefined diff --git a/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx b/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx index e3b8389232c73..ba554365f5a95 100644 --- a/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx @@ -6,9 +6,13 @@ import type { MemberWithFreeProjectLimit } from '@/data/organizations/free-proje interface FreeProjectLimitWarningProps { membersExceededLimit: MemberWithFreeProjectLimit[] + showVercelReturnHint?: boolean } -export const FreeProjectLimitWarning = ({ membersExceededLimit }: FreeProjectLimitWarningProps) => { +export const FreeProjectLimitWarning = ({ + membersExceededLimit, + showVercelReturnHint = false, +}: FreeProjectLimitWarningProps) => { return ( + {showVercelReturnHint && ( +

+ Or return to Vercel and restart with a different organization. +

+ )} canCreateProject: boolean @@ -29,7 +32,7 @@ interface ProjectCreationFooterProps { organizationProjects: OrgProject[] isCreatingNewProject: boolean isSuccessNewProject: boolean - hideCancelButton: boolean + cancelAction?: ProjectCreationCancelAction } export const ProjectCreationFooter = ({ @@ -39,9 +42,10 @@ export const ProjectCreationFooter = ({ organizationProjects, isCreatingNewProject, isSuccessNewProject, - hideCancelButton, + cancelAction = 'studio', }: ProjectCreationFooterProps) => { const router = useRouter() + const { next } = useParams() const { data: currentOrg } = useSelectedOrganizationQuery() const isFreePlan = currentOrg?.plan?.id === 'free' const { lastVisitedOrganization } = useLastVisitedOrganization() @@ -53,6 +57,9 @@ export const ProjectCreationFooter = ({ ? 0 : monthlyInstancePrice(instanceSize) - availableComputeCredits + const vercelReturnUrl = getValidVercelReturnUrl(next) + const canReturnToVercel = cancelAction === 'vercel' && vercelReturnUrl !== undefined + // [kevin] This will eventually all be provided by a new API endpoint to preview and validate project creation, this is just for kaizen now const monthlyComputeCosts = // current project costs @@ -66,6 +73,17 @@ export const ProjectCreationFooter = ({ // compute credits 10 + const onCancel = () => { + if (canReturnToVercel && vercelReturnUrl) { + window.location.href = vercelReturnUrl + return + } + + // Fall back to Studio when cancelAction is studio, or when vercel next is missing/invalid + if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`) + else router.push('/organizations') + } + return (
@@ -169,16 +187,13 @@ export const ProjectCreationFooter = ({
- {!hideCancelButton && ( + {cancelAction !== 'hidden' && ( )}