mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
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>
427 lines
13 KiB
TypeScript
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)
|