From 89cd156e3929943a4be9a55cd417868b56fa9ee9 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:47:50 +1000 Subject: [PATCH 1/5] fix(studio): clarify MFA backup authenticator copy (#49083) ## What kind of change does this PR introduce? Bug fix (copy and layout) ## What is the current behavior? After setting up a single MFA factor, Account > Security warns you to add a "backup sign-in method". That reads like another account identity (email / Google / SSO), not a second authenticator app. The add action also sits at the bottom of the MFA card, so the callout has no nearby control. Fixes [FE-4171](https://linear.app/supabase/issue/FE-4171/clarify-backup-sign-in-method-after-mfa-setup) ## What is the new behavior? The MFA block is a `PageSection` with **Add app** in the aside. When one factor is configured, a danger callout above the card tells you to add a backup authenticator app, with **Add another app** opening the same modal. | Before | After | | --- | --- | | CleanShot 2026-08-14 at 10 27
33@2x | CleanShot 2026-08-14 at 10 57
06@2x | ## To test 1. Open **Account > Security** (`/account/security`). 2. **0 apps:** empty card, **Add app** in the section aside. Click it. The add-factor modal should open. 3. **1 app:** danger callout under the section title. Copy should mention a backup authenticator app, not a sign-in method. **Add another app** and **Add app** should both open the same modal. 4. **2 apps:** callout and add buttons gone. Remove still works. Add or remove an authenticator app on that page to hit each state. If you already have one factor, step 3 is the important check. ## Summary by CodeRabbit * **New Features** * Improved the multi-factor authentication interface with clearer sections, cards, and guidance. * Added an empty state when no authenticator apps are configured. * Added a warning when only one authenticator remains to help prevent account lockout. * Limited authenticator app setup to two configured factors. * **Bug Fixes** * Improved loading and error-state presentation for authentication factor management. * Simplified the security page to provide a more consistent MFA experience. --- .../interfaces/Account/TOTPFactors/index.tsx | 123 ++++++++++++------ apps/studio/pages/account/security.tsx | 33 +---- 2 files changed, 84 insertions(+), 72 deletions(-) diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx index 61c79ac6bec4e..d8952f9625b31 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx @@ -1,6 +1,17 @@ import dayjs from 'dayjs' +import { Plus } from 'lucide-react' import { useState } from 'react' -import { Button } from 'ui' +import { Button, Card, CardContent } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { + PageSection, + PageSectionAside, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { AddNewFactorModal } from './AddNewFactorModal' @@ -14,54 +25,86 @@ export const TOTPFactors = () => { const [factorToBeDeleted, setFactorToBeDeleted] = useState(null) const { data, isPending: isLoading, isError, isSuccess, error } = useMfaListFactorsQuery() + const totpFactors = data?.totp ?? [] + const canAddApp = isSuccess && totpFactors.length < 2 + const shouldShowLockoutWarning = isSuccess && totpFactors.length === 1 + + const handleAddNewApp = () => setIsAddNewFactorOpen(true) + return ( <> -
-

- Use an authenticator app (like Google Authenticator or 1Password) to protect your account. -

-
- {isLoading && } + + + + Multi-factor authentication + + Use an authenticator app (like Google Authenticator or 1Password) to protect your + account. + + + {canAddApp && ( + + + + )} + + + {shouldShowLockoutWarning && ( + } onClick={handleAddNewApp}> + Add another app + + } + /> + )} + {isLoading && ( + + + + + + )} {isError && ( )} {isSuccess && ( - <> -
- {data.totp.map((factor) => { - return ( -
-

- Name:{' '} - {factor.friendly_name ?? 'No name provided'} -

-
-

+ + {totpFactors.length === 0 ? ( + +

No authenticator apps yet.

+ + ) : ( +
+ {totpFactors.map((factor) => ( + +
+

{factor.friendly_name ?? 'No name provided'}

+

Added on {dayjs(factor.created_at).format(DATETIME_FORMAT)}

-
-
- ) - })} -
- {data.totp.length < 2 ? ( - <> -
- -
- - ) : null} - + + + ))} +
+ )} + )} -
-
+ + setIsAddNewFactorOpen(false)} @@ -69,7 +112,7 @@ export const TOTPFactors = () => { setFactorToBeDeleted(null)} /> diff --git a/apps/studio/pages/account/security.tsx b/apps/studio/pages/account/security.tsx index db7ec4be0b798..95ceb21923920 100644 --- a/apps/studio/pages/account/security.tsx +++ b/apps/studio/pages/account/security.tsx @@ -1,6 +1,3 @@ -import { Lock } from 'lucide-react' -import { Badge, Card, CardContent, CardHeader } from 'ui' -import { Admonition } from 'ui-patterns/Admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, @@ -15,15 +12,12 @@ import AccountLayout from '@/components/layouts/AccountLayout/AccountLayout' import { AppLayout } from '@/components/layouts/AppLayout/AppLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { UnknownInterface } from '@/components/ui/UnknownInterface' -import { useMfaListFactorsQuery } from '@/data/profile/mfa-list-factors-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from '@/types' const Security: NextPageWithLayout = () => { const showSecuritySettings = useIsFeatureEnabled('account:show_security_settings') - const { data } = useMfaListFactorsQuery({ enabled: showSecuritySettings }) - if (!showSecuritySettings) { return } @@ -41,32 +35,7 @@ const Security: NextPageWithLayout = () => { - {data?.totp.length === 1 && ( - - )} - - -
- - Multi-factor authentication (MFA) -
- - {data ? ( - - {data.totp.length} app{data.totp.length === 1 ? '' : 's'} configured - - ) : null} -
- - - -
+
) From 3d966e3709d9ef3859daa2f82697efe4546aa60d Mon Sep 17 00:00:00 2001 From: Stephen Morgan <3104761+dnywh@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:06:26 +1200 Subject: [PATCH 2/5] feat(studio): show PrivateLink resource IDs and use connection copy (#48967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature and docs ## What is the current behavior? PrivateLink is labelled as an AWS account, and there is no way to tell which resource configuration belongs to the primary vs a read replica. Put simply: you’re not adding an AWS account. You’re adding a connection. One AWS account can have multiple PrivateLink connections, just to different databases, with more fields also coming soon. Part of PRODSEC-238 and fixes SEC-939. ## What is the new behavior? Each connection shows resource configuration IDs so primary and replica are distinguishable. Customer-facing copy says **connection**. API paths and AWS console labels still say association. | Before | After | | --- | --- | | CleanShot 2026-08-14 at 12 33
49@2x | CleanShot 2026-08-14 at 12 34
30@2x | | CleanShot 2026-08-14 at 12 33
28@2x | CleanShot 2026-08-14 at 12 34
39@2x | | CleanShot 2026-08-14 at 12 33
10@2x | CleanShot 2026-08-14 at 12 32
15@2x | ## Additional context First PR in a stacked PrivateLink series (#49084 onwards). See https://github.com/supabase/supabase/pull/49030 for the end state, as it may already include fixes you might propose. ## To test - **Project Settings → Integrations → AWS PrivateLink.** Open **Add connection**, or **View** an existing one. Confirm the UI says connection, and that resource config IDs are copyable. - **Docs preview → Platform → PrivateLink.** Procedure steps should say Add connection / View connection. --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com> --- .../content/guides/platform/privatelink.mdx | 23 ++-- .../DropReplicaConfirmationModal.tsx | 2 +- .../AWSPrivateLinkAccountItem.tsx | 40 +++++- .../AWSPrivateLink/AWSPrivateLinkForm.tsx | 115 ++++++++++++------ .../AWSPrivateLink/AWSPrivateLinkSection.tsx | 22 ++-- .../aws-account-create-mutation.ts | 2 +- .../aws-account-delete-mutation.ts | 2 +- .../data/aws-accounts/aws-accounts-query.ts | 5 +- 8 files changed, 142 insertions(+), 69 deletions(-) diff --git a/apps/docs/content/guides/platform/privatelink.mdx b/apps/docs/content/guides/platform/privatelink.mdx index a934dc535bd23..8d079f5d3bc25 100644 --- a/apps/docs/content/guides/platform/privatelink.mdx +++ b/apps/docs/content/guides/platform/privatelink.mdx @@ -7,7 +7,6 @@ description: 'Secure private network connectivity to your Supabase database usin PrivateLink is available only to Team and Enterprise customers. -Contact support if you would like to create a PrivateLink connection for a read-only replica. @@ -37,19 +36,24 @@ To use PrivateLink with your Supabase project: ## Getting started -### Step 1: Add AWS account +### Step 1: Add connection Navigate to your project's Integrations section to set up PrivateLink: 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 +4. Click **Add connection** +5. Provide a description (recommended) +6. Select the database target: the primary database or a specific read replica +7. Enter your AWS account ID +8. Click **Add connection** 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. +Each database, whether the primary or a read replica, needs its own connection. Create a separate connection for every database you want to reach over PrivateLink. + +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 connection will show a "Ready" status, indicating that the resource share has been sent to your AWS account and is ready to be accepted. You must accept the resource share within 12 hours, or the request expires and can no longer be accepted in AWS. You'll need to create a new connection to try again. + +Once ready, select **View connection** to see the VPC Lattice resource configuration ID and ARNs for the connection. This is useful for confirming which resource configuration corresponds to which database when a project has multiple connections, for example one for the primary database and one for each read replica. ### Step 2: Accept resource share @@ -61,6 +65,7 @@ Supabase will send you an AWS Resource Share containing the VPC Lattice Resource 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 has the format `sspl-[project_ref]-[random alphanumeric string]` + - If your project has multiple connections, for example one for the primary database and one for each read replica, match the share's ARN to the **Resource share ARN** shown for that connection in **View connection** to confirm you're accepting the correct one 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 @@ -95,6 +100,7 @@ In your AWS account, you have two options to establish connectivity: 5. Under Type, select **Resources** 6. In the **Resource configurations** section select the appropriate resource configuration - The resource configuration name will be in the format `[organisation]-[project-ref]-rc` + - If you have multiple connections, match the **Resource configuration ID** shown for that connection in **View connection** to confirm you select the configuration for the correct database 7. Select your VPC from the dropdown. This should match the VPC you selected for your security group in Step 3 8. Enable the **Enable DNS name** option if you want to use a DNS record instead of the endpoints IP address(es) 9. Choose the appropriate subnets for your network @@ -115,6 +121,7 @@ In your AWS account, you have two options to establish connectivity: 4. In the service network details, go to the **Resource configuration associations** tab 5. Click **Create associations** 6. Select the appropriate **Resource configuration** from the dropdown + - If you have multiple connections, match the **Resource configuration ID** shown for that connection in **View connection** to confirm you select the configuration for the correct database 7. Click **Save changes** 8. After creation, you will see the resource configuration in the Resource configurations section of your service network with the status "Active" 9. For connectivity, click on the association details and the domain name will be listed in the **DNS entries** section @@ -174,7 +181,7 @@ For maximum security, you can restrict public database access in your project se ## Limitations -- **Read Replicas**: To establish PrivateLink with a Read Replica, reach out to your account rep. +- **Service Scope**: PrivateLink only supports database connections (Postgres and PgBouncer). Other Supabase services (API, Storage, Auth, Realtime) will continue to operate over public internet connections. - **Feature Evolution**: The setup process and capabilities may evolve as we refine the offering ## Compatibility diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal.tsx index 91f7c2fb97fea..0e769e290bf8a 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal.tsx @@ -34,7 +34,7 @@ export const DropReplicaConfirmationModal = ({ Remove the replica{' '} - PrivateLink association + PrivateLink connection {' '} before dropping this read replica diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx index 6d7af86331ddd..f383d3e71ebc2 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx @@ -8,6 +8,9 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { formatDatabaseID } from '@/data/read-replicas/replicas.utils' @@ -17,6 +20,9 @@ interface AWSPrivateLinkAccountItemProps { account_name?: string database_type?: 'PRIMARY' | 'READ_REPLICA' database_identifier?: string + resource_access_manager_resource_config_id?: string + resource_access_manager_resource_config_arn?: string + resource_access_manager_share_arn?: string status: | 'CREATING' | 'READY' @@ -34,6 +40,9 @@ export const AWSPrivateLinkAccountItem = ({ account_name, database_type, database_identifier, + resource_access_manager_resource_config_id, + resource_access_manager_resource_config_arn, + resource_access_manager_share_arn, status, onEdit, onDelete, @@ -65,9 +74,30 @@ export const AWSPrivateLinkAccountItem = ({ return (
-
{aws_account_id}
-
{databaseTarget}
-
{account_name || 'No description'}
+ {account_name &&
{account_name}
} +
Database: {databaseTarget}
+
Destination account: {aws_account_id}
+ {resource_access_manager_resource_config_id && ( +
+ Resource configuration: + + + {resource_access_manager_resource_config_id} + + {(resource_access_manager_resource_config_arn || + resource_access_manager_share_arn) && ( + + {resource_access_manager_resource_config_arn && ( +

Resource config ARN: {resource_access_manager_resource_config_arn}

+ )} + {resource_access_manager_share_arn && ( +

Resource share ARN: {resource_access_manager_share_arn}

+ )} +
+ )} +
+
+ )}
{getStatusBadge()} @@ -79,12 +109,12 @@ export const AWSPrivateLinkAccountItem = ({ - View account + View connection - Delete account + Delete connection diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx index a41ec80e7b353..52b0ba6ac351f 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx @@ -25,6 +25,7 @@ import { SheetTitle, } from 'ui' import { Admonition } from 'ui-patterns/Admonition' +import { Input as CopyableInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InlineLink } from '@/components/ui/InlineLink' @@ -76,24 +77,24 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi : account?.status === 'READY' ? 'Connection is ready to accept' : account?.status === 'CREATING' - ? 'This account connection is being created' + ? 'This connection is being created' : account?.status === 'DELETING' - ? 'This account is being deleted' + ? 'This connection is being deleted' : account?.status === 'ASSOCIATION_REQUEST_EXPIRED' - ? 'Account acceptance request has expired' + ? 'This request has expired' : account?.status === 'CREATION_FAILED' - ? 'Failed to create account' - : 'This account needs to be accepted by the AWS account owner.' + ? "Couldn't create this connection" + : 'This connection 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.' + ? 'The AWS account owner has accepted the resource share.' : account?.status === 'READY' - ? 'It may be waiting acceptance from the AWS account owner. Association requests are automatically deleted if not accepted within 12 hours.' + ? 'It may be waiting acceptance from the AWS account owner. Requests expire after 12 hours.' : account?.status === 'ASSOCIATION_REQUEST_EXPIRED' - ? 'Reconnect this account to initiate a new connection request' + ? 'Add a new connection to try again.' : account?.status === 'CREATION_FAILED' - ? 'Reconnect this account to initiate a new connection request' + ? 'Add a new connection to try again.' : '' const onSubmit = (values: FormValues) => { @@ -112,7 +113,7 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi { onSuccess: () => { form.reset(defaultValues) - toast.success('Successfully added AWS account') + toast.success('Connection added') onOpenChange(false) }, } @@ -133,15 +134,17 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi - {isNew ? 'Add AWS Account' : 'AWS Account Details'} + {isNew ? 'Add connection' : 'Connection details'} - Connect to your Supabase project from your AWS VPC using AWS PrivateLink.{' '} + {isNew + ? 'Enter an AWS account to connect. You’ll need to accept the share in AWS. ' + : 'These values identify the resource share in AWS. '} Learn more
- - + + {!isNew && account && ( <> - How to accept? + How to accept ) @@ -200,6 +203,28 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi /> )} + ( + + + { + if (!isNew) { + e.target.blur() + } + }} + /> + + + )} + /> {(showPrivateLinkReadReplica || !isNew) && ( ( { - if (!isNew) { - e.target.blur() - } - }} - /> - - - )} - /> + {!isNew && account?.resource_access_manager_resource_config_id && ( + + + + )} + {!isNew && account?.resource_access_manager_resource_config_arn && ( + + + + )} + {!isNew && account?.resource_access_manager_share_arn && ( + + + + )} @@ -293,7 +328,7 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi {isNew && ( )} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx index e8d1595f2397c..ecc6759da4229 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx @@ -34,7 +34,7 @@ export const AWSPrivateLinkSection = () => { const { mutate: deleteAccount, isPending: isDeleting } = useAWSAccountDeleteMutation({ onSuccess: () => { - toast.success('Account will be deleted shortly') + toast.success('Connection will be deleted shortly') setShowDeleteModal(false) setSelectedAccount(undefined) }, @@ -80,9 +80,7 @@ export const AWSPrivateLinkSection = () => { AWS PrivateLink - Connect to your Supabase project from your AWS VPC using AWS PrivateLink. Create a - private connection between your AWS VPC and your Supabase project without traffic - traversing the public internet. + Connect to this project from your AWS VPC without using the public internet. @@ -92,17 +90,17 @@ export const AWSPrivateLinkSection = () => { {promptPlanUpgrade && ( )}
-

AWS Accounts

+

Connections

{(accounts?.length ?? 0) > 0 ? ( @@ -119,7 +117,7 @@ export const AWSPrivateLinkSection = () => { ) : ( -

No accounts connected

+

No connections yet

)} @@ -133,15 +131,15 @@ export const AWSPrivateLinkSection = () => { setShowDeleteModal(false)} onConfirm={onConfirmDelete} >

- Are you sure you want to delete the AWS account connection for{' '} - {selectedAccount?.aws_account_id}? + This removes the PrivateLink connection for {selectedAccount?.aws_account_id}. + Applications using this private path will lose access.

Database:{' '} diff --git a/apps/studio/data/aws-accounts/aws-account-create-mutation.ts b/apps/studio/data/aws-accounts/aws-account-create-mutation.ts index 9bfe6bedf8c4b..060e6f9e56629 100644 --- a/apps/studio/data/aws-accounts/aws-account-create-mutation.ts +++ b/apps/studio/data/aws-accounts/aws-account-create-mutation.ts @@ -56,7 +56,7 @@ export const useAWSAccountCreateMutation = ({ }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to create AWS account: ${data.message}`) + toast.error(`Failed to create connection: ${data.message}`) } else { onError(data, variables, context) } diff --git a/apps/studio/data/aws-accounts/aws-account-delete-mutation.ts b/apps/studio/data/aws-accounts/aws-account-delete-mutation.ts index 9389d2eca9533..2fc22aa974a0b 100644 --- a/apps/studio/data/aws-accounts/aws-account-delete-mutation.ts +++ b/apps/studio/data/aws-accounts/aws-account-delete-mutation.ts @@ -63,7 +63,7 @@ export const useAWSAccountDeleteMutation = ({ }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to delete AWS account: ${data.message}`) + toast.error(`Failed to delete connection: ${data.message}`) } else { onError(data, variables, context) } diff --git a/apps/studio/data/aws-accounts/aws-accounts-query.ts b/apps/studio/data/aws-accounts/aws-accounts-query.ts index 19f2208ef0a34..ca378da61fdf9 100644 --- a/apps/studio/data/aws-accounts/aws-accounts-query.ts +++ b/apps/studio/data/aws-accounts/aws-accounts-query.ts @@ -11,11 +11,14 @@ type AWSAccountsVariables = { projectRef?: string } -// [Joshen] API types should be updated with these 2 parameters, so remove once verified +// [Joshen] API types should be updated with these parameters, so remove once verified export type AWSAccount = components['schemas']['GetPrivateLinkResponse']['private_link_associations'][number] & { database_type?: 'PRIMARY' | 'READ_REPLICA' database_identifier?: string + resource_access_manager_resource_config_id?: string + resource_access_manager_resource_config_arn?: string + resource_access_manager_share_arn?: string } async function getAWSAccounts({ projectRef }: AWSAccountsVariables, signal?: AbortSignal) { From c15a8b67397b758eed43dfe8c1659ff35cb0b7fd Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:29:59 +1000 Subject: [PATCH 3/5] chore(studio): lighten FormLayout descriptions and tighten GitHub copy (#49091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? UI nits ## What is the current behavior? `FormLayout` descriptions that are not in a react-hook-form field use `text-foreground-light`, which fights the component’s `text-foreground-lighter` variant. New-project GitHub helper copy is one long colon sentence. ## What is the new behavior? Those `FormLayout` descriptions use the shared description variant (`foreground-lighter`). New-project GitHub copy is two short sentences. | Figure | | --- | | CleanShot 2026-08-14 at 14 33
32@2x | | _Example call site of **before** the `FormLayout` fix._ | | CleanShot 2026-08-14 at 14 53
39@2x | | _**After** New-project copy shortening._ | ## To test - **Organization → New project.** GitHub (optional): “Ideal for agent-first workflows. Update your schema in code and push it to GitHub. Supabase deploys the changes.” - **Project Settings → Database → SSL configuration.** “Reject non-SSL connections to your database” should look more muted (`foreground-lighter`), not the brighter `foreground-light`. ## Summary by CodeRabbit * **Documentation** * Simplified the GitHub repository field description while preserving deployment guidance and the “Learn more” link. * **Style** * Lightened the color of descriptive text in form layouts for improved visual hierarchy. --- .../interfaces/ProjectCreation/ProjectCreationForm.tsx | 4 ++-- packages/ui-patterns/src/form/Layout/FormLayout.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index 99f9cb7658ede..6ef98e5749da5 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -676,8 +676,8 @@ export const ProjectCreationForm = ({ label="GitHub (optional)" description={ <> - Ideal for agent-first workflows: update your schema in code, push it - to GitHub, and Supabase deploys the changes automatically.{' '} + Ideal for agent-first workflows. Update your schema in code and push + it to GitHub. Supabase deploys the changes.{' '} {description} From 44292c09968a818c763240f2e0bd54758476a9d5 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:32:18 +1000 Subject: [PATCH 4/5] feat(studio): extract PrivateLink status copy (#49084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Refactor ## What is the current behavior? Status labels and sheet copy are inline switches in the list and form. ## What is the new behavior? One status lookup drives the badge and the view-sheet copy. No intended visual change. Ready is still green. | Before and After | | --- | | CleanShot 2026-08-14 at 12 37
13@2x | | _No visual changes_ | ## Additional context Stacked on #48967. See https://github.com/supabase/supabase/pull/49030 for the end state, as it may already include fixes you might propose. ## To test - **Project Settings → Integrations → AWS PrivateLink.** Look at a connection row badge, then **View** it. Labels should match today’s statuses. Ready should still be green. ## Summary by CodeRabbit * **New Features** * Added clear status messaging for AWS PrivateLink connections, including accepted, ready, creating, deleting, expired, and failed states. * Added fallback messaging for unavailable or unrecognized connection statuses. * **Bug Fixes** * Improved consistency of PrivateLink status badges, labels, descriptions, and visual styles. --- .../AWSPrivateLink.utils.test.ts | 69 +++++++++++++++++++ .../AWSPrivateLink/AWSPrivateLink.utils.ts | 63 +++++++++++++++++ .../AWSPrivateLinkAccountItem.tsx | 23 +------ 3 files changed, 135 insertions(+), 20 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts new file mode 100644 index 0000000000000..7cc18dd989c9f --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' + +import { getConnectionStatusUi, type PrivateLinkConnectionStatus } from './AWSPrivateLink.utils' + +describe('getConnectionStatusUi', () => { + it.each([ + [ + 'ASSOCIATION_ACCEPTED', + { + badge: 'Connected', + badgeVariant: 'success', + title: 'This connection is active', + }, + ], + [ + 'READY', + { + badge: 'Ready', + badgeVariant: 'success', + title: 'Waiting for the AWS account owner to accept', + description: 'This request expires after 12 hours.', + }, + ], + [ + 'CREATING', + { + badge: 'Creating', + badgeVariant: 'warning', + title: 'This connection is being created', + }, + ], + [ + 'DELETING', + { + badge: 'Deleting', + badgeVariant: 'destructive', + title: 'This connection is being deleted', + }, + ], + [ + 'ASSOCIATION_REQUEST_EXPIRED', + { + badge: 'Expired', + badgeVariant: 'destructive', + title: 'This request has expired', + }, + ], + [ + 'CREATION_FAILED', + { + badge: 'Failed', + badgeVariant: 'destructive', + title: "Couldn't create this connection", + }, + ], + ] as const satisfies ReadonlyArray< + [PrivateLinkConnectionStatus, Partial>] + >)('maps %s', (status, expected) => { + expect(getConnectionStatusUi(status)).toMatchObject(expected) + }) + + it('returns unknown copy when status is missing', () => { + const ui = getConnectionStatusUi() + + expect(ui.badge).toBe('Unknown') + expect(ui.badgeVariant).toBe('default') + expect(ui.title).toBe("Couldn't determine this connection's status") + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts new file mode 100644 index 0000000000000..07baa806d76d7 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts @@ -0,0 +1,63 @@ +import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query' + +export type PrivateLinkConnectionStatus = AWSAccount['status'] + +type BadgeVariant = 'success' | 'warning' | 'destructive' | 'default' + +export type ConnectionStatusUi = { + title: string + description: string + badge: string + badgeVariant: BadgeVariant +} + +const CONNECTION_STATUS_UI: Record = { + ASSOCIATION_ACCEPTED: { + title: 'This connection is active', + description: 'The AWS account owner has accepted the resource share.', + badge: 'Connected', + badgeVariant: 'success', + }, + READY: { + title: 'Waiting for the AWS account owner to accept', + description: 'This request expires after 12 hours.', + badge: 'Ready', + badgeVariant: 'success', + }, + CREATING: { + title: 'This connection is being created', + description: '', + badge: 'Creating', + badgeVariant: 'warning', + }, + DELETING: { + title: 'This connection is being deleted', + description: '', + badge: 'Deleting', + badgeVariant: 'destructive', + }, + ASSOCIATION_REQUEST_EXPIRED: { + title: 'This request has expired', + description: 'Add a new connection to try again.', + badge: 'Expired', + badgeVariant: 'destructive', + }, + CREATION_FAILED: { + title: "Couldn't create this connection", + description: 'Add a new connection to try again.', + badge: 'Failed', + badgeVariant: 'destructive', + }, +} + +const UNKNOWN_STATUS_UI: ConnectionStatusUi = { + title: "Couldn't determine this connection's status", + description: '', + badge: 'Unknown', + badgeVariant: 'default', +} + +export function getConnectionStatusUi(status?: PrivateLinkConnectionStatus): ConnectionStatusUi { + if (!status) return UNKNOWN_STATUS_UI + return CONNECTION_STATUS_UI[status] ?? UNKNOWN_STATUS_UI +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx index f383d3e71ebc2..225ad2ca66627 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx @@ -13,6 +13,7 @@ import { TooltipTrigger, } from 'ui' +import { getConnectionStatusUi } from './AWSPrivateLink.utils' import { formatDatabaseID } from '@/data/read-replicas/replicas.utils' interface AWSPrivateLinkAccountItemProps { @@ -51,25 +52,7 @@ export const AWSPrivateLinkAccountItem = ({ database_type === 'READ_REPLICA' ? `Read replica (ID: ${database_identifier ? formatDatabaseID(database_identifier) : 'Unknown identifier'})` : 'Primary database' - - const getStatusBadge = () => { - switch (status) { - case 'ASSOCIATION_ACCEPTED': - return Connected - case 'READY': - return Ready - case 'CREATING': - return Creating - case 'DELETING': - return Deleting - case 'ASSOCIATION_REQUEST_EXPIRED': - return Expired - case 'CREATION_FAILED': - return Failed - default: - return Unknown - } - } + const statusUi = getConnectionStatusUi(status) return ( @@ -100,7 +83,7 @@ export const AWSPrivateLinkAccountItem = ({ )}

- {getStatusBadge()} + {statusUi.badge} From 0c1da8fd098932af45146f5f1cc63a668d838985 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:46:41 +1000 Subject: [PATCH 5/5] feat(studio): tighten PrivateLink sheet fields (#49085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature ## What is the current behavior? Add connection field order and nickname handling are harder to scan. Empty description can still show up as a blank name. ## What is the new behavior? Add connection is AWS account ID, then database, then optional description. An empty description is omitted from the list title. | Before | After | | --- | --- | | CleanShot 2026-08-14 at 12 42
33@2x | CleanShot 2026-08-14 at 12 43
01@2x | ## Additional context Stacked on #49084. See #49030 for the end state, as it may already include fixes you might propose. ## To test - **Project Settings → Integrations → AWS PrivateLink → Add connection.** Confirm field order: account ID, database, description. - Save once with a description and once without. Without one, the row title should fall back to the account ID. ## Summary by CodeRabbit - **New Features** - Added AWS account ID and database target fields to the PrivateLink setup form. - Added validation and improved preservation of entered values while editing. - Made the connection description optional. - Updated connection status labels and badges for clearer status visibility. - **Documentation** - Updated PrivateLink setup instructions to reflect the revised field order and optional description. --- .../content/guides/platform/privatelink.mdx | 6 +- .../AWSPrivateLink/AWSPrivateLinkForm.tsx | 227 +++++++----------- 2 files changed, 94 insertions(+), 139 deletions(-) diff --git a/apps/docs/content/guides/platform/privatelink.mdx b/apps/docs/content/guides/platform/privatelink.mdx index 8d079f5d3bc25..cdaa3aaeb148c 100644 --- a/apps/docs/content/guides/platform/privatelink.mdx +++ b/apps/docs/content/guides/platform/privatelink.mdx @@ -44,9 +44,9 @@ Navigate to your project's Integrations section to set up PrivateLink: 2. Navigate to [**Settings** > **Integrations**](/dashboard/project/_/settings/integrations) 3. Find the **AWS PrivateLink** section 4. Click **Add connection** -5. Provide a description (recommended) -6. Select the database target: the primary database or a specific read replica -7. Enter your AWS account ID +5. Enter the destination AWS account ID +6. Select the database: the primary database or a specific read replica +7. Optionally add a description 8. Click **Add connection** to submit Each database, whether the primary or a read replica, needs its own connection. Create a separate connection for every database you want to reach over PrivateLink. diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx index 52b0ba6ac351f..53c293ecddc66 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx @@ -1,7 +1,7 @@ +import { zodResolver } from '@hookform/resolvers/zod' import { useFlag } from 'common' import { ExternalLink } from 'lucide-react' import Link from 'next/link' -import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { @@ -27,7 +27,9 @@ import { import { Admonition } from 'ui-patterns/Admonition' import { Input as CopyableInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { z } from 'zod' +import { getConnectionStatusUi } from './AWSPrivateLink.utils' 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' @@ -36,18 +38,25 @@ import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/rep import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' +const FORM_ID = 'privatelink-connection-form' + +const FormSchema = z.object({ + awsAccountId: z + .string() + .trim() + .regex(/^\d{12}$/, 'Enter a 12-digit AWS account ID'), + databaseIdentifier: z.string().min(1, 'Select a database'), + accountName: z.string(), +}) + +type FormValues = z.infer + interface AWSPrivateLinkFormProps { account?: AWSAccount open: boolean onOpenChange: (open: boolean) => void } -interface FormValues { - awsAccountId: string - accountName: string - databaseIdentifier: string -} - export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLinkFormProps) => { const isNew = !account const { data: project } = useSelectedProjectQuery() @@ -62,40 +71,24 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi const { mutate: createAccount, isPending } = useAWSAccountCreateMutation() const readReplicas = databases.filter((database) => database.identifier !== project?.ref) - - const defaultValues = { + const showDatabaseTarget = showPrivateLinkReadReplica || !isNew + const statusUi = getConnectionStatusUi(account?.status) + const formValues: FormValues = { awsAccountId: account?.aws_account_id ?? '', - accountName: account?.account_name ?? '', databaseIdentifier: account?.database_identifier ?? project?.ref ?? '', + accountName: account?.account_name ?? '', } - const form = useForm({ defaultValues }) - - const title = - account?.status === 'ASSOCIATION_ACCEPTED' - ? 'This connection is active' - : account?.status === 'READY' - ? 'Connection is ready to accept' - : account?.status === 'CREATING' - ? 'This connection is being created' - : account?.status === 'DELETING' - ? 'This connection is being deleted' - : account?.status === 'ASSOCIATION_REQUEST_EXPIRED' - ? 'This request has expired' - : account?.status === 'CREATION_FAILED' - ? "Couldn't create this connection" - : 'This connection needs to be accepted by the AWS account owner.' - - const description = - account?.status === 'ASSOCIATION_ACCEPTED' - ? 'The AWS account owner has accepted the resource share.' - : account?.status === 'READY' - ? 'It may be waiting acceptance from the AWS account owner. Requests expire after 12 hours.' - : account?.status === 'ASSOCIATION_REQUEST_EXPIRED' - ? 'Add a new connection to try again.' - : account?.status === 'CREATION_FAILED' - ? 'Add a new connection to try again.' - : '' + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + awsAccountId: '', + databaseIdentifier: '', + accountName: '', + }, + values: formValues, + resetOptions: { keepDirtyValues: true }, + }) const onSubmit = (values: FormValues) => { if (!project) return @@ -104,7 +97,7 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi { projectRef: project.ref, awsAccountId: values.awsAccountId, - accountName: values.accountName, + accountName: values.accountName.trim() || undefined, databaseIdentifier: values.databaseIdentifier && values.databaseIdentifier !== project.ref ? values.databaseIdentifier @@ -112,7 +105,7 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi }, { onSuccess: () => { - form.reset(defaultValues) + form.reset(formValues) toast.success('Connection added') onOpenChange(false) }, @@ -121,17 +114,13 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi } } - // Reset form when account changes - useEffect(() => { - form.reset({ - awsAccountId: account?.aws_account_id ?? '', - accountName: account?.account_name ?? '', - databaseIdentifier: account?.database_identifier ?? project?.ref ?? '', - }) - }, [account, form, project?.ref]) + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) form.reset(formValues) + onOpenChange(nextOpen) + } return ( - + {isNew ? 'Add connection' : 'Connection details'} @@ -143,96 +132,72 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi - + {!isNew && account && ( - <> - - {title} - + {statusUi.title} + + {statusUi.badge} + + + } + description={statusUi.description} + actions={ + account.status === 'READY' && ( + - ) - } - /> - + How to accept + + + ) + } + /> )} ( { - if (!isNew) { - e.target.blur() - } + if (!isNew) e.target.blur() }} /> )} /> - {(showPrivateLinkReadReplica || !isNew) && ( + {showDatabaseTarget && ( ( { - if (!isNew) { - e.target.blur() - } + if (!isNew) e.target.blur() }} /> @@ -289,10 +248,7 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi )} /> {!isNew && account?.resource_access_manager_resource_config_id && ( - + )} {!isNew && account?.resource_access_manager_resource_config_arn && ( - + )} {!isNew && account?.resource_access_manager_share_arn && ( - + )} - {isNew && ( - )}