restack vercel privatelink prototype onto the generic ui stack

This commit is contained in:
Danny White committed 2026-08-18 12:42:40 +10:00
1 parent 2cb24f06b0
commit e5cede3d4a
22 files changed
+1004 -99

No files matched your search

@@ -1,7 +1,9 @@
import { useParams } from 'common'
import { Check, KeyRound } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Admonition } from 'ui-patterns/Admonition'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { Input as CopyableInput } from 'ui-patterns/DataInputs/Input'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { buildConnectionStringPooler, getConnectionStrings } from '../../../DatabaseSettings.utils'
@@ -29,6 +31,10 @@ import {
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote'
import { ResetDbPasswordDialog } from '@/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog'
import {
PRIVATE_LINK_PREVIEW_HOSTNAME,
usePrivateLinkPreview,
} from '@/components/interfaces/Settings/Integrations/AWSPrivateLink/preview'
import { InlineLink } from '@/components/ui/InlineLink'
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
@@ -153,6 +159,7 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) {
const track = useTrack()
const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler')
const isHighAvailability = useIsHighAvailability()
const { showPrivateHostname } = usePrivateLinkPreview()
const [temporaryDatabasePassword, setTemporaryDatabasePassword] = useState('')
const connectionSource = state.connectionSource
@@ -245,6 +252,15 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) {
return (
<div className="flex flex-col gap-3">
{showPrivateHostname && (
<Admonition
type="note"
title="Private hostname"
description="Use this hostname on the PrivateLink path. Vercel may inject it for you; this is the Studio-owned variant."
>
<CopyableInput readOnly copy value={PRIVATE_LINK_PREVIEW_HOSTNAME} />
</Admonition>
)}
<div className="overflow-hidden rounded-lg border bg-surface-75">
{showStringTitleRow && (
<div className="flex items-center justify-between gap-2 border-b bg-surface-100 py-2 pl-4 pr-2">
@@ -14,6 +14,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import {
PageSection,
PageSectionAside,
@@ -28,6 +29,7 @@ import AddRestrictionModal from './AddRestrictionModal'
import AllowAllModal from './AllowAllModal'
import DisallowAllModal from './DisallowAllModal'
import RemoveRestrictionModal from './RemoveRestrictionModal'
import { usePrivateLinkPreview } from '@/components/interfaces/Settings/Integrations/AWSPrivateLink/preview'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DocsButton } from '@/components/ui/DocsButton'
import { HighAvailabilityDisabledSectionNotice } from '@/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice'
@@ -86,6 +88,7 @@ export const NetworkRestrictions = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { isHighAvailability } = useHighAvailability()
const { showRestrictPublicAccess } = usePrivateLinkPreview()
const [isAddingAddress, setIsAddingAddress] = useState<undefined | 'IPv4' | 'IPv6'>()
const [isAllowingAll, setIsAllowingAll] = useState(false)
const [isDisallowingAll, setIsDisallowingAll] = useState(false)
@@ -178,6 +181,15 @@ export const NetworkRestrictions = () => {
</PageSectionAside>
</PageSectionMeta>
<PageSectionContent>
{showRestrictPublicAccess && (
<div className="mb-4">
<Admonition
type="warning"
title="PrivateLink is active"
description="Restrict public database access if traffic should only use the private path."
/>
</div>
)}
{isHighAvailability && (
<div className="mb-4">
<HighAvailabilityDisabledSectionNotice
@@ -5,6 +5,7 @@ import {
getConnectionsAttentionCopy,
getConnectionStatusUi,
getConnectionTitle,
isIamRoleArn,
type PrivateLinkConnectionStatus,
} from './AWSPrivateLink.utils'
@@ -33,11 +34,16 @@ describe('getConnectionTitle', () => {
getConnectionTitle({
account_name: 'Production VPC',
aws_account_id: '123456789012',
partner: 'vercel',
})
).toBe('Production VPC')
})
it('falls back to the AWS account ID for an unnamed connection', () => {
it('falls back to Vercel when that partner has no nickname', () => {
expect(getConnectionTitle({ aws_account_id: '111122223333', partner: 'vercel' })).toBe('Vercel')
})
it('falls back to the AWS account ID for an unnamed AWS-direct connection', () => {
expect(getConnectionTitle({ aws_account_id: '123456789012' })).toBe('123456789012')
})
@@ -51,6 +57,18 @@ describe('getConnectionTitle', () => {
})
})
describe('isIamRoleArn', () => {
it('accepts a role ARN', () => {
expect(isIamRoleArn('arn:aws:iam::111122223333:role/TenantConnector')).toBe(true)
})
it('rejects empty and non-role ARNs', () => {
expect(isIamRoleArn('')).toBe(false)
expect(isIamRoleArn('111122223333')).toBe(false)
expect(isIamRoleArn('arn:aws:iam::111122223333:user/admin')).toBe(false)
})
})
describe('getConnectionsAttentionCopy', () => {
it('returns null when nothing needs attention', () => {
expect(getConnectionsAttentionCopy({ waitingCount: 0, expiredCount: 0 })).toBeNull()
@@ -47,13 +47,20 @@ export function getConnectionStatusUi(status?: PrivateLinkConnectionStatus): Con
}
export function getConnectionTitle(
account: Pick<AWSAccount, 'account_name' | 'aws_account_id'>
account: Pick<AWSAccount, 'account_name' | 'aws_account_id' | 'partner'>
): string {
const nickname = account.account_name?.trim()
if (nickname) return nickname
if (account.partner === 'vercel') return 'Vercel'
return account.aws_account_id
}
const IAM_ROLE_ARN_PATTERN = /^arn:aws:iam::\d{12}:role\/.+/
export function isIamRoleArn(value: string) {
return IAM_ROLE_ARN_PATTERN.test(value.trim())
}
export type ConnectionsAttention = {
waitingCount: number
expiredCount: number
@@ -1,9 +1,11 @@
import { Badge } from 'ui'
import { getConnectionStatusUi, getConnectionTitle } from './AWSPrivateLink.utils'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { ResourceItem } from '@/components/ui/Resource/ResourceItem'
import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query'
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
import { MANAGED_BY } from '@/lib/constants/infrastructure'
export const AWSPrivateLinkAccountItem = ({
account,
@@ -12,8 +14,9 @@ export const AWSPrivateLinkAccountItem = ({
account: AWSAccount
onView: () => void
}) => {
const { account_name, aws_account_id, database_identifier, database_type, status } = account
const title = getConnectionTitle({ account_name, aws_account_id })
const { account_name, aws_account_id, database_identifier, database_type, partner, status } =
account
const title = getConnectionTitle({ account_name, aws_account_id, partner })
const statusUi = getConnectionStatusUi(status)
const replicaId = database_identifier ? formatDatabaseID(database_identifier) : undefined
const showDatabase = database_type === 'READ_REPLICA' || title === aws_account_id
@@ -29,7 +32,16 @@ export const AWSPrivateLinkAccountItem = ({
meta={<Badge variant={statusUi.badgeVariant}>{statusUi.badge}</Badge>}
>
<div className="min-w-0">
<div className="font-medium text-foreground truncate">{title}</div>
<div className="flex items-center gap-1.5">
<div className="font-medium text-foreground truncate">{title}</div>
{partner === 'vercel' && (
<PartnerIcon
organization={{ managed_by: MANAGED_BY.VERCEL_MARKETPLACE }}
tooltipText="Connected via Vercel"
size="small"
/>
)}
</div>
{showDatabase && <p className="text-xs text-foreground-lighter">{databaseLabel}</p>}
</div>
</ResourceItem>
@@ -25,7 +25,9 @@ import { Input as CopyableInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
import { isIamRoleArn } from './AWSPrivateLink.utils'
import { AWSPrivateLinkAttentionAdmonition } from './AWSPrivateLinkAttentionAdmonition'
import { replayPrivateLinkAddedToast, usePrivateLinkPreview } from './preview'
import { InlineLink } from '@/components/ui/InlineLink'
import { useAWSAccountCreateMutation } from '@/data/aws-accounts/aws-account-create-mutation'
import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query'
@@ -43,6 +45,10 @@ const FormSchema = z.object({
.regex(/^\d{12}$/, 'Enter a 12-digit AWS account ID'),
databaseIdentifier: z.string().min(1, 'Select a database'),
accountName: z.string(),
destinationIamRoleArn: z
.string()
.trim()
.refine((value) => value === '' || isIamRoleArn(value), 'Enter an IAM role ARN'),
})
type FormValues = z.infer<typeof FormSchema>
@@ -71,13 +77,15 @@ export const AWSPrivateLinkForm = ({
{ enabled: shouldLoadReadReplicas }
)
const { mutate: createAccount, isPending } = useAWSAccountCreateMutation()
const preview = usePrivateLinkPreview()
const readReplicas = databases.filter((database) => database.identifier !== project?.ref)
const showDatabaseTarget = showPrivateLinkReadReplica || !isNew
const formValues: FormValues = {
awsAccountId: account?.aws_account_id ?? '',
awsAccountId: account?.aws_account_id ?? preview.prefillAwsAccountId ?? '',
databaseIdentifier: account?.database_identifier ?? project?.ref ?? '',
accountName: account?.account_name ?? '',
destinationIamRoleArn: account?.destination_iam_role_arn ?? '',
}
const form = useForm<FormValues>({
@@ -86,6 +94,7 @@ export const AWSPrivateLinkForm = ({
awsAccountId: '',
databaseIdentifier: '',
accountName: '',
destinationIamRoleArn: '',
},
values: formValues,
resetOptions: { keepDirtyValues: true },
@@ -93,26 +102,33 @@ export const AWSPrivateLinkForm = ({
const onSubmit = (values: FormValues) => {
if (!project) return
if (isNew) {
createAccount(
{
projectRef: project.ref,
awsAccountId: values.awsAccountId,
accountName: values.accountName.trim() || undefined,
databaseIdentifier:
values.databaseIdentifier && values.databaseIdentifier !== project.ref
? values.databaseIdentifier
: undefined,
},
{
onSuccess: () => {
form.reset(formValues)
toast.success('Connection added')
onOpenChange(false)
},
}
)
if (!isNew) return
if (preview.enabled) {
form.reset(formValues)
replayPrivateLinkAddedToast()
onOpenChange(false)
return
}
createAccount(
{
projectRef: project.ref,
awsAccountId: values.awsAccountId,
accountName: values.accountName.trim() || undefined,
databaseIdentifier:
values.databaseIdentifier && values.databaseIdentifier !== project.ref
? values.databaseIdentifier
: undefined,
},
{
onSuccess: () => {
form.reset(formValues)
toast.success('Connection added')
onOpenChange(false)
},
}
)
}
const handleOpenChange = (nextOpen: boolean) => {
@@ -146,7 +162,11 @@ export const AWSPrivateLinkForm = ({
render={({ field }) => (
<FormItemLayout
label="AWS account ID"
description="12-digit ID of the destination account."
description={
!isNew && account?.partner === 'vercel'
? 'Connected via Vercel'
: '12-digit ID of the destination account.'
}
>
<FormControl>
<Input
@@ -162,6 +182,31 @@ export const AWSPrivateLinkForm = ({
</FormItemLayout>
)}
/>
{isNew && (
<FormField
control={form.control}
name="destinationIamRoleArn"
render={({ field }) => (
<FormItemLayout
label="Destination IAM role ARN"
labelOptional="Optional"
description="Restricts the share to a single role. Leave empty to share with the whole AWS account."
>
<FormControl>
<Input {...field} placeholder="arn:aws:iam::123456789012:role/Connector" />
</FormControl>
</FormItemLayout>
)}
/>
)}
{!isNew && account?.destination_iam_role_arn && (
<FormItemLayout
label="Destination IAM role ARN"
description="This share is restricted to this role."
>
<CopyableInput readOnly copy value={account.destination_iam_role_arn} />
</FormItemLayout>
)}
{showDatabaseTarget && (
<FormField
control={form.control}
@@ -29,6 +29,7 @@ import { getConnectionTitle } from './AWSPrivateLink.utils'
import { AWSPrivateLinkAccountItem } from './AWSPrivateLinkAccountItem'
import { AWSPrivateLinkAttentionAdmonition } from './AWSPrivateLinkAttentionAdmonition'
import { AWSPrivateLinkForm } from './AWSPrivateLinkForm'
import { usePrivateLinkPreview } from './preview'
import { ResourceList } from '@/components/ui/Resource/ResourceList'
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
import { useAWSAccountDeleteMutation } from '@/data/aws-accounts/aws-account-delete-mutation'
@@ -41,7 +42,12 @@ import { IS_PLATFORM } from '@/lib/constants'
export const AWSPrivateLinkSection = () => {
const { data: project } = useSelectedProjectQuery()
const { data: accounts } = useAWSAccountsQuery({ projectRef: project?.ref })
const preview = usePrivateLinkPreview()
const { data: liveAccounts } = useAWSAccountsQuery(
{ projectRef: project?.ref },
{ enabled: !preview.enabled }
)
const accounts = preview.enabled ? preview.accounts : liveAccounts
const [selectedAccount, setSelectedAccount] = useState<AWSAccount>()
const [showForm, setShowForm] = useState(false)
@@ -57,7 +63,7 @@ export const AWSPrivateLinkSection = () => {
})
const { hasAccess: hasPrivateLinkAccess } = useCheckEntitlements('security.private_link')
const promptPlanUpgrade = IS_PLATFORM && !hasPrivateLinkAccess
const promptPlanUpgrade = IS_PLATFORM && !hasPrivateLinkAccess && !preview.skipUpgradeWall
const onAddAccount = () => {
setSelectedAccount(undefined)
@@ -0,0 +1,48 @@
# PrivateLink preview extract
Prototype-only. Delete this folder when splitting Track B into real PRs. Do not ship the presenter or mocks.
Enable: `?privatelinkPreview=1` on Project Settings → Integrations. Optional: `?privatelinkPreviewScenario=<id>`.
Grep: `usePrivateLinkPreview`, `usePreviewNavManagedBy`, `PrivateLinkPreview`, `privatelinkPreview`, `replayPrivateLinkAddedToast`, `PRIVATE_LINK_PREVIEW_`.
## Delete this folder
`apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/`
Includes the floating presenter (`PrivateLinkPreviewPanel`), scenario mocks, Vercel card overrides, and this file.
## Session keys to drop
- `supabase.privatelink-preview`
- `supabase.privatelink-preview-scenario`
## Production leaks (revert these)
| File | What to remove |
| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/studio/components/layouts/DefaultLayout.tsx` | `PrivateLinkPreviewPanel` import and render |
| `apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx` | `usePreviewNavManagedBy`. Restore `<PartnerIcon organization={selectedOrganization} />` |
| `apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx` | `usePreviewNavManagedBy`. Restore the live `selectedProjectManagedBy` ternary |
| `apps/studio/components/layouts/AppLayout/OrganizationDropdown.test.tsx` | Preview hook mock |
| `apps/studio/components/layouts/AppLayout/ProjectDropdown.test.tsx` | Preview hook mock |
| `apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx` | Preview import, `vercelCard` / `showPreviewCard` / `showInstallEmpty` overlay, `<PrivateLinkPreviewVercelOverride />`. Keep the real Install empty state |
| `apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx` | Preview import, mock `accounts`, `skipUpgradeWall` |
| `apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx` | Preview import, `prefillAwsAccountId`, preview-enabled submit that skips POST and replays the toast |
| `apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx` | `PRIVATE_LINK_PREVIEW_HOSTNAME` and `showPrivateHostname` block |
| `apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx` | `showRestrictPublicAccess` block |
## What the mocks cover
- Empty PrivateLink list, AWS-direct statuses, mixed statuses, mixed Vercel + AWS-direct rows
- Vercel-initiated partner cue on a PrivateLink row (`partner: 'vercel'`)
- Marketplace Vercel card (`acme-app`) with and without a PrivateLink pointer footer
- Optional destination IAM role on the add sheet (UI only, not POSTed)
- Nav `managed_by` fake for Marketplace, Marketplace plus PrivateLink, and B5 only (org + project switcher). Tooltip stays “Managed via Vercel Marketplace.” Not used for Vercel-initiated / mixed rows / Empty
- B6 private hostname in Connect, restrict-public-access copy on Network Restrictions
- B5 presenter note only. No fake Vercel dashboard
- Post-add toast: static “Connection added”. Accept copy lives on the list admonition
## Keep (not preview)
Copy and layout that should survive extract: static Vercel / GitHub / PrivateLink section subtitles, GitHub empty-state copy, PrivateLink row title/status/delete, list admonition, optional IAM role field once the API exists, Vercel partner mark on real partner rows.
@@ -0,0 +1,116 @@
/**
* Prototype-only floating presenter. Enable with `?privatelinkPreview=1`.
*/
import { useEffect } from 'react'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from 'ui'
import {
PRIVATE_LINK_PREVIEW_QUERY,
PRIVATE_LINK_PREVIEW_SCENARIOS,
type PrivateLinkPreviewScenario,
} from './privateLinkPreview.constants'
import { privateLinkPreviewState, usePrivateLinkPreview } from './privateLinkPreview.store'
export function replayPrivateLinkAddedToast() {
toast.success('Connection added')
}
export const PrivateLinkPreviewPanel = () => {
const preview = usePrivateLinkPreview()
useEffect(() => {
privateLinkPreviewState.hydrate()
}, [])
if (!preview.enabled) return null
const realApiScenarios = PRIVATE_LINK_PREVIEW_SCENARIOS.filter(
(scenario) => scenario.source === 'real-api'
)
const mockedScenarios = PRIVATE_LINK_PREVIEW_SCENARIOS.filter(
(scenario) => scenario.source === 'mocked-platform'
)
return (
<div className="fixed bottom-4 right-4 z-50 w-[22rem] max-w-[calc(100vw-2rem)]">
<Card className="border-warning-500 bg-surface-100 shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">PrivateLink preview</CardTitle>
<p className="text-xs text-foreground-lighter">
Prototype only. Off unless the URL has `{PRIVATE_LINK_PREVIEW_QUERY}=1`. Do not ship.
</p>
</CardHeader>
<CardContent className="space-y-3">
<Select
value={preview.scenario}
onValueChange={(value) =>
privateLinkPreviewState.setScenario(value as PrivateLinkPreviewScenario)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a scenario" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Real API states</SelectLabel>
{realApiScenarios.map((scenario) => (
<SelectItem key={scenario.id} value={scenario.id}>
{scenario.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>Mocked platform</SelectLabel>
{mockedScenarios.map((scenario) => (
<SelectItem key={scenario.id} value={scenario.id}>
{scenario.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<p className="text-xs text-foreground-light">{preview.config.description}</p>
<p className="text-xs text-foreground-muted">
{preview.config.source === 'real-api' ? 'Real API' : 'Mocked platform'}
</p>
{preview.b5Note && (
<p className="text-xs text-foreground-light border-l-2 border-warning-500 pl-2">
{preview.b5Note}
</p>
)}
{preview.scenario === 'aws-direct-waiting' && (
<Button variant="default" size="tiny" onClick={replayPrivateLinkAddedToast}>
Replay add toast
</Button>
)}
<Button
size="tiny"
variant="outline"
onClick={() => privateLinkPreviewState.setEnabled(false)}
>
Dismiss preview
</Button>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,68 @@
/**
* Prototype-only Vercel card overrides. Not a fake Vercel dashboard.
*/
import SVG from 'react-inlinesvg'
import { Card, CardContent, CardFooter, Input } from 'ui'
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
import { usePrivateLinkPreview } from './privateLinkPreview.store'
import { BASE_PATH } from '@/lib/constants'
function VercelProjectField() {
return (
<FormLayout
layout="flex-row-reverse"
label="Vercel project"
description="Managed via Vercel Marketplace."
>
<div className="relative w-full md:w-64">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2">
<SVG
src={`${BASE_PATH}/img/icons/vercel-icon.svg`}
title="Vercel icon"
className="w-3.5 shrink-0 text-foreground-muted"
/>
</span>
<Input readOnly value="acme-app" className="pl-9" onFocus={(e) => e.target.blur()} />
</div>
</FormLayout>
)
}
function MarketplaceVercelCard() {
return (
<Card>
<CardContent>
<VercelProjectField />
</CardContent>
</Card>
)
}
function MarketplacePlusVercelCard() {
return (
<Card>
<CardContent>
<VercelProjectField />
</CardContent>
<CardFooter>
<p className="text-sm text-foreground-lighter">
Private database path is in AWS PrivateLink below.
</p>
</CardFooter>
</Card>
)
}
export const PrivateLinkPreviewVercelOverride = () => {
const { vercelCard } = usePrivateLinkPreview()
if (vercelCard === 'marketplace') return <MarketplaceVercelCard />
if (vercelCard === 'marketplace-plus' || vercelCard === 'distinguish-billing') {
return <MarketplacePlusVercelCard />
}
return null
}
@@ -0,0 +1,6 @@
export { PrivateLinkPreviewPanel } from './PrivateLinkPreviewPanel'
export { PrivateLinkPreviewVercelOverride } from './PrivateLinkPreviewVercelOverride'
export { usePreviewNavManagedBy, usePrivateLinkPreview } from './privateLinkPreview.store'
export { replayPrivateLinkAddedToast } from './PrivateLinkPreviewPanel'
export { PRIVATE_LINK_PREVIEW_HOSTNAME } from './privateLinkPreview.constants'
export type { PreviewAWSAccount } from './privateLinkPreview.mocks'
@@ -0,0 +1,193 @@
/**
* Prototype-only. Delete this folder when splitting Track B into real PRs.
* Enable with `?privatelinkPreview=1`. Off by default. Do not ship.
*/
import { MANAGED_BY, type ManagedBy } from '@/lib/constants/infrastructure'
export const PRIVATE_LINK_PREVIEW_QUERY = 'privatelinkPreview'
export const PRIVATE_LINK_PREVIEW_SCENARIO_QUERY = 'privatelinkPreviewScenario'
export const PRIVATE_LINK_PREVIEW_STORAGE_KEY = 'supabase.privatelink-preview'
export const PRIVATE_LINK_PREVIEW_SCENARIO_STORAGE_KEY = 'supabase.privatelink-preview-scenario'
export const VERCEL_PREVIEW_AWS_ACCOUNT_ID = '111122223333'
export const AWS_DIRECT_PREVIEW_ACCOUNT_ID = '123456789012'
export const PRIVATE_LINK_PREVIEW_HOSTNAME = 'db.privatelink.supabase.com'
export type PrivateLinkPreviewScenario =
| 'empty'
| 'aws-direct-connected'
| 'aws-direct-waiting'
| 'aws-direct-expired'
| 'mixed-statuses'
| 'vercel-initiated'
| 'marketplace'
| 'marketplace-plus-privatelink'
| 'vercel-fallback'
| 'mixed-rows'
| 'b6-private-hostname'
| 'b5-studio-copy'
export type PrivateLinkPreviewSource = 'real-api' | 'mocked-platform'
export type PrivateLinkPreviewVercelCard =
| 'live'
| 'not-connected'
| 'marketplace'
| 'marketplace-plus'
| 'distinguish-billing'
export type PrivateLinkPreviewScenarioConfig = {
id: PrivateLinkPreviewScenario
label: string
source: PrivateLinkPreviewSource
description: string
vercelCard: PrivateLinkPreviewVercelCard
showPrivateHostname: boolean
showRestrictPublicAccess: boolean
prefillAwsAccountId?: string
b5Note?: string
}
export const PRIVATE_LINK_PREVIEW_SCENARIOS: PrivateLinkPreviewScenarioConfig[] = [
{
id: 'empty',
label: 'Empty (reset)',
source: 'real-api',
description:
'Honest empty PrivateLink list. Vercel section is live. No mocked rows, hostname, or prefill.',
vercelCard: 'live',
showPrivateHostname: false,
showRestrictPublicAccess: false,
},
{
id: 'aws-direct-connected',
label: 'AWS-direct, connected',
source: 'real-api',
description: 'User-owned AWS account. No Vercel cue.',
vercelCard: 'not-connected',
showPrivateHostname: false,
showRestrictPublicAccess: false,
},
{
id: 'aws-direct-waiting',
label: 'AWS-direct, just added (Waiting)',
source: 'real-api',
description: 'Post-add accept moment. Toast, list warning, row second line.',
vercelCard: 'not-connected',
showPrivateHostname: false,
showRestrictPublicAccess: false,
},
{
id: 'aws-direct-expired',
label: 'AWS-direct, Expired',
source: 'real-api',
description: '12-hour window missed.',
vercelCard: 'not-connected',
showPrivateHostname: false,
showRestrictPublicAccess: false,
},
{
id: 'mixed-statuses',
label: 'Mixed statuses',
source: 'real-api',
description: 'Waiting, connected, and expired in one list.',
vercelCard: 'not-connected',
showPrivateHostname: false,
showRestrictPublicAccess: false,
},
{
id: 'vercel-initiated',
label: 'Vercel-initiated, no Studio install',
source: 'mocked-platform',
description: 'Vercel-created PrivateLink row. Install remains available for env sync.',
vercelCard: 'not-connected',
showPrivateHostname: false,
showRestrictPublicAccess: false,
},
{
id: 'marketplace',
label: 'Marketplace / env sync, no PrivateLink',
source: 'mocked-platform',
description: 'Vercel card is billing and env sync. PrivateLink is empty.',
vercelCard: 'marketplace',
showPrivateHostname: false,
showRestrictPublicAccess: false,
},
{
id: 'marketplace-plus-privatelink',
label: 'Marketplace plus PrivateLink',
source: 'mocked-platform',
description: 'Two cards, two jobs. Partner cue on the PrivateLink row.',
vercelCard: 'marketplace-plus',
showPrivateHostname: false,
showRestrictPublicAccess: false,
},
{
id: 'vercel-fallback',
label: 'Vercel fallback (paste account ID)',
source: 'mocked-platform',
description: 'Same add-connection sheet, including optional IAM role.',
vercelCard: 'not-connected',
showPrivateHostname: false,
showRestrictPublicAccess: false,
prefillAwsAccountId: VERCEL_PREVIEW_AWS_ACCOUNT_ID,
},
{
id: 'mixed-rows',
label: 'Mixed rows',
source: 'mocked-platform',
description: 'Vercel cue and AWS-direct in one list. Install remains available. Not a split.',
vercelCard: 'not-connected',
showPrivateHostname: false,
showRestrictPublicAccess: false,
},
{
id: 'b6-private-hostname',
label: 'B6: private hostname and restrict public access',
source: 'mocked-platform',
description: 'Open Connect and Database Settings → Network restrictions.',
vercelCard: 'not-connected',
showPrivateHostname: true,
showRestrictPublicAccess: true,
},
{
id: 'b5-studio-copy',
label: 'B5: Studio copy only (not Vercel UI)',
source: 'mocked-platform',
description:
'If Vercel shows two Supabase rows, Studio copy distinguishes billing from the private path.',
vercelCard: 'distinguish-billing',
showPrivateHostname: false,
showRestrictPublicAccess: false,
b5Note:
'B5 is Vercel’s dashboard. We are not faking it. This scenario only tries Studio copy: Marketplace manages billing; PrivateLink below is the private database path.',
},
]
export function isPrivateLinkPreviewScenario(value: string): value is PrivateLinkPreviewScenario {
return PRIVATE_LINK_PREVIEW_SCENARIOS.some((scenario) => scenario.id === value)
}
export function getPrivateLinkPreviewScenarioConfig(
id: PrivateLinkPreviewScenario
): PrivateLinkPreviewScenarioConfig {
const config = PRIVATE_LINK_PREVIEW_SCENARIOS.find((scenario) => scenario.id === id)
return config ?? PRIVATE_LINK_PREVIEW_SCENARIOS[0]
}
export function isVercelMarketplacePreviewCard(vercelCard: PrivateLinkPreviewVercelCard): boolean {
return (
vercelCard === 'marketplace' ||
vercelCard === 'marketplace-plus' ||
vercelCard === 'distinguish-billing'
)
}
export function getPreviewNavManagedBy(
managedBy: ManagedBy | undefined,
showVercelMarketplaceNav: boolean
): ManagedBy | undefined {
if (showVercelMarketplaceNav) return MANAGED_BY.VERCEL_MARKETPLACE
return managedBy
}
@@ -0,0 +1,90 @@
/**
* Prototype-only. Fake associations for presenter scenarios.
*/
import {
AWS_DIRECT_PREVIEW_ACCOUNT_ID,
VERCEL_PREVIEW_AWS_ACCOUNT_ID,
type PrivateLinkPreviewScenario,
} from './privateLinkPreview.constants'
import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query'
export type PreviewAWSAccount = AWSAccount & {
partner?: 'vercel'
destination_iam_role_arn?: string
}
const VERCEL_IAM_ROLE_ARN = `arn:aws:iam::${VERCEL_PREVIEW_AWS_ACCOUNT_ID}:role/TenantConnector`
function account(
overrides: Partial<PreviewAWSAccount> & Pick<PreviewAWSAccount, 'status'>
): PreviewAWSAccount {
return {
aws_account_id: AWS_DIRECT_PREVIEW_ACCOUNT_ID,
account_name: 'Production VPC',
database_type: 'PRIMARY',
database_identifier: undefined,
resource_access_manager_resource_config_id: 'rcfg-0123456789abcdef0',
resource_access_manager_resource_config_arn:
'arn:aws:vpc-lattice:us-east-1:000000000000:resourceconfiguration/rcfg-0123456789abcdef0',
resource_access_manager_share_arn:
'arn:aws:ram:us-east-1:000000000000:resource-share/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
shared_at: '2026-08-13T00:00:00.000Z',
...overrides,
}
}
export function getPreviewAccounts(
scenario: PrivateLinkPreviewScenario,
projectRef?: string
): PreviewAWSAccount[] {
const primaryId = projectRef
const awsConnected = account({
status: 'ASSOCIATION_ACCEPTED',
database_identifier: primaryId,
})
const awsWaiting = account({
status: 'READY',
account_name: undefined,
database_identifier: primaryId,
})
const awsExpired = account({
status: 'ASSOCIATION_REQUEST_EXPIRED',
account_name: 'Staging VPC',
aws_account_id: '210987654321',
database_identifier: primaryId,
})
const vercelConnected = account({
status: 'ASSOCIATION_ACCEPTED',
account_name: undefined,
aws_account_id: VERCEL_PREVIEW_AWS_ACCOUNT_ID,
partner: 'vercel',
destination_iam_role_arn: VERCEL_IAM_ROLE_ARN,
database_identifier: primaryId,
})
switch (scenario) {
case 'aws-direct-connected':
return [awsConnected]
case 'aws-direct-waiting':
return [awsWaiting]
case 'aws-direct-expired':
return [awsExpired]
case 'mixed-statuses':
return [awsWaiting, awsConnected, awsExpired]
case 'vercel-initiated':
case 'marketplace-plus-privatelink':
case 'b6-private-hostname':
case 'b5-studio-copy':
return [vercelConnected]
case 'empty':
case 'marketplace':
case 'vercel-fallback':
return []
case 'mixed-rows':
return [vercelConnected, awsConnected]
default:
return []
}
}
@@ -0,0 +1,111 @@
/**
* Prototype-only. Delete with the preview folder when splitting Track B.
*/
import { proxy, useSnapshot } from 'valtio'
import {
getPreviewNavManagedBy,
getPrivateLinkPreviewScenarioConfig,
isPrivateLinkPreviewScenario,
isVercelMarketplacePreviewCard,
PRIVATE_LINK_PREVIEW_QUERY,
PRIVATE_LINK_PREVIEW_SCENARIO_QUERY,
PRIVATE_LINK_PREVIEW_SCENARIO_STORAGE_KEY,
PRIVATE_LINK_PREVIEW_STORAGE_KEY,
type PrivateLinkPreviewScenario,
} from './privateLinkPreview.constants'
import { getPreviewAccounts } from './privateLinkPreview.mocks'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import type { ManagedBy } from '@/lib/constants/infrastructure'
const DEFAULT_SCENARIO: PrivateLinkPreviewScenario = 'empty'
function readSession(key: string) {
if (typeof window === 'undefined') return null
try {
return window.sessionStorage.getItem(key)
} catch {
return null
}
}
function writeSession(key: string, value: string | null) {
if (typeof window === 'undefined') return
try {
if (value === null) window.sessionStorage.removeItem(key)
else window.sessionStorage.setItem(key, value)
} catch {
// Ignore quota / private mode.
}
}
function readEnabledFromWindow() {
if (typeof window === 'undefined') return false
const params = new URLSearchParams(window.location.search)
if (params.get(PRIVATE_LINK_PREVIEW_QUERY) === '1') return true
return readSession(PRIVATE_LINK_PREVIEW_STORAGE_KEY) === '1'
}
function readScenarioFromWindow(): PrivateLinkPreviewScenario {
if (typeof window === 'undefined') return DEFAULT_SCENARIO
const params = new URLSearchParams(window.location.search)
const fromQuery = params.get(PRIVATE_LINK_PREVIEW_SCENARIO_QUERY)
if (fromQuery && isPrivateLinkPreviewScenario(fromQuery)) return fromQuery
const fromSession = readSession(PRIVATE_LINK_PREVIEW_SCENARIO_STORAGE_KEY)
if (fromSession && isPrivateLinkPreviewScenario(fromSession)) return fromSession
return DEFAULT_SCENARIO
}
export const privateLinkPreviewState = proxy({
hydrated: false,
enabled: false,
scenario: DEFAULT_SCENARIO as PrivateLinkPreviewScenario,
hydrate: () => {
const enabled = readEnabledFromWindow()
const scenario = readScenarioFromWindow()
privateLinkPreviewState.enabled = enabled
privateLinkPreviewState.scenario = scenario
privateLinkPreviewState.hydrated = true
if (enabled) {
writeSession(PRIVATE_LINK_PREVIEW_STORAGE_KEY, '1')
writeSession(PRIVATE_LINK_PREVIEW_SCENARIO_STORAGE_KEY, scenario)
}
},
setEnabled: (enabled: boolean) => {
privateLinkPreviewState.enabled = enabled
writeSession(PRIVATE_LINK_PREVIEW_STORAGE_KEY, enabled ? '1' : null)
if (!enabled) writeSession(PRIVATE_LINK_PREVIEW_SCENARIO_STORAGE_KEY, null)
},
setScenario: (scenario: PrivateLinkPreviewScenario) => {
privateLinkPreviewState.scenario = scenario
writeSession(PRIVATE_LINK_PREVIEW_SCENARIO_STORAGE_KEY, scenario)
},
})
export function usePrivateLinkPreview() {
const snap = useSnapshot(privateLinkPreviewState)
const { data: project } = useSelectedProjectQuery()
const config = getPrivateLinkPreviewScenarioConfig(snap.scenario)
const isActive = snap.enabled && snap.hydrated
return {
hydrated: snap.hydrated,
enabled: isActive,
scenario: snap.scenario,
config,
accounts: isActive ? getPreviewAccounts(snap.scenario, project?.ref) : undefined,
vercelCard: isActive ? config.vercelCard : 'live',
showPrivateHostname: isActive && config.showPrivateHostname,
showRestrictPublicAccess: isActive && config.showRestrictPublicAccess,
prefillAwsAccountId: isActive ? config.prefillAwsAccountId : undefined,
skipUpgradeWall: isActive,
b5Note: isActive ? config.b5Note : undefined,
showVercelMarketplaceNav: isActive && isVercelMarketplacePreviewCard(config.vercelCard),
}
}
export function usePreviewNavManagedBy(managedBy: ManagedBy | undefined): ManagedBy | undefined {
const { showVercelMarketplaceNav } = usePrivateLinkPreview()
return getPreviewNavManagedBy(managedBy, showVercelMarketplaceNav)
}
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import {
getPreviewNavManagedBy,
getPrivateLinkPreviewScenarioConfig,
isPrivateLinkPreviewScenario,
isVercelMarketplacePreviewCard,
} from './privateLinkPreview.constants'
import { getPreviewAccounts } from './privateLinkPreview.mocks'
import { MANAGED_BY } from '@/lib/constants/infrastructure'
describe('isPrivateLinkPreviewScenario', () => {
it('accepts known scenarios', () => {
expect(isPrivateLinkPreviewScenario('empty')).toBe(true)
expect(isPrivateLinkPreviewScenario('vercel-initiated')).toBe(true)
expect(isPrivateLinkPreviewScenario('b5-studio-copy')).toBe(true)
})
it('rejects unknown values', () => {
expect(isPrivateLinkPreviewScenario('nope')).toBe(false)
})
})
describe('getPreviewAccounts', () => {
it('keeps Vercel and AWS-direct in one list for mixed rows', () => {
const accounts = getPreviewAccounts('mixed-rows', 'abc')
expect(accounts).toHaveLength(2)
expect(accounts.some((account) => account.partner === 'vercel')).toBe(true)
expect(accounts.some((account) => account.partner === undefined)).toBe(true)
})
it('omits a nickname on Vercel-initiated rows', () => {
const [account] = getPreviewAccounts('vercel-initiated', 'abc')
expect(account?.partner).toBe('vercel')
expect(account?.account_name).toBeUndefined()
})
it('leaves marketplace without PrivateLink rows', () => {
expect(getPreviewAccounts('marketplace')).toEqual([])
})
it('resets PrivateLink to an empty list', () => {
expect(getPreviewAccounts('empty')).toEqual([])
})
})
describe('getPrivateLinkPreviewScenarioConfig', () => {
it('uses live Vercel and an empty PrivateLink list for reset', () => {
const config = getPrivateLinkPreviewScenarioConfig('empty')
expect(config.vercelCard).toBe('live')
expect(config.showPrivateHostname).toBe(false)
expect(config.showRestrictPublicAccess).toBe(false)
expect(config.prefillAwsAccountId).toBeUndefined()
})
it('marks marketplace plus PrivateLink as two jobs', () => {
expect(getPrivateLinkPreviewScenarioConfig('marketplace-plus-privatelink').vercelCard).toBe(
'marketplace-plus'
)
})
it('keeps mixed rows installable, not marketplace', () => {
expect(getPrivateLinkPreviewScenarioConfig('mixed-rows').vercelCard).toBe('not-connected')
})
it('keeps Vercel-initiated installable, with the Vercel cue on the PrivateLink row', () => {
expect(getPrivateLinkPreviewScenarioConfig('vercel-initiated').vercelCard).toBe('not-connected')
})
it('marks B5 as Studio copy only', () => {
const config = getPrivateLinkPreviewScenarioConfig('b5-studio-copy')
expect(config.vercelCard).toBe('distinguish-billing')
expect(config.b5Note).toMatch(/Vercel’s dashboard/)
})
})
describe('isVercelMarketplacePreviewCard', () => {
it('is true only for Marketplace card states', () => {
expect(isVercelMarketplacePreviewCard('marketplace')).toBe(true)
expect(isVercelMarketplacePreviewCard('marketplace-plus')).toBe(true)
expect(isVercelMarketplacePreviewCard('distinguish-billing')).toBe(true)
expect(isVercelMarketplacePreviewCard('not-connected')).toBe(false)
expect(isVercelMarketplacePreviewCard('live')).toBe(false)
})
})
describe('getPreviewNavManagedBy', () => {
it('fakes Vercel Marketplace managed_by when the nav chrome flag is on', () => {
expect(getPreviewNavManagedBy(MANAGED_BY.SUPABASE, true)).toBe(MANAGED_BY.VERCEL_MARKETPLACE)
expect(getPreviewNavManagedBy(undefined, true)).toBe(MANAGED_BY.VERCEL_MARKETPLACE)
})
it('leaves the real managed_by alone when the flag is off', () => {
expect(getPreviewNavManagedBy(MANAGED_BY.SUPABASE, false)).toBe(MANAGED_BY.SUPABASE)
expect(getPreviewNavManagedBy(undefined, false)).toBeUndefined()
})
})
@@ -1,8 +1,10 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ExternalLink } from 'lucide-react'
import { Plus, SquareArrowOutUpRight } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useMemo } from 'react'
import { toast } from 'sonner'
import { cn } from 'ui'
import { Button, Card, CardContent, cn } from 'ui'
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
import {
PageSection,
PageSectionContent,
@@ -13,6 +15,7 @@ import {
} from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { PrivateLinkPreviewVercelOverride, usePrivateLinkPreview } from '../AWSPrivateLink/preview'
import { IntegrationSectionIcon } from '../IntegrationsSettings'
import VercelIntegrationConnectionForm from './VercelIntegrationConnectionForm'
import { IntegrationConnectionItem } from '@/components/interfaces/Integrations/VercelGithub/IntegrationConnection'
@@ -21,6 +24,7 @@ import {
IntegrationConnectionHeader,
IntegrationInstallation,
} from '@/components/interfaces/Integrations/VercelGithub/IntegrationPanels'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { InlineLink } from '@/components/ui/InlineLink'
import { NoPermission } from '@/components/ui/NoPermission'
import { useOrgIntegrationsQuery } from '@/data/integrations/integrations-query-org-only'
@@ -43,6 +47,7 @@ export const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean })
const { data: org } = useSelectedOrganizationQuery()
const { data } = useOrgIntegrationsQuery({ orgSlug: org?.slug })
const sidePanelsStateSnapshot = useSidePanelsStateSnapshot()
const preview = usePrivateLinkPreview()
const isBranch = project?.parent_project_ref !== undefined
const { can: canReadVercelConnection, isLoading: isLoadingPermissions } =
@@ -138,88 +143,87 @@ export const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean })
connections.length,
'connection'
)} `
const description = isProjectScoped
? 'Connect Vercel projects to this Supabase project. Supabase keeps environment variables up to date in each connected Vercel project.'
: 'Connect your Vercel teams to this Supabase organization. Supabase keeps environment variables up to date in each connected project. You can also link multiple Vercel projects to the same Supabase project.'
const vercelCard = preview.vercelCard
const showPreviewCard =
vercelCard === 'marketplace' ||
vercelCard === 'marketplace-plus' ||
vercelCard === 'distinguish-billing'
const showLive = vercelCard === 'live'
const showInstallEmpty =
vercelCard === 'not-connected' ||
(showLive && !isLoadingPermissions && canReadVercelConnection && !vercelIntegration)
return (
<PageSection>
<PageSectionMeta>
<div className="flex flex-1 items-start gap-6">
<div className="flex flex-1 items-start gap-5">
<IntegrationSectionIcon title="vercel" />
<PageSectionSummary>
<PageSectionTitle>Vercel</PageSectionTitle>
<PageSectionDescription>{description}</PageSectionDescription>
<PageSectionDescription>
Sync environment variables with connected Vercel projects.
</PageSectionDescription>
</PageSectionSummary>
</div>
</PageSectionMeta>
<PageSectionContent>
{isLoadingPermissions ? (
<GenericSkeletonLoader />
) : !canReadVercelConnection ? (
{showPreviewCard && <PrivateLinkPreviewVercelOverride />}
{showInstallEmpty && <VercelInstallEmptyState href={integrationUrl} disabled={isBranch} />}
{showLive && isLoadingPermissions && <GenericSkeletonLoader />}
{showLive && !isLoadingPermissions && !canReadVercelConnection && (
<NoPermission resourceText="view this organization's Vercel connections" />
) : (
)}
{showLive && !isLoadingPermissions && canReadVercelConnection && vercelIntegration && (
<div className="space-y-6">
<div>
{vercelIntegration ? (
<div key={vercelIntegration.id}>
<IntegrationInstallation title="Vercel" integration={vercelIntegration} />
<IntegrationConnectionHeader
title={ConnectionHeaderTitle}
className={connections.length === 0 ? 'pb-0' : undefined}
markdown="Project connections for Vercel"
/>
{connections.length > 0 && (
<ul className="flex flex-col gap-y-2">
{connections.map((connection) => (
<div
key={connection.id}
className={cn(
isProjectScoped && 'relative flex flex-col -gap-[1px] [&>li]:pb-0'
)}
>
<IntegrationConnectionItem
connection={connection}
disabled={isBranch || !canUpdateVercelConnection}
type={'Vercel' as IntegrationName}
onDeleteConnection={onDeleteVercelConnection}
className={cn(isProjectScoped && 'rounded-b-none! mb-0!')}
/>
{isProjectScoped ? (
<div className="relative pl-8 ml-6 border-l border-muted pb-6">
<div className="border-b border-l border-r rounded-b-lg">
<VercelIntegrationConnectionForm
connection={connection}
integration={vercelIntegration}
disabled={isBranch || !canUpdateVercelConnection}
/>
</div>
<div key={vercelIntegration.id}>
<IntegrationInstallation title="Vercel" integration={vercelIntegration} />
<IntegrationConnectionHeader
title={ConnectionHeaderTitle}
className={connections.length === 0 ? 'pb-0' : undefined}
markdown="Project connections for Vercel"
/>
{connections.length > 0 && (
<ul className="flex flex-col gap-y-2">
{connections.map((connection) => (
<div
key={connection.id}
className={cn(
isProjectScoped && 'relative flex flex-col -gap-[1px] [&>li]:pb-0'
)}
>
<IntegrationConnectionItem
connection={connection}
disabled={isBranch || !canUpdateVercelConnection}
type={'Vercel' as IntegrationName}
onDeleteConnection={onDeleteVercelConnection}
className={cn(isProjectScoped && 'rounded-b-none! mb-0!')}
/>
{isProjectScoped ? (
<div className="relative pl-8 ml-6 border-l border-muted pb-6">
<div className="border-b border-l border-r rounded-b-lg">
<VercelIntegrationConnectionForm
connection={connection}
integration={vercelIntegration}
disabled={isBranch || !canUpdateVercelConnection}
/>
</div>
) : null}
</div>
))}
</ul>
)}
<EmptyIntegrationConnection
disabled={isBranch || !canCreateVercelConnection}
onClick={() => onAddVercelConnection(vercelIntegration.id)}
>
Add new project connection
</EmptyIntegrationConnection>
</div>
) : (
</div>
) : null}
</div>
))}
</ul>
)}
<EmptyIntegrationConnection
showNode={false}
disabled={isBranch}
href={integrationUrl}
icon={<ExternalLink />}
disabledTooltip="Install the Vercel integration on your project's main branch"
disabled={isBranch || !canCreateVercelConnection}
onClick={() => onAddVercelConnection(vercelIntegration.id)}
icon={<Plus />}
>
Install Vercel integration
Add connection
</EmptyIntegrationConnection>
)}
</div>
</div>
{vercelProjectCount > 0 && vercelIntegration !== undefined && (
{vercelProjectCount > 0 && (
<p className="text-sm text-foreground-light">
Your Vercel connection can access {vercelProjectCount} Vercel projects. To change
which projects Supabase may use, open your organization’s{' '}
@@ -235,3 +239,40 @@ export const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean })
</PageSection>
)
}
function VercelInstallEmptyState({ href, disabled = false }: { href: string; disabled?: boolean }) {
return (
<Card>
<CardContent>
<FormLayout
layout="flex-row-reverse"
label="Vercel project"
description="Install the integration to connect a Vercel project"
>
{disabled ? (
<ButtonTooltip
icon={<SquareArrowOutUpRight />}
variant="default"
size="tiny"
disabled
tooltip={{
content: {
side: 'bottom',
text: "Install the Vercel integration on your project's main branch",
},
}}
>
Install Vercel integration
</ButtonTooltip>
) : (
<Button icon={<SquareArrowOutUpRight />} variant="default" size="tiny" asChild>
<Link href={href} target="_blank" rel="noreferrer">
Install integration
</Link>
</Button>
)}
</FormLayout>
</CardContent>
</Card>
)
}
@@ -29,6 +29,10 @@ vi.mock('@/components/ui/PartnerIcon', () => ({
organization.managed_by === MANAGED_BY.SUPABASE ? null : <div data-testid="partner-icon" />,
}))
vi.mock('@/components/interfaces/Settings/Integrations/AWSPrivateLink/preview', () => ({
usePreviewNavManagedBy: (managedBy: string | undefined) => managedBy,
}))
describe('OrganizationDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -12,6 +12,7 @@ import {
} from './AppLayoutDropdown'
import { OrganizationDropdownCommandContent } from './OrganizationDropdownCommandContent'
import { useEmbeddedCloseHandler } from './useEmbeddedCloseHandler'
import { usePreviewNavManagedBy } from '@/components/interfaces/Settings/Integrations/AWSPrivateLink/preview'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
@@ -42,6 +43,7 @@ export const OrganizationDropdown = ({
const slug = selectedOrganization?.slug
const orgName = selectedOrganization?.name
const navManagedBy = usePreviewNavManagedBy(selectedOrganization?.managed_by)
const [open, setOpen] = useState(false)
const close = useEmbeddedCloseHandler(embedded, onClose, setOpen)
@@ -87,7 +89,9 @@ export const OrganizationDropdown = ({
>
{orgName ?? 'Select an organization'}
</span>
{!!selectedOrganization && <PartnerIcon organization={selectedOrganization} />}
{!!selectedOrganization && navManagedBy && (
<PartnerIcon organization={{ managed_by: navManagedBy }} />
)}
{!!selectedOrganization && (
<Badge variant="default">{selectedOrganization?.plan.name}</Badge>
)}
@@ -69,6 +69,10 @@ vi.mock('@/components/ui/PartnerIcon', () => ({
),
}))
vi.mock('@/components/interfaces/Settings/Integrations/AWSPrivateLink/preview', () => ({
usePreviewNavManagedBy: (managedBy: string | undefined) => managedBy,
}))
describe('ProjectDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -11,6 +11,7 @@ import { AppLayoutDropdownTriggerButton } from './AppLayoutDropdown'
import { sanitizeRoute } from './ProjectDropdown.utils'
import { ProjectRowLink } from './ProjectRowLink'
import { useEmbeddedCloseHandler } from './useEmbeddedCloseHandler'
import { usePreviewNavManagedBy } from '@/components/interfaces/Settings/Integrations/AWSPrivateLink/preview'
import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { getManagedByFromOrganizationPartner } from '@/data/organizations/managed-by-utils'
@@ -152,11 +153,13 @@ export const ProjectDropdown = ({
const [open, setOpen] = useState(false)
const close = useEmbeddedCloseHandler(embedded, onClose, setOpen)
const selectedProjectManagedBy = selectedProject?.integration_source
? getManagedByFromOrganizationPartner(undefined, selectedProject.integration_source)
: selectedOrganization?.billing_partner
? selectedOrganization.managed_by
: undefined
const selectedProjectManagedBy = usePreviewNavManagedBy(
selectedProject?.integration_source
? getManagedByFromOrganizationPartner(undefined, selectedProject.integration_source)
: selectedOrganization?.billing_partner
? selectedOrganization.managed_by
: undefined
)
if (isLoadingProject || (isBranch && isLoadingParentProject) || !selectedProject) {
if (!embedded) return <ShimmeringLoader className="p-2 md:mr-2 md:w-[90px]" />
@@ -16,6 +16,7 @@ import {
} from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ProjectContextProvider } from './ProjectLayout/ProjectContext'
import { AppBannerWrapper } from '@/components/interfaces/App/AppBannerWrapper'
import { PrivateLinkPreviewPanel } from '@/components/interfaces/Settings/Integrations/AWSPrivateLink/preview'
import { Sidebar } from '@/components/interfaces/Sidebar'
import { useSyncScopedIntrospection } from '@/data/scoped-introspection'
import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganization'
@@ -141,6 +142,7 @@ export const DefaultLayout = ({
<BannerStack />
<StudioMobileSheetNav />
<PrivateLinkPreviewPanel />
</MobileSheetProvider>
</ProjectContextProvider>
</LayoutSidebarProvider>
@@ -19,6 +19,9 @@ export type AWSAccount =
resource_access_manager_resource_config_id?: string
resource_access_manager_resource_config_arn?: string
resource_access_manager_share_arn?: string
// Prototype-only until the associations API exposes partner metadata and IAM role.
partner?: 'vercel'
destination_iam_role_arn?: string
}
async function getAWSAccounts({ projectRef }: AWSAccountsVariables, signal?: AbortSignal) {