Feature/private link (#41094)

* privatelink integration

* mock queries

* add integration endpoints

* poll for updates on transition states

* prevent ui for multiple posts

* changes to copy for existing accounts

* update privatelink documentation

* hide privatelink for customer under Team plan

* added connected state tip

* linting fixes

* fixed issue due to merge

* Add cc feature flag

* Nit clean up

* Small revert

* Nudge UI a little

* Update react queries to fit new structure

* Fix

* Clean up

* nit

* revert padding

* Fix

* fix: tanstack changes

* added feature to pricing page

* Update apps/docs/content/guides/platform/privatelink.mdx

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix: dropdown copy change

* Update apps/docs/content/guides/platform/privatelink.mdx

Co-authored-by: Chris Chinchilla <chris.ward@supabase.io>

* Update apps/docs/content/guides/platform/privatelink.mdx

Co-authored-by: Chris Chinchilla <chris.ward@supabase.io>

* Smol clean up

---------

Co-authored-by: Saxon Fletcher <saxonafletcher@gmail.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Chris Chinchilla <chris.ward@supabase.io>
This commit is contained in:
authored and GitHub committed 2025-12-18 07:28:38 +13:00
1 parent c740f5ade0
commit 48b372d29b
21 files changed
+774 -39

No files matched your search

@@ -6,7 +6,8 @@ description: 'Secure private network connectivity to your Supabase database usin
<Admonition type="note">
PrivateLink is currently in alpha and available exclusively to Enterprise customers. Contact your account manager or [reach out to our team](/contact/enterprise) to enable this feature.
PrivateLink is currently in beta and available only to Team and Enterprise customers.
Contact support if you would like to create a PrivateLink connection for a read-only replica.
</Admonition>
@@ -31,19 +32,25 @@ Supabase PrivateLink is currently just for direct database and PgBouncer connect
To use PrivateLink with your Supabase project:
- Enterprise Supabase subscription
- Team or Enterprise Supabase subscription
- AWS VPC in the same region as your Supabase project
- Appropriate permissions to accept Resource Shares, and create and manage endpoints
## Getting started
#### Step 1: Contact Supabase support
#### Step 1: Add AWS account
Reach out to your Enterprise account manager or [contact our team](/contact/enterprise) to initiate PrivateLink setup. During this initial contact, be prepared to provide:
Navigate to your project's Integrations section to set up PrivateLink:
- Your Supabase organization slug
- The specific projects you want to enable PrivateLink for (optional)
- Your AWS Account ID(s)
1. Go to your Supabase project dashboard
2. Navigate to [**Settings** > **Integrations**](/dashboard/project/_/settings/integrations)
3. Find the **AWS PrivateLink** section
4. Click **Add Account**
5. Enter your AWS Account ID
6. Provide a description for the account (recommended)
7. Click **Add Account** to submit
After submission, Supabase creates a VPC Lattice Resource Configuration for your project and sends an AWS Resource Share to the specified AWS Account ID. This process may take a few moments. Once complete, the account will show a "Ready" status, indicating that the resource share has been sent to your AWS account and is ready to be accepted.
#### Step 2: Accept resource share
@@ -54,7 +61,7 @@ Supabase will send you an AWS Resource Share containing the VPC Lattice Resource
{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
3. Go to [Shared with me > Resource shares](https://console.aws.amazon.com/ram/home#SharedResourceShares)
4. Locate the resource share from Supabase.
- The resource share will have the format `cust-prod-[region]-pl-[organisation]-rc-share`
- The resource share has the format `sspl-[project_ref]-[random alphanumeric string]`
5. Click on the resource share name to view details. Review the list of resource shares - it should only include resources of type vpc-lattice:ResourceConfiguration.
6. Click **Accept resource share**
7. Confirm the acceptance in the dialog box
@@ -115,7 +122,7 @@ In your AWS account, you have two options to establish connectivity:
Verify the private connection is working correctly from your VPC:
1. Launch an EC2 instance or use an existing instance in your VPC
1. Launch an EC2 instance or use an existing instance within your VPC
2. Install a Postgres client (e.g., `psql`)
3. Test the connection using the private endpoint:
@@ -144,7 +151,7 @@ postgresql://user:pass@db.[project-ref].supabase.co:5432/postgres
postgresql://user:pass@your-private-endpoint.vpce.amazonaws.com:5432/postgres
```
#### Step 8: Disable public connectivity (optional)
#### Step 7: Disable public connectivity (optional)
For maximum security, you can disable public internet access for your database:
@@ -152,11 +159,11 @@ For maximum security, you can disable public internet access for your database:
2. Ensure all applications are successfully using the private connection
3. Update any monitoring or backup tools to use the private endpoint
## Alpha limitations
## Beta limitations
During the alpha phase:
During the beta phase:
- **Setup Coordination**: Configuration requires direct coordination with Supabase support team
- **Read Replicas**: PrivateLink does not currently support read replicas
- **Feature Evolution**: The setup process and capabilities may evolve as we refine the offering
## Compatibility
@@ -4,7 +4,7 @@ import { toast } from 'sonner'
import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels'
import { Markdown } from 'components/interfaces/Markdown'
import VercelSection from 'components/interfaces/Settings/Integrations/VercelIntegration/VercelSection'
import { VercelSection } from 'components/interfaces/Settings/Integrations/VercelIntegration/VercelSection'
import {
ScaffoldContainer,
ScaffoldContainerLegacy,
@@ -24,7 +24,7 @@ const VERCEL_ICON = (
</svg>
)
const SidePanelVercelProjectLinker = () => {
export const SidePanelVercelProjectLinker = () => {
const { ref } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const sidePanelStateSnapshot = useSidePanelsStateSnapshot()
@@ -0,0 +1,82 @@
import { Edit, MoreVertical, Trash } from 'lucide-react'
import {
Badge,
Button,
CardContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
interface AWSPrivateLinkAccountItemProps {
aws_account_id: string
account_name?: string
status:
| 'CREATING'
| 'READY'
| 'ASSOCIATION_REQUEST_EXPIRED'
| 'ASSOCIATION_ACCEPTED'
| 'CREATION_FAILED'
| 'DELETING'
shared_at: string | null
onEdit: () => void
onDelete: () => void
}
export const AWSPrivateLinkAccountItem = ({
aws_account_id,
account_name,
status,
onEdit,
onDelete,
}: AWSPrivateLinkAccountItemProps) => {
const getStatusBadge = () => {
switch (status) {
case 'ASSOCIATION_ACCEPTED':
return <Badge variant="success">Connected</Badge>
case 'READY':
return <Badge variant="success">Ready</Badge>
case 'CREATING':
return <Badge variant="warning">Creating</Badge>
case 'DELETING':
return <Badge variant="destructive">Deleting</Badge>
case 'ASSOCIATION_REQUEST_EXPIRED':
return <Badge variant="destructive">Expired</Badge>
case 'CREATION_FAILED':
return <Badge variant="destructive">Failed</Badge>
default:
return <Badge>Unknown</Badge>
}
}
return (
<CardContent className="flex items-center justify-between text-sm gap-4">
<div className="flex-1">
<div>{aws_account_id}</div>
<div className="text-sm text-foreground-lighter">{account_name || 'No description'}</div>
</div>
{getStatusBadge()}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="text" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={onEdit} className="gap-x-2">
<Edit size={14} />
<span>View account</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="gap-x-2">
<Trash size={14} />
<span>Delete account</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardContent>
)
}
@@ -0,0 +1,237 @@
import Link from 'next/link'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
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'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import {
Badge,
Button,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
interface AWSPrivateLinkFormProps {
account?: AWSAccount
open: boolean
onOpenChange: (open: boolean) => void
}
interface FormValues {
awsAccountId: string
accountName: string
}
export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLinkFormProps) => {
const isNew = !account
const { data: project } = useSelectedProjectQuery()
const { mutate: createAccount, isPending } = useAWSAccountCreateMutation()
const form = useForm<FormValues>({
defaultValues: {
awsAccountId: account?.aws_account_id ?? '',
accountName: account?.account_name ?? '',
},
})
const title =
account?.status === 'ASSOCIATION_ACCEPTED'
? 'This connection is active'
: account?.status === 'READY'
? 'Connection is ready'
: account?.status === 'CREATING'
? 'This account connection is being created'
: account?.status === 'DELETING'
? 'This account is being deleted'
: account?.status === 'ASSOCIATION_REQUEST_EXPIRED'
? 'Account acceptance request has expired'
: account?.status === 'CREATION_FAILED'
? 'Failed to create account'
: 'This account needs to be accepted by the AWS account owner.'
const description =
account?.status === 'ASSOCIATION_ACCEPTED'
? 'The resource share has been accepted by the AWS account owner and the connection is established.'
: account?.status === 'READY'
? 'It may be waiting acceptance from the AWS account owner. Association requests are automatically deleted if not accepted within 12 hours.'
: account?.status === 'ASSOCIATION_REQUEST_EXPIRED'
? 'Reconnect this account to initiate a new connection request'
: account?.status === 'CREATION_FAILED'
? 'Reconnect this account to initiate a new connection request'
: ''
const onSubmit = (values: FormValues) => {
if (!project) return
if (isNew) {
createAccount(
{
projectRef: project.ref,
awsAccountId: values.awsAccountId,
accountName: values.accountName,
},
{
onSuccess: () => {
toast.success('Successfully added AWS account')
onOpenChange(false)
},
}
)
}
}
// Reset form when account changes
useEffect(() => {
form.reset({
awsAccountId: account?.aws_account_id ?? '',
accountName: account?.account_name ?? '',
})
}, [account, form])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>{isNew ? 'Add AWS Account' : 'AWS Account Details'}</SheetTitle>
<SheetDescription>
Connect to your Supabase project from your AWS VPC using AWS PrivateLink.{' '}
<InlineLink href={`${DOCS_URL}/guides/platform/privatelink`}>Learn more</InlineLink>
</SheetDescription>
</SheetHeader>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1">
<SheetSection className="space-y-4 flex-1">
{!isNew && account && (
<>
<Admonition
showIcon={false}
type="default"
childProps={{ title: { className: 'flex-row gap-x-2 items-center' } }}
// @ts-ignore
title={
<>
<span>{title}</span>
<Badge
variant={
account.status === 'ASSOCIATION_ACCEPTED'
? 'success'
: account.status === 'READY'
? 'success'
: account.status === 'CREATING'
? 'warning'
: account.status === 'CREATION_FAILED' ||
account.status === 'ASSOCIATION_REQUEST_EXPIRED'
? 'destructive'
: 'warning'
}
>
{account.status === 'ASSOCIATION_ACCEPTED'
? 'Connected'
: account.status === 'READY'
? 'Ready'
: account.status === 'CREATING'
? 'Creating'
: account.status === 'CREATION_FAILED'
? 'Failed'
: account.status === 'ASSOCIATION_REQUEST_EXPIRED'
? 'Expired'
: account.status === 'DELETING'
? 'Deleting'
: 'Unknown'}
</Badge>
</>
}
description={description}
actions={
account.status === 'READY' && (
<Button type="default" className="w-min mt-2">
<Link
target="_blank"
rel="noopener noreferrer"
href={`${DOCS_URL}/guides/platform/privatelink#step-2-accept-resource-share`}
>
How to accept?
</Link>
</Button>
)
}
/>
</>
)}
<FormField_Shadcn_
control={form.control}
name="awsAccountId"
render={({ field }) => (
<FormItemLayout
label="AWS Account ID"
description="The ID of the AWS account you want to connect to."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
readOnly={!isNew}
autoFocus={isNew}
onFocus={(e) => {
if (!isNew) {
e.target.blur()
}
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="accountName"
render={({ field }) => (
<FormItemLayout
label="Account Name"
description="A name for this account connection."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
readOnly={!isNew}
onFocus={(e) => {
if (!isNew) {
e.target.blur()
}
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<SheetFooter>
<Button type="default" disabled={isPending} onClick={() => onOpenChange(false)}>
Cancel
</Button>
{isNew && (
<Button htmlType="submit" loading={isPending}>
Add Account
</Button>
)}
</SheetFooter>
</form>
</Form_Shadcn_>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,141 @@
import { useState } from 'react'
import { toast } from 'sonner'
import {
ScaffoldContainer,
ScaffoldSection,
ScaffoldSectionContent,
ScaffoldSectionDetail,
} from 'components/layouts/Scaffold'
import { ResourceList } from 'components/ui/Resource/ResourceList'
import { UpgradeToPro } from 'components/ui/UpgradeToPro'
import { useAWSAccountDeleteMutation } from 'data/aws-accounts/aws-account-delete-mutation'
import { useAWSAccountsQuery } from 'data/aws-accounts/aws-accounts-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { IS_PLATFORM } from 'lib/constants'
import { Button, Card, CardContent, cn } from 'ui'
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
import { IntegrationImageHandler } from '../IntegrationsSettings'
import { AWSPrivateLinkAccountItem } from './AWSPrivateLinkAccountItem'
import { AWSPrivateLinkForm } from './AWSPrivateLinkForm'
export const AWSPrivateLinkSection = () => {
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const { data: accounts } = useAWSAccountsQuery({ projectRef: project?.ref })
const [selectedAccount, setSelectedAccount] = useState<any>(null)
const [showForm, setShowForm] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const { mutate: deleteAccount, isPending: isDeleting } = useAWSAccountDeleteMutation({
onSuccess: () => {
toast.success('Account will be deleted shortly')
setShowDeleteModal(false)
setSelectedAccount(null)
},
})
const isTeamsOrEnterpriseAndUp =
organization?.plan?.id === 'enterprise' || organization?.plan?.id === 'team'
const promptPlanUpgrade = IS_PLATFORM && !isTeamsOrEnterpriseAndUp
const onAddAccount = () => {
setSelectedAccount(null)
setShowForm(true)
}
const onEditAccount = (account: any) => {
setSelectedAccount(account)
setShowForm(true)
}
const onDeleteAccount = (account: any) => {
setSelectedAccount(account)
setShowDeleteModal(true)
}
const onConfirmDelete = () => {
if (selectedAccount && project) {
deleteAccount({ projectRef: project.ref, awsAccountId: selectedAccount.aws_account_id })
}
}
return (
<>
<ScaffoldContainer>
<ScaffoldSection className="py-12">
<ScaffoldSectionDetail title="AWS PrivateLink">
<p>Connect to your Supabase project from your AWS VPC using AWS PrivateLink.</p>
<IntegrationImageHandler title="aws" />
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
<div className="space-y-6">
<div>
<h5 className="text-foreground mb-2">
How does the AWS PrivateLink integration work?
</h5>
<p className="text-foreground-light text-sm mb-6">
Connecting to AWS PrivateLink allows you to create a private connection between
your AWS VPC and your Supabase project.
</p>
{promptPlanUpgrade && (
<div className="mb-6">
<UpgradeToPro
primaryText="Upgrade to Team or Enterprise to use AWS PrivateLink"
secondaryText="Connect your AWS VPC privately to your Supabase project using AWS PrivateLink."
buttonText="Upgrade to Team"
source="aws-privatelink-integration"
/>
</div>
)}
</div>
<div className={cn(promptPlanUpgrade && 'opacity-25 pointer-events-none')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-foreground text-sm">AWS Accounts</h3>
<Button onClick={onAddAccount}>Add Account</Button>
</div>
{(accounts?.length ?? 0) > 0 ? (
<ResourceList>
{accounts?.map((account) => (
<AWSPrivateLinkAccountItem
key={account.aws_account_id}
{...account}
onEdit={() => onEditAccount(account)}
onDelete={() => onDeleteAccount(account)}
/>
))}
</ResourceList>
) : (
<Card>
<CardContent>
<p className="text-foreground-lighter text-sm">No accounts connected</p>
</CardContent>
</Card>
)}
</div>
</div>
</ScaffoldSectionContent>
</ScaffoldSection>
</ScaffoldContainer>
<AWSPrivateLinkForm account={selectedAccount} open={showForm} onOpenChange={setShowForm} />
<ConfirmationModal
variant="destructive"
visible={showDeleteModal}
title="Confirm to delete AWS Account"
confirmLabel="Delete"
loading={isDeleting}
onCancel={() => setShowDeleteModal(false)}
onConfirm={onConfirmDelete}
>
<p className="text-sm text-foreground-light">
Are you sure you want to delete the AWS account connection for{' '}
{selectedAccount?.aws_account_id}?
</p>
</ConfirmationModal>
</>
)
}
@@ -28,7 +28,7 @@ const IntegrationImageHandler = ({ title }: { title: 'vercel' | 'github' }) => {
)
}
const GitHubSection = () => {
export const GitHubSection = () => {
const { ref: projectRef } = useParams()
const { data: organization } = useSelectedOrganizationQuery()
@@ -52,7 +52,7 @@ const GitHubSection = () => {
return (
<ScaffoldContainer>
<ScaffoldSection>
<ScaffoldSection className="py-12">
<ScaffoldSectionDetail title={GitHubTitle}>
<p>Connect any of your GitHub repositories to a project.</p>
<IntegrationImageHandler title="github" />
@@ -93,5 +93,3 @@ const GitHubSection = () => {
</ScaffoldContainer>
)
}
export default GitHubSection
@@ -1,16 +1,18 @@
import Link from 'next/link'
import SidePanelVercelProjectLinker from 'components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker'
import { useFlag } from 'common'
import { SidePanelVercelProjectLinker } from 'components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker'
import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { BASE_PATH } from 'lib/constants'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, WarningIcon } from 'ui'
import GitHubSection from './GithubIntegration/GithubSection'
import VercelSection from './VercelIntegration/VercelSection'
import { AWSPrivateLinkSection } from './AWSPrivateLink/AWSPrivateLinkSection'
import { GitHubSection } from './GithubIntegration/GithubSection'
import { VercelSection } from './VercelIntegration/VercelSection'
export const IntegrationImageHandler = ({ title }: { title: 'vercel' | 'github' }) => {
export const IntegrationImageHandler = ({ title }: { title: 'vercel' | 'github' | 'aws' }) => {
return (
<img
className="border rounded-lg shadow w-full sm:w-48 mt-6 border-body"
@@ -26,6 +28,9 @@ const IntegrationSettings = () => {
const isBranch = project?.parent_project_ref !== undefined
const showVercelIntegration = useIsFeatureEnabled('integrations:vercel')
const showAWSPrivateLinkFeature = useIsFeatureEnabled('integrations:aws_private_link')
const showAWSPrivateLinkConfigCat = useFlag('awsPrivateLinkIntegration')
const showAWSPrivateLink = showAWSPrivateLinkFeature && showAWSPrivateLinkConfigCat
return (
<>
@@ -54,6 +59,12 @@ const IntegrationSettings = () => {
<SidePanelVercelProjectLinker />
</>
)}
{showAWSPrivateLink && (
<>
<ScaffoldDivider />
<AWSPrivateLinkSection />
</>
)}
</>
)
}
@@ -37,7 +37,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { IntegrationImageHandler } from '../IntegrationsSettings'
import VercelIntegrationConnectionForm from './VercelIntegrationConnectionForm'
const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) => {
export const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) => {
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const { data } = useOrgIntegrationsQuery({ orgSlug: org?.slug })
@@ -165,7 +165,7 @@ You can change the scope of the access for Supabase by configuring
return (
<ScaffoldContainer>
<ScaffoldSection>
<ScaffoldSection className="py-12">
<ScaffoldSectionDetail title={VercelTitle}>
<Markdown content={VercelDetailsSection} />
<IntegrationImageHandler title="vercel" />
@@ -263,5 +263,3 @@ You can change the scope of the access for Supabase by configuring
</ScaffoldContainer>
)
}
export default VercelSection
+1 -1
View File
@@ -105,7 +105,7 @@ export const ScaffoldSection = forwardRef<
*/
export const ScaffoldDivider = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
return <div ref={ref} {...props} className={cn('w-full h-px bg-border', className)} />
return <div ref={ref} {...props} className={cn('w-full h-px bg-border shrink-0', className)} />
}
)
@@ -1,17 +1,31 @@
import { ChevronRight } from 'lucide-react'
import { ChevronRight, MoreVertical } from 'lucide-react'
import { forwardRef, HTMLAttributes, ReactNode } from 'react'
import { CardContent, cn } from 'ui'
import {
Button,
CardContent,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
export interface ResourceAction {
label: string
onClick: () => void
}
export interface ResourceItemProps extends HTMLAttributes<HTMLDivElement> {
media?: ReactNode
meta?: ReactNode
onClick?: () => void
children?: ReactNode
actions?: ResourceAction[]
}
export const ResourceItem = forwardRef<HTMLDivElement, ResourceItemProps>(
({ media, meta, onClick, children, className, ...props }, ref) => {
({ media, meta, onClick, children, className, actions, ...props }, ref) => {
return (
<CardContent
ref={ref}
@@ -29,7 +43,35 @@ export const ResourceItem = forwardRef<HTMLDivElement, ResourceItemProps>(
)}
<div className="flex-1">{children}</div>
{meta && <div>{meta}</div>}
{onClick && <ChevronRight strokeWidth={1.5} size={16} />}
{actions && actions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
className="px-1"
icon={<MoreVertical size={16} />}
onClick={(e) => {
e.stopPropagation()
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{actions.map((action) => (
<DropdownMenuItem
key={action.label}
onClick={(e) => {
e.stopPropagation()
action.onClick()
}}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
onClick && <ChevronRight strokeWidth={1.5} size={16} />
)}
</CardContent>
)
}
@@ -55,13 +55,15 @@ export const UpgradePlanButton = ({
const subject = `Enquiry to upgrade ${!!plan ? `to ${plan} ` : ''}plan for organization`
const message = `Name: ${organization?.name}\nSlug: ${slug}\nRequested plan: ${plan ?? PLAN_REQUEST_EMPTY_PLACEHOLDER}`
const href = billingAll
? isFreePlan
? `/org/${slug ?? '_'}/billing?panel=subscriptionPlan&source=${source}`
: addon === 'spendCap'
? `/org/${slug ?? '_'}/billing?panel=costControl&source=${source}`
: `/project/${ref ?? '_'}/settings/addons?panel=${addon}&source=${source}`
: '/'
const isRequestingToDisableSpendCap = addon === 'spendCap'
const isOnPaidPlanAndRequestingToPurchaseAddon = !isFreePlan && !!addon
// [Joshen] URL for button based on the "upgrade request" and the org's plan. Falls back to URL for opening subscription plan
const href = isRequestingToDisableSpendCap
? `/org/${slug ?? '_'}/billing?panel=costControl&source=${source}`
: isOnPaidPlanAndRequestingToPurchaseAddon
? `/project/${ref ?? '_'}/settings/addons?panel=${addon}&source=${source}`
: `/org/${slug ?? '_'}/billing?panel=subscriptionPlan&source=${source}`
const linkChildren = children || (!!addon ? 'Enable add-on' : `Upgrade to ${plan}`)
const link = billingAll ? (
@@ -0,0 +1,63 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { awsAccountKeys } from './keys'
export type AWSAccountCreateVariables = {
projectRef: string
awsAccountId: string
accountName?: string
}
export async function createAWSAccount({
projectRef,
awsAccountId,
accountName,
}: AWSAccountCreateVariables) {
const { data, error } = await post(
'/platform/projects/{ref}/privatelink/associations/aws-account',
{
params: {
path: { ref: projectRef },
},
body: {
aws_account_id: awsAccountId,
account_name: accountName,
},
}
)
if (error) handleError(error)
return data
}
type AWSAccountCreateData = Awaited<ReturnType<typeof createAWSAccount>>
export const useAWSAccountCreateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<AWSAccountCreateData, ResponseError, AWSAccountCreateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<AWSAccountCreateData, ResponseError, AWSAccountCreateVariables>({
mutationFn: (vars) => createAWSAccount(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries({ queryKey: awsAccountKeys.list(projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create AWS account: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,57 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { del, handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { awsAccountKeys } from './keys'
export type AWSAccountDeleteVariables = {
projectRef: string
awsAccountId: string
}
export async function deleteAWSAccount({ projectRef, awsAccountId }: AWSAccountDeleteVariables) {
const { data, error } = await del(
'/platform/projects/{ref}/privatelink/associations/aws-account/{aws_account_id}',
{
params: {
path: {
ref: projectRef,
aws_account_id: awsAccountId,
},
},
}
)
if (error) handleError(error)
return data
}
type AWSAccountDeleteData = Awaited<ReturnType<typeof deleteAWSAccount>>
export const useAWSAccountDeleteMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<AWSAccountDeleteData, ResponseError, AWSAccountDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<AWSAccountDeleteData, ResponseError, AWSAccountDeleteVariables>({
mutationFn: (vars) => deleteAWSAccount(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries({ queryKey: awsAccountKeys.list(projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to delete AWS account: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,64 @@
import type { UseQueryOptions } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { IS_PLATFORM } from 'lib/constants'
import type { ResponseError } from 'types'
import { awsAccountKeys } from './keys'
type AWSAccountsVariables = {
projectRef?: string
}
export interface AWSAccount {
aws_account_id: string
account_name?: string
status:
| 'CREATING'
| 'READY'
| 'ASSOCIATION_REQUEST_EXPIRED'
| 'ASSOCIATION_ACCEPTED'
| 'CREATION_FAILED'
| 'DELETING'
shared_at: string | null
}
async function getAWSAccounts({ projectRef }: AWSAccountsVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('Project ref is required')
const { data, error } = await get('/platform/projects/{ref}/privatelink/associations', {
params: {
path: { ref: projectRef },
},
signal,
})
if (error) handleError(error)
return data.private_link_associations as AWSAccount[]
}
type AWSAccountsData = Awaited<ReturnType<typeof getAWSAccounts>>
type AWSAccountsError = ResponseError
export const useAWSAccountsQuery = <TData = AWSAccountsData>(
{ projectRef }: AWSAccountsVariables,
{
enabled = true,
...options
}: Omit<UseQueryOptions<AWSAccountsData, AWSAccountsError, TData>, 'queryKey' | 'queryFn'> = {}
) => {
return useQuery<AWSAccountsData, AWSAccountsError, TData>({
queryKey: awsAccountKeys.list(projectRef),
queryFn: ({ signal }) => getAWSAccounts({ projectRef }, signal),
enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined',
refetchInterval: (query) => {
// Poll every 5 seconds if any accounts are in transitional states
const accounts = query.state.data
const hasTransitionalStates = accounts?.some(
(account: AWSAccount) => account.status === 'CREATING' || account.status === 'DELETING'
)
return hasTransitionalStates ? 5000 : false
},
...options,
})
}
+3
View File
@@ -0,0 +1,3 @@
export const awsAccountKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'aws-accounts'] as const,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

@@ -92,6 +92,18 @@ export const pricingTooltips: PricingTooltips = {
'security.hipaa': {
main: 'Available as a paid add-on on Team Plan and above.',
},
'security.privateLink': {
main: (
<span className="prose text-xs">
AWS PrivateLink enables private connectivity between your AWS VPC and Supabase, keeping
traffic within the AWS network. Read more in our{' '}
<Link href="/docs/guides/platform/privatelink" target="_blank">
docs
</Link>
.
</span>
),
},
'security.accessRoles': {
main: (
@@ -72,6 +72,7 @@
"integrations:partners": true,
"integrations:wrappers": true,
"integrations:vercel": true,
"integrations:aws_private_link": true,
"infrastructure:read_replicas": true,
@@ -252,6 +252,10 @@
"type": "boolean",
"description": "Enable the vercel integration section in the organization and project settings pages"
},
"integrations:aws_private_link": {
"type": "boolean",
"description": "Enable the AWS PrivateLink integration section in the project settings pages"
},
"infrastructure:read_replicas": {
"type": "boolean",
@@ -482,6 +486,7 @@
"integrations:partners",
"integrations:wrappers",
"integrations:vercel",
"integrations:aws_private_link",
"infrastructure:read_replicas",
"profile:show_email",
"profile:show_information",
+12
View File
@@ -76,6 +76,7 @@ export type FeatureKey =
| 'security.metricsEndpoint'
| 'security.soc2'
| 'security.hipaa'
| 'security.privateLink'
| 'security.sso'
| 'security.uptimeSla'
| 'security.accessRoles'
@@ -639,6 +640,17 @@ export const pricing: Pricing = {
},
usage_based: false,
},
{
key: 'security.privateLink',
title: 'AWS PrivateLink',
plans: {
free: false,
pro: false,
team: true,
enterprise: true,
},
usage_based: false,
},
{
key: 'security.sso',
title: 'SSO',