From d61477085e8f3fe76913ecdfb320c479b1ed6dae Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 17 Aug 2026 13:36:31 +0800 Subject: [PATCH 01/10] Joshenlim/fe 4175 add row limit in explorerquerysourcemenu (#49098) ## Context Related to Explorer/Notebook - builds on the ExplorerQuerySourceMenu by adding an option for row limit in both Notebook Query cell + Query Tab ## Side note RE persistence of the selected row limit value Note that for QueryTab - its intentional that for now that the row limit isn't scoped to the query draft atm as I wanna avoid making changes to `explorer-query` atm as there's a couple of PRs in flux that touches that file. So will handle that separately ^ This means that switching between query tabs will not change nor persist the row limit image image ## To test - [ ] Verify that row limit behaviour works in notebooks - [ ] Verify that row limit behaviour works in explorer query tab --- .../Explorer/ExplorerQuerySourceMenu.test.tsx | 11 +++++++- .../Explorer/ExplorerQuerySourceMenu.tsx | 25 ++++++++++++++----- .../QueryCell/QueryCell.utils.test.ts | 11 ++++++++ .../Explorer/QueryCell/QueryCell.utils.ts | 16 ++++++++++++ .../interfaces/Explorer/QueryCell/index.tsx | 5 ++++ .../interfaces/Explorer/QueryEditor.tsx | 6 ++++- .../interfaces/Explorer/QueryTab.tsx | 8 +++--- .../QuerySourceMenu/QuerySourceMenu.tsx | 5 +++- .../QuerySourceMenu/RowLimitSubMenu.tsx | 16 ++++++------ 9 files changed, 83 insertions(+), 20 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx index 0f6ed7b2be2b9..6717b27d57ff8 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx @@ -44,7 +44,12 @@ describe('ExplorerQuerySourceMenu', () => { ) => customRender( - + ) @@ -53,6 +58,8 @@ describe('ExplorerQuerySourceMenu', () => { customRender( { customRender( void source: QuerySourceBinding onSourceChange: (source: QuerySourceBinding) => void } @@ -37,6 +40,8 @@ export type ExplorerQuerySourceMenuProps = { * has SQL to preserve or discard and a fresh draft does not. */ export const ExplorerQuerySourceMenu = ({ + rowLimit = 100, + onRowLimitChange, source, onSourceChange, }: ExplorerQuerySourceMenuProps) => { @@ -97,12 +102,20 @@ export const ExplorerQuerySourceMenu = ({ {source._tag === 'database' ? ( - - onSourceChange({ _tag: 'database', database_identifier }) - } - /> + <> + + onSourceChange({ _tag: 'database', database_identifier }) + } + /> + {onRowLimitChange !== undefined && ( + onRowLimitChange(Number(val))} + /> + )} + ) : ( { }) }) +describe('setCellRowLimit', () => { + it('writes the row limit onto a database cell without touching its query', () => { + expect(setCellRowLimit(DATABASE_CELL, 500)).toEqual({ ...DATABASE_CELL, row_limit: 500 }) + }) + + it('leaves a log cell unchanged, since it has no row limit concept', () => { + expect(setCellRowLimit(LOG_CELL, 500)).toEqual(LOG_CELL) + }) +}) + describe('cloneQueryCell', () => { it('copies the chart series array rather than aliasing it', () => { const clone = cloneQueryCell(DATABASE_CELL) diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts index 0e5386d9831d1..ca6cb3edb57c4 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts +++ b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts @@ -117,6 +117,22 @@ export function setCellSql(cell: Snapshot, sql: string): QueryCell { } } +/** + * Writes a new row limit onto a database cell. A log cell has no row limit concept, so it + * passes through unchanged. + */ +export function setCellRowLimit(cell: Snapshot, rowLimit: number): QueryCell { + if (cell._tag === 'log_cell') return cloneQueryCell(cell) + + return { + ...copyQueryCellBase(cell), + _tag: 'database_cell', + unchecked_sql: cell.unchecked_sql, + row_limit: rowLimit, + database_identifier: cell.database_identifier, + } +} + /** * Builds the editor's query model from a cell and the editor's live text buffer. Branding * the buffer is the editor boundary the safe-SQL model expects; which brand applies is diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index 3807fca17c8a9..a58d844543370 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -10,6 +10,7 @@ import { cloneChartConfig, cloneQueryCell, getCellDisplay, + setCellRowLimit, setCellSql, toQueryModel, } from './QueryCell.utils' @@ -78,6 +79,9 @@ export const QueryCell = ({ cell }: QueryCellProps) => { chart: cloneChartConfig(display.chart), })) + const handleRowLimitChange = (rowLimit: number) => + updateQueryCell((candidate) => setCellRowLimit(candidate, rowLimit)) + return ( { onSqlCommit={handleSqlCommit} onSourceChange={handleSourceChange} onResultChange={setResult} + onRowLimitChange={handleRowLimitChange} onDisplayChange={handleDisplayChange} /> diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx index 06e391b58aeb8..a9116d848e5ca 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -76,6 +76,7 @@ export type QueryEditorProps = { onSqlCommit?: (sql: string) => void onSourceChange?: (source: QuerySourceBinding) => void onResultChange: (result: QueryResult) => void + onRowLimitChange?: (val: number) => void onDisplayChange?: (display: QueryDisplay) => void } @@ -97,6 +98,7 @@ export const QueryEditor = ({ onSqlCommit, onSourceChange, onResultChange, + onRowLimitChange, onDisplayChange, }: QueryEditorProps) => { const sql = query.uncheckedSql @@ -205,6 +207,8 @@ export const QueryEditor = ({ )} {display && onDisplayChange && ( @@ -267,7 +271,7 @@ export const QueryEditor = ({ {rowLimit && ( <>

·

-

Limit {rowLimit} rows

+

{rowLimit < 0 ? 'No row limit' : `Limit ${rowLimit} rows`}

)} diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.tsx index 83f32f4aa5efa..365dad0600d53 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryTab.tsx @@ -10,15 +10,16 @@ import { toQuerySourceBinding } from '@/data/query-sources/query-source-registry import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query' import { createTabId, TabsStateContext } from '@/state/tabs' -const QUERY_ROW_LIMIT = 100 - /** Query-tab lifecycle adapter around the shared QueryEditor. */ export const QueryTab = () => { const { id, ref } = useParams() const router = useRouter() const tabs = useContext(TabsStateContext) const querySnap = useExplorerQueryStateSnapshot() + + const [rowLimit, setRowLimit] = useState(100) const [restoredQueryKey, setRestoredQueryKey] = useState() + const stateDraft = id ? querySnap.drafts[id] : undefined const draft = stateDraft?.projectRef === ref ? stateDraft : undefined const result = draft && id ? querySnap.results[id] : undefined @@ -81,7 +82,7 @@ export const QueryTab = () => { : { ...toQuerySourceBinding(draft), uncheckedSql: draft.uncheckedSql, - rowLimit: QUERY_ROW_LIMIT, + rowLimit, } return ( @@ -99,6 +100,7 @@ export const QueryTab = () => { onSqlChange={(sql) => explorerQueryState.updateDraft({ id, sql })} onSourceChange={(source) => explorerQueryState.updateDraft({ id, source })} onResultChange={handleResultChange} + onRowLimitChange={setRowLimit} /> ) } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx index 2bb4f249667b4..2725c004c3ae6 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx @@ -149,7 +149,10 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo /> )} - + sessionSnap.setLimit(Number(val))} + /> )} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx index 9015dbada1357..01d7b88b19975 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx @@ -7,11 +7,14 @@ import { } from 'ui' import { ROWS_PER_PAGE_OPTIONS } from '../../SQLEditor.constants' -import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' -export const RowLimitSubMenu = () => { - const sessionSnap = useSqlEditorSessionSnapshot() - const currentLabel = ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === sessionSnap.limit)?.label +interface RowLimitSubMenuProps { + value: number + onValueChange: (value: string) => void +} + +export const RowLimitSubMenu = ({ value, onValueChange }: RowLimitSubMenuProps) => { + const currentLabel = ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === value)?.label return ( @@ -22,10 +25,7 @@ export const RowLimitSubMenu = () => { - sessionSnap.setLimit(Number(val))} - > + {ROWS_PER_PAGE_OPTIONS.map((option) => ( {option.label} From 4433d9ddaffd705808997638dfc705a1d23298c6 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:19:10 +1000 Subject: [PATCH 02/10] feat(studio): mark PrivateLink waiting as a warning (#49086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? UI ## What is the current behavior? Waiting (still labelled Ready in #49085) is green. Creating is orange. Deleting is red. ## What is the new behavior? Waiting is orange. Creating is grey. Deleting is orange. Connected stays the only green state. | Before | After | | --- | --- | | CleanShot 2026-08-14 at 12 43
59@2x | CleanShot 2026-08-14 at 12 44
59@2x | | CleanShot 2026-08-14 at 12 44
21@2x | CleanShot 2026-08-14 at 12 44
49@2x | ## Additional context Stacked on #49085. See #49030 for the end state, as it may already include fixes you might propose. ## To test - **Project Settings → Integrations → AWS PrivateLink.** A connection that AWS has not accepted yet should show an orange **Waiting** badge, not green Ready. - Creating should be grey. Deleting orange. Expired and Failed stay red. - **Docs preview → Platform → PrivateLink.** Should say Waiting, not Ready. ## Summary by CodeRabbit * **Bug Fixes** * Updated AWS PrivateLink connection statuses to accurately show “Waiting” while the AWS Resource Share is pending acceptance. * Refined status badge styling for creating, waiting, and deleting connections. * Clarified that Resource Shares must be accepted within 12 hours. * **Documentation** * Updated PrivateLink setup instructions to reflect the revised connection status flow. --- apps/docs/content/guides/platform/privatelink.mdx | 4 ++-- .../AWSPrivateLink/AWSPrivateLink.utils.test.ts | 8 ++++---- .../Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/docs/content/guides/platform/privatelink.mdx b/apps/docs/content/guides/platform/privatelink.mdx index cdaa3aaeb148c..e1ac45325ed3f 100644 --- a/apps/docs/content/guides/platform/privatelink.mdx +++ b/apps/docs/content/guides/platform/privatelink.mdx @@ -51,9 +51,9 @@ Navigate to your project's Integrations section to set up PrivateLink: 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. +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 "Waiting" status, indicating that the resource share has been sent to your AWS account and still needs 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. +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 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 index 7cc18dd989c9f..34671bf9f596c 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts @@ -15,8 +15,8 @@ describe('getConnectionStatusUi', () => { [ 'READY', { - badge: 'Ready', - badgeVariant: 'success', + badge: 'Waiting', + badgeVariant: 'warning', title: 'Waiting for the AWS account owner to accept', description: 'This request expires after 12 hours.', }, @@ -25,7 +25,7 @@ describe('getConnectionStatusUi', () => { 'CREATING', { badge: 'Creating', - badgeVariant: 'warning', + badgeVariant: 'default', title: 'This connection is being created', }, ], @@ -33,7 +33,7 @@ describe('getConnectionStatusUi', () => { 'DELETING', { badge: 'Deleting', - badgeVariant: 'destructive', + badgeVariant: 'warning', title: 'This connection is being deleted', }, ], diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts index 07baa806d76d7..b850b4bb09b58 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts @@ -21,20 +21,20 @@ const CONNECTION_STATUS_UI: Record Date: Mon, 17 Aug 2026 14:22:50 +0800 Subject: [PATCH 03/10] joshenlim/fe 4176 add role impersonation to explorerquerysourcemenu (#49101) ## Context Stacked off from https://github.com/supabase/supabase/pull/49098 - adds role impersonation for both Notebook Query cell + Explorer Query tab Note that this refactors the role impersonation state a little to decouple some stuffs to make this work, since the role impersonation state is global and we need a local state to support this UX Similarly to row limit, for query tab its intentional that for now that the role impersonation isn't scoped to the query draft atm as I wanna avoid making changes to explorer-query given there was a couple of PRs in flux that adjusts that file - will handle that separately image image ## To test - [ ] Verify that role impersonation works in notebook query cell - [ ] Verify that role impersonation works in notebook query tab ## Summary by CodeRabbit * **New Features** * Added role impersonation support to SQL Explorer queries. * Users can select an impersonated role directly from database query menus. * Query execution now applies the selected role when configured. * Added local role selection state for individual query tabs and cells. * Improved reuse and consistency of role impersonation controls across the interface. * Role selections and impersonation details remain synchronized across supported query components. --- .../Explorer/ExplorerQuerySourceMenu.tsx | 7 + .../interfaces/Explorer/QueryCell/index.tsx | 3 + .../interfaces/Explorer/QueryEditor.tsx | 11 +- .../interfaces/Explorer/QueryTab.tsx | 3 + .../UserImpersonationSelector.tsx | 9 +- .../RoleImpersonationSelector/index.tsx | 50 ++++-- .../QuerySourceMenu/RunAsSubMenu.tsx | 26 ++- .../studio/state/role-impersonation-state.tsx | 160 +++++++++++++----- 8 files changed, 196 insertions(+), 73 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx index 85d1094c37df2..fb46469c4a682 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx @@ -10,6 +10,7 @@ import { } from 'ui' import { RowLimitSubMenu } from '../SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu' +import { RunAsSubMenu } from '../SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu' import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/DatabaseParametersSubMenu' import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog' import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu' @@ -22,10 +23,12 @@ import { QUERY_SOURCES, type QuerySourceBinding, } from '@/data/query-sources/query-source-registry' +import { type RoleImpersonationController } from '@/state/role-impersonation-state' export type ExplorerQuerySourceMenuProps = { rowLimit?: number onRowLimitChange?: (val: number) => void + roleImpersonationState?: RoleImpersonationController source: QuerySourceBinding onSourceChange: (source: QuerySourceBinding) => void } @@ -42,6 +45,7 @@ export type ExplorerQuerySourceMenuProps = { export const ExplorerQuerySourceMenu = ({ rowLimit = 100, onRowLimitChange, + roleImpersonationState, source, onSourceChange, }: ExplorerQuerySourceMenuProps) => { @@ -109,6 +113,9 @@ export const ExplorerQuerySourceMenu = ({ onSourceChange({ _tag: 'database', database_identifier }) } /> + {roleImpersonationState !== undefined && ( + + )} {onRowLimitChange !== undefined && ( @@ -33,6 +34,7 @@ export const QueryCell = ({ cell }: QueryCellProps) => { const [sql, setSql] = useState(cell.unchecked_sql) const [result, setResult] = useState() + const roleImpersonationState = useLocalRoleImpersonationState() const title = cell.title ?? 'Untitled query' @@ -95,6 +97,7 @@ export const QueryCell = ({ cell }: QueryCellProps) => { title={title} query={toQueryModel(cell, sql)} result={result} + roleImpersonationState={roleImpersonationState} display={getCellDisplay(cell)} onTitleChange={handleTitleChange} onSqlChange={setSql} diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx index a9116d848e5ca..1259b8714ebcd 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -45,6 +45,11 @@ import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { applyAutoLimit } from '@/data/sql/utils' import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { wrapWithRoleImpersonation } from '@/lib/role-impersonation' +import { + isRoleImpersonationEnabled, + type RoleImpersonationController, +} from '@/state/role-impersonation-state' /** * The query this editor is showing, tagged by backend. The tag correlates the SQL's @@ -69,6 +74,7 @@ export type QueryEditorProps = { title: string query: ExplorerQueryModel result?: QueryResult + roleImpersonationState?: RoleImpersonationController display?: QueryDisplay toolbarActions?: ReactNode onTitleChange: (title: string) => void @@ -91,6 +97,7 @@ export const QueryEditor = ({ title, query, result, + roleImpersonationState, display, toolbarActions, onTitleChange, @@ -185,10 +192,11 @@ export const QueryEditor = ({ executeSql({ projectRef: project.ref, connectionString, - sql: limitedSql.sql, + sql: wrapWithRoleImpersonation(limitedSql.sql, roleImpersonationState), autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined, contextualInvalidation: true, isStatementTimeoutDisabled: true, + isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role), }) } @@ -209,6 +217,7 @@ export const QueryEditor = ({ onSourceChange={onSourceChange} rowLimit={rowLimit} onRowLimitChange={onRowLimitChange} + roleImpersonationState={roleImpersonationState} /> )} {display && onDisplayChange && ( diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.tsx index 365dad0600d53..ee94abf4ea894 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryTab.tsx @@ -8,6 +8,7 @@ import { QueryEditor, type ExplorerQueryModel } from './QueryEditor' import { type QueryResult } from './types' import { toQuerySourceBinding } from '@/data/query-sources/query-source-registry' import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query' +import { useLocalRoleImpersonationState } from '@/state/role-impersonation-state' import { createTabId, TabsStateContext } from '@/state/tabs' /** Query-tab lifecycle adapter around the shared QueryEditor. */ @@ -16,6 +17,7 @@ export const QueryTab = () => { const router = useRouter() const tabs = useContext(TabsStateContext) const querySnap = useExplorerQueryStateSnapshot() + const roleImpersonationState = useLocalRoleImpersonationState() const [rowLimit, setRowLimit] = useState(100) const [restoredQueryKey, setRestoredQueryKey] = useState() @@ -92,6 +94,7 @@ export const QueryTab = () => { title={draft.name} query={query} result={result} + roleImpersonationState={roleImpersonationState} onTitleChange={(value) => { const name = value.trim() || 'Untitled query' explorerQueryState.updateDraft({ id, name }) diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx index 01ce9af2de293..f649e93c9aea6 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx @@ -34,12 +34,12 @@ import { useCustomAccessTokenHookDetails } from '@/hooks/misc/useCustomAccessTok import { useLocalStorage } from '@/hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' -import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' +import { type RoleImpersonationController } from '@/state/role-impersonation-state' import type { ResponseError } from '@/types' type AuthenticatorAssuranceLevels = 'aal1' | 'aal2' -export const UserImpersonationSelector = () => { +export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationController }) => { const [searchText, setSearchText] = useState('') const [aal, setAal] = useState('aal1') const [externalUserId, setExternalUserId] = useState('') @@ -53,7 +53,6 @@ export const UserImpersonationSelector = () => { [] ) - const state = useRoleImpersonationStateSnapshot() const debouncedSearchText = useDebounce(searchText, 300) const { data: project } = useSelectedProjectQuery() @@ -179,7 +178,7 @@ export const UserImpersonationSelector = () => { impersonatingUser.email ?? impersonatingUser.phone ?? impersonatingUser.id ?? 'Unknown' ) : isExternalAuthImpersonating - ? state.role.externalAuth.sub + ? isExternalAuthImpersonating.sub : undefined // Clear all search history @@ -210,7 +209,7 @@ export const UserImpersonationSelector = () => { )} {isExternalAuthImpersonating && ( { + // valtio's Snapshot<> type is deep-readonly (incl. nested arrays), which isn't + // structurally assignable to RoleImpersonationController's plain array fields — same + // rationale as the cast in useGetImpersonatedRoleState. + const state = useRoleImpersonationStateSnapshot() as unknown as RoleImpersonationController + + return +} + +type RoleImpersonationSelectorInterfaceProps = RoleImpersonationSelectorProps & { + state: RoleImpersonationController +} + +export const RoleImpersonationSelectorInterface = ({ + state, + orientation, serviceRoleLabel = 'Postgres', disallowAuthenticatedOption = false, - orientation = 'horizontal', -}: RoleImpersonationSelectorProps) => { + header = 'Impersonate a database role', +}: RoleImpersonationSelectorInterfaceProps) => { const isVertical = orientation === 'vertical' - const state = useRoleImpersonationStateSnapshot() - - const [selectedOption, setSelectedOption] = useState(() => { - if ( - state.role?.type === 'postgrest' && - (state.role.role === 'anon' || state.role.role === 'authenticated') - ) { - return state.role.role - } - return 'service_role' - }) + const [selectedOption, setSelectedOption] = useState(() => + state.role?.type === 'postgrest' && + (state.role.role === 'anon' || state.role.role === 'authenticated') + ? state.role.role + : 'service_role' + ) const isAuthenticatedOptionFullySelected = Boolean( selectedOption === 'authenticated' && @@ -155,7 +171,7 @@ export const RoleImpersonationSelector = ({ {selectedOption === 'authenticated' && ( - + )} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx index a44a95d2d6010..1f2b714a315b9 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx @@ -1,12 +1,25 @@ import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from 'ui' -import { RoleImpersonationSelector } from '@/components/interfaces/RoleImpersonationSelector' -import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' +import { RoleImpersonationSelectorInterface } from '@/components/interfaces/RoleImpersonationSelector' +import { + useRoleImpersonationStateSnapshot, + type RoleImpersonationController, +} from '@/state/role-impersonation-state' const SERVICE_ROLE_LABEL = 'postgres' -export const RunAsSubMenu = () => { - const state = useRoleImpersonationStateSnapshot() +type RunAsSubMenuProps = + | { + controlled?: false + } + | { + controlled: true + state: RoleImpersonationController + } + +export const RunAsSubMenu = (props: RunAsSubMenuProps) => { + const globalState = useRoleImpersonationStateSnapshot() as unknown as RoleImpersonationController + const state = props.controlled ? props.state : globalState const currentRole = state.role?.role ?? SERVICE_ROLE_LABEL return ( @@ -20,10 +33,11 @@ export const RunAsSubMenu = () => { {/* Stops propagation so the authenticated-user search input isn't swallowed by the dropdown's typeahead. */} e.stopPropagation()}> - diff --git a/apps/studio/state/role-impersonation-state.tsx b/apps/studio/state/role-impersonation-state.tsx index a45590be82e3a..e1ba37494ffdb 100644 --- a/apps/studio/state/role-impersonation-state.tsx +++ b/apps/studio/state/role-impersonation-state.tsx @@ -1,6 +1,13 @@ import { ident, literal, safeSql } from '@supabase/pg-meta/src/pg-format' import { useConstant } from 'common' -import { createContext, PropsWithChildren, useCallback, useContext, useEffect } from 'react' +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useState, +} from 'react' import { proxy, snapshot, subscribe, useSnapshot } from 'valtio' import { CustomAccessTokenHookDetails } from '../hooks/misc/useCustomAccessTokenHookDetails' @@ -9,38 +16,77 @@ import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { getPostgrestClaims, ImpersonationRole } from '@/lib/role-impersonation' -export function createRoleImpersonationState( +type PostgrestClaims = ReturnType +type CustomizeAccessToken = (args: { + schema: string + functionName: string + claims: PostgrestClaims +}) => Promise + +/** Calls the project's customize-access-token hook, shared by the global context and any + * per-instance controller so the RPC-calling logic isn't duplicated between them. */ +function useCustomizeAccessToken( + projectRef: string | undefined, + connectionString: string | null | undefined +): CustomizeAccessToken { + return useCallback( + async ({ schema, functionName, claims }) => { + const event = { user_id: claims.sub, claims, authentication_method: 'password' } + + const result = await executeSql({ + projectRef, + connectionString, + sql: safeSql`select ${ident(schema)}.${ident(functionName)}(${literal(JSON.stringify(event))}::jsonb) as event;`, + queryKey: ['customize-access-token', projectRef], + }) + + return result?.result?.[0]?.event?.claims + }, + [projectRef, connectionString] + ) +} + +/** Computes the claims for a role selection, refining them through the customize-access-token + * hook when one is configured. Shared by `createRoleImpersonationState` (global context) and + * `useLocalRoleImpersonationState` (per-instance) so both resolve claims identically. */ +async function resolveRoleClaims( projectRef: string, - customizeAccessTokenRef: { - current: (args: { - schema: string - functionName: string - claims: ReturnType - }) => Promise + role: ImpersonationRole | undefined, + customAccessTokenHookDetails: CustomAccessTokenHookDetails | undefined, + customizeAccessToken: CustomizeAccessToken +): Promise { + let claims = role?.type === 'postgrest' ? getPostgrestClaims(projectRef, role) : undefined + + if (customAccessTokenHookDetails?.type === 'postgres' && claims !== undefined) { + const { schema, functionName } = customAccessTokenHookDetails + const updatedClaims = await customizeAccessToken({ schema, functionName, claims }) + // The hook is an arbitrary user-defined Postgres function — its output can't be + // statically typed, so we trust it wholesale here rather than partially. + if (updatedClaims) claims = updatedClaims as PostgrestClaims } + + return claims +} + +export function createRoleImpersonationState( + projectRef: string, + customizeAccessTokenRef: { current: CustomizeAccessToken } ) { const roleImpersonationState = proxy({ projectRef, role: undefined as ImpersonationRole | undefined, - claims: undefined as ReturnType | undefined, + claims: undefined as PostgrestClaims | undefined, setRole: async ( role: ImpersonationRole | undefined, customAccessTokenHookDetails?: CustomAccessTokenHookDetails ) => { - let claims = role?.type === 'postgrest' ? getPostgrestClaims(projectRef, role) : undefined - - if (customAccessTokenHookDetails?.type === 'postgres' && claims !== undefined) { - const { schema, functionName } = customAccessTokenHookDetails - const updatedClaims = await customizeAccessTokenRef.current({ - schema, - functionName, - claims, - }) - if (updatedClaims) { - claims = updatedClaims - } - } + const claims = await resolveRoleClaims( + projectRef, + role, + customAccessTokenHookDetails, + customizeAccessTokenRef.current + ) roleImpersonationState.role = role if (claims) { @@ -54,33 +100,24 @@ export function createRoleImpersonationState( export type RoleImpersonationState = ReturnType +/** + * The subset of `RoleImpersonationState` a role-picker UI needs: the current selection, its + * resolved claims, and the setter. Satisfied by both the shared project-wide context (via + * `useRoleImpersonationStateSnapshot`) and `useLocalRoleImpersonationState`, so role-picking + * components can work against either without knowing which one they got. + */ +export type RoleImpersonationController = Pick< + RoleImpersonationState, + 'role' | 'claims' | 'setRole' +> + export const RoleImpersonationStateContext = createContext( - createRoleImpersonationState('', { current: async () => {} }) + createRoleImpersonationState('', { current: async () => undefined }) ) export const RoleImpersonationStateContextProvider = ({ children }: PropsWithChildren) => { const { data: project } = useSelectedProjectQuery() - async function customizeAccessToken({ - schema, - functionName, - claims, - }: { - schema: string - functionName: string - claims: ReturnType - }) { - const event = { user_id: claims.sub, claims, authentication_method: 'password' } - - const result = await executeSql({ - projectRef: project?.ref, - connectionString: project?.connectionString, - sql: safeSql`select ${ident(schema)}.${ident(functionName)}(${literal(JSON.stringify(event))}::jsonb) as event;`, - queryKey: ['customize-access-token', project?.ref], - }) - - return result?.result?.[0]?.event?.claims - } - + const customizeAccessToken = useCustomizeAccessToken(project?.ref, project?.connectionString) const customizeAccessTokenRef = useLatest(customizeAccessToken) const state = useConstant(() => @@ -100,6 +137,41 @@ export function useRoleImpersonationStateSnapshot(options?: Parameters(undefined) + const [claims, setClaims] = useState(undefined) + + const setRole = useCallback( + async ( + nextRole: ImpersonationRole | undefined, + customAccessTokenHookDetails?: CustomAccessTokenHookDetails + ) => { + const nextClaims = await resolveRoleClaims( + projectRef, + nextRole, + customAccessTokenHookDetails, + customizeAccessTokenRef.current + ) + + setRoleValue(nextRole) + if (nextClaims) setClaims(nextClaims) + }, + [projectRef, customizeAccessTokenRef] + ) + + return { role, claims, setRole } +} + export function useGetImpersonatedRoleState() { const roleImpersonationState = useContext(RoleImpersonationStateContext) From fe347d88760337805d6a0092b58c7eaabc634d91 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:41:27 +1000 Subject: [PATCH 04/10] feat(studio): show PrivateLink accept guidance on the list (#49087) 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? Accept-in-AWS guidance lives only in the view sheet, as a per-status essay with a nested button/link. Delete is easy to miss and does not name the database. ## What is the new behavior? The list warns when any connection is Waiting or Expired. View connection uses that same admonition. Delete uses a confirm dialog that names the database, including from **View connection**. | Before | After | | --- | --- | | CleanShot 2026-08-14 at 12 46
23@2x | CleanShot 2026-08-14 at 12 47
17@2x | | CleanShot 2026-08-14 at 12 46
49@2x | CleanShot 2026-08-14 at 12 47
09@2x | ## Additional context Stacked on #49086. See #49030 for the end state, as it may already include fixes you might propose. ## To test - **Project Settings → Integrations → AWS PrivateLink → Add connection.** Leave it unaccepted in AWS. The list should show the 12-hour accept warning. - **View connection** on that row. Same warning, **View instructions**, not a status essay. - **⋯ → Delete** on a row, and **Delete** inside **View connection.** The dialog should name the database. ## Summary by CodeRabbit * **New Features** * Added clearer AWS PrivateLink connection alerts for pending and expired connections, including guidance and acceptance links when applicable. * Existing PrivateLink connections can now be deleted directly from the connection form. * Added confirmation dialogs with loading and completion states for deletion. * Added more specific descriptions for primary databases and read replicas. * **Bug Fixes** * Improved connection status messaging and handling for AWS PrivateLink integrations. --- .../AWSPrivateLink.utils.test.ts | 100 ++++++++---------- .../AWSPrivateLink/AWSPrivateLink.utils.ts | 68 +++++++++--- .../AWSPrivateLinkAttentionAdmonition.tsx | 42 ++++++++ .../AWSPrivateLink/AWSPrivateLinkForm.tsx | 83 ++++++--------- .../AWSPrivateLink/AWSPrivateLinkSection.tsx | 77 +++++++++----- 5 files changed, 223 insertions(+), 147 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAttentionAdmonition.tsx 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 index 34671bf9f596c..ab1d4ef9dd2e3 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts @@ -1,69 +1,57 @@ import { describe, expect, it } from 'vitest' -import { getConnectionStatusUi, type PrivateLinkConnectionStatus } from './AWSPrivateLink.utils' +import { + getConnectionsAttention, + getConnectionsAttentionCopy, + getConnectionStatusUi, + type PrivateLinkConnectionStatus, +} from './AWSPrivateLink.utils' describe('getConnectionStatusUi', () => { it.each([ - [ - 'ASSOCIATION_ACCEPTED', - { - badge: 'Connected', - badgeVariant: 'success', - title: 'This connection is active', - }, - ], - [ - 'READY', - { - badge: 'Waiting', - badgeVariant: 'warning', - title: 'Waiting for the AWS account owner to accept', - description: 'This request expires after 12 hours.', - }, - ], - [ - 'CREATING', - { - badge: 'Creating', - badgeVariant: 'default', - title: 'This connection is being created', - }, - ], - [ - 'DELETING', - { - badge: 'Deleting', - badgeVariant: 'warning', - 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", - }, - ], + ['ASSOCIATION_ACCEPTED', { badge: 'Connected', badgeVariant: 'success' }], + ['READY', { badge: 'Waiting', badgeVariant: 'warning' }], + ['CREATING', { badge: 'Creating', badgeVariant: 'default' }], + ['DELETING', { badge: 'Deleting', badgeVariant: 'warning' }], + ['ASSOCIATION_REQUEST_EXPIRED', { badge: 'Expired', badgeVariant: 'destructive' }], + ['CREATION_FAILED', { badge: 'Failed', badgeVariant: 'destructive' }], ] as const satisfies ReadonlyArray< - [PrivateLinkConnectionStatus, Partial>] + [PrivateLinkConnectionStatus, ReturnType] >)('maps %s', (status, expected) => { - expect(getConnectionStatusUi(status)).toMatchObject(expected) + expect(getConnectionStatusUi(status)).toEqual(expected) }) it('returns unknown copy when status is missing', () => { - const ui = getConnectionStatusUi() + expect(getConnectionStatusUi()).toEqual({ badge: 'Unknown', badgeVariant: 'default' }) + }) +}) + +describe('getConnectionsAttentionCopy', () => { + it('returns null when nothing needs attention', () => { + expect(getConnectionsAttentionCopy({ waitingCount: 0, expiredCount: 0 })).toBeNull() + }) + + it('warns when a connection is waiting', () => { + const copy = getConnectionsAttentionCopy({ waitingCount: 1, expiredCount: 0 }) + expect(copy?.type).toBe('warning') + expect(copy?.title).toBe('Waiting for the AWS account owner') + expect(copy?.showAcceptLink).toBe(true) + }) + + it('uses destructive copy when only expired', () => { + const copy = getConnectionsAttentionCopy({ waitingCount: 0, expiredCount: 2 }) + expect(copy?.type).toBe('destructive') + expect(copy?.title).toBe('Connection requests expired') + expect(copy?.showAcceptLink).toBe(false) + }) - expect(ui.badge).toBe('Unknown') - expect(ui.badgeVariant).toBe('default') - expect(ui.title).toBe("Couldn't determine this connection's status") + it('counts statuses from a list', () => { + expect( + getConnectionsAttention([ + { status: 'READY' }, + { status: 'ASSOCIATION_ACCEPTED' }, + { status: 'ASSOCIATION_REQUEST_EXPIRED' }, + ]) + ).toEqual({ waitingCount: 1, expiredCount: 1 }) }) }) diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts index b850b4bb09b58..abb1c170f9e8b 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts @@ -5,54 +5,38 @@ 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: 'Waiting', badgeVariant: 'warning', }, CREATING: { - title: 'This connection is being created', - description: '', badge: 'Creating', badgeVariant: 'default', }, DELETING: { - title: 'This connection is being deleted', - description: '', badge: 'Deleting', badgeVariant: 'warning', }, 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', } @@ -61,3 +45,55 @@ export function getConnectionStatusUi(status?: PrivateLinkConnectionStatus): Con if (!status) return UNKNOWN_STATUS_UI return CONNECTION_STATUS_UI[status] ?? UNKNOWN_STATUS_UI } + +export type ConnectionsAttention = { + waitingCount: number + expiredCount: number +} + +export function getConnectionsAttention( + accounts: Array> | undefined +): ConnectionsAttention { + const waitingCount = accounts?.filter((account) => account.status === 'READY').length ?? 0 + const expiredCount = + accounts?.filter((account) => account.status === 'ASSOCIATION_REQUEST_EXPIRED').length ?? 0 + + return { waitingCount, expiredCount } +} + +export function getConnectionsAttentionCopy(attention: ConnectionsAttention): { + type: 'warning' | 'destructive' + title: string + description: string + showAcceptLink: boolean +} | null { + const { waitingCount, expiredCount } = attention + if (waitingCount === 0 && expiredCount === 0) return null + + if (expiredCount > 0 && waitingCount === 0) { + return { + type: 'destructive', + title: expiredCount === 1 ? 'A connection request expired' : 'Connection requests expired', + description: 'Add a new connection to try again. AWS can no longer accept this share.', + showAcceptLink: false, + } + } + + if (waitingCount > 0 && expiredCount > 0) { + return { + type: 'warning', + title: 'Some connections need attention', + description: + 'Accept waiting resource shares in AWS within 12 hours. Expired requests need a new connection.', + showAcceptLink: true, + } + } + + return { + type: 'warning', + title: + waitingCount === 1 ? 'Waiting for the AWS account owner' : 'Waiting for AWS account owners', + description: 'Accept the resource share in AWS within 12 hours.', + showAcceptLink: true, + } +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAttentionAdmonition.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAttentionAdmonition.tsx new file mode 100644 index 0000000000000..70e00e5c023c5 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAttentionAdmonition.tsx @@ -0,0 +1,42 @@ +import { SquareArrowOutUpRight } from 'lucide-react' +import Link from 'next/link' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' + +import { getConnectionsAttention, getConnectionsAttentionCopy } from './AWSPrivateLink.utils' +import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query' +import { DOCS_URL } from '@/lib/constants' + +export function AWSPrivateLinkAttentionAdmonition({ + accounts, + className, +}: { + accounts: Array> | undefined + className?: string +}) { + const copy = getConnectionsAttentionCopy(getConnectionsAttention(accounts)) + if (!copy) return null + + return ( + } asChild> + + View instructions + + + ) + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx index 53c293ecddc66..97f1b0a28282d 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx @@ -1,11 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useFlag } from 'common' -import { ExternalLink } from 'lucide-react' -import Link from 'next/link' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { - Badge, Button, Form, FormControl, @@ -24,12 +21,11 @@ import { SheetSection, 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 { z } from 'zod' -import { getConnectionStatusUi } from './AWSPrivateLink.utils' +import { AWSPrivateLinkAttentionAdmonition } from './AWSPrivateLinkAttentionAdmonition' 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' @@ -55,9 +51,15 @@ interface AWSPrivateLinkFormProps { account?: AWSAccount open: boolean onOpenChange: (open: boolean) => void + onDelete?: () => void } -export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLinkFormProps) => { +export const AWSPrivateLinkForm = ({ + account, + open, + onOpenChange, + onDelete, +}: AWSPrivateLinkFormProps) => { const isNew = !account const { data: project } = useSelectedProjectQuery() const showPrivateLinkReadReplica = useFlag('privatelinkReadReplica') @@ -72,7 +74,6 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi const readReplicas = databases.filter((database) => database.identifier !== project?.ref) const showDatabaseTarget = showPrivateLinkReadReplica || !isNew - const statusUi = getConnectionStatusUi(account?.status) const formValues: FormValues = { awsAccountId: account?.aws_account_id ?? '', databaseIdentifier: account?.database_identifier ?? project?.ref ?? '', @@ -138,36 +139,7 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi className="flex flex-col flex-1 min-h-0" > - {!isNew && account && ( - - {statusUi.title} - - {statusUi.badge} - - - } - description={statusUi.description} - actions={ - account.status === 'READY' && ( - - ) - } - /> - )} + {!isNew && account && } - - - {isNew && ( - + + {!isNew ? ( + <> + + + + ) : ( + <> + + + )} diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx index ecc6759da4229..76bc92d718819 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx @@ -1,7 +1,19 @@ import { useState } from 'react' import { toast } from 'sonner' -import { Button, Card, CardContent, cn } from 'ui' -import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Card, + CardContent, + cn, +} from 'ui' import { PageSection, PageSectionContent, @@ -13,6 +25,7 @@ import { import { IntegrationSectionIcon } from '../IntegrationsSettings' import { AWSPrivateLinkAccountItem } from './AWSPrivateLinkAccountItem' +import { AWSPrivateLinkAttentionAdmonition } from './AWSPrivateLinkAttentionAdmonition' import { AWSPrivateLinkForm } from './AWSPrivateLinkForm' import { ResourceList } from '@/components/ui/Resource/ResourceList' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' @@ -36,6 +49,7 @@ export const AWSPrivateLinkSection = () => { onSuccess: () => { toast.success('Connection will be deleted shortly') setShowDeleteModal(false) + setShowForm(false) setSelectedAccount(undefined) }, }) @@ -71,6 +85,13 @@ export const AWSPrivateLinkSection = () => { } } + const deleteDatabaseCopy = + selectedAccount?.database_type === 'READ_REPLICA' + ? selectedAccount.database_identifier + ? `the read replica (ID: ${formatDatabaseID(selectedAccount.database_identifier)})` + : 'a read replica' + : 'the primary database' + return ( <> @@ -103,6 +124,7 @@ export const AWSPrivateLinkSection = () => { Add connection + {(accounts?.length ?? 0) > 0 ? ( {accounts?.map((account) => ( @@ -126,31 +148,36 @@ export const AWSPrivateLinkSection = () => { - + setShowDeleteModal(true)} + /> - setShowDeleteModal(false)} - onConfirm={onConfirmDelete} + { + if (!open && !isDeleting) setShowDeleteModal(false) + }} > -

- This removes the PrivateLink connection for {selectedAccount?.aws_account_id}. - Applications using this private path will lose access. -

-

- Database:{' '} - {selectedAccount && - ` ${ - selectedAccount.database_type === 'READ_REPLICA' - ? `Read replica (ID: ${selectedAccount.database_identifier ? formatDatabaseID(selectedAccount.database_identifier) : 'Unknown identifier'})` - : 'Primary database' - }`} -

-
+ + + Delete connection + + This removes the PrivateLink connection for{' '} + {selectedAccount?.aws_account_id} on{' '} + {deleteDatabaseCopy}. Applications using this private path will lose access. + + + + Cancel + + Delete + + + + ) } From 65a5e593ca4d7349e24f8c9344453c3f160570b6 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:11:52 +1000 Subject: [PATCH 05/10] feat(studio): tighten Integrations layout (#49088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? UI ## What is the current behavior? GitHub’s empty state and Integrations icon spacing do not match the PrivateLink list. Add connection has no plus. PrivateLink rows use a kebab instead of click-to-view. ## What is the new behavior? GitHub empty state matches the connections list. **Connect GitHub** is tiny and asks you to connect before choosing a repo. Section icons align. **Add connection** has a plus. PrivateLink rows are clickable; delete stays in the sheet. | Before | After | | --- | --- | | CleanShot 2026-08-14 at 12 48
48@2x | CleanShot 2026-08-14 at 14 22
19@2x | ## Additional context Stacked on #49087. No Vercel card work in this PR. See #49030 for the end state, as it may already include fixes you might propose. ## To test - **Project Settings → Integrations.** Check GitHub, Vercel, and PrivateLink icon alignment. - GitHub not connected: description should say **Connect GitHub to link a repository to this project.** Button should be tiny. - If GitHub has no repo (org page), the empty state should ask you to add a connection. - PrivateLink **Add connection** should show a plus. Click a connection row to view it. No kebab. ## Summary by CodeRabbit * **New Features** * AWS PrivateLink connections now display clearer titles, status, replica details, and database visibility. * GitHub organization integrations now provide improved empty states and clearer connection actions. * Added plus icons to connection buttons. * **Improvements** * Updated GitHub guidance based on authorization and repository selection status. * Standardized connection labels and refined integration page layouts. * Improved AWS integration icon presentation and responsive upgrade prompts. * **Bug Fixes** * Simplified default connection button wording across integrations. * Improved AWS account title fallback behavior when a nickname is unavailable. --- .../VercelGithub/IntegrationPanels.tsx | 2 +- .../AWSPrivateLink.utils.test.ts | 25 ++++ .../AWSPrivateLink/AWSPrivateLink.utils.ts | 8 ++ .../AWSPrivateLinkAccountItem.tsx | 121 ++++-------------- .../AWSPrivateLink/AWSPrivateLinkSection.tsx | 19 +-- .../GitHubIntegrationConnectionForm.tsx | 13 +- .../GitHubRepositoryField.tsx | 2 +- .../GithubIntegration/GithubSection.tsx | 92 +++++++++---- .../Integrations/IntegrationsSettings.tsx | 16 ++- apps/studio/components/ui/UpgradeToPro.tsx | 4 +- 10 files changed, 160 insertions(+), 142 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx index 63c447fd0ddf0..79adf60745693 100644 --- a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx @@ -291,7 +291,7 @@ export const EmptyIntegrationConnection = forwardRef< }, ref ) => { - const label = children ?? 'Add new project connection' + const label = children ?? 'Add connection' return (
{ }) }) +describe('getConnectionTitle', () => { + it('uses the customer nickname when present', () => { + expect( + getConnectionTitle({ + account_name: 'Production VPC', + aws_account_id: '123456789012', + }) + ).toBe('Production VPC') + }) + + it('falls back to the AWS account ID for an unnamed connection', () => { + expect(getConnectionTitle({ aws_account_id: '123456789012' })).toBe('123456789012') + }) + + it('falls back to the AWS account ID for a blank nickname', () => { + expect( + getConnectionTitle({ + account_name: ' ', + aws_account_id: '123456789012', + }) + ).toBe('123456789012') + }) +}) + describe('getConnectionsAttentionCopy', () => { it('returns null when nothing needs attention', () => { expect(getConnectionsAttentionCopy({ waitingCount: 0, expiredCount: 0 })).toBeNull() diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts index abb1c170f9e8b..8820e95f2877a 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts @@ -46,6 +46,14 @@ export function getConnectionStatusUi(status?: PrivateLinkConnectionStatus): Con return CONNECTION_STATUS_UI[status] ?? UNKNOWN_STATUS_UI } +export function getConnectionTitle( + account: Pick +): string { + const nickname = account.account_name?.trim() + if (nickname) return nickname + return account.aws_account_id +} + export type ConnectionsAttention = { waitingCount: number expiredCount: number diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx index 225ad2ca66627..0894c39c797b6 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx @@ -1,106 +1,37 @@ -import { Edit, MoreVertical, Trash } from 'lucide-react' -import { - Badge, - Button, - CardContent, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { Badge } from 'ui' -import { getConnectionStatusUi } from './AWSPrivateLink.utils' +import { getConnectionStatusUi, getConnectionTitle } from './AWSPrivateLink.utils' +import { ResourceItem } from '@/components/ui/Resource/ResourceItem' +import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query' import { formatDatabaseID } from '@/data/read-replicas/replicas.utils' -interface AWSPrivateLinkAccountItemProps { - aws_account_id: string - 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' - | 'ASSOCIATION_REQUEST_EXPIRED' - | 'ASSOCIATION_ACCEPTED' - | 'CREATION_FAILED' - | 'DELETING' - shared_at: string | null - onEdit: () => void - onDelete: () => void -} - export const AWSPrivateLinkAccountItem = ({ - aws_account_id, - 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, -}: AWSPrivateLinkAccountItemProps) => { - const databaseTarget = + account, + onView, +}: { + account: AWSAccount + onView: () => void +}) => { + const { account_name, aws_account_id, database_identifier, database_type, status } = account + const title = getConnectionTitle({ account_name, aws_account_id }) + const statusUi = getConnectionStatusUi(status) + const replicaId = database_identifier ? formatDatabaseID(database_identifier) : undefined + const showDatabase = database_type === 'READ_REPLICA' || title === aws_account_id + const databaseLabel = database_type === 'READ_REPLICA' - ? `Read replica (ID: ${database_identifier ? formatDatabaseID(database_identifier) : 'Unknown identifier'})` + ? `Read replica${replicaId ? ` (ID: ${replicaId})` : ''}` : 'Primary database' - const statusUi = getConnectionStatusUi(status) return ( - -
- {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}

- )} -
- )} -
-
- )} + {statusUi.badge}} + > +
+
{title}
+ {showDatabase &&

{databaseLabel}

}
- - {statusUi.badge} - - - -
@@ -130,9 +126,8 @@ export const AWSPrivateLinkSection = () => { {accounts?.map((account) => ( onEditAccount(account)} - onDelete={() => onDeleteAccount(account)} + account={account} + onView={() => onEditAccount(account)} /> ))} diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index d56634f0111eb..6de70c98b1529 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -388,6 +388,13 @@ export const GitHubIntegrationConnectionForm = ({ isDeletingConnection || isLoadingRepositoryOptions + let repositoryDescription = 'Select the repository to connect to your project' + if (connection) { + repositoryDescription = 'Change the connected repository' + } else if (gitHubAuthorization === null) { + repositoryDescription = 'Connect GitHub to link a repository to this project' + } + return ( <>
@@ -402,11 +409,7 @@ export const GitHubIntegrationConnectionForm = ({ name="repositoryId" label="GitHub repository" layout="flex-row-reverse" - description={ - connection - ? 'Change the connected repository' - : 'Select the repository to connect to your project' - } + description={repositoryDescription} disabled={ (!connection && !canCreateGitHubConnection) || (connection && !canUpdateGitHubConnection) diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx index c1149f96f0c31..4e2b8112d4460 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx @@ -139,7 +139,7 @@ export const GitHubRepositoryField = ({ + )} + + + + ) +} diff --git a/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx b/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx index 80405ab50b3c7..a4c68e74162b1 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx @@ -35,10 +35,18 @@ const INTEGRATION_ICONS: Record< ), aws: (className) => ( - - - - + + + + + + ), } diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index 4c8ddac9616df..3893386355320 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -1,6 +1,6 @@ import { ReactNode } from 'react' import { cn } from 'ui' -import { Admonition } from 'ui-patterns/Admonition' +import { Admonition, type AdmonitionLayout } from 'ui-patterns/Admonition' import { DocsButton } from './DocsButton' import { UpgradePlanButton } from './UpgradePlanButton' @@ -19,7 +19,7 @@ interface UpgradeToProProps { source?: string disabled?: boolean fullWidth?: boolean - layout?: 'vertical' | 'horizontal' + layout?: AdmonitionLayout variant?: 'default' | 'primary' className?: string docsUrl?: string From 14fe0c0cc88eed16e97aecf31907c96e54891482 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:28:22 +1000 Subject: [PATCH 06/10] fix(studio): slightly round split-button corners on focus (#49129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? UI polish for split buttons (primary action + dropdown chevron). Follow-up to #49055. ## What is the current behavior? The focus ring sits above the neighbouring half, but the inner edge stays square, so the ring has two sharp corners at the join. ## What is the new behavior? On keyboard focus, the squared-off edge uses a slight radius so the ring matches the outer corners more closely. Resting state is unchanged. Split-button callsites now share the same join classes as the design-system example. | Before | After | | --- | --- | | 43471 | CleanShot 2026-08-17 at 10 45
09@2x | ## To test Tab to each half (labelled button, then chevron). Inner corners of the focus ring should be slightly rounded, not square. 1. [Split with dropdown](https://design-system-git-fix-split-button-focus-radius-supabase.vercel.app/design-system/docs/components/button#split-with-dropdown) (no login) 2. [Access Tokens](https://studio-staging-git-fix-split-button-focus-radius-supabase.vercel.app/dashboard/account/tokens) → Generate new token 3. Any project on [studio staging](https://studio-staging-git-fix-split-button-focus-radius-supabase.vercel.app/dashboard/_/settings/general) → Settings → General → Restart project ## Summary by CodeRabbit - **Accessibility** - Added accessible labels to dropdown and export controls. - Improved keyboard-focus visibility, layering, and rounded edge treatment across joined buttons and menus. - Removed misleading or redundant screen-reader text and titles. - **Bug Fixes** - Prevented split-button controls from shrinking or displaying awkward borders and corners. - Refined hover and focus behavior for action buttons throughout settings, database, storage, account, and documentation interfaces. - **Documentation** - Clarified guidance for using overflow menus and responsive split-button actions. --- apps/design-system/content/docs/components/button.mdx | 9 +++++---- .../registry/default/example/admonition-button-split.tsx | 4 ++-- .../registry/default/example/button-split-dropdown.tsx | 8 ++++++-- .../AccessTokens/Classic/ExperimentalTokenDropdown.tsx | 2 +- .../Account/AccessTokens/Classic/NewTokenButton.tsx | 2 +- .../Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx | 5 ++++- .../CustomEmailTemplateRestrictionAdmonition.tsx | 4 ++-- .../ReplicationPipelineStatus.tsx | 5 +++-- .../interfaces/Database/Schemas/SchemaGraph.tsx | 9 ++++----- .../interfaces/LogDrains/OrgAuditLogDrains.tsx | 6 +++--- .../QueryInsights/hooks/useQueryInsightsTableColumns.tsx | 5 +++-- .../General/Infrastructure/RestartServerButton.tsx | 4 ++-- .../CreateTable/CreateTableInstructionsDialog.tsx | 6 ++++-- apps/studio/components/ui/AiAssistantDropdown.tsx | 4 ++-- apps/studio/pages/project/[ref]/settings/log-drains.tsx | 6 +++--- .../components/Changelog/ChangelogLlmMarkdownButton.tsx | 4 ++-- .../src/form/KeyValueFieldArray/KeyValueFieldArray.tsx | 8 ++++++-- 17 files changed, 53 insertions(+), 38 deletions(-) diff --git a/apps/design-system/content/docs/components/button.mdx b/apps/design-system/content/docs/components/button.mdx index b1369d8174adc..fc7c1575839ee 100644 --- a/apps/design-system/content/docs/components/button.mdx +++ b/apps/design-system/content/docs/components/button.mdx @@ -125,17 +125,18 @@ Supports slot behavior with `asChild` prop. Pair a button with a chevron `DropdownMenu` trigger when there are variations of the same action, or alternative ways to accomplish the same goal. The default or most likely option should be used on the exposed button. -When secondary actions are related but distinct—not alternatives to the primary action—display the primary action as a button and place the rest in an overflow menu instead. See [Table multiple actions](./table#multiple-actions). +When secondary actions are related but distinct (not alternatives to the primary action) display the primary action as a button and place the rest in an overflow menu instead. See [Table multiple actions](./table#multiple-actions). -The shared middle border is the tricky part. Do **not** use `border-l-0` on the chevron button — that drops the divider on hover/focus. Instead: +Ensure the middle border is shared rather than doubled-up. Do not use `border-l-0` on the chevron button as that drops the divider on hover/focus. Instead: -- Primary: `rounded-r-none` and `hover:z-10` so its border stacks above the chevron on hover. +- Primary action: `rounded-r-none` and `hover:z-10` so its border stacks above the chevron on hover. - Chevron trigger: `rounded-l-none`, `shrink-0`, `px-[4px] py-[5px]`, and `-ml-px` to overlap the adjacent border by one pixel. +- Both: `focus-visible:z-10` so the focus ring stacks above the neighbour, and `focus-visible:rounded-r-sm` / `focus-visible:rounded-l-sm` so the squared-off edge is slightly rounded while the ring is shown. - Chevron trigger only: `aria-label` describing the menu (the icon is decorative). -Inside [Admonition](../fragments/admonition#split-button-with-dropdown) actions, also use `flex w-full @lg:w-auto` with `flex-1 @lg:flex-none` on the primary when `layout="responsive"`. +Inside [Admonition](../fragments/admonition#split-button-with-dropdown) actions when `layout="responsive"`: also use `flex w-full @lg:w-auto` with `flex-1 @lg:flex-none` on the primary action. ## Accessibility diff --git a/apps/design-system/registry/default/example/admonition-button-split.tsx b/apps/design-system/registry/default/example/admonition-button-split.tsx index edd0ef32a74bf..0300ac430f794 100644 --- a/apps/design-system/registry/default/example/admonition-button-split.tsx +++ b/apps/design-system/registry/default/example/admonition-button-split.tsx @@ -20,7 +20,7 @@ export default function AdmonitionButtonSplitDemo() { @@ -30,7 +30,7 @@ export default function AdmonitionButtonSplitDemo() { type="button" variant="default" aria-label="More email template editing options" - className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px" + className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px focus-visible:z-10 focus-visible:rounded-l-sm" icon={} /> diff --git a/apps/design-system/registry/default/example/button-split-dropdown.tsx b/apps/design-system/registry/default/example/button-split-dropdown.tsx index 885e1bbebaa9f..54a1c621c7235 100644 --- a/apps/design-system/registry/default/example/button-split-dropdown.tsx +++ b/apps/design-system/registry/default/example/button-split-dropdown.tsx @@ -11,7 +11,11 @@ import { export default function ButtonSplitDropdownDemo() { return (
- @@ -20,7 +24,7 @@ export default function ButtonSplitDropdownDemo() { type="button" variant="default" aria-label="More actions" - className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px" + className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px focus-visible:z-10 focus-visible:rounded-l-sm" icon={} /> diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx index 04452562840ac..db886aad67d9c 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx @@ -35,7 +35,7 @@ export const ExperimentalTokenDropdown = ({ onCreateToken }: ExperimentalTokenDr diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx index 23c6ba82701ea..f6322debb672f 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx @@ -28,7 +28,7 @@ export const CustomEmailTemplateRestrictionAdmonition = () => { @@ -37,7 +37,7 @@ export const CustomEmailTemplateRestrictionAdmonition = () => { + /> Add destination @@ -166,8 +166,8 @@ export function OrgAuditLogDrains() { @@ -224,8 +224,8 @@ const LogDrainsSettings: NextPageWithLayout = () => { @@ -201,7 +205,7 @@ export const KeyValueFieldArray = < icon={} aria-label={addActionsLabel} disabled={disabled} - className="rounded-l-none px-[4px] py-[5px]" + className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px focus-visible:z-10 focus-visible:rounded-l-sm" /> From dfb0603a36069abde8e410963669e75dc0689aec Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 17 Aug 2026 16:11:07 +0800 Subject: [PATCH 07/10] Joshenlim/fe 4063 set up incremental default opt in for database connections (#49132) ## Context As per PR title - sets up incremental default opt in for the Database Connections feature preview Database Connections preview banner should still only show up if it's never been dismissed before, but the CTA's changed to "Explore" rather than "Enable" if the user's default opted in Related discussion here: https://github.com/orgs/supabase/discussions/48639 ## Summary by CodeRabbit * **New Features** * Database Connections preview is now enabled by default. * Added clearer handling for preview state and initialization. * Banner actions open Database Connections when enabled, or the feature preview when disabled. * **Bug Fixes** * Improved banner and menu visibility while preview settings initialize. * Preserved banner dismissal behavior after a previous preference change. * Improved navigation consistency across Database Connections entry points. --- .../FeaturePreview/FeaturePreviewContext.tsx | 16 +++++-- .../App/FeaturePreview/useFeaturePreviews.ts | 4 +- .../ObservabilityLayout.tsx | 13 +++--- .../ObservabilityMenu.utils.test.tsx | 2 +- .../ObservabilityMenu.utils.tsx | 2 +- .../layouts/SQLEditorLayout/SQLEditorMenu.tsx | 2 +- .../Banners/BannerDatabaseConnections.tsx | 45 ++++++++++++++----- .../[ref]/observability/connections.tsx | 2 +- apps/studio/tests/lib/custom-render.tsx | 2 +- 9 files changed, 60 insertions(+), 28 deletions(-) diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 557c0e1327e27..03691172a714d 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -13,6 +13,7 @@ import { } from 'react' import { useFeaturePreviews } from './useFeaturePreviews' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { IS_PLATFORM } from '@/lib/constants' import { EMPTY_OBJ } from '@/lib/void' @@ -152,9 +153,18 @@ export const useIsMarketplaceEnabled = () => { } export const useIsDatabaseConnectionsEnabled = () => { - const { flags } = useFeaturePreviewContext() - const isDatabaseConnectionsEnabled = useFlag('topForPostgres') - return isDatabaseConnectionsEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS] + const { flags, isInitialized } = useFeaturePreviewContext() + const [localStorageFlag] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS, + null + ) + const previouslyToggled = localStorageFlag !== null + + return { + enabled: flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS], + isInitialized, + previouslyToggled, + } } export const useFeaturePreviewModal = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 3587e6b175b9b..a30be45c94144 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -133,8 +133,8 @@ export const useFeaturePreviews = (): FeaturePreview[] => { discussionsUrl: 'https://github.com/orgs/supabase/discussions/48639', isNew: true, isPlatformOnly: false, - isDefaultOptIn: false, - enabled: isDatabaseConnectionsEnabled, + isDefaultOptIn: isDatabaseConnectionsEnabled, + enabled: true, getRoute: (ref?: string) => `/project/${ref}/observability/connections`, bannerId: 'database-connections-banner', }, diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx index c350faa23328c..acdd6ac0b9862 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, useFeatureFlags, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { usePathname } from 'next/navigation' import { PropsWithChildren, useEffect, useRef } from 'react' @@ -24,7 +24,6 @@ const ObservabilityLayoutContent = ({ }: PropsWithChildren) => { const { ref } = useParams() const pathname = usePathname() - const { hasLoaded } = useFeatureFlags() const { addBanner, dismissBanner } = useBannerStack() const { isIndexAdvisorAvailable, isIndexAdvisorEnabled } = useIndexAdvisorStatus() @@ -33,7 +32,7 @@ const ObservabilityLayoutContent = ({ false ) - const isDatabaseConnectionsEnabled = useIsDatabaseConnectionsEnabled() + const { isInitialized, previouslyToggled } = useIsDatabaseConnectionsEnabled() const [isDatabaseConnectionsBannerDismissed, , { isSuccess: isLocalStorageReady }] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.DATABASE_CONNECTIONS_BANNER_DISMISSED(ref ?? ''), false) @@ -42,10 +41,10 @@ const ObservabilityLayoutContent = ({ useEffect(() => { if ( - !hasLoaded || + !isInitialized || !isLocalStorageReady || isDatabaseConnectionsBannerDismissed || - isDatabaseConnectionsEnabled + previouslyToggled ) return @@ -56,11 +55,11 @@ const ObservabilityLayoutContent = ({ content: , }) }, [ - hasLoaded, addBanner, dismissBanner, + isInitialized, isDatabaseConnectionsBannerDismissed, - isDatabaseConnectionsEnabled, + previouslyToggled, isLocalStorageReady, ]) diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.test.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.test.tsx index f63bdd9f11610..a61aa7a3b8e50 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.test.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.test.tsx @@ -1,4 +1,3 @@ -import { renderHook } from '@testing-library/react' import { useFlag, useParams } from 'common' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,6 +8,7 @@ import { import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus' import { useContentQuery } from '@/data/content/content-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { customRenderHook as renderHook } from '@/tests/lib/custom-render' import { routerMock } from '@/tests/lib/route-mock' const { REF, mockIsPlatform } = vi.hoisted(() => ({ diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx index 44ff90b6442d6..7301943bd0a84 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx @@ -49,7 +49,7 @@ export const useGenerateObservabilityMenu = () => { const { isSupamonitorEnabled } = useSupamonitorStatus() const showOverview = useFlag('observabilityOverview') - const isDatabaseConnectionsEnabled = useIsDatabaseConnectionsEnabled() + const { enabled: isDatabaseConnectionsEnabled } = useIsDatabaseConnectionsEnabled() const storageSupported = useIsFeatureEnabled('project_storage:all') const baseUrl = `/project/${ref}/observability` diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 3c3d66e8355cc..6938b6ce9f8e9 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -41,7 +41,7 @@ export const SQLEditorMenu = () => { const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() - const isDatabaseConnectionsEnabled = useIsDatabaseConnectionsEnabled() + const { enabled: isDatabaseConnectionsEnabled } = useIsDatabaseConnectionsEnabled() const sqlEditorLogsSource = useFlag('sqlEditorLogsSource') const otelLegacyLogs = useFlag('otelLegacyLogs') const canCreateLogsSnippet = sqlEditorLogsSource && otelLegacyLogs diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerDatabaseConnections.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerDatabaseConnections.tsx index 4ca36cb90f935..c80f67615ea04 100644 --- a/apps/studio/components/ui/BannerStack/Banners/BannerDatabaseConnections.tsx +++ b/apps/studio/components/ui/BannerStack/Banners/BannerDatabaseConnections.tsx @@ -1,10 +1,14 @@ import { LOCAL_STORAGE_KEYS } from 'common' import { useParams } from 'common/hooks' +import Link from 'next/link' import { Badge, Button, WarningIcon } from 'ui' import { BannerCard } from '../BannerCard' import { useBannerStack } from '../BannerStackProvider' -import { useFeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { + useFeaturePreviewModal, + useIsDatabaseConnectionsEnabled, +} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useTrack } from '@/lib/telemetry/track' @@ -19,6 +23,8 @@ export const BannerDatabaseConnections = () => { false ) + const { enabled: isEnabled } = useIsDatabaseConnectionsEnabled() + return ( { @@ -72,16 +78,33 @@ export const BannerDatabaseConnections = () => {

- + {isEnabled ? ( + + ) : ( + + )}
diff --git a/apps/studio/pages/project/[ref]/observability/connections.tsx b/apps/studio/pages/project/[ref]/observability/connections.tsx index a49cb5178cc69..10cc40a4cafe4 100644 --- a/apps/studio/pages/project/[ref]/observability/connections.tsx +++ b/apps/studio/pages/project/[ref]/observability/connections.tsx @@ -33,7 +33,7 @@ export const DatabaseConnections: NextPageWithLayout = () => { const { data: project } = useSelectedProjectQuery() const { openSidebar } = useSidebarManagerSnapshot() const aiSnap = useAiAssistantStateSnapshot() - const isDatabaseConnectionsEnabled = useIsDatabaseConnectionsEnabled() + const { enabled: isDatabaseConnectionsEnabled } = useIsDatabaseConnectionsEnabled() const { selectFeaturePreview } = useFeaturePreviewModal() const [live, setLive] = useState(true) diff --git a/apps/studio/tests/lib/custom-render.tsx b/apps/studio/tests/lib/custom-render.tsx index f182c109a3e60..d23ace9f00cdf 100644 --- a/apps/studio/tests/lib/custom-render.tsx +++ b/apps/studio/tests/lib/custom-render.tsx @@ -66,7 +66,7 @@ export const customRender = (component: React.ReactElement, renderOptions?: Cust }) } -export const customRenderHook = (hook: () => any, renderOptions?: CustomRenderOpts) => { +export const customRenderHook = (hook: () => T, renderOptions?: CustomRenderOpts) => { return renderHook(hook, { wrapper: ({ children }) => CustomWrapper({ From b044408e79cc25299139c68959863e6f06cc1e4d Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 17 Aug 2026 17:15:53 +0800 Subject: [PATCH 08/10] [FE-4185] feat(studio): custom content key for auth page logo link (#49130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `dashboard_auth:logo_link_url` custom content key so white-labeled deployments can point the logged-out logo link at their own marketing site instead of the hardcoded `https://supabase.com`. **Added:** - `dashboard_auth:logo_link_url` custom content key (schema, types, default `null`, sample value) **Changed:** - `SignInLayout` and `ForgotPasswordLayout` now resolve the marketing-site logo href from custom content, falling back to `https://supabase.com` — these two shared layouts cover all auth pages (sign-in, sign-in-sso, sign-in-mfa, sign-in-partner, forgot/reset password) in both the Next and TanStack runtimes ## To test - On a normal deployment (key `null`): visit `/sign-in` and `/forgot-password` logged out — the logo should still link to `https://supabase.com` - Set `"dashboard_auth:logo_link_url": "https://example.com"` in `apps/studio/hooks/custom-content/custom-content.json` locally — the logo on those pages should link to `https://example.com` - Signed-in contexts (`logoLinkToMarketingSite` unset) still link to `/organizations` ## Summary by CodeRabbit * **New Features** * Added support for configuring the URL linked from authentication-page logos. * Authentication logos now use the configured destination when available. * Added a default destination to ensure logo links remain functional when no custom URL is set. * **Documentation** * Added sample configuration for the customizable authentication logo link. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Joshen Lim --- .../components/layouts/SignInLayout/ForgotPasswordLayout.tsx | 5 ++++- apps/studio/components/layouts/SignInLayout/SignInLayout.tsx | 5 ++++- apps/studio/hooks/custom-content/CustomContent.types.ts | 2 ++ apps/studio/hooks/custom-content/custom-content.json | 2 ++ apps/studio/hooks/custom-content/custom-content.sample.json | 2 ++ apps/studio/hooks/custom-content/custom-content.schema.json | 5 +++++ 6 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx index c7b24a8d3b27b..2b0824e92dfe8 100644 --- a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx +++ b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { PropsWithChildren, useEffect, useState } from 'react' import { cn } from 'ui' +import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { BASE_PATH } from '@/lib/constants' type ForgotPasswordLayoutProps = { @@ -23,6 +24,8 @@ export const ForgotPasswordLayout = ({ children, }: PropsWithChildren) => { const { resolvedTheme } = useTheme() + const { dashboardAuthLogoLinkUrl } = useCustomContent(['dashboard_auth:logo_link_url']) + const marketingSiteUrl = dashboardAuthLogoLinkUrl ?? 'https://supabase.com' // Addresses hydration issue with `resolvedTheme` as its undefined during SSR and the first (hydrating) client render const [mounted, setMounted] = useState(false) @@ -39,7 +42,7 @@ export const ForgotPasswordLayout = ({