mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ const VERCEL_ICON = (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SidePanelVercelProjectLinker = () => {
|
||||
export const SidePanelVercelProjectLinker = () => {
|
||||
const { ref } = useParams()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const sidePanelStateSnapshot = useSidePanelsStateSnapshot()
|
||||
|
||||
+82
@@ -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>
|
||||
)
|
||||
}
|
||||
+237
@@ -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>
|
||||
)
|
||||
}
|
||||
+141
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+2
-4
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-4
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user