diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index a0e19ea5d92b0..2c05bd85b65e4 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -2,6 +2,10 @@ import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag } from 'common' import dayjs from 'dayjs' import { usePathname } from 'next/navigation' import { PropsWithChildren, useEffect, useRef, useState } from 'react' +import { + SELECT_26_STUDIO_DISMISSAL_KEY, + useSelect26PromotionActive, +} from 'ui-patterns/Banners/Select26Promotion' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' import { isLogsOrObservabilityPath } from './AppBannerWrapper.utils' @@ -9,6 +13,11 @@ import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner' import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner' import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner' import { BannerLogsAllDeprecation } from '@/components/ui/BannerStack/Banners/BannerLogsAllDeprecation' +import { BannerSelect2026 } from '@/components/ui/BannerStack/Banners/BannerSelect2026' +import { + SELECT_26_BANNER_PRIORITY, + shouldShowSelect26Banner, +} from '@/components/ui/BannerStack/Banners/BannerSelect2026.utils' import { BannerTOSUpdate } from '@/components/ui/BannerStack/Banners/BannerTOSUpdate' import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' @@ -36,6 +45,38 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { false ) + const [isSelect26BannerDismissed, , { isSuccess: isSelect26DismissalLoaded }] = + useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false) + const isSelect26PromotionActive = useSelect26PromotionActive() + + useEffect(() => { + if (!isSelect26DismissalLoaded) return + + const shouldShow = shouldShowSelect26Banner({ + isPlatform: IS_PLATFORM, + dismissalLoaded: isSelect26DismissalLoaded, + isActive: isSelect26PromotionActive, + isDismissed: isSelect26BannerDismissed, + }) + + if (shouldShow) { + addBanner({ + id: BANNER_ID.SELECT_26, + isDismissed: false, + content: , + priority: SELECT_26_BANNER_PRIORITY, + }) + } else { + dismissBanner(BANNER_ID.SELECT_26) + } + }, [ + isSelect26DismissalLoaded, + isSelect26PromotionActive, + isSelect26BannerDismissed, + addBanner, + dismissBanner, + ]) + useEffect(() => { if (Date.now() >= TOSUpdateExpiry.getTime()) return diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx index 7d05a6b9e609f..7bfab5a8b1da0 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx @@ -1,11 +1,14 @@ import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react' -import { useParams } from 'common' -import { ArrowRight, Loader2, Square, X, type LucideIcon } from 'lucide-react' +import { useParams, useReducedMotion } from 'common' import { useMemo } from 'react' -import { cn } from 'ui' import { getStatusName } from '../Pipeline.utils' import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' +import { + EdgeVisualChip, + getEdgeVisual, + type ReplicationState, +} from '@/components/ui/ReactFlow/EdgeVisual' import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query' import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query' import { @@ -19,67 +22,6 @@ type EdgeData = { shiftEdgeEnd: boolean } -interface ReplicationState { - isComingUp: boolean - isReplicating: boolean - isFailed: boolean -} - -interface EdgeVisual { - Icon: LucideIcon - // CSS color shared by the icon and the connecting line so they always match. - color: string - opacity: number - dashArray: string - shouldAnimate: boolean - shouldSpin?: boolean - isFilled?: boolean - strokeWidth?: number -} - -// Picks the icon + line appearance for a replication state. Both the icon and the line are derived -// here from the same state so they always stay in sync. We deliberately don't surface lag: the line -// just communicates whether data is moving, stopped, starting, or broken. -const getEdgeVisual = ({ isComingUp, isReplicating, isFailed }: ReplicationState): EdgeVisual => { - if (isFailed) { - return { - Icon: X, - color: 'hsl(var(--destructive-default))', - opacity: 1, - dashArray: '5 5', - shouldAnimate: false, - strokeWidth: 4, - } - } - if (isComingUp) { - return { - Icon: Loader2, - color: 'var(--foreground-light)', - opacity: 1, - dashArray: '5', - shouldAnimate: true, - shouldSpin: true, - } - } - if (isReplicating) { - return { - Icon: ArrowRight, - color: 'hsl(var(--brand-default))', - opacity: 1, - dashArray: '5', - shouldAnimate: true, - } - } - return { - Icon: Square, - color: 'var(--foreground-lighter)', - opacity: 0.5, - dashArray: '5 5', - shouldAnimate: false, - isFilled: true, - } -} - export const SmoothstepEdge = ({ sourceX, sourceY, @@ -92,6 +34,7 @@ export const SmoothstepEdge = ({ data, }: EdgeProps) => { const { ref: projectRef = 'default' } = useParams() + const prefersReducedMotion = useReducedMotion() const { identifier, shiftEdgeEnd } = (data || {}) as EdgeData const { data: pipelinesData } = useReplicationPipelinesQuery({ projectRef }) @@ -126,16 +69,7 @@ export const SmoothstepEdge = ({ targetPosition, }) - const { - Icon, - color, - opacity, - dashArray, - shouldAnimate, - shouldSpin, - isFilled, - strokeWidth = 2, - } = getEdgeVisual(replicationState) + const visual = getEdgeVisual(replicationState) return ( <> @@ -144,11 +78,14 @@ export const SmoothstepEdge = ({ markerEnd={markerEnd} style={{ ...style, - stroke: color, - strokeWidth, - opacity, - strokeDasharray: dashArray, - animation: shouldAnimate ? 'dashdraw 0.5s linear infinite' : undefined, + stroke: visual.color, + strokeWidth: visual.strokeWidth, + opacity: visual.opacity, + strokeDasharray: visual.dashArray, + animation: + visual.shouldAnimate && !prefersReducedMotion + ? 'dashdraw 0.5s linear infinite' + : undefined, }} /> @@ -160,18 +97,7 @@ export const SmoothstepEdge = ({ }} className="nodrag nopan" > -
- -
+
diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index b62220e86b74d..d4125fe3b2b47 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -12,6 +12,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable' import { acceptUntrustedSql } from '@supabase/pg-meta' +import { useQueryClient } from '@tanstack/react-query' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { Check, @@ -59,7 +60,10 @@ import { type QueryEditorHandle } from './QueryEditor' import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useContentDeleteMutation } from '@/data/content/content-delete-mutation' -import { hasDiscardableChanges } from '@/data/content/notebooks/notebook-cache' +import { + evictNotebookFromCaches, + hasDiscardableChanges, +} from '@/data/content/notebooks/notebook-cache' import { isQueryCell, WritableCell, @@ -80,6 +84,7 @@ export const ExplorerNotebookTab = () => { const { id, ref } = useParams() const tabs = useTabsStateSnapshot() const snap = useNotebooksStateSnapshot() + const queryClient = useQueryClient() const { createChat, isCreating } = useCreateChat() const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery( @@ -96,6 +101,7 @@ export const ExplorerNotebookTab = () => { const [isRunningNotebook, setIsRunningNotebook] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [isSaveBeforeAnalyzeOpen, setIsSaveBeforeAnalyzeOpen] = useState(false) + const [isSaveConflictOpen, setIsSaveConflictOpen] = useState(false) const [pendingMutationCells, setPendingMutationCells] = useState< { id: string; title: string }[] | null >(null) @@ -199,7 +205,7 @@ export const ExplorerNotebookTab = () => { runNotebook({ cellIdsToRun, force: true }) } - const handleSaveNotebook = () => { + const persistNotebook = () => { const notebookId = currentNotebook?.notebook.id if (!ref || !notebookId || !name || !content) return @@ -231,6 +237,10 @@ export const ExplorerNotebookTab = () => { }), } + if (snap.serverDivergedWhileDirty.get(notebookId) === 'deleted') { + writableContent.cells = writableContent.cells.map(({ _id: _, ...cell }) => cell) + } + // [Joshen] For tracking if a notebook is updated while being saved, so that we do not // incorrectly show the saved toast if it's subsequently then saved once again while // the initial save is midflight @@ -245,6 +255,32 @@ export const ExplorerNotebookTab = () => { }) } + const handleSaveNotebook = () => { + const notebookId = currentNotebook?.notebook.id + if (notebookId && snap.serverDivergedWhileDirty.get(notebookId)) { + setIsSaveConflictOpen(true) + return + } + + persistNotebook() + } + + const handleSaveAnyway = () => { + setIsSaveConflictOpen(false) + persistNotebook() + } + + const handleDiscardNotebookChanges = async () => { + if (!ref || !id) return + + const wasDeletedOnServer = snap.serverDivergedWhileDirty.get(id) === 'deleted' + setIsSaveConflictOpen(false) + const evicted = await evictNotebookFromCaches({ queryClient, projectRef: ref, id }) + if (wasDeletedOnServer && evicted) { + tabs.removeTab(createTabId('notebook', { id })) + } + } + const handleAnalyze = () => { createChat({ name: `Analyze ${name} notebook`, @@ -463,6 +499,25 @@ export const ExplorerNotebookTab = () => {

+ setIsSaveConflictOpen(false)} + onConfirm={handleSaveAnyway} + > +

+ {id && snap.serverDivergedWhileDirty.get(id) === 'deleted' + ? 'An assistant deleted this notebook after your local changes. Saving will recreate it.' + : "An assistant updated this notebook after your local changes. Saving will overwrite the assistant's update."} +

+
+ { notebooksState.setNotebook({ projectRef: PROJECT_REF, notebook }) } -const renderNotebookTab = (queryClient: QueryClient) => +const renderNotebookTab = (queryClient: QueryClient, tabsState = createTabsState(PROJECT_REF)) => customRender( - + , { queryClient } ) +afterEach(() => { + notebooksState.serverDivergedWhileDirty.clear() +}) + describe('ExplorerNotebookTab — assistant cache invalidation', () => { it('refetches and renders the updated cells after an assistant update_notebook tool call completes', async () => { setupSqlEditorMocks() @@ -205,4 +210,235 @@ describe('ExplorerNotebookTab — assistant cache invalidation', () => { expect(await screen.findByText('Notebook not found')).toBeInTheDocument() expect(screen.queryByText('Original content')).not.toBeInTheDocument() }) + + it('saves normally without a conflict dialog when the notebook has not diverged', async () => { + setupSqlEditorMocks() + seedNotebook() + const queryClient = new QueryClient() + let mutationCount = 0 + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: () => { + mutationCount += 1 + return HttpResponse.json({ id: NOTEBOOK_ID }) + }, + }) + + renderNotebookTab(queryClient) + + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + + await waitFor(() => expect(mutationCount).toBe(1)) + expect( + screen.queryByRole('dialog', { name: 'Assistant changes detected' }) + ).not.toBeInTheDocument() + }) + + it.each([ + { + type: 'updated' as const, + description: + "An assistant updated this notebook after your local changes. Saving will overwrite the assistant's update.", + saveLabel: 'Save anyway', + }, + { + type: 'deleted' as const, + description: + 'An assistant deleted this notebook after your local changes. Saving will recreate it.', + saveLabel: 'Recreate', + }, + ])('shows the $type conflict copy before saving', async ({ type, description, saveLabel }) => { + setupSqlEditorMocks() + seedNotebook() + notebooksState.updateCells({ + id: NOTEBOOK_ID, + cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells, + }) + notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type }) + + renderNotebookTab(new QueryClient()) + + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + + const dialog = await screen.findByRole('dialog', { name: 'Assistant changes detected' }) + expect(dialog).toHaveTextContent(description) + expect(dialog).toHaveTextContent(saveLabel) + expect(dialog).toHaveTextContent('Discard changes') + }) + + it('saves exactly once and clears the conflict only after a successful save', async () => { + setupSqlEditorMocks() + seedNotebook() + notebooksState.updateCells({ + id: NOTEBOOK_ID, + cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells, + }) + notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'updated' }) + const queryClient = new QueryClient() + let mutationCount = 0 + let resolveSave: (() => void) | undefined + const savePromise = new Promise((resolve) => { + resolveSave = resolve + }) + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: async () => { + mutationCount += 1 + await savePromise + return HttpResponse.json({ id: NOTEBOOK_ID }) + }, + }) + + renderNotebookTab(queryClient) + + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK_ID)).toBe('updated') + await userEvent.click(await screen.findByRole('button', { name: 'Save anyway' })) + + await waitFor(() => expect(mutationCount).toBe(1)) + expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK_ID)).toBe('updated') + resolveSave?.() + await waitFor(() => + expect(notebooksState.serverDivergedWhileDirty.has(NOTEBOOK_ID)).toBe(false) + ) + }) + + it('keeps the conflict marker when saving anyway fails', async () => { + setupSqlEditorMocks() + seedNotebook() + notebooksState.updateCells({ + id: NOTEBOOK_ID, + cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells, + }) + notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'updated' }) + let mutationCount = 0 + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: () => { + mutationCount += 1 + return HttpResponse.json({ message: 'Save failed' }, { status: 500 }) + }, + }) + + renderNotebookTab(new QueryClient()) + + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + await userEvent.click(await screen.findByRole('button', { name: 'Save anyway' })) + + await waitFor(() => expect(mutationCount).toBe(1)) + expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK_ID)).toBe('updated') + }) + + it('omits existing cell IDs when saving anyway recreates an assistant-deleted notebook', async () => { + setupSqlEditorMocks() + seedNotebook() + notebooksState.updateCells({ + id: NOTEBOOK_ID, + cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells, + }) + notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'deleted' }) + let sentBody: Record | undefined + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: async ({ request }) => { + sentBody = (await request.json()) as Record + return HttpResponse.json({ id: NOTEBOOK_ID }) + }, + }) + + renderNotebookTab(new QueryClient()) + + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + await userEvent.click(await screen.findByRole('button', { name: 'Recreate' })) + + await waitFor(() => expect(sentBody).toBeDefined()) + const content = sentBody?.content as { cells: Array> } + content.cells.forEach((cell) => expect(cell).not.toHaveProperty('_id')) + }) + + it('discards local changes without mutating and evicts the conflicted notebook', async () => { + setupSqlEditorMocks() + seedNotebook() + notebooksState.updateCells({ + id: NOTEBOOK_ID, + cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells, + }) + notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'updated' }) + const queryClient = new QueryClient() + queryClient.setQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID), { id: NOTEBOOK_ID }) + let mutationCount = 0 + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: () => { + mutationCount += 1 + return HttpResponse.json({ id: NOTEBOOK_ID }) + }, + }) + + renderNotebookTab(queryClient) + + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + await userEvent.click(await screen.findByRole('button', { name: 'Discard changes' })) + + await waitFor(() => expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeUndefined()) + expect(notebooksState.serverDivergedWhileDirty.has(NOTEBOOK_ID)).toBe(false) + expect(queryClient.getQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID))).toBeUndefined() + expect(mutationCount).toBe(0) + }) + + it('closes the notebook tab when discarding edits after an assistant deletion', async () => { + setupSqlEditorMocks() + seedNotebook() + notebooksState.updateCells({ + id: NOTEBOOK_ID, + cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells, + }) + notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'deleted' }) + const tabsState = createTabsState(PROJECT_REF) + const tabId = createTabId('notebook', { id: NOTEBOOK_ID }) + tabsState.addTab({ id: tabId, type: 'notebook', metadata: { notebookId: NOTEBOOK_ID } }) + + renderNotebookTab(new QueryClient(), tabsState) + + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + await userEvent.click(await screen.findByRole('button', { name: 'Discard changes' })) + + await waitFor(() => expect(tabsState.tabsMap[tabId]).toBeUndefined()) + }) + + it.each(['the close button', 'the backdrop'])( + 'dismisses the conflict with %s without changing notebook state', + async (dismissal) => { + setupSqlEditorMocks() + seedNotebook() + notebooksState.updateCells({ + id: NOTEBOOK_ID, + cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells, + }) + notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'updated' }) + + renderNotebookTab(new QueryClient()) + + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + const dialog = await screen.findByRole('dialog', { name: 'Assistant changes detected' }) + if (dismissal === 'the close button') { + await userEvent.click(screen.getByRole('button', { name: 'Close' })) + } else { + fireEvent.pointerDown(dialog.parentElement!, { button: 0, ctrlKey: false }) + } + + await waitFor(() => + expect( + screen.queryByRole('dialog', { name: 'Assistant changes detected' }) + ).not.toBeInTheDocument() + ) + expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeDefined() + expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK_ID)).toBe('updated') + } + ) }) diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx index b5a29d8f9e941..b40f791e27a38 100644 --- a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx +++ b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx @@ -31,7 +31,7 @@ import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state' const ROLE_IMPERSONATION_PLUGIN: GraphiQLPlugin = { title: 'Role Impersonation', icon: () => , - content: () => , + content: () => , } /** diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinkerComponents.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinkerComponents.tsx index dcdf496453fcf..b26abf0755f85 100644 --- a/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinkerComponents.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinkerComponents.tsx @@ -1,7 +1,6 @@ import { useParams } from 'common' import { Check, ChevronDown, Plus, PlusIcon } from 'lucide-react' import Link from 'next/link' -import { useRouter } from 'next/router' import { HTMLAttributes } from 'react' import { Badge, @@ -20,6 +19,7 @@ import { } from 'ui' import { Project, type ForeignProject, type ProjectLinkerProps } from './VercelGithub.types' +import { CommandItemLink } from '@/components/ui/CommandItemLink' import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' @@ -161,7 +161,6 @@ export const SupabaseProjectSelector = ({ setOpen: (val: boolean) => void setSelectedSupabaseProject: (project: Project) => void } & Pick) => { - const router = useRouter() const { data: selectedOrganization } = useSelectedOrganizationQuery() const projectCreationEnabled = useIsFeatureEnabled('projects:create') @@ -226,23 +225,14 @@ export const SupabaseProjectSelector = ({ return ( projectCreationEnabled && ( - { - setOpen(false) - router.push(`/new/${selectedOrganization?.slug}`) - }} - onClick={() => setOpen(false)} + setOpen(false)} > - setOpen(false)} - > - -

Create a new project

- -
+ +

Create a new project

+
) ) diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 03f5663a64976..5e09ad47feec7 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -251,11 +251,7 @@ export const RegionSelector = ({
{isLoading && } {selectedRegion?.code && ( - + )} {triggerLabel}
diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/Icons.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/Icons.tsx deleted file mode 100644 index 918edb085f888..0000000000000 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/Icons.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useTheme } from 'next-themes' - -export interface IconProps { - isSelected?: boolean -} - -export const ServiceRoleIcon = ({ isSelected = false }: IconProps) => { - const { resolvedTheme } = useTheme() - - return ( - - - - - - - - - ) -} - -export const AnonIcon = ({ isSelected = false }: IconProps) => { - const { resolvedTheme } = useTheme() - - return ( - - - - - - - - - - - ) -} - -export const AuthenticatedIcon = ({ isSelected = false }: IconProps) => { - const { resolvedTheme } = useTheme() - - return ( - - - - - - - - - - - - ) -} diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationRadio.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationRadio.tsx deleted file mode 100644 index 74f182e7dc611..0000000000000 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationRadio.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Check, Minus } from 'lucide-react' -import { cn } from 'ui' - -export interface RoleImpersonationRadioProps { - label?: string - description?: string - value: T - isSelected: boolean | 'partially' - onSelectedChange: (value: T) => void - icon?: React.ReactNode - fullWidth?: boolean -} - -export function RoleImpersonationRadio({ - label, - description, - value, - isSelected, - onSelectedChange, - icon, - fullWidth = false, -}: RoleImpersonationRadioProps) { - return ( - - ) -} diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.utils.ts b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.utils.ts new file mode 100644 index 0000000000000..92b8c46f92e3a --- /dev/null +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.utils.ts @@ -0,0 +1,25 @@ +import type { ImpersonationRole, PostgrestRole } from '@/lib/role-impersonation' + +export function getSelectedRoleOption(role: ImpersonationRole | undefined): PostgrestRole { + if (role?.type === 'postgrest' && (role.role === 'anon' || role.role === 'authenticated')) { + return role.role + } + + return 'service_role' +} + +type RoleSelectionUpdate = + | { shouldSetRole: false } + | { shouldSetRole: true; role: ImpersonationRole | undefined } + +export function getRoleSelectionUpdate(value: PostgrestRole): RoleSelectionUpdate { + if (value === 'service_role') { + return { shouldSetRole: true, role: undefined } + } + + if (value === 'anon') { + return { shouldSetRole: true, role: { type: 'postgrest', role: 'anon' } } + } + + return { shouldSetRole: false } +} diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx index f649e93c9aea6..b4ec509cb6ce7 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx @@ -1,62 +1,75 @@ import { keepPreviousData } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' -import { ChevronDown, User as IconUser, Loader2, Search, X } from 'lucide-react' +import { ChevronsUpDown, User as IconUser, Loader2, X } from 'lucide-react' import { useMemo, useState } from 'react' import { toast } from 'sonner' import { Button, cn, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, - DropdownMenuSeparator, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, - ScrollArea, - Switch, - Tabs, - TabsContent, - TabsList, - TabsTrigger, + Popover, + PopoverContent, + PopoverTrigger, + ToggleGroup, + ToggleGroupItem, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' -import { getAvatarUrl, getDisplayName } from '../Auth/Users/Users.utils' +import { getDisplayName } from '../Auth/Users/Users.utils' import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query' import { useCustomAccessTokenHookDetails } from '@/hooks/misc/useCustomAccessTokenHookDetails' -import { useLocalStorage } from '@/hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' -import { type RoleImpersonationController } from '@/state/role-impersonation-state' +import type { RoleImpersonationController } from '@/state/role-impersonation-state' import type { ResponseError } from '@/types' type AuthenticatorAssuranceLevels = 'aal1' | 'aal2' +type UserSource = 'user' | 'external' -export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationController }) => { +type UserImpersonationSelectorProps = { + state: RoleImpersonationController + disabled?: boolean + onUserImpersonationCleared?: () => void +} + +export const UserImpersonationSelector = ({ + state, + disabled = false, + onUserImpersonationCleared, +}: UserImpersonationSelectorProps) => { const [searchText, setSearchText] = useState('') - const [aal, setAal] = useState('aal1') + const [aal, setAal] = useState(() => + state.role?.type === 'postgrest' && state.role.role === 'authenticated' + ? (state.role.aal ?? 'aal1') + : 'aal1' + ) const [externalUserId, setExternalUserId] = useState('') const [additionalClaims, setAdditionalClaims] = useState('') - - const { id: tableId } = useParams() - const [selectedTab, setSelectedTab] = useState<'user' | 'external'>('user') - - const [previousSearches, setPreviousSearches] = useLocalStorage( - LOCAL_STORAGE_KEYS.USER_IMPERSONATION_SELECTOR_PREVIOUS_SEARCHES(tableId!), - [] + const [isUserComboboxOpen, setIsUserComboboxOpen] = useState(false) + const [selectedSource, setSelectedSource] = useState(() => + state.role?.type === 'postgrest' && + state.role.role === 'authenticated' && + state.role.userType === 'external' + ? 'external' + : 'user' ) + const [isImpersonateLoading, setIsImpersonateLoading] = useState(false) const debouncedSearchText = useDebounce(searchText, 300) - const { data: project } = useSelectedProjectQuery() - const { data, isSuccess, @@ -71,10 +84,9 @@ export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationC connectionString: project?.connectionString, keywords: debouncedSearchText.trim().toLocaleLowerCase(), }, - { - placeholderData: keepPreviousData, - } + { placeholderData: keepPreviousData } ) + const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages]) const isSearching = isPlaceholderData && isFetching const impersonatingUser = @@ -82,29 +94,26 @@ export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationC state.role.role === 'authenticated' && state.role.userType === 'native' && state.role.user - - // Check if we're currently impersonating an external auth user (e.g. OAuth, SAML) - // This is used to show the correct UI state and impersonation details - const isExternalAuthImpersonating = + ? state.role.user + : undefined + const impersonatingExternalUser = state.role?.type === 'postgrest' && state.role.role === 'authenticated' && state.role.userType === 'external' && state.role.externalAuth - + ? state.role.externalAuth + : undefined + const displayName = impersonatingUser + ? getDisplayName( + impersonatingUser, + impersonatingUser.email ?? impersonatingUser.phone ?? impersonatingUser.id ?? 'Unknown' + ) + : impersonatingExternalUser?.sub + const isUserSelected = Boolean(displayName) const customAccessTokenHookDetails = useCustomAccessTokenHookDetails(project?.ref) - const [isImpersonateLoading, setIsImpersonateLoading] = useState(false) - async function impersonateUser(user: User) { setIsImpersonateLoading(true) - setPreviousSearches((prev) => { - // Remove if already present - const filtered = prev.filter((u) => u.id !== user.id) - // Add new user to the start of the list (last used first) - const updated = [user, ...filtered] - // Keep only the last 6 - return updated.slice(0, 5) - }) if (customAccessTokenHookDetails?.type === 'https') { toast.info( @@ -125,459 +134,363 @@ export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationC ) } catch (error) { toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`) + } finally { + setIsImpersonateLoading(false) } - - setIsImpersonateLoading(false) } - // Impersonates an external auth user (e.g. OAuth, SAML) by setting the sub and any additional claims - // This allows testing RLS policies for external auth users without needing to set up the full OAuth/SAML flow async function impersonateExternalUser() { setIsImpersonateLoading(true) let parsedClaims = {} try { parsedClaims = additionalClaims ? JSON.parse(additionalClaims) : {} - } catch (e) { + } catch { toast.error('Invalid JSON in additional claims') setIsImpersonateLoading(false) return } + try { await state.setRole( { type: 'postgrest', role: 'authenticated', userType: 'external', - externalAuth: { - sub: externalUserId, - additionalClaims: parsedClaims, - }, + externalAuth: { sub: externalUserId, additionalClaims: parsedClaims }, aal, }, customAccessTokenHookDetails ) } catch (error) { toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`) + } finally { + setIsImpersonateLoading(false) } - - setIsImpersonateLoading(false) } - function stopImpersonating() { - state.setRole(undefined) - } + async function changeSelectedSource(value: UserSource) { + if (!isUserSelected) { + setSelectedSource(value) + return + } - function toggleAalState() { - setAal((prev) => (prev === 'aal2' ? 'aal1' : 'aal2')) + try { + await state.setRole(undefined) + setSelectedSource(value) + onUserImpersonationCleared?.() + } catch (error) { + toast.error(`Failed to stop impersonating user: ${(error as ResponseError).message}`) + } } - const displayName = impersonatingUser - ? getDisplayName( - impersonatingUser, - impersonatingUser.email ?? impersonatingUser.phone ?? impersonatingUser.id ?? 'Unknown' - ) - : isExternalAuthImpersonating - ? isExternalAuthImpersonating.sub - : undefined + async function changeAal(value: AuthenticatorAssuranceLevels) { + const previousAal = aal + setAal(value) + + if ( + state.role?.type !== 'postgrest' || + state.role.role !== 'authenticated' || + !isUserSelected + ) { + return + } - // Clear all search history - function clearSearchHistory() { - setPreviousSearches([]) + try { + await state.setRole({ ...state.role, aal: value }, customAccessTokenHookDetails) + } catch (error) { + setAal(previousAal) + toast.error(`Failed to update MFA assurance level: ${(error as ResponseError).message}`) + } } return ( - <> -
-

- {displayName ? `Impersonating ${displayName}` : 'Impersonate a user'} -

-

- {!impersonatingUser && !isExternalAuthImpersonating - ? "Select a user to respect your database's RLS policies for that particular user." - : "Results will respect your database's RLS policies for this user."} -

- - {impersonatingUser && ( - - )} - {isExternalAuthImpersonating && ( - + + Users + + Project users come from Supabase Auth. External users let you test RLS policies with + providers such as Clerk or Auth0. + + + } + > + { + if (value === 'user' || value === 'external') void changeSelectedSource(value) + }} + variant="default" + size="tiny" + aria-label="User source" + className="w-full" + > + + Project + + + External + + + + + + { + await state.setRole(undefined) + onUserImpersonationCleared?.() + }} + isUserComboboxOpen={isUserComboboxOpen} + setIsUserComboboxOpen={setIsUserComboboxOpen} + searchText={searchText} + setSearchText={setSearchText} + isLoading={isLoading} + isSearching={isSearching} + isError={isError} + error={error} + isSuccess={isSuccess} + users={users} + impersonateUser={impersonateUser} + externalUserId={externalUserId} + setExternalUserId={setExternalUserId} + impersonateExternalUser={impersonateExternalUser} + /> + + + {selectedSource === 'external' && !isUserSelected && ( + + setAdditionalClaims(event.target.value)} /> - )} - - {!impersonatingUser && !isExternalAuthImpersonating && ( - setSelectedTab(value)}> - - Project user - - External user - - Test RLS policies with external auth providers like Clerk or Auth0 by providing a - user ID and optional claims. - - - - - -
- - setSearchText(e.target.value)} - value={searchText} - /> - - {isSearching ? ( - - ) : ( - - )} - - - {searchText && ( - setSearchText('')} - > - Clear search - - - )} - - - {isLoading && ( -
- - Loading users... -
- )} - - {isError && } - - {isSuccess && - (users.length > 0 ? ( -
-
    - {users.map((user) => ( -
  • - -
  • - ))} -
-
- ) : ( -
-

- No users found -

-
- ))} - - <> - {previousSearches.length > 0 && ( -
- {previousSearches.length > 0 ? ( - <> - - -
-

- Recents -

- -
-
- - - - 3 ? 'h-36' : 'h-auto')} - > -
    - {previousSearches.map((search) => ( -
  • - -
  • - ))} -
-
-
-
- - ) : ( -
- No recent searches -
- )} -
- )} - -
-
- - -
- - setExternalUserId(e.target.value)} - /> - - - setAdditionalClaims(e.target.value)} - /> - -
- -
-
-
-
- )} -
- - {/* Check for both regular user and external auth impersonation since they use different data structures but both need to be handled for displaying impersonation UI */} - {!impersonatingUser && !isExternalAuthImpersonating ? ( - <> - -
- - -
-

- Advanced options -

- -
-
- -
-
-

MFA assurance level

- - AAL1 verifies users via standard login methods, while AAL2 adds a second - authentication factor. If you're not using MFA, you can leave this on AAL1. - Learn more about MFA{' '} - here. - -
- -
-

AAL1

- -

AAL2

-
-
-
-
-
- - ) : null} - + + )} + + + MFA level + + AAL1 verifies users via standard login methods, while AAL2 adds a second + authentication factor. If you are not using MFA, leave this on AAL1. Learn more in the{' '} + MFA guide. + + + } + > + { + if (value === 'aal1' || value === 'aal2') void changeAal(value) + }} + variant="default" + size="tiny" + aria-label="MFA assurance level" + className="w-full" + > + + AAL1 + + + AAL2 + + + + ) } -// Base interface for shared impersonation row props to reduce -// duplication between user and external auth impersonation displays -interface BaseImpersonatingRowProps { - onClick: () => void - aal: AuthenticatorAssuranceLevels - displayName: string - avatarUrl?: string - isImpersonating: boolean - isLoading?: boolean +type ImpersonationControlProps = { + selectedSource: UserSource + displayName?: string + isImpersonateLoading: boolean + stopImpersonating: () => void + isUserComboboxOpen: boolean + setIsUserComboboxOpen: (open: boolean) => void + searchText: string + setSearchText: (value: string) => void + isLoading: boolean + isSearching: boolean + isError: boolean + error: ResponseError | null + isSuccess: boolean + users: User[] + impersonateUser: (user: User) => Promise + externalUserId: string + setExternalUserId: (value: string) => void + impersonateExternalUser: () => Promise } -const BaseImpersonatingRow = ({ - onClick, - aal, +const ImpersonationControl = ({ + selectedSource, displayName, - avatarUrl, - isImpersonating = false, - isLoading = false, -}: BaseImpersonatingRowProps) => { - return ( -
-
- {avatarUrl ? ( - {displayName} - ) : ( -
- -
- )} - - - {displayName}{' '} - - {aal === 'aal2' ? 'AAL2' : 'AAL1'} - - + isImpersonateLoading, + stopImpersonating, + isUserComboboxOpen, + setIsUserComboboxOpen, + searchText, + setSearchText, + isLoading, + isSearching, + isError, + error, + isSuccess, + users, + impersonateUser, + externalUserId, + setExternalUserId, + impersonateExternalUser, +}: ImpersonationControlProps) => { + if (displayName) { + return ( +
+ {displayName} +
+ ) + } - -
- ) -} - -const UserImpersonatingRow = ({ - user, - onClick, - isImpersonating = false, - isLoading = false, - aal, -}: UserRowProps & { aal: AuthenticatorAssuranceLevels }) => { - const avatarUrl = getAvatarUrl(user) - const displayName = - getDisplayName(user, user.email ?? user.phone ?? user.id ?? 'Unknown') + - (user.is_anonymous ? ' (anonymous)' : '') - - return ( - onClick(user)} - aal={aal} - displayName={displayName} - avatarUrl={avatarUrl} - isImpersonating={isImpersonating} - isLoading={isLoading} - /> - ) -} - -interface ExternalAuthImpersonatingRowProps { - sub: string - onClick: () => void - aal: AuthenticatorAssuranceLevels - isLoading?: boolean -} - -const ExternalAuthImpersonatingRow = ({ - sub, - onClick, - aal, - isLoading = false, -}: ExternalAuthImpersonatingRowProps) => { - return ( - - ) -} - -interface UserRowProps { - user: User - onClick: (user: User) => void - isImpersonating?: boolean - isLoading?: boolean -} - -const UserRow = ({ user, onClick, isImpersonating = false, isLoading = false }: UserRowProps) => { - const avatarUrl = getAvatarUrl(user) - const emailOrPhone = user.email || user.phone - const displayName = getDisplayName(user, '') - const isAnonymous = user.is_anonymous - const showDisplayName = displayName && displayName !== emailOrPhone + if (selectedSource === 'external') { + return ( + + setExternalUserId(event.target.value)} + /> + + + Apply + + + + ) + } return ( -
-
- {avatarUrl ? ( - {displayName - ) : ( -
- -
- )} - - - {emailOrPhone} - {showDisplayName && ( - <> - - {displayName} - {isAnonymous ? ' (anonymous)' : ''} - - - )} - - {user?.id?.slice(0, 8)} - - -
- - -
+ + + + + + + + + {(isLoading || isSearching) && ( +
+ + Loading users… +
+ )} + + {isError && } + + {isSuccess && !isSearching && users.length === 0 && ( + No users found + )} + + {isSuccess && !isSearching && users.length > 0 && ( + + {users.map((user) => { + const emailOrPhone = user.email || user.phone + const userDisplayName = getDisplayName(user, '') + + return ( + { + void impersonateUser(user) + setIsUserComboboxOpen(false) + }} + className="gap-2" + > + + + {emailOrPhone || userDisplayName || user.id} + + {userDisplayName && userDisplayName !== emailOrPhone && ( + + {userDisplayName} + + )} + + ) + })} + + )} +
+
+
+
) } diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/index.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/index.tsx index 634648b3d15e1..6ea39c373c468 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/index.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/index.tsx @@ -1,12 +1,8 @@ -import { useState } from 'react' -import { Badge, Card, CardContent, CardHeader, CardTitle, cn } from 'ui' +import { Check, Database, UserCheck, UserX } from 'lucide-react' +import { Separator, ToggleGroup, ToggleGroupItem } from 'ui' -import { AnonIcon, AuthenticatedIcon, ServiceRoleIcon } from './Icons' -import { RoleImpersonationRadio } from './RoleImpersonationRadio' import { UserImpersonationSelector } from './UserImpersonationSelector' -import { DocsButton } from '@/components/ui/DocsButton' -import { DOCS_URL } from '@/lib/constants' -import { PostgrestRole } from '@/lib/role-impersonation' +import { useRoleImpersonationSelection } from './useRoleImpersonationSelection' import { useRoleImpersonationStateSnapshot, type RoleImpersonationController, @@ -17,17 +13,13 @@ export interface RoleImpersonationSelectorProps { serviceRoleLabel?: string disallowAuthenticatedOption?: boolean title?: string - orientation?: 'horizontal' | 'vertical' } /** - * Tightly coupled with the global role impersonation store - * Use RoleImpersonationSelectorInterface to control the logic externally + * Tightly coupled with the global role impersonation store. + * Use RoleImpersonationSelectorInterface to control the logic externally. */ export const RoleImpersonationSelector = (props: RoleImpersonationSelectorProps) => { - // 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 @@ -37,143 +29,97 @@ type RoleImpersonationSelectorInterfaceProps = RoleImpersonationSelectorProps & state: RoleImpersonationController } -export const RoleImpersonationSelectorInterface = ({ - state, - orientation, - serviceRoleLabel = 'Postgres', - disallowAuthenticatedOption = false, - header = 'Impersonate a database role', -}: RoleImpersonationSelectorInterfaceProps) => { - const isVertical = orientation === 'vertical' - - 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' && - state.role?.type === 'postgrest' && - state.role.role === 'authenticated' && - (('user' in state.role && state.role.user) || - ('externalAuth' in state.role && state.role.externalAuth)) // Check for either auth type - ) - - function onSelectedChange(value: PostgrestRole) { - if (value === 'service_role') { - // do not set a role for service role - // as the default role is the "service role" - state.setRole(undefined) - } - - if (value === 'anon') { - state.setRole({ - type: 'postgrest', - role: value, - }) - } - - setSelectedOption(value) - } +export const RoleImpersonationSelectorInterface = ( + props: RoleImpersonationSelectorInterfaceProps +) => { + const { state } = props + const serviceRoleLabel = props.serviceRoleLabel ?? 'Postgres' + const disallowAuthenticatedOption = props.disallowAuthenticatedOption ?? false + const { selectedOption, onSelectedChange, keepAuthenticatedSelected } = + useRoleImpersonationSelection(state) + + const roleSummary = { + service_role: 'Bypasses RLS and can return all rows.', + anon: 'Returns rows available to anonymous users.', + authenticated: 'Returns rows available to the selected user.', + }[selectedOption] return ( - - - {header} - - - -
{ - // don't allow form submission - e.preventDefault() +
+ { + event.preventDefault() + }} + > + { + if (value === 'service_role' || value === 'anon' || value === 'authenticated') { + void onSelectedChange(value) + } }} + variant="default" + aria-label={props.header ?? props.title ?? 'Run query as role'} + className="w-full flex-col items-stretch gap-0.5" > -
- } - fullWidth={isVertical} - /> - - } - fullWidth={isVertical} + + + + + {serviceRoleLabel} + Superuser + + + {selectedOption === 'service_role' && } + + + + + + + Anonymous + Not logged in + + + {selectedOption === 'anon' && } + + + {!disallowAuthenticatedOption && ( + + + + + Authenticated + + Logged-in user + + + + {selectedOption === 'authenticated' && } + + )} + + + {!disallowAuthenticatedOption && ( + <> + + - - {!disallowAuthenticatedOption && ( - } - fullWidth={isVertical} - /> - )} -
- - - {selectedOption === 'service_role' && ( -
-

- Full admin access - Default -

-

- The postgres role, which bypasses all Row - Level Security (RLS) policies. -

-
- )} - - {selectedOption === 'anon' && ( -
-

For unauthenticated access

-

- The anon role, which the API (PostgREST) - uses when a user is not logged in. -
- Row Level Security (RLS) policies apply. -

-
- )} - - {selectedOption === 'authenticated' && ( -
-

For authenticated access

-

- The authenticated role, which the API - (PostgREST) uses when a user is logged in. -
- Row Level Security (RLS) policies apply. -

-
+ )} - - - {selectedOption === 'authenticated' && ( - - - - )} - + + +
+

+ {roleSummary} +

+
+
) } diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/useRoleImpersonationSelection.ts b/apps/studio/components/interfaces/RoleImpersonationSelector/useRoleImpersonationSelection.ts new file mode 100644 index 0000000000000..49f6dc3260eb4 --- /dev/null +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/useRoleImpersonationSelection.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { getRoleSelectionUpdate, getSelectedRoleOption } from './RoleImpersonationSelector.utils' +import type { PostgrestRole } from '@/lib/role-impersonation' +import type { RoleImpersonationController } from '@/state/role-impersonation-state' +import type { ResponseError } from '@/types' + +export const useRoleImpersonationSelection = (state: RoleImpersonationController) => { + const [isAuthenticatedPending, setIsAuthenticatedPending] = useState(false) + + useEffect(() => { + if ( + state.role?.type === 'postgrest' && + state.role.role === 'authenticated' && + isAuthenticatedPending + ) { + setIsAuthenticatedPending(false) + } + }, [isAuthenticatedPending, state.role]) + + const selectedOption = isAuthenticatedPending + ? 'authenticated' + : getSelectedRoleOption(state.role) + + async function onSelectedChange(value: PostgrestRole) { + const update = getRoleSelectionUpdate(value) + if (!update.shouldSetRole) { + setIsAuthenticatedPending(true) + return + } + + try { + await state.setRole(update.role) + setIsAuthenticatedPending(false) + } catch (error) { + toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`) + } + } + + function keepAuthenticatedSelected() { + setIsAuthenticatedPending(true) + } + + return { selectedOption, onSelectedChange, keepAuthenticatedSelected } +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx index 1f2b714a315b9..99ebf6f37894b 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx @@ -34,7 +34,6 @@ export const RunAsSubMenu = (props: RunAsSubMenuProps) => { dropdown's typeahead. */} e.stopPropagation()}> { + const { ref } = useParams() + + const { + cpu, + disk, + memory, + connections, + isLoading: isMetricsLoading, + isError: isMetricsError, + } = useComputeMetrics({ + projectRef: ref, + }) + + const observabilityUrl = `/project/${ref}/observability/database` + + return ( + + + + {/* Stable live region: announces loading/failure, never the polled values */} + + {isMetricsLoading && 'Loading metrics'} + {!isMetricsLoading && isMetricsError && 'Metrics unavailable'} + + {/* h-4 matches the text-xs line height so the card doesn't shift when metrics load */} + {isMetricsLoading && ( +