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/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/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/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/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 = () => { + /> { ) => customRender( - + ) @@ -53,6 +58,8 @@ describe('ExplorerQuerySourceMenu', () => { customRender( { customRender( void + roleImpersonationState?: RoleImpersonationController source: QuerySourceBinding onSourceChange: (source: QuerySourceBinding) => void } @@ -37,6 +43,9 @@ export type ExplorerQuerySourceMenuProps = { * has SQL to preserve or discard and a fresh draft does not. */ export const ExplorerQuerySourceMenu = ({ + rowLimit = 100, + onRowLimitChange, + roleImpersonationState, source, onSourceChange, }: ExplorerQuerySourceMenuProps) => { @@ -97,12 +106,23 @@ export const ExplorerQuerySourceMenu = ({ {source._tag === 'database' ? ( - - onSourceChange({ _tag: 'database', database_identifier }) - } - /> + <> + + onSourceChange({ _tag: 'database', database_identifier }) + } + /> + {roleImpersonationState !== undefined && ( + + )} + {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..80adb841a0396 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' @@ -20,6 +21,7 @@ import { } from '@/data/content/notebooks/notebook-schema' import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' +import { useLocalRoleImpersonationState } from '@/state/role-impersonation-state' interface QueryCellProps { cell: Snapshot @@ -32,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' @@ -78,6 +81,9 @@ export const QueryCell = ({ cell }: QueryCellProps) => { chart: cloneChartConfig(display.chart), })) + const handleRowLimitChange = (rowLimit: number) => + updateQueryCell((candidate) => setCellRowLimit(candidate, rowLimit)) + return ( { title={title} query={toQueryModel(cell, sql)} result={result} + roleImpersonationState={roleImpersonationState} display={getCellDisplay(cell)} onTitleChange={handleTitleChange} onSqlChange={setSql} 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..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 @@ -76,6 +82,7 @@ export type QueryEditorProps = { onSqlCommit?: (sql: string) => void onSourceChange?: (source: QuerySourceBinding) => void onResultChange: (result: QueryResult) => void + onRowLimitChange?: (val: number) => void onDisplayChange?: (display: QueryDisplay) => void } @@ -90,6 +97,7 @@ export const QueryEditor = ({ title, query, result, + roleImpersonationState, display, toolbarActions, onTitleChange, @@ -97,6 +105,7 @@ export const QueryEditor = ({ onSqlCommit, onSourceChange, onResultChange, + onRowLimitChange, onDisplayChange, }: QueryEditorProps) => { const sql = query.uncheckedSql @@ -183,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), }) } @@ -205,6 +215,9 @@ export const QueryEditor = ({ )} {display && onDisplayChange && ( @@ -267,7 +280,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..ee94abf4ea894 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryTab.tsx @@ -8,17 +8,20 @@ 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' -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 roleImpersonationState = useLocalRoleImpersonationState() + + 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 +84,7 @@ export const QueryTab = () => { : { ...toQuerySourceBinding(draft), uncheckedSql: draft.uncheckedSql, - rowLimit: QUERY_ROW_LIMIT, + rowLimit, } return ( @@ -91,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 }) @@ -99,6 +103,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/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 (
Add destination @@ -166,8 +166,8 @@ export function OrgAuditLogDrains() {
- sessionSnap.setLimit(Number(val))} - > + {ROWS_PER_PAGE_OPTIONS.map((option) => ( {option.label} 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/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index 2839970088052..8eda0ce5e837b 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -110,7 +110,7 @@ export const RestartServerButton = () => { variant="default" className={cn( 'flex-1 px-3 hover:z-10 focus-visible:z-10 @lg:flex-none', - canRestartProject && canRestart ? 'rounded-r-none' : '' + canRestartProject && canRestart ? 'rounded-r-none focus-visible:rounded-r-sm' : '' )} disabled={ project === undefined || @@ -143,7 +143,7 @@ export const RestartServerButton = () => { + ) + } + /> + ) +} 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..3688e994d5409 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx @@ -1,7 +1,20 @@ +import { Plus } from 'lucide-react' 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 +26,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 +50,7 @@ export const AWSPrivateLinkSection = () => { onSuccess: () => { toast.success('Connection will be deleted shortly') setShowDeleteModal(false) + setShowForm(false) setSelectedAccount(undefined) }, }) @@ -53,11 +68,6 @@ export const AWSPrivateLinkSection = () => { setShowForm(true) } - const onDeleteAccount = (account: AWSAccount) => { - setSelectedAccount(account) - setShowDeleteModal(true) - } - const onConfirmDelete = () => { if (selectedAccount && project) { deleteAccount({ @@ -71,16 +81,23 @@ 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 ( <> -
+
AWS PrivateLink - Connect to this project from your AWS VPC without using the public internet. + Private connectivity from a connected AWS VPC, without the public internet.
@@ -89,7 +106,7 @@ export const AWSPrivateLinkSection = () => {
{promptPlanUpgrade && ( {

Connections

-
+ {(accounts?.length ?? 0) > 0 ? ( {accounts?.map((account) => ( onEditAccount(account)} - onDelete={() => onDeleteAccount(account)} + account={account} + onView={() => onEditAccount(account)} /> ))} @@ -126,31 +143,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 + + + + ) } 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/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableInstructionsDialog.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableInstructionsDialog.tsx index 69faefbe29869..b9d9e6b60f7b7 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableInstructionsDialog.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableInstructionsDialog.tsx @@ -31,7 +31,8 @@ export const CreateTableInstructionsDialog = () => { variant="primary" icon={} className={cn( - enableCreationOfTablesFromDashboard && 'rounded-r-none hover:z-10 focus-visible:z-10' + enableCreationOfTablesFromDashboard && + 'rounded-r-none hover:z-10 focus-visible:z-10 focus-visible:rounded-r-sm' )} onClick={() => { if (enableCreationOfTablesFromDashboard) setShowSheet(true) @@ -45,7 +46,8 @@ export const CreateTableInstructionsDialog = () => { } /> 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/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 = ({
- +
- + } className={cn( - 'rounded-r-none border-r-0 focus-visible:z-10', + 'rounded-r-none hover:z-10 focus-visible:z-10 focus-visible:rounded-r-sm', iconOnly && 'px-1.5', className )} @@ -150,7 +150,7 @@ export function AiAssistantDropdown({ variant={variant} size={size} disabled={disabled} - className={cn('rounded-l-none px-1 focus-visible:z-10', iconOnly && 'px-1')} + 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/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/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 diff --git a/apps/studio/hooks/custom-content/CustomContent.types.ts b/apps/studio/hooks/custom-content/CustomContent.types.ts index d965508912dbc..f8ac5f665f6f2 100644 --- a/apps/studio/hooks/custom-content/CustomContent.types.ts +++ b/apps/studio/hooks/custom-content/CustomContent.types.ts @@ -7,6 +7,8 @@ export type CustomContentTypes = { dashboardAuthCustomProviders: string[] + dashboardAuthLogoLinkUrl: string + docsRowLevelSecurityGuidePath: string organizationLegalDocuments: { diff --git a/apps/studio/hooks/custom-content/custom-content.json b/apps/studio/hooks/custom-content/custom-content.json index a3ffff7d1d0d4..0f094e3788cfc 100644 --- a/apps/studio/hooks/custom-content/custom-content.json +++ b/apps/studio/hooks/custom-content/custom-content.json @@ -7,6 +7,8 @@ "dashboard_auth:custom_providers": null, + "dashboard_auth:logo_link_url": null, + "docs:row_level_security_guide_path": "/guides/auth/row-level-security", "organization:legal_documents": null, diff --git a/apps/studio/hooks/custom-content/custom-content.sample.json b/apps/studio/hooks/custom-content/custom-content.sample.json index 23ac2df49b501..1fcc2e6a8101d 100644 --- a/apps/studio/hooks/custom-content/custom-content.sample.json +++ b/apps/studio/hooks/custom-content/custom-content.sample.json @@ -7,6 +7,8 @@ "dashboard_auth:custom_providers": ["Nimbus"], + "dashboard_auth:logo_link_url": "https://example.com", + "docs:row_level_security_guide_path": "/guides/database/postgres/row-level-security", "organization:legal_documents": [ diff --git a/apps/studio/hooks/custom-content/custom-content.schema.json b/apps/studio/hooks/custom-content/custom-content.schema.json index 3fed50e746c86..508f26527b494 100644 --- a/apps/studio/hooks/custom-content/custom-content.schema.json +++ b/apps/studio/hooks/custom-content/custom-content.schema.json @@ -24,6 +24,11 @@ } }, + "dashboard_auth:logo_link_url": { + "type": ["string", "null"], + "description": "The URL for the logo on the auth pages (sign in, forgot password, etc.) links to. Defaults to https://supabase.com" + }, + "docs:row_level_security_guide_path": { "type": ["string"], "description": "The path to the row level security guide in the docs" 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/pages/project/[ref]/settings/log-drains.tsx b/apps/studio/pages/project/[ref]/settings/log-drains.tsx index d92f7971b5e68..42b784d40dfa1 100644 --- a/apps/studio/pages/project/[ref]/settings/log-drains.tsx +++ b/apps/studio/pages/project/[ref]/settings/log-drains.tsx @@ -215,7 +215,7 @@ const LogDrainsSettings: NextPageWithLayout = () => { disabled={!hasAccessToLogDrains || !canManageLogDrains} onClick={handleAddDestinationClick} variant="primary" - className="rounded-r-none px-3 hover:z-10 focus-visible:z-10" + className="rounded-r-none px-3 hover:z-10 focus-visible:z-10 focus-visible:rounded-r-sm" > Add destination @@ -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" />