From e5cede3d4a4d4536daec11f214ce3da3d0aaa0b4 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:11:40 +1000 Subject: [PATCH] restack vercel privatelink prototype onto the generic ui stack --- .../steps/direct-connection/content.tsx | 16 ++ .../NetworkRestrictions.tsx | 12 ++ .../AWSPrivateLink.utils.test.ts | 20 +- .../AWSPrivateLink/AWSPrivateLink.utils.ts | 9 +- .../AWSPrivateLinkAccountItem.tsx | 18 +- .../AWSPrivateLink/AWSPrivateLinkForm.tsx | 87 ++++++-- .../AWSPrivateLink/AWSPrivateLinkSection.tsx | 10 +- .../AWSPrivateLink/preview/EXTRACT.md | 48 +++++ .../preview/PrivateLinkPreviewPanel.tsx | 116 +++++++++++ .../PrivateLinkPreviewVercelOverride.tsx | 68 ++++++ .../AWSPrivateLink/preview/index.ts | 6 + .../preview/privateLinkPreview.constants.ts | 193 ++++++++++++++++++ .../preview/privateLinkPreview.mocks.ts | 90 ++++++++ .../preview/privateLinkPreview.store.ts | 111 ++++++++++ .../preview/privateLinkPreview.utils.test.ts | 96 +++++++++ .../VercelIntegration/VercelSection.tsx | 171 ++++++++++------ .../AppLayout/OrganizationDropdown.test.tsx | 4 + .../AppLayout/OrganizationDropdown.tsx | 6 +- .../AppLayout/ProjectDropdown.test.tsx | 4 + .../layouts/AppLayout/ProjectDropdown.tsx | 13 +- .../components/layouts/DefaultLayout.tsx | 2 + .../data/aws-accounts/aws-accounts-query.ts | 3 + 22 files changed, 1004 insertions(+), 99 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/EXTRACT.md create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/PrivateLinkPreviewPanel.tsx create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/PrivateLinkPreviewVercelOverride.tsx create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/index.ts create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.constants.ts create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.mocks.ts create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.store.ts create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.utils.test.ts diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx index d6d3dc3c130..770073cb1e3 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx @@ -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 (
+ {showPrivateHostname && ( + + + + )}
{showStringTitleRow && (
diff --git a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx index f4275aa8748..32d56a73720 100644 --- a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx +++ b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -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() const [isAllowingAll, setIsAllowingAll] = useState(false) const [isDisallowingAll, setIsDisallowingAll] = useState(false) @@ -178,6 +181,15 @@ export const NetworkRestrictions = () => { + {showRestrictPublicAccess && ( +
+ +
+ )} {isHighAvailability && (
{ 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() diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts index a32db4a66d7..2b1a617aa5e 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts @@ -47,13 +47,20 @@ export function getConnectionStatusUi(status?: PrivateLinkConnectionStatus): Con } export function getConnectionTitle( - account: Pick + account: Pick ): 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 diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx index 0894c39c797..1b027ce90c7 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx @@ -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={{statusUi.badge}} >
-
{title}
+
+
{title}
+ {partner === 'vercel' && ( + + )} +
{showDatabase &&

{databaseLabel}

}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx index e08aed73315..806cdbf7f45 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx @@ -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 @@ -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({ @@ -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 }) => ( )} /> + {isNew && ( + ( + + + + + + )} + /> + )} + {!isNew && account?.destination_iam_role_arn && ( + + + + )} {showDatabaseTarget && ( { 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() 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) diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/EXTRACT.md b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/EXTRACT.md new file mode 100644 index 00000000000..c9d47069d9c --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/EXTRACT.md @@ -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=`. + +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 `` | +| `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, ``. 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. diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/PrivateLinkPreviewPanel.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/PrivateLinkPreviewPanel.tsx new file mode 100644 index 00000000000..f89019a13e7 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/PrivateLinkPreviewPanel.tsx @@ -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 ( +
+ + + PrivateLink preview +

+ Prototype only. Off unless the URL has `{PRIVATE_LINK_PREVIEW_QUERY}=1`. Do not ship. +

+
+ + + +

{preview.config.description}

+

+ {preview.config.source === 'real-api' ? 'Real API' : 'Mocked platform'} +

+ + {preview.b5Note && ( +

+ {preview.b5Note} +

+ )} + + {preview.scenario === 'aws-direct-waiting' && ( + + )} + + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/PrivateLinkPreviewVercelOverride.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/PrivateLinkPreviewVercelOverride.tsx new file mode 100644 index 00000000000..2d1ba4a17e6 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/PrivateLinkPreviewVercelOverride.tsx @@ -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 ( + +
+ + + + e.target.blur()} /> +
+
+ ) +} + +function MarketplaceVercelCard() { + return ( + + + + + + ) +} + +function MarketplacePlusVercelCard() { + return ( + + + + + +

+ Private database path is in AWS PrivateLink below. +

+
+
+ ) +} + +export const PrivateLinkPreviewVercelOverride = () => { + const { vercelCard } = usePrivateLinkPreview() + + if (vercelCard === 'marketplace') return + + if (vercelCard === 'marketplace-plus' || vercelCard === 'distinguish-billing') { + return + } + + return null +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/index.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/index.ts new file mode 100644 index 00000000000..a95b7272dd6 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/index.ts @@ -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' diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.constants.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.constants.ts new file mode 100644 index 00000000000..d8c785245b3 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.constants.ts @@ -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 +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.mocks.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.mocks.ts new file mode 100644 index 00000000000..a35b186e82d --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.mocks.ts @@ -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 & Pick +): 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 [] + } +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.store.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.store.ts new file mode 100644 index 00000000000..7410c54862a --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.store.ts @@ -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) +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.utils.test.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.utils.test.ts new file mode 100644 index 00000000000..c0afc8cba90 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/preview/privateLinkPreview.utils.test.ts @@ -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() + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index e7f48840b45..f9446cb1d1b 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -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 ( -
+
Vercel - {description} + + Sync environment variables with connected Vercel projects. +
- {isLoadingPermissions ? ( - - ) : !canReadVercelConnection ? ( + {showPreviewCard && } + {showInstallEmpty && } + {showLive && isLoadingPermissions && } + {showLive && !isLoadingPermissions && !canReadVercelConnection && ( - ) : ( + )} + {showLive && !isLoadingPermissions && canReadVercelConnection && vercelIntegration && (
- {vercelIntegration ? ( -
- - - {connections.length > 0 && ( -
    - {connections.map((connection) => ( -
    li]:pb-0' - )} - > - - {isProjectScoped ? ( -
    -
    - -
    +
    + + + {connections.length > 0 && ( +
      + {connections.map((connection) => ( +
      li]:pb-0' + )} + > + + {isProjectScoped ? ( +
      +
      +
      - ) : null} -
      - ))} -
    - )} - onAddVercelConnection(vercelIntegration.id)} - > - Add new project connection - -
    - ) : ( +
    + ) : null} +
    + ))} +
+ )} } - disabledTooltip="Install the Vercel integration on your project's main branch" + disabled={isBranch || !canCreateVercelConnection} + onClick={() => onAddVercelConnection(vercelIntegration.id)} + icon={} > - Install Vercel integration + Add connection - )} +
- {vercelProjectCount > 0 && vercelIntegration !== undefined && ( + {vercelProjectCount > 0 && (

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 }) ) } + +function VercelInstallEmptyState({ href, disabled = false }: { href: string; disabled?: boolean }) { + return ( + + + + {disabled ? ( + } + variant="default" + size="tiny" + disabled + tooltip={{ + content: { + side: 'bottom', + text: "Install the Vercel integration on your project's main branch", + }, + }} + > + Install Vercel integration + + ) : ( + + )} + + + + ) +} diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.test.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.test.tsx index dbe0ed14b76..c7f142acf72 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.test.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.test.tsx @@ -29,6 +29,10 @@ vi.mock('@/components/ui/PartnerIcon', () => ({ organization.managed_by === MANAGED_BY.SUPABASE ? null :

, })) +vi.mock('@/components/interfaces/Settings/Integrations/AWSPrivateLink/preview', () => ({ + usePreviewNavManagedBy: (managedBy: string | undefined) => managedBy, +})) + describe('OrganizationDropdown', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index 95ababa6939..acc433b4f62 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -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'} - {!!selectedOrganization && } + {!!selectedOrganization && navManagedBy && ( + + )} {!!selectedOrganization && ( {selectedOrganization?.plan.name} )} diff --git a/apps/studio/components/layouts/AppLayout/ProjectDropdown.test.tsx b/apps/studio/components/layouts/AppLayout/ProjectDropdown.test.tsx index 667e6ab3a20..d610dd0d702 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectDropdown.test.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectDropdown.test.tsx @@ -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() diff --git a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx index b5c6ec77c60..c853a2bd836 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -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 diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index 734493bb7bb..c66da57456a 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -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 = ({ + diff --git a/apps/studio/data/aws-accounts/aws-accounts-query.ts b/apps/studio/data/aws-accounts/aws-accounts-query.ts index ca378da61fd..24976a236d9 100644 --- a/apps/studio/data/aws-accounts/aws-accounts-query.ts +++ b/apps/studio/data/aws-accounts/aws-accounts-query.ts @@ -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) {