Files
Saxon FletcherandDanny White 09ea558d54 Vercel install layout (#47550)
Bring the Vercel install layout up to date with other connect screens.
Resolves DEPR-615.

| Before | After |
| --- | --- |
| <img width="1600" height="1200" alt="CleanShot 2026-07-03 at 10 51
59@2x"
src="https://github.com/user-attachments/assets/6a8c2910-8c45-4c7d-8d38-8b80c5cf4c83"
/> | <img width="1150" height="1318" alt="CleanShot 2026-07-03 at 11 56
17@2x"
src="https://github.com/user-attachments/assets/3a5add5c-fea4-44f3-a368-5732257b27d9"
/> |

## Testing

- Open the deploy preview or staging URL for
`/dashboard/integrations/vercel/install` with callback params from a
real Vercel Marketplace install redirect (see _Vercel_ subheading
below).
- Confirm the install screen renders with the selected Supabase account,
organization picker, and primary install CTA.
- Remove required callback params such as `code`, `configurationId`, or
`source` to verify the "Missing Vercel installation details" warning
state.
- Clicking "Install integration" with an expired or reused `code` can
show `Creating Vercel integration failed: Failed to get Vercel access
token`; that is expected for preview UI validation. A full successful
install requires a fresh Vercel-generated code from the install flow.

To reiterate; this won’t work because the code will be invalid. But it
should show that everything is hooked up right for prod.

### Vercel

1. Go to Supabase. Open either org-level or project (settings) level
integrations.
2. Tap "[Install Vercel
Integration](https://vercel.com/integrations/supabase-local)".
3. Follow the install instructions from that Vercel page. This should
open a browser window.
4. Copy the URL of that browser window. Take the params and paste them
instead at the end of the deploy preview URL.

Example of #4:

```txt
Before:
https://supabase.com/dashboard/integrations/vercel/install?code=jDhIBDlD58zzLVtuSNjJpUSu&configurationId=icfg_v3dKllQIniSOwdVI3gypnZh3&next=https%3A%2F%2Fvercel.com%2Ftest-5706s-projects%2F~%2Fintegrations%2Ficfg_v3dKllQIniSOwdVI3gypnZh3%2Finstalled&source=marketplace&teamId=team_mPkGQZjTLBEUXh15b03iVsTg

After
https://studio-staging-git-chore-install-layout-supabase.vercel.app/dashboard/integrations/vercel/install?code=jDhIBDlD58zzLVtuSNjJpUSu&configurationId=icfg_v3dKllQIniSOwdVI3gypnZh3&next=https%3A%2F%2Fvercel.com%2Ftest-5706s-projects%2F~%2Fintegrations%2Ficfg_v3dKllQIniSOwdVI3gypnZh3%2Finstalled&source=marketplace&teamId=team_mPkGQZjTLBEUXh15b03iVsTg
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Updated the installation experience with a cleaner, more guided
layout.
* Added clearer organization selection during setup, including
visibility into which organizations are already installed.

* **Bug Fixes**
  * Improved loading and error handling during installation.
* Added clearer warnings for missing setup details, already-installed
integrations, and cases with no available organizations.
* Fixed routing behavior so organization selection is preserved more
reliably.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
2026-07-06 19:34:20 +10:00

427 lines
13 KiB
TypeScript

import { useParams } from 'common'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Badge,
Button,
Card,
CardContent,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { getHasInstalledObject } from '@/components/layouts/IntegrationsLayout/Integrations.utils'
import {
InterstitialAccountRow,
InterstitialLayout,
LogoPair,
PartnerLogo,
SupabaseLogo,
} from '@/components/layouts/InterstitialLayout'
import { useIntegrationsQuery } from '@/data/integrations/integrations-query'
import { useVercelIntegrationCreateMutation } from '@/data/integrations/vercel-integration-create-mutation'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { withAuth } from '@/hooks/misc/withAuth'
import { BASE_PATH } from '@/lib/constants'
import {
buildVercelInstallRouteQuery,
getErrorMessage,
getVercelInstallSource,
} from '@/lib/integrations/vercel-install.utils'
import { buildStudioPageTitle } from '@/lib/page-title'
import { useProfileNameAndPicture } from '@/lib/profile'
import { useIntegrationInstallationSnapshot } from '@/state/integration-installation'
import type { NextPageWithLayout, Organization } from '@/types'
const PAGE_TITLE = buildStudioPageTitle({
section: 'Install Vercel Integration',
brand: 'Supabase',
})
/**
* Variations of the Vercel integration flow.
* They require different UI and logic.
*
* Deploy Button - the flow that starts from the Deploy Button - https://vercel.com/docs/integrations#deploy-button
* Marketplace - the flow that starts from the Marketplace - https://vercel.com/integrations
*
*/
export type VercelIntegrationFlow = 'deploy-button' | 'marketing'
const VercelIntegration: NextPageWithLayout = () => {
const router = useRouter()
const { code, configurationId, currentProjectId, externalId, next, teamId, source } = useParams()
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null)
const { username, primaryEmail, avatarUrl } = useProfileNameAndPicture()
const snapshot = useIntegrationInstallationSnapshot()
const displayName = primaryEmail ?? username ?? ''
/**
* Fetch the list of organization based integration installations for Vercel.
*
* Array of integrations installed on all
*/
const {
data: integrationData,
isPending: isLoadingIntegrationsQuery,
isError: isIntegrationsError,
error: integrationsError,
} = useIntegrationsQuery()
const {
data: organizationsData,
isPending: isLoadingOrganizationsQuery,
isSuccess: isOrganizationsDataSuccess,
isError: isOrganizationsError,
error: organizationsError,
} = useOrganizationsQuery()
useEffect(() => {
if (organizationsData !== undefined && integrationData !== undefined) {
const firstOrg = organizationsData[0]
if (firstOrg && selectedOrg === null) {
setSelectedOrg(firstOrg)
}
}
}, [organizationsData, integrationData, selectedOrg])
/**
* Organizations with extra `installationInstalled` attribute
*
* Used to show label/badge and allow/disallow installing
*
*/
const installed = useMemo(
() =>
integrationData && organizationsData
? getHasInstalledObject({
integrationName: 'Vercel',
integrationData,
organizationsData,
installationId: configurationId,
})
: {},
[configurationId, integrationData, organizationsData]
)
/**
* Handle the correct route change based on whether the vercel integration
* is following the 'marketplace/external' flow or 'deploy button' flow.
* See:
* - https://vercel.com/docs/integrations/create-integration/submit-integration#query-parameters-for-marketplace
* - https://vercel.com/docs/integrations/create-integration/submit-integration#query-parameters-for-external-flow
* - https://vercel.com/docs/integrations/create-integration/submit-integration#query-parameters-for-deploy-button
*/
function handleRouteChange() {
const orgSlug = selectedOrg?.slug
const vercelInstallSource = getVercelInstallSource(source)
const query = buildVercelInstallRouteQuery({
source: vercelInstallSource,
organizationSlug: orgSlug,
configurationId,
currentProjectId,
externalId,
next,
})
switch (vercelInstallSource) {
case 'deploy-button': {
router.push({
pathname: `/integrations/vercel/${orgSlug}/deploy-button/new-project`,
query,
})
break
}
case 'marketplace':
case 'external': {
router.push({
pathname: `/integrations/vercel/${orgSlug}/marketplace/choose-project`,
query,
})
break
}
default:
toast.error(
`Unsupported Vercel installation source: ${source}. Please contact support if this error persists.`
)
}
}
const { mutate, isPending: isLoadingVercelIntegrationCreateMutation } =
useVercelIntegrationCreateMutation({
onMutate() {
snapshot.setLoading(true)
},
onSuccess() {
handleRouteChange()
snapshot.setLoading(false)
},
onError(error) {
snapshot.setLoading(false)
toast.error(`Creating Vercel integration failed: ${error.message}`)
},
})
function onInstall() {
const orgSlug = selectedOrg?.slug
const isIntegrationInstalled = orgSlug ? installed[orgSlug] : false
if (!orgSlug) {
return toast.error('Please select an organization')
}
if (!code) {
return toast.error('Vercel code missing')
}
if (!configurationId) {
return toast.error('Vercel Configuration ID missing')
}
if (!source) {
return toast.error('Vercel Configuration source missing')
}
/**
* Only install if integration hasn't already been installed
*/
if (!isIntegrationInstalled) {
mutate({
code,
configurationId,
orgSlug,
metadata: {},
source,
teamId: teamId,
})
} else {
handleRouteChange()
}
}
const dataLoading =
isLoadingVercelIntegrationCreateMutation ||
isLoadingOrganizationsQuery ||
isLoadingIntegrationsQuery
const noOrganizations = useMemo(() => {
return isOrganizationsDataSuccess && organizationsData?.length === 0 ? true : false
}, [isOrganizationsDataSuccess, organizationsData])
const alreadyInstalled = useMemo(() => {
return selectedOrg && installed[selectedOrg.slug] && source === 'marketplace' && !dataLoading
? true
: false
}, [dataLoading, installed, selectedOrg, source])
const missingParams = [
!code ? 'code' : undefined,
!configurationId ? 'configurationId' : undefined,
!source ? 'source' : undefined,
].filter(Boolean) as string[]
const isError = isOrganizationsError || isIntegrationsError
const errorMessage = getErrorMessage(organizationsError) ?? getErrorMessage(integrationsError)
const showLoadingState = isLoadingOrganizationsQuery || isLoadingIntegrationsQuery
const disableInstallationForm =
dataLoading ||
// disables installation button if integration is already installed and it is Marketplace flow
alreadyInstalled ||
noOrganizations ||
!selectedOrg ||
missingParams.length > 0 ||
isError
return (
<>
<Head>
<title>{PAGE_TITLE}</title>
</Head>
<InterstitialLayout
logo={
<LogoPair
left={
<PartnerLogo
src={`${BASE_PATH}/img/icons/vercel-icon.svg`}
alt="Vercel"
className="bg-surface-75"
imageClassName="size-7 object-contain dark:invert"
/>
}
right={<SupabaseLogo />}
/>
}
title="Install Vercel integration"
description="Choose an organization to connect to Vercel"
>
<div className="px-6 pb-6">
{showLoadingState ? (
<InstallationLoadingState />
) : isError ? (
<InstallationErrorState errorMessage={errorMessage} />
) : (
<div className="flex flex-col gap-5">
<InterstitialAccountRow avatarUrl={avatarUrl} displayName={displayName} />
<OrganizationSelect
organizations={organizationsData ?? []}
selectedOrg={selectedOrg}
disabled={noOrganizations || dataLoading}
installed={installed}
onSelectedOrgChange={setSelectedOrg}
/>
{missingParams.length > 0 && (
<Admonition
type="warning"
title="Missing Vercel installation details"
description={`Retry from Vercel. The installation URL is missing: ${missingParams.join(
', '
)}.`}
/>
)}
{alreadyInstalled && (
<Admonition
type="warning"
title="Vercel integration is already installed"
description="Choose another organization to install this marketplace integration."
/>
)}
{noOrganizations && (
<Admonition
type="warning"
title="No Supabase organizations found"
description={
<>
Create a Supabase organization before installing the Vercel integration. You
can create a new organization{' '}
<Link href="https://supabase.com/dashboard/new" target="_blank">
here
</Link>
.
</>
}
/>
)}
<div className="flex flex-col gap-2">
<Button
block
variant="primary"
disabled={disableInstallationForm}
loading={dataLoading}
onClick={onInstall}
>
Install integration
</Button>
</div>
</div>
)}
</div>
</InterstitialLayout>
</>
)
}
const InstallationLoadingState = () => (
<div className="flex flex-col gap-5">
<Card className="shadow-none">
<CardContent className="flex items-center gap-3 border-none px-4 py-3">
<ShimmeringLoader className="size-8 flex-shrink-0 rounded-full py-0" />
<div className="min-w-0 flex-1 space-y-2">
<ShimmeringLoader className="h-3 w-20 py-0" />
<ShimmeringLoader className="h-4 w-40 max-w-full py-0" />
</div>
</CardContent>
</Card>
<section className="space-y-2" aria-label="Organization loading">
<ShimmeringLoader className="h-3 w-24 py-0" />
<ShimmeringLoader className="h-[34px] w-full rounded-md py-0" />
</section>
<ShimmeringLoader className="h-10 w-full rounded-md py-0" />
</div>
)
const InstallationErrorState = ({ errorMessage }: { errorMessage?: string }) => (
<div className="flex flex-col gap-3">
<Admonition
type="warning"
title="Unable to load installation"
description={
<>
Retry the installation request from Vercel.
{errorMessage && (
<span className="mt-1 block text-foreground-lighter">Error: {errorMessage}</span>
)}
</>
}
/>
<Button variant="default" block asChild>
<Link href="/">Back to dashboard</Link>
</Button>
</div>
)
interface OrganizationSelectProps {
organizations: Organization[]
selectedOrg: Organization | null
disabled?: boolean
installed: Record<string, boolean>
onSelectedOrgChange: (organization: Organization) => void
}
function OrganizationSelect({
organizations,
selectedOrg,
disabled,
installed,
onSelectedOrgChange,
}: OrganizationSelectProps) {
return (
<section className="space-y-2" aria-label="Organization">
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
Organization
</p>
<Select
value={selectedOrg?.slug ?? ''}
disabled={disabled}
onValueChange={(slug) => {
const org = organizations.find((org) => org.slug === slug)
if (org) onSelectedOrgChange(org)
}}
>
<SelectTrigger size="small" aria-label="Supabase organization to install Vercel into">
<SelectValue placeholder="Choose an organization" />
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.slug} value={org.slug} className="text-xs">
<div className="flex items-center gap-2">
<span className="truncate">{org.name}</span>
{installed[org.slug] && <Badge className="flex-none!">Installed</Badge>}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</section>
)
}
export default withAuth(VercelIntegration)