mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
restack vercel privatelink prototype onto the generic ui stack
This commit is contained in:
1 parent
2cb24f06b0
commit
e5cede3d4a
22 files changed
+1004
-99
No files matched your search
+16
@@ -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">
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
+19
-1
@@ -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()
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+15
-3
@@ -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>
|
||||
|
||||
+66
-21
@@ -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}
|
||||
|
||||
+8
-2
@@ -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)
|
||||
|
||||
+48
@@ -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.
|
||||
+116
@@ -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>
|
||||
)
|
||||
}
|
||||
+68
@@ -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
|
||||
}
|
||||
+6
@@ -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'
|
||||
+193
@@ -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
|
||||
}
|
||||
+90
@@ -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 []
|
||||
}
|
||||
}
|
||||
+111
@@ -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)
|
||||
}
|
||||
+96
@@ -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()
|
||||
})
|
||||
})
|
||||
+106
-65
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user