- {planName} plan only includes up to {retentionDuration} day
- {retentionDuration > 1 ? 's' : ''} of log retention
+ Your plan only includes up to {retentionDays} day
+ {retentionDays !== undefined && retentionDays > 1 ? 's' : ''} of log retention
{organizationSlug ? (
diff --git a/apps/studio/components/ui/Logs/logs.utils.ts b/apps/studio/components/ui/Logs/logs.utils.ts
index b6eed6c53a4d1..5369cbe4c1475 100644
--- a/apps/studio/components/ui/Logs/logs.utils.ts
+++ b/apps/studio/components/ui/Logs/logs.utils.ts
@@ -1,13 +1,5 @@
import type { ChartIntervals } from 'types'
-export const LOG_RETENTION = {
- free: 1,
- pro: 7,
- team: 28,
- enterprise: 90,
- platform: 1,
-}
-
export const CHART_INTERVALS: ChartIntervals[] = [
{
key: '1hr',
@@ -15,7 +7,6 @@ export const CHART_INTERVALS: ChartIntervals[] = [
startValue: 1,
startUnit: 'hour',
format: 'MMM D, h:mma',
- availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
},
{
key: '1day',
@@ -23,7 +14,6 @@ export const CHART_INTERVALS: ChartIntervals[] = [
startValue: 24,
startUnit: 'hour',
format: 'MMM D, ha',
- availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
},
{
key: '7day',
@@ -31,6 +21,5 @@ export const CHART_INTERVALS: ChartIntervals[] = [
startValue: 7,
startUnit: 'day',
format: 'MMM D',
- availableIn: ['pro', 'team', 'enterprise'],
},
]
diff --git a/apps/studio/types/ui.ts b/apps/studio/types/ui.ts
index bd47d809ad5b1..f642cce1fc801 100644
--- a/apps/studio/types/ui.ts
+++ b/apps/studio/types/ui.ts
@@ -1,6 +1,5 @@
import type { PostgresColumn } from '@supabase/postgres-meta'
import { ProjectLogStatsVariables } from 'data/analytics/project-log-stats-query'
-import { PlanId } from 'data/subscriptions/types'
export interface Notification {
category: 'info' | 'error' | 'success' | 'loading'
@@ -23,7 +22,6 @@ export interface ChartIntervals {
startValue: number
startUnit: 'minute' | 'hour' | 'day'
format?: 'MMM D, h:mm:ssa' | 'MMM D, h:mma' | 'MMM D, ha' | 'MMM D'
- availableIn?: PlanId[]
}
export interface VaultSecret {
From 4b994fcb543db8e53c7955e1dde04329578a1fdf Mon Sep 17 00:00:00 2001
From: Jordi Enric <37541088+jordienr@users.noreply.github.com>
Date: Fri, 6 Mar 2026 13:24:37 +0100
Subject: [PATCH 05/12] feat: copy multiple logs (#43218)
Allows users to select logs and copy their contents for debugging, or
pass them on to assistant with one click.
## To test
- go to logs or log explorer
- select some logs
- try copying as json, markdown, or sending it to assistant.
---
.../interfaces/Settings/Logs/LogTable.tsx | 376 ++++++++++++------
.../Settings/Logs/Logs.utils.test.ts | 86 ++++
.../interfaces/Settings/Logs/Logs.utils.ts | 94 ++++-
.../Settings/Logs/MultiSelectActionBar.tsx | 98 +++++
.../components/ui/AiAssistantDropdown.tsx | 24 +-
.../project/[ref]/logs/explorer/index.tsx | 1 +
apps/studio/styles/react-data-grid-logs.scss | 10 +
.../tests/features/logs/LogTable.test.tsx | 42 +-
.../features/logs/LogsPreviewer.test.tsx | 32 +-
.../ui/src/components/shadcn/ui/checkbox.tsx | 2 +-
10 files changed, 606 insertions(+), 159 deletions(-)
create mode 100644 apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts
create mode 100644 apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx
diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx
index 09c2fab36b391..194a175f8bd43 100644
--- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx
+++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx
@@ -1,8 +1,8 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM, useParams } from 'common'
import { isEqual } from 'lodash'
-import { Copy, Eye, EyeOff, Play } from 'lucide-react'
-import { Key, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
+import { Copy, Eye, EyeOff, Play, X as XIcon } from 'lucide-react'
+import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Item, Menu, useContextMenu } from 'react-contexify'
import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid'
import { createPortal } from 'react-dom'
@@ -10,6 +10,7 @@ import { toast } from 'sonner'
import type { ResponseError } from 'types'
import {
Button,
+ Checkbox_Shadcn_,
cn,
copyToClipboard,
ResizableHandle,
@@ -24,11 +25,12 @@ import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewCol
import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender'
import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender'
import type { LogData, LogQueryError, QueryType } from './Logs.types'
-import { isDefaultLogPreviewFormat } from './Logs.utils'
+import { formatLogsAsJson, formatLogsAsMarkdown, isDefaultLogPreviewFormat } from './Logs.utils'
import LogSelection from './LogSelection'
import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer'
import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer'
import { LogsTableEmptyState } from './LogsTableEmptyState'
+import { MultiSelectActionBar } from './MultiSelectActionBar'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
import { useSelectedLog } from '@/hooks/analytics/useSelectedLog'
@@ -56,6 +58,7 @@ interface Props {
isSelectedLogLoading?: boolean
selectedLogError?: LogQueryError | ResponseError
onSelectedLogChange?: (log: LogData | null) => void
+ sqlQuery?: string
}
type LogMap = { [id: string]: LogData }
@@ -84,6 +87,7 @@ export const LogTable = ({
isSelectedLogLoading,
selectedLogError,
onSelectedLogChange,
+ sqlQuery,
}: Props) => {
const { ref } = useParams()
const { profile } = useProfile()
@@ -91,8 +95,10 @@ export const LogTable = ({
const { show: showContextMenu } = useContextMenu()
const [cellPosition, setCellPosition] = useState()
- const [selectionOpen, setSelectionOpen] = useState(false)
const [selectedRow, setSelectedRow] = useState(null)
+ const [selectedRows, setSelectedRows] = useState>(new Set())
+ const [anchorRowId, setAnchorRowId] = useState(null)
+ const [copiedFormat, setCopiedFormat] = useState<'json' | 'markdown' | null>(null)
const { can: canCreateLogQuery } = useAsyncCheckPermissions(
PermissionAction.CREATE,
@@ -105,14 +111,10 @@ export const LogTable = ({
const firstRow = data[0]
- // move timestamp to the first column, if it exists
function getFirstRow() {
if (!firstRow) return {}
-
const { timestamp, ...rest } = firstRow
-
if (!timestamp) return firstRow
-
return { timestamp, ...rest }
}
@@ -124,6 +126,82 @@ export const LogTable = ({
const panelContentMaxSize = 60
const LOGS_EXPLORER_CONTEXT_MENU_ID = 'logs-explorer-context-menu'
+
+ const getRowKey = useCallback(
+ (row: LogData): string => {
+ if (!hasId) return JSON.stringify(row)
+ return (row as LogData).id
+ },
+ [hasId]
+ )
+
+ const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => {
+ const deduped = [...new Set(data)] as LogData[]
+ if (!hasId) return [deduped, {}]
+ const map = deduped.reduce((acc: LogMap, d: LogData) => {
+ acc[d.id] = d
+ return acc
+ }, {})
+ return [deduped, map]
+ }, [data, hasId])
+
+ const logDataRows = useMemo(() => {
+ if (hasId && hasTimestamp) {
+ return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp)
+ } else {
+ return dedupedData
+ }
+ }, [dedupedData, hasId, hasTimestamp, logMap])
+
+ // Side panel is open only when a single row is selected via regular click (not multi-select)
+ const selectionOpen = Boolean((selectedLog || isSelectedLogLoading) && selectedRows.size === 0)
+
+ const selectedRowsData = useMemo(
+ () => logDataRows.filter((r) => selectedRows.has(getRowKey(r))),
+ [logDataRows, selectedRows, getRowKey]
+ )
+
+ const checkboxColumn: Column = {
+ key: 'multi-select',
+ name: '',
+ width: 32,
+ maxWidth: 32,
+ minWidth: 32,
+ renderCell: ({ row }) => {
+ const key = getRowKey(row)
+ const toggle = () => {
+ const next = new Set(selectedRows)
+ if (next.has(key)) {
+ next.delete(key)
+ } else {
+ next.add(key)
+ }
+ setSelectedRows(next)
+ setAnchorRowId(key)
+ if (next.size > 0) {
+ setSelectedRow(null)
+ onSelectedLogChange?.(null)
+ }
+ }
+ return (
+ {
+ e.stopPropagation()
+ toggle()
+ }}
+ >
+ e.stopPropagation()}
+ onCheckedChange={toggle}
+ />
+
+ )
+ },
+ }
+
const DEFAULT_COLUMNS = columnNames.map((v: keyof LogData, idx) => {
const column = `logs-column-${idx}`
const result: Column = {
@@ -148,7 +226,6 @@ export const LogTable = ({
},
minWidth: 128,
}
-
return result
})
@@ -161,25 +238,21 @@ export const LogTable = ({
case 'api':
columns = DatabaseApiColumnRender
break
-
case 'database':
columns = DatabasePostgresColumnRender
break
-
case 'fn_edge':
columns = FunctionsEdgeColumnRender
break
case 'functions':
columns = FunctionsLogsColumnRender
break
-
case 'auth':
columns = AuthColumnRenderer
break
case 'pg_cron':
columns = DatabasePostgresColumnRender
break
-
default:
if (firstRow && isDefaultLogPreviewFormat(firstRow)) {
columns = DefaultPreviewColumnRenderer
@@ -190,37 +263,25 @@ export const LogTable = ({
}
}
- const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => {
- const deduped = [...new Set(data)] as LogData[]
-
- if (!hasId) {
- return [deduped, {}]
- }
-
- const map = deduped.reduce((acc: LogMap, d: LogData) => {
- acc[d.id] = d
- return acc
- }, {})
-
- return [deduped, map]
- }, [data, hasId])
-
- const logDataRows = useMemo(() => {
- if (hasId && hasTimestamp) {
- return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp)
- } else {
- return dedupedData
- }
- }, [dedupedData, hasId, hasTimestamp, logMap])
+ if (columns.length > 0) {
+ columns = [checkboxColumn, ...columns]
+ }
const RowRenderer = useCallback<(key: Key, props: RenderRowProps) => ReactNode>(
(key, props) => {
const handleContextMenu = (e: React.MouseEvent) => {
- if (columns.length > 0) {
- setCellPosition({ row: props.row, column: columns[0] })
+ const firstDataColumn = columns.find((c) => c.key !== 'multi-select')
+ if (firstDataColumn) {
+ setCellPosition({ row: props.row, column: firstDataColumn })
}
showContextMenu(e, { id: LOGS_EXPLORER_CONTEXT_MENU_ID })
}
+ const handleClick = (e: React.MouseEvent) => {
+ // Check if clicking on the checkbox column - let that handler handle it
+ const target = e.target as HTMLElement
+ if (target.closest('[data-column-key="multi-select"]')) return
+ onRowClick(props.row)
+ }
return (
)
},
@@ -250,6 +312,89 @@ export const LogTable = ({
})
}
+ function onRowClick(row: LogData) {
+ const key = getRowKey(row)
+
+ // Regular single click — clear multi-select, open side panel
+ setSelectedRows(new Set())
+ setAnchorRowId(key)
+ setSelectedRow(row)
+ onSelectedLogChange?.(row)
+ }
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setSelectedRows(new Set())
+ setAnchorRowId(null)
+ return
+ }
+
+ // Arrow navigation only in single-select mode
+ if (!logDataRows.length || !selectedRow || selectedRows.size > 0) return
+
+ const currentIndex = logDataRows.findIndex((row) => isEqual(row, selectedRow))
+ if (currentIndex === -1) return
+
+ if (event.key === 'ArrowUp' && currentIndex > 0) {
+ const prevRow = logDataRows[currentIndex - 1]
+ onRowClick(prevRow)
+ } else if (event.key === 'ArrowDown' && currentIndex < logDataRows.length - 1) {
+ const nextRow = logDataRows[currentIndex + 1]
+ onRowClick(nextRow)
+ }
+ },
+ [logDataRows, selectedRow, selectedRows, onRowClick]
+ )
+
+ useEffect(() => {
+ if (!isSelectedLogLoading && !selectedLog) {
+ setSelectedRow(null)
+ }
+ }, [selectedLog, isSelectedLogLoading])
+
+ useEffect(() => {
+ window.addEventListener('keydown', handleKeyDown)
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [handleKeyDown])
+
+ useEffect(() => {
+ if (!isLoading && !selectedRow) {
+ const logData = data.find((x) => x.id === selectedLogId)
+ if (logData) setSelectedRow(logData)
+ }
+ }, [isLoading, data, selectedRow, selectedLogId])
+
+ // Clear multi-select when a new query starts loading
+ useEffect(() => {
+ if (isLoading) {
+ setSelectedRows(new Set())
+ setAnchorRowId(null)
+ }
+ }, [isLoading])
+
+ // Copy feedback timeout
+ useEffect(() => {
+ if (!copiedFormat) return
+ const timer = setTimeout(() => setCopiedFormat(null), 2000)
+ return () => clearTimeout(timer)
+ }, [copiedFormat])
+
+ function handleCopySelectedRows(format: 'json' | 'markdown') {
+ const text =
+ format === 'json'
+ ? formatLogsAsJson(selectedRowsData)
+ : formatLogsAsMarkdown(selectedRowsData)
+ copyToClipboard(text, () => {
+ setCopiedFormat(format)
+ toast.success(
+ `Copied ${selectedRowsData.length} log${selectedRowsData.length !== 1 ? 's' : ''} as ${format.toUpperCase()}`
+ )
+ })
+ }
+
const LogsExplorerTableHeader = () => (
{
if (!error) return null
-
const childProps = {
isCustomQuery: queryType ? false : true,
error: error!,
}
-
if (
typeof error === 'object' &&
error.error?.errors.find((err) => err.reason === 'resourcesExceeded')
) {
return
}
-
return (
@@ -339,55 +481,6 @@ export const LogTable = ({
else return
}
- function onRowClick(row: LogData) {
- setSelectedRow(row)
- onSelectedLogChange?.(row)
- }
-
- // Keyboard navigation
- const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
- if (!logDataRows.length || !selectedRow) return
-
- const currentIndex = logDataRows.findIndex((row) => isEqual(row, selectedRow))
- if (currentIndex === -1) return
-
- if (event.key === 'ArrowUp' && currentIndex > 0) {
- const prevRow = logDataRows[currentIndex - 1]
- onRowClick(prevRow)
- } else if (event.key === 'ArrowDown' && currentIndex < logDataRows.length - 1) {
- const nextRow = logDataRows[currentIndex + 1]
- onRowClick(nextRow)
- }
- },
- [logDataRows, selectedRow, onRowClick]
- )
-
- useEffect(() => {
- if (selectedLog || isSelectedLogLoading) {
- setSelectionOpen(true)
- }
- if (!isSelectedLogLoading && !selectedLog) {
- setSelectedRow(null)
- }
- }, [selectedLog, isSelectedLogLoading])
-
- useEffect(() => {
- window.addEventListener('keydown', handleKeyDown)
- return () => {
- window.removeEventListener('keydown', handleKeyDown)
- }
- }, [handleKeyDown])
-
- useEffect(() => {
- if (!isLoading && !selectedRow) {
- // [Joshen] Only want to run this once on a fresh session when log param is provided in URL
- // Subsequently, selectedRow state is just controlled by the user's clicks on LogTable
- const logData = data.find((x) => x.id === selectedLogId)
- if (logData) setSelectedRow(logData)
- }
- }, [isLoading])
-
if (!data) return null
return (
@@ -400,46 +493,70 @@ export const LogTable = ({
maxSize={`${panelContentMaxSize}`}
defaultSize={`${panelContentMaxSize}`}
>
-
{
- setCellPosition(row)
- }}
- onCellClick={(row) => {
- onRowClick(row.row)
- }}
- columns={columns}
- rowClass={(row: LogData) => {
- return cn(
- 'font-mono tracking-tight !bg-studio hover:!bg-surface-100 cursor-pointer',
- {
- '!bg-surface-200 rdg-row--focused': isEqual(row, selectedRow),
- }
- )
- }}
- rows={logDataRows}
- rowKeyGetter={(r) => {
- if (!hasId) return JSON.stringify(r)
- const row = r as LogData
- return row.id
- }}
- renderers={{
- renderRow: RowRenderer,
- noRowsFallback: !isLoading ? (
- <>
- {logDataRows.length === 0 && !error && }
- {error && }
- >
- ) : null,
- }}
- />
+
+
0 ? 40 : 0,
+ overflow: 'hidden',
+ transition: 'max-height 150ms ease',
+ }}
+ >
+ {
+ setSelectedRows(new Set())
+ setAnchorRowId(null)
+ }}
+ />
+
+
{
+ setCellPosition(row)
+ }}
+ columns={columns}
+ rowClass={(row: LogData) => {
+ const key = getRowKey(row)
+ const isMultiSelected = selectedRows.has(key)
+ const isSingleSelected = isEqual(row, selectedRow)
+ return cn(
+ 'font-mono tracking-tight !bg-studio hover:!bg-surface-100 cursor-pointer',
+ {
+ '!bg-surface-200 rdg-row--focused': isSingleSelected || isMultiSelected,
+ }
+ )
+ }}
+ rows={logDataRows}
+ rowKeyGetter={(r) => {
+ if (!hasId) return JSON.stringify(r)
+ const row = r as LogData
+ return row.id
+ }}
+ renderers={{
+ renderRow: RowRenderer,
+ noRowsFallback: !isLoading ? (
+ // gridColumn: '1 / -1' makes the fallback span all CSS grid columns,
+ // including the checkbox column we prepend, so it fills the full width.
+
+ {logDataRows.length === 0 && !error && }
+ {error && }
+
+ ) : null,
+ }}
+ />
+
{typeof window !== 'undefined' &&
createPortal(
diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx
index f0d6a7c638791..df6375cc0532d 100644
--- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx
+++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx
@@ -414,6 +414,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
projectRef={projectRef}
onSelectedLogChange={setSelectedLog}
selectedLog={selectedLog || undefined}
+ sqlQuery={editorValue}
/>
diff --git a/apps/studio/styles/react-data-grid-logs.scss b/apps/studio/styles/react-data-grid-logs.scss
index 1ec5c499e83f1..5274dc6b14d6e 100644
--- a/apps/studio/styles/react-data-grid-logs.scss
+++ b/apps/studio/styles/react-data-grid-logs.scss
@@ -13,6 +13,11 @@
text-overflow: clip;
}
+ .rdg-cell {
+ display: flex;
+ align-items: center;
+ }
+
.rdg-cell:first-child {
@apply pl-5;
}
@@ -48,6 +53,11 @@
text-overflow: clip;
}
+ .rdg-cell {
+ display: flex;
+ align-items: center;
+ }
+
.rdg-cell:first-child {
@apply pl-5;
}
diff --git a/apps/studio/tests/features/logs/LogTable.test.tsx b/apps/studio/tests/features/logs/LogTable.test.tsx
index d25554b3ad801..9ce8b87478a2a 100644
--- a/apps/studio/tests/features/logs/LogTable.test.tsx
+++ b/apps/studio/tests/features/logs/LogTable.test.tsx
@@ -6,24 +6,42 @@ import customParseFormat from 'dayjs/plugin/customParseFormat'
import relativeTime from 'dayjs/plugin/relativeTime'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
-import { beforeAll, expect, test, vi } from 'vitest'
-
-import { render } from '../../helpers'
+import { customRender as render } from 'tests/lib/custom-render'
+import { expect, test, vi } from 'vitest'
dayjs.extend(customParseFormat)
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(relativeTime)
-beforeAll(() => {
- vi.mock('next/router', () => import('next-router-mock'))
- vi.mock('nuqs', async () => {
- let queryValue = 'example'
- return {
- useQueryState: () => [queryValue, (v: string) => (queryValue = v)],
- }
- })
-})
+vi.mock('next/router', () => import('next-router-mock'))
+
+vi.mock('react-data-grid', () => ({
+ default: ({ columns, rows, renderers, role, headerRowHeight }: any) => (
+
+ {headerRowHeight !== 0 && (
+
+ {columns.map((col: any, colIdx: number) => (
+
+ {col.renderHeaderCell ? col.renderHeaderCell({}) : col.name}
+
+ ))}
+
+ )}
+ {rows.map((row: any, rowIdx: number) => (
+
+ {columns.map((col: any, colIdx: number) => (
+
+ {col.renderCell?.({ row, rowIdx, isCellSelected: false })}
+
+ ))}
+
+ ))}
+ {rows.length === 0 && renderers?.noRowsFallback}
+
+ ),
+ Row: ({ row, ...props }: any) =>
,
+}))
const fakeMicroTimestamp = dayjs().unix() * 1000
diff --git a/apps/studio/tests/features/logs/LogsPreviewer.test.tsx b/apps/studio/tests/features/logs/LogsPreviewer.test.tsx
index 9f11eaefeeade..463f2351ab621 100644
--- a/apps/studio/tests/features/logs/LogsPreviewer.test.tsx
+++ b/apps/studio/tests/features/logs/LogsPreviewer.test.tsx
@@ -1,18 +1,30 @@
import { screen, waitFor } from '@testing-library/react'
-import dayjs from 'dayjs'
-import utc from 'dayjs/plugin/utc'
-import { beforeEach, describe, expect, test, vi } from 'vitest'
+import userEvent from '@testing-library/user-event'
import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants'
import {
- LogsPreviewer,
calculateBarClickTimeRange,
+ LogsPreviewer,
} from 'components/interfaces/Settings/Logs/LogsPreviewer'
+import dayjs from 'dayjs'
+import utc from 'dayjs/plugin/utc'
+import useLogsPreview from 'hooks/analytics/useLogsPreview'
import { customRender, customRenderHook } from 'tests/lib/custom-render'
-import userEvent from '@testing-library/user-event'
+import { addAPIMock } from 'tests/lib/msw'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
-import useLogsPreview from 'hooks/analytics/useLogsPreview'
import { LOGS_API_MOCKS } from './logs.mocks'
-import { addAPIMock } from 'tests/lib/msw'
+
+vi.mock('components/interfaces/Settings/Logs/LogTable', () => ({
+ LogTable: ({ data }: { data: any[] }) => (
+
+ {data.map((row) => (
+
+ {row.event_message}
+
+ ))}
+
+ ),
+}))
dayjs.extend(utc)
@@ -80,15 +92,11 @@ test('useLogsPreview returns data from MSW', async () => {
expect(result.current.logData).toEqual(LOGS_API_MOCKS.result)
})
-test('LogsPreviewer renders the expected data from the API', async () => {
+test('LogsPreviewer passes API data to LogTable', async () => {
customRender(
)
- await waitFor(() => {
- expect(screen.getByRole('table')).toBeInTheDocument()
- })
-
const firstLogEventMessage = LOGS_API_MOCKS.result[0].event_message
await waitFor(() => {
diff --git a/packages/ui/src/components/shadcn/ui/checkbox.tsx b/packages/ui/src/components/shadcn/ui/checkbox.tsx
index 09d634e25ba11..ecc4e38410b9f 100644
--- a/packages/ui/src/components/shadcn/ui/checkbox.tsx
+++ b/packages/ui/src/components/shadcn/ui/checkbox.tsx
@@ -23,7 +23,7 @@ const Checkbox = React.forwardRef<
{...props}
>
-
+
))
From 5894e37b40080a7788e12725e5bdb91f1fe6afc2 Mon Sep 17 00:00:00 2001
From: Alaister Young
Date: Fri, 6 Mar 2026 21:51:37 +0900
Subject: [PATCH 06/12] =?UTF-8?q?[FE-2158]=20=E2=80=93=20feat(studio):=20A?=
=?UTF-8?q?dd=20exposed=20tables=20config=20to=20Postgrest=20settings=20(#?=
=?UTF-8?q?43280)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a feature flagged exposed tables config to postgrest settings:
To test:
- make sure the existing (feature flag off) settings work as expected
- make sure exposing and removing schemas works in the new mode
- make sure exposing and removing tables works in the new mode
- ideally try with a lot of tables to check search and infinite scroll
work as expected
- try exposing a schema without any tables (like `graphql_public`) and
make sure it doesn't get removed randomly when editing tables
- try with a table with custom permissions (for example `REVOKE SELECT
ON public.posts FROM anon;`) and make sure the user is informed with the
tooltip
---------
Co-authored-by: Nick Babadzhanian <33933459+pgnickb@users.noreply.github.com>
---
.../Settings/API/ExposedSchemaSelector.tsx | 143 +++++++
.../Settings/API/ExposedTableSelector.tsx | 305 +++++++++++++
.../Settings/API/PostgrestConfig.tsx | 401 ++++++++++++------
.../TableEditor/ApiAccessToggle.tsx | 133 +-----
.../TableEditor/TableEditor.tsx | 1 +
apps/studio/components/ui/SchemaSelector.tsx | 19 +-
.../privileges/exposed-table-counts-query.ts | 62 +++
.../exposed-tables-infinite-query.ts | 91 ++++
apps/studio/data/privileges/keys.ts | 10 +
apps/studio/data/privileges/privileges.sql.ts | 162 +++++++
.../update-exposed-tables-mutation.ts | 88 ++++
e2e/studio/features/api-access-toggle.spec.ts | 195 ++-------
12 files changed, 1198 insertions(+), 412 deletions(-)
create mode 100644 apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx
create mode 100644 apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx
create mode 100644 apps/studio/data/privileges/exposed-table-counts-query.ts
create mode 100644 apps/studio/data/privileges/exposed-tables-infinite-query.ts
create mode 100644 apps/studio/data/privileges/privileges.sql.ts
create mode 100644 apps/studio/data/privileges/update-exposed-tables-mutation.ts
diff --git a/apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx b/apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx
new file mode 100644
index 0000000000000..5b990eb7dc207
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx
@@ -0,0 +1,143 @@
+import { Check, ChevronsUpDown } from 'lucide-react'
+import { useMemo, useState } from 'react'
+import {
+ Button,
+ cn,
+ Command_Shadcn_,
+ CommandEmpty_Shadcn_,
+ CommandGroup_Shadcn_,
+ CommandInput_Shadcn_,
+ CommandItem_Shadcn_,
+ CommandList_Shadcn_,
+ Popover_Shadcn_,
+ PopoverContent_Shadcn_,
+ PopoverTrigger_Shadcn_,
+ ScrollArea,
+} from 'ui'
+import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
+import { useSchemasQuery } from '@/data/database/schemas-query'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
+import { pluralize } from '@/lib/helpers'
+
+interface ExposedSchemaSelectorProps {
+ disabled?: boolean
+ selectedSchemas: string[]
+ onToggleSchema: (schema: string) => void
+}
+
+export const ExposedSchemaSelector = ({
+ disabled = false,
+ selectedSchemas,
+ onToggleSchema,
+}: ExposedSchemaSelectorProps) => {
+ const [open, setOpen] = useState(false)
+
+ const { data: project } = useSelectedProjectQuery()
+
+ const {
+ data: allSchemas,
+ isPending,
+ isError,
+ isSuccess,
+ } = useSchemasQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+
+ const schemas = useMemo(
+ () =>
+ (allSchemas ?? [])
+ .filter((s) => {
+ if (s.name === 'graphql_public') return true
+ return !INTERNAL_SCHEMAS.includes(s.name)
+ })
+ .sort((a, b) => a.name.localeCompare(b.name)),
+ [allSchemas]
+ )
+
+ const selectedSet = useMemo(() => new Set(selectedSchemas), [selectedSchemas])
+ const selectedCount = schemas.filter((s) => selectedSet.has(s.name)).length
+
+ return (
+
+
+ }
+ >
+
+
+ {isSuccess
+ ? `${selectedCount} of ${schemas.length} ${pluralize(schemas.length, 'schema')} exposed`
+ : 'Loading schemas...'}
+
+
+
+
+
+
+
+
+
+ {isPending ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : isError ? (
+
+
Failed to retrieve schemas
+
+ ) : (
+ <>
+
+
+ No schemas found
+
+
+ 7 ? 'h-[210px]' : ''}>
+ {schemas.map((schema) => {
+ const isExposed = selectedSet.has(schema.name)
+
+ return (
+ {
+ onToggleSchema(schema.name)
+ }}
+ >
+
+ {isExposed && }
+ {schema.name}
+
+
+ )
+ })}
+
+ >
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx b/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx
new file mode 100644
index 0000000000000..18371b96ad7b9
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx
@@ -0,0 +1,305 @@
+import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
+import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
+import { Check, ChevronsUpDown, CircleAlert, Info } from 'lucide-react'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import {
+ Button,
+ cn,
+ Command_Shadcn_,
+ CommandGroup_Shadcn_,
+ CommandInput_Shadcn_,
+ CommandItem_Shadcn_,
+ CommandList_Shadcn_,
+ Popover_Shadcn_,
+ PopoverContent_Shadcn_,
+ PopoverTrigger_Shadcn_,
+ ScrollArea,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from 'ui'
+import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
+import { exposedTableCountsQueryOptions } from '@/data/privileges/exposed-table-counts-query'
+import { exposedTablesInfiniteQueryOptions } from '@/data/privileges/exposed-tables-infinite-query'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { pluralize } from '@/lib/helpers'
+
+interface ExposedTableSelectorProps {
+ className?: string
+ disabled?: boolean
+ selectedSchemas: string[]
+ pendingAddTableIds: number[]
+ pendingRemoveTableIds: number[]
+ onTogglePendingAdd: (tableId: number) => void
+ onTogglePendingRemove: (tableId: number) => void
+}
+
+export const ExposedTableSelector = ({
+ className,
+ disabled = false,
+ selectedSchemas,
+ pendingAddTableIds,
+ pendingRemoveTableIds,
+ onTogglePendingAdd,
+ onTogglePendingRemove,
+}: ExposedTableSelectorProps) => {
+ const [open, setOpen] = useState(false)
+ const [search, setSearch] = useState('')
+ const debouncedSearch = useDebounce(search, 300)
+
+ const { data: project } = useSelectedProjectQuery()
+
+ const scrollRootRef = useRef(null)
+ const [sentinelRef, entry] = useIntersectionObserver({
+ root: scrollRootRef.current,
+ threshold: 0,
+ rootMargin: '0px',
+ })
+
+ const { data: countsData, isPending: isCountsPending } = useQuery({
+ ...exposedTableCountsQueryOptions({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ selectedSchemas,
+ }),
+ placeholderData: keepPreviousData,
+ })
+ const pendingCount = pendingAddTableIds.length + pendingRemoveTableIds.length
+
+ const totalCount = countsData?.total_count ?? 0
+ const grantsCount = countsData?.grants_count ?? 0
+
+ const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } =
+ useInfiniteQuery({
+ ...exposedTablesInfiniteQueryOptions({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ search: search.length === 0 ? undefined : debouncedSearch || undefined,
+ }),
+ placeholderData: search.length > 0 ? keepPreviousData : undefined,
+ })
+
+ const tables = useMemo(() => data?.pages.flatMap((page) => page.tables) ?? [], [data?.pages])
+
+ const pendingAddSet = useMemo(() => new Set(pendingAddTableIds), [pendingAddTableIds])
+ const pendingRemoveSet = useMemo(() => new Set(pendingRemoveTableIds), [pendingRemoveTableIds])
+
+ useEffect(() => {
+ if (!isPending && !isFetching && entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage()
+ }
+ }, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage])
+
+ return (
+
+
+
+
+ }
+ >
+
+
+ {isCountsPending
+ ? 'Loading tables...'
+ : `${grantsCount} of ${totalCount} tables exposed${
+ pendingCount > 0
+ ? `, ${pendingCount} pending ${pluralize(pendingCount, 'change')}`
+ : ''
+ }`}
+
+
+
+
+
+
+
+
+
+ {isPending ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : isError ? (
+
+
Failed to retrieve tables
+
+ ) : (
+ <>
+ {search.length > 0 && tables.length === 0 && (
+
+ No tables found
+
+ )}
+ 7 ? 'h-[210px]' : ''}
+ >
+ {tables.map((table) => {
+ const isSchemaExposed = selectedSchemas.includes(table.schema)
+ const hasPendingAdd = pendingAddSet.has(table.id)
+ const hasPendingRemove = pendingRemoveSet.has(table.id)
+
+ const isCustomTable = table.status === 'custom'
+ const isGranted = table.status === 'granted'
+
+ const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove
+ const isExposed =
+ isSchemaExposed &&
+ (isCustomTable
+ ? hasPendingAdd
+ : isGranted
+ ? !hasPendingRemove
+ : hasPendingAdd)
+
+ const customGrantsTooltip = getCustomGrantsTooltip({
+ hasPendingAdd,
+ hasPendingRemove,
+ })
+
+ return (
+ {
+ if (!isSchemaExposed) return
+
+ if (isCustomTable) {
+ if (hasPendingAdd) {
+ onTogglePendingAdd(table.id)
+ onTogglePendingRemove(table.id)
+ } else if (hasPendingRemove) {
+ onTogglePendingRemove(table.id)
+ onTogglePendingAdd(table.id)
+ } else {
+ onTogglePendingAdd(table.id)
+ }
+ return
+ }
+
+ if (isGranted) {
+ onTogglePendingRemove(table.id)
+ } else {
+ onTogglePendingAdd(table.id)
+ }
+ }}
+ >
+
+
+ {isExposed && }
+
+
+ {`${table.schema}.${table.name}`}
+
+
+
+ {isCustomTable && (
+
+
+
+
+
+
+
+ {customGrantsTooltip}
+
+
+ )}
+ {!isSchemaExposed && (
+
+
+
+
+
+
+
+ {`The schema "${table.schema}" must be exposed before enabling this table.`}
+
+
+ )}
+
+
+
+ )
+ })}
+
+ {hasNextPage && (
+
+
+
+ )}
+
+ >
+ )}
+
+
+
+
+
+
+ )
+}
+
+const getCustomGrantsTooltip = ({
+ hasPendingAdd,
+ hasPendingRemove,
+}: {
+ hasPendingAdd: boolean
+ hasPendingRemove: boolean
+}) => {
+ if (hasPendingAdd) {
+ return 'This table has custom grants. Saving will override them with standard Data API grants for anon, authenticated, and service_role. Select again to revoke all grants instead.'
+ }
+
+ if (hasPendingRemove) {
+ return 'This table has custom grants. Saving will revoke all grants for anon, authenticated, and service_role. Select again to override with standard Data API grants instead.'
+ }
+
+ return 'This table has custom grants. Select it to override with standard Data API grants for anon, authenticated, and service_role.'
+}
diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
index 5a1da6e630977..24df32ed98682 100644
--- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
+++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
-import { indexOf } from 'lodash'
import { Lock } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -19,6 +19,7 @@ import {
Input_Shadcn_,
PrePostTab,
Skeleton,
+ useWatch_Shadcn_,
} from 'ui'
import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns'
import { Admonition } from 'ui-patterns/admonition'
@@ -32,16 +33,26 @@ import {
} from 'ui-patterns/multi-select'
import { z } from 'zod'
+import { ExposedSchemaSelector } from './ExposedSchemaSelector'
import { HardenAPIModal } from './HardenAPIModal'
+import { ExposedTableSelector } from '@/components/interfaces/Settings/API/ExposedTableSelector'
import { FormActions } from '@/components/ui/Forms/FormActions'
import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-postgrest-config-update-mutation'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useSchemasQuery } from '@/data/database/schemas-query'
+import { privilegeKeys } from '@/data/privileges/keys'
+import { useUpdateExposedTablesMutation } from '@/data/privileges/update-exposed-tables-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
+import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled'
+import useLatest from '@/hooks/misc/useLatest'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
+import { noop } from '@/lib/void'
+import type { ResponseError } from '@/types'
const formSchema = z.object({
+ // Fields for updatePostgrestConfig
dbSchema: z.array(z.string()),
dbExtraSearchPath: z.array(z.string()),
maxRows: z.number().max(1000000, "Can't be more than 1,000,000"),
@@ -51,11 +62,17 @@ const formSchema = z.object({
.max(1000, "Can't be more than 1000")
.optional()
.nullable(),
+
+ // Fields for expose toggles
+ tableIdsToAdd: z.array(z.number()),
+ tableIdsToRemove: z.array(z.number()),
})
export const PostgrestConfig = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
+ const queryClient = useQueryClient()
+ const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled()
const [showModal, setShowModal] = useState(false)
@@ -63,6 +80,7 @@ export const PostgrestConfig = () => {
data: config,
isError,
isPending: isLoadingConfig,
+ isSuccess: isSuccessConfig,
} = useProjectPostgrestConfigQuery({ projectRef })
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
@@ -77,27 +95,36 @@ export const PostgrestConfig = () => {
connectionString: project?.connectionString,
})
+ const configDbSchemas = useMemo(
+ () => (config?.db_schema ? config.db_schema.split(',').map((x) => x.trim()) : []),
+ [config?.db_schema]
+ )
+
const isLoading = isLoadingConfig || isLoadingSchemas
- const { mutate: updatePostgrestConfig, isPending: isUpdating } =
- useProjectPostgrestConfigUpdateMutation({
- onSuccess: () => {
- toast.success('Successfully saved settings')
- },
- })
+ const schemas = useMemo(
+ () =>
+ allSchemas
+ .filter((x) => !INTERNAL_SCHEMAS.some((schema) => schema === x.name))
+ .map((x) => {
+ return {
+ id: x.id,
+ value: x.name,
+ name: x.name,
+ disabled: false,
+ }
+ }) ?? [],
+ [allSchemas]
+ )
+
+ const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation()
+
+ const { mutateAsync: updateExposedTables } = useUpdateExposedTablesMutation()
+
+ const [isUpdating, setIsUpdating] = useState(false)
const formId = 'project-postgres-config'
- const hiddenSchema = [
- 'auth',
- 'pgbouncer',
- 'hooks',
- 'extensions',
- 'vault',
- 'storage',
- 'realtime',
- 'pgsodium',
- 'pgsodium_masks',
- ]
+
const { can: canUpdatePostgrestConfig, isSuccess: isPermissionsLoaded } =
useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest')
@@ -105,17 +132,19 @@ export const PostgrestConfig = () => {
(extensions ?? []).find((ext) => ext.name === 'pg_graphql')?.installed_version !== null
const defaultValues = useMemo(() => {
- const dbSchema = config?.db_schema ? config?.db_schema.split(',').map((x) => x.trim()) : []
return {
- dbSchema,
+ dbSchema: configDbSchemas,
maxRows: config?.max_rows,
+ // TODO: only display schemas that exist in the db
dbExtraSearchPath: (config?.db_extra_search_path ?? '')
.split(',')
.map((x) => x.trim())
- .filter((x) => x.length > 0 && allSchemas.find((y) => y.name === x)),
+ .filter(Boolean),
dbPool: config?.db_pool,
+ tableIdsToAdd: [] as number[],
+ tableIdsToRemove: [] as number[],
}
- }, [config, allSchemas])
+ }, [config, configDbSchemas])
const form = useForm>({
resolver: zodResolver(formSchema),
@@ -123,43 +152,81 @@ export const PostgrestConfig = () => {
defaultValues,
})
- const schemas =
- allSchemas
- .filter((x) => {
- const find = indexOf(hiddenSchema, x.name)
- if (find < 0) return x
- })
- .map((x) => {
- return {
- id: x.id,
- value: x.name,
- name: x.name,
- disabled: false,
- }
- }) ?? []
-
const resetForm = useCallback(() => {
form.reset({ ...defaultValues })
}, [form, defaultValues])
const onSubmit = async (values: z.infer) => {
- if (!projectRef) return console.error('Project ref is required') // is this needed ?
+ if (!projectRef) return console.error('Project ref is required')
+
+ setIsUpdating(true)
+
+ try {
+ let dbSchema = values.dbSchema.join(',')
+
+ if (isApiGrantTogglesEnabled) {
+ await updateExposedTables({
+ projectRef,
+ connectionString: project?.connectionString,
+ tableIdsToAdd: values.tableIdsToAdd,
+ tableIdsToRemove: values.tableIdsToRemove,
+ })
+ }
- updatePostgrestConfig({
- projectRef,
- dbSchema: values.dbSchema.join(', '),
- maxRows: values.maxRows,
- dbExtraSearchPath: values.dbExtraSearchPath.join(','),
- dbPool: values.dbPool ? values.dbPool : null,
- })
+ await updatePostgrestConfig(
+ {
+ projectRef,
+ dbSchema,
+ maxRows: values.maxRows,
+ dbExtraSearchPath: values.dbExtraSearchPath.join(','),
+ dbPool: values.dbPool ? values.dbPool : null,
+ },
+ { onError: noop }
+ )
+
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: privilegeKeys.exposedTablesInfinite(projectRef),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: privilegeKeys.exposedTableCounts(projectRef, watchedDbSchema),
+ }),
+ ])
+
+ toast.success('Successfully saved settings')
+ form.reset({
+ dbSchema: dbSchema
+ .split(',')
+ .map((x) => x.trim())
+ .filter(Boolean),
+ maxRows: values.maxRows,
+ dbExtraSearchPath: values.dbExtraSearchPath,
+ dbPool: values.dbPool,
+ tableIdsToAdd: [],
+ tableIdsToRemove: [],
+ })
+ } catch (error) {
+ toast.error('Failed to save settings: ' + (error as ResponseError).message || 'Unknown error')
+ } finally {
+ setIsUpdating(false)
+ }
}
+ const resetFormRef = useLatest(resetForm)
+ const isReady = isSuccessConfig && isSuccessSchemas
useEffect(() => {
- if (config && isSuccessSchemas) {
- resetForm()
+ if (isReady) {
+ resetFormRef.current()
}
- }, [config, isSuccessSchemas, resetForm])
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isReady])
+ const watchedDbSchema = useWatch_Shadcn_({ control: form.control, name: 'dbSchema' })
+ const watchedTableIdsToAdd = useWatch_Shadcn_({ control: form.control, name: 'tableIdsToAdd' })
+ const watchedTableIdsToRemove = useWatch_Shadcn_({
+ control: form.control,
+ name: 'tableIdsToRemove',
+ })
return (
@@ -176,88 +243,170 @@ export const PostgrestConfig = () => {
) : (
<>
-
- (
-
-
+
+ {
+ const current = form.getValues('dbSchema')
+ if (current.includes(schema)) {
+ form.setValue(
+ 'dbSchema',
+ current.filter((x) => x !== schema),
+ { shouldDirty: true }
+ )
+ } else {
+ form.setValue('dbSchema', [...current, schema], {
+ shouldDirty: true,
+ })
+ }
+ }}
+ />
+
+
+
+ {
+ const current = form.getValues('tableIdsToAdd')
+ if (current.includes(tableId)) {
+ form.setValue(
+ 'tableIdsToAdd',
+ current.filter((x) => x !== tableId),
+ { shouldDirty: true }
+ )
+ } else {
+ form.setValue('tableIdsToAdd', [...current, tableId], {
+ shouldDirty: true,
+ })
+ }
+ }}
+ onTogglePendingRemove={(tableId) => {
+ const current = form.getValues('tableIdsToRemove')
+ if (current.includes(tableId)) {
+ form.setValue(
+ 'tableIdsToRemove',
+ current.filter((x) => x !== tableId),
+ { shouldDirty: true }
+ )
+ } else {
+ form.setValue('tableIdsToRemove', [...current, tableId], {
+ shouldDirty: true,
+ })
+ }
+ }}
+ />
+
+
+ {watchedDbSchema.length === 0 && (
+
+ )}
+
+ ) : (
+
+ (
+
+
- {isLoadingSchemas ? (
-
-
-
- ) : (
-
-
-
-
- {schemas.length <= 0 ? (
-
- no
-
- ) : (
- schemas.map((x) => (
-
- {x.name}
+ layout="flex-row-reverse"
+ >
+ {isLoadingSchemas ? (
+
+
+
+ ) : (
+
+
+
+
+ {schemas.length <= 0 ? (
+
+ no
- ))
+ ) : (
+ schemas.map((x) => (
+
+ {x.name}
+
+ ))
+ )}
+
+
+
+ )}
+
+ {!field.value.includes('public') && field.value.length > 0 && (
+
+
+ You will not be able to query tables and views in the{' '}
+ public schema via
+ supabase-js or HTTP clients.
+
+ {isGraphqlExtensionEnabled && (
+ <>
+
+ Tables in the{' '}
+ public schema
+ are still exposed over our GraphQL endpoints.
+
+
+
+ Disable the pg_graphql extension
+
+
+ >
)}
-
-
-
+ >
+ }
+ />
)}
-
- {!field.value.includes('public') && field.value.length > 0 && (
-
-
- You will not be able to query tables and views in the{' '}
- public schema via
- supabase-js or HTTP clients.
-
- {isGraphqlExtensionEnabled && (
- <>
-
- Tables in the{' '}
- public schema are
- still exposed over our GraphQL endpoints.
-
-
-
- Disable the pg_graphql extension
-
-
- >
- )}
- >
- }
- />
- )}
-
- )}
- />
-
+
+ )}
+ />
+
+ )}
= {
- anon: 'Anonymous (anon)',
- authenticated: 'Authenticated',
-}
-
namespace ApiAccessToggleProps {
type New = {
type: 'new'
@@ -255,10 +238,11 @@ export const useTableApiAccessHandlerWithHistory = (
}
}
-type ApiAccessToggleProps = {
+type ApiAccessToggleComponentProps = {
projectRef?: string
schemaName?: string
tableName?: string
+ isNewRecord: boolean
handler: TableApiAccessHandlerWithHistoryReturn
}
@@ -266,10 +250,9 @@ export const ApiAccessToggle = ({
projectRef,
schemaName,
tableName,
+ isNewRecord,
handler,
-}: ApiAccessToggleProps): ReactNode => {
- const [isPrivilegesPopoverOpen, setIsPrivilegesPopoverOpen] = useState(false)
-
+}: ApiAccessToggleComponentProps): ReactNode => {
const isPending = handler.isPending
const isError = handler.isError
const isSchemaExposed = handler.data?.schemaExposed
@@ -290,104 +273,33 @@ export const ApiAccessToggle = ({
}
}
- const handlePrivilegesChange = (role: ApiAccessRole) => (values: string[]) => {
- if (!handler.isSuccess) return
- if (!isSchemaExposed) return
- if (!privileges) return
-
- handler.data?.setPrivileges((oldPrivileges) => {
- return {
- ...oldPrivileges,
- [role]: values.filter(isApiPrivilegeType),
- }
- })
- }
-
- const totalAvailablePrivileges = API_ACCESS_ROLES.length * API_PRIVILEGE_TYPES.length
- const totalSelectedPrivileges = Object.values(privileges ?? {}).reduce(
- (sum, rolePrivileges) => sum + rolePrivileges.length,
- 0
- )
- const hasPartialPrivileges =
- totalSelectedPrivileges > 0 && totalSelectedPrivileges < totalAvailablePrivileges
-
return (
-
-
- Data API Access
-
- This controls which operations the anon{' '}
- and authenticated roles
- can perform on this table via the Data API. Unselected privileges are revoked from
- these roles.
-
-
+
+
Data API Access
Allow this table to be queried via Supabase client libraries or the API directly
-
-
-
-
-
- {hasPartialPrivileges && (
-
- )}
-
-
-
- {!isDisabled && (
- <>
- Adjust API privileges per role
-
- {API_ACCESS_ROLES.map((role) => (
-
-
- {ROLE_LABELS[role]}
-
-
-
-
-
- {API_PRIVILEGE_TYPES.map((privilege) => (
-
- {privilege}
-
- ))}
-
-
-
-
- ))}
-
- >
- )}
-
-
+ {isNewRecord ? (
-
+ ) : (
+
}>
+
+ Manage access
+
+
+ )}
@@ -398,7 +310,6 @@ export const ApiAccessToggle = ({
isPending={isPending}
isError={isError}
isSchemaExposed={isSchemaExposed}
- hasNonEmptyPrivileges={!!privileges ? hasNonEmptyPrivileges : undefined}
/>
)
@@ -411,7 +322,6 @@ const SchemaExposureOptions = ({
isPending,
isError,
isSchemaExposed,
- hasNonEmptyPrivileges,
}: {
projectRef?: string
schemaName?: string
@@ -419,7 +329,6 @@ const SchemaExposureOptions = ({
isPending: boolean
isError: boolean
isSchemaExposed?: boolean
- hasNonEmptyPrivileges?: boolean
}): ReactNode => {
const { selectedDatabaseId } = useDatabaseSelectorStateSnapshot()
@@ -459,7 +368,7 @@ const SchemaExposureOptions = ({
)}
- {isSchemaExposed && apiUrl && hasNonEmptyPrivileges && (
+ {isSchemaExposed && apiUrl && (
)}
diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx
index d6871bc5b8518..0859b4f5dc864 100644
--- a/apps/studio/components/ui/SchemaSelector.tsx
+++ b/apps/studio/components/ui/SchemaSelector.tsx
@@ -1,25 +1,24 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { Check, ChevronsUpDown, Plus } from 'lucide-react'
-import { useState } from 'react'
-
import { useSchemasQuery } from 'data/database/schemas-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { Check, ChevronsUpDown, Plus } from 'lucide-react'
+import { useState } from 'react'
import {
+ Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
- Alert_Shadcn_,
Button,
+ Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
- Command_Shadcn_,
+ Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
- Popover_Shadcn_,
ScrollArea,
Skeleton,
} from 'ui'
@@ -29,7 +28,8 @@ interface SchemaSelectorProps {
disabled?: boolean
size?: 'tiny' | 'small'
showError?: boolean
- selectedSchemaName: string
+ selectedSchemaName?: string
+ placeholderLabel?: string
supportSelectAll?: boolean
excludedSchemas?: string[]
onSelectSchema: (name: string) => void
@@ -43,6 +43,7 @@ export const SchemaSelector = ({
size = 'tiny',
showError = true,
selectedSchemaName,
+ placeholderLabel = 'Choose a schema...',
supportSelectAll = false,
excludedSchemas = [],
onSelectSchema,
@@ -122,7 +123,7 @@ export const SchemaSelector = ({
) : (
-
Choose a schema...
+
{placeholderLabel}
)}
@@ -158,7 +159,7 @@ export const SchemaSelector = ({
)}
)}
- {schemas?.map((schema) => (
+ {schemas.map((schema) => (
{
+ if (!projectRef) throw new Error('projectRef is required')
+ if (!selectedSchemas) throw new Error('selectedSchemas is required')
+
+ const sql = getExposedTableCountsSql({ selectedSchemas })
+
+ const { result } = await executeSql(
+ {
+ projectRef,
+ connectionString,
+ sql,
+ queryKey: ['exposed-table-counts', selectedSchemas],
+ },
+ signal
+ )
+
+ return result[0] as ExposedTableCountsResponse
+}
+
+export type ExposedTableCountsData = Awaited>
+export type ExposedTableCountsError = ResponseError
+
+export const exposedTableCountsQueryOptions = (
+ { projectRef, connectionString, selectedSchemas }: ExposedTableCountsVariables,
+ { enabled = true }: { enabled?: boolean } = {}
+) => {
+ return queryOptions({
+ // eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query
+ queryKey: privilegeKeys.exposedTableCounts(projectRef, selectedSchemas),
+ queryFn: ({ signal }) =>
+ getExposedTableCounts(
+ {
+ projectRef,
+ connectionString,
+ selectedSchemas,
+ },
+ signal
+ ),
+ enabled: enabled && typeof projectRef !== 'undefined',
+ })
+}
diff --git a/apps/studio/data/privileges/exposed-tables-infinite-query.ts b/apps/studio/data/privileges/exposed-tables-infinite-query.ts
new file mode 100644
index 0000000000000..9b4fd1eebec37
--- /dev/null
+++ b/apps/studio/data/privileges/exposed-tables-infinite-query.ts
@@ -0,0 +1,91 @@
+import { infiniteQueryOptions } from '@tanstack/react-query'
+import { executeSql } from 'data/sql/execute-sql-query'
+import type { ResponseError } from 'types'
+
+import { privilegeKeys } from './keys'
+import { getExposedTablesSql } from './privileges.sql'
+
+export const EXPOSED_TABLES_PAGE_LIMIT = 50
+
+export type ExposedTablesVariables = {
+ projectRef?: string
+ connectionString?: string | null
+ search?: string
+}
+
+export type ExposedTable = {
+ id: number
+ schema: string
+ name: string
+ status: 'granted' | 'revoked' | 'custom'
+}
+
+export type ExposedTablesResponse = {
+ total_count: number
+ tables: ExposedTable[]
+}
+
+export async function getExposedTables(
+ {
+ projectRef,
+ connectionString,
+ search,
+ page = 0,
+ limit = EXPOSED_TABLES_PAGE_LIMIT,
+ }: ExposedTablesVariables & { page?: number; limit?: number },
+ signal?: AbortSignal
+): Promise {
+ if (!projectRef) throw new Error('projectRef is required')
+
+ const offset = page * limit
+
+ const sql = getExposedTablesSql({ search, offset, limit })
+
+ const { result } = await executeSql(
+ {
+ projectRef,
+ connectionString,
+ sql,
+ queryKey: ['exposed-tables', page],
+ },
+ signal
+ )
+
+ return result[0] as ExposedTablesResponse
+}
+
+export type ExposedTablesData = Awaited>
+export type ExposedTablesError = ResponseError
+
+export const exposedTablesInfiniteQueryOptions = (
+ { projectRef, connectionString, search }: ExposedTablesVariables,
+ { enabled = true }: { enabled?: boolean } = {}
+) => {
+ return infiniteQueryOptions({
+ // eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query
+ queryKey: privilegeKeys.exposedTablesInfinite(projectRef, search),
+ queryFn: ({ signal, pageParam }) =>
+ getExposedTables(
+ {
+ projectRef,
+ connectionString,
+ search,
+ page: pageParam,
+ },
+ signal
+ ),
+ enabled: enabled && typeof projectRef !== 'undefined',
+ initialPageParam: 0,
+ getNextPageParam(lastPage, pages) {
+ const page = pages.length
+ const currentTotalCount = page * EXPOSED_TABLES_PAGE_LIMIT
+ const totalCount = lastPage.total_count ?? 0
+
+ if (currentTotalCount >= totalCount) {
+ return undefined
+ }
+
+ return page
+ },
+ })
+}
diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts
index 9e92f4ed95c39..328e39c325f00 100644
--- a/apps/studio/data/privileges/keys.ts
+++ b/apps/studio/data/privileges/keys.ts
@@ -3,4 +3,14 @@ export const privilegeKeys = {
['projects', projectRef, 'database', 'table-privileges'] as const,
columnPrivilegesList: (projectRef: string | undefined) =>
['projects', projectRef, 'database', 'column-privileges'] as const,
+ exposedTablesInfinite: (projectRef: string | undefined, search?: string) =>
+ [
+ 'projects',
+ projectRef,
+ 'privileges',
+ 'exposed-tables-infinite',
+ ...(search ? ([{ search }] as const) : []),
+ ] as const,
+ exposedTableCounts: (projectRef: string | undefined, selectedSchemas: string[]) =>
+ ['projects', projectRef, 'privileges', 'exposed-table-counts', ...selectedSchemas] as const,
}
diff --git a/apps/studio/data/privileges/privileges.sql.ts b/apps/studio/data/privileges/privileges.sql.ts
new file mode 100644
index 0000000000000..2f72c9699f25f
--- /dev/null
+++ b/apps/studio/data/privileges/privileges.sql.ts
@@ -0,0 +1,162 @@
+import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
+
+export const IGNORED_SCHEMAS = [...INTERNAL_SCHEMAS, 'pg_catalog']
+
+const IGNORED_SCHEMAS_LIST = IGNORED_SCHEMAS.map((s) => `'${s}'`).join(', ')
+
+/**
+ * Builds the shared `table_privileges` and `table_grants` CTEs used by
+ * both the exposed-tables list query and the counts-only query.
+ *
+ * Returns SQL text meant to follow `WITH` (no leading `WITH` keyword).
+ * Callers that append additional CTEs should add a comma after interpolation.
+ */
+function getTableGrantsCTEs({ search }: { search?: string } = {}) {
+ return /* SQL */ `
+ table_privileges as (
+ select
+ c.oid::int as id,
+ n.nspname as schema_name,
+ c.relname as name,
+ c.relkind as kind,
+
+ -- Anon Privileges
+ bool_or(pr.rolname = 'anon' and acl.privilege_type = 'SELECT') as anon_select,
+ bool_or(pr.rolname = 'anon' and acl.privilege_type = 'INSERT') as anon_insert,
+ bool_or(pr.rolname = 'anon' and acl.privilege_type = 'UPDATE') as anon_update,
+ bool_or(pr.rolname = 'anon' and acl.privilege_type = 'DELETE') as anon_delete,
+
+ -- Authenticated Privileges
+ bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'SELECT') as auth_select,
+ bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'INSERT') as auth_insert,
+ bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'UPDATE') as auth_update,
+ bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'DELETE') as auth_delete,
+
+ -- Service Role Privileges
+ bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'SELECT') as srv_select,
+ bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'INSERT') as srv_insert,
+ bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'UPDATE') as srv_update,
+ bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'DELETE') as srv_delete
+
+ from pg_class c
+ join pg_namespace n
+ on n.oid = c.relnamespace
+ left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl
+ on true
+ left join pg_roles pr
+ on pr.oid = acl.grantee
+ where c.relkind in ('r', 'p', 'v', 'm', 'f')
+ and n.nspname not in (${IGNORED_SCHEMAS_LIST})
+ ${search ? `and (n.nspname || '.' || c.relname) ilike '%${search}%'` : ''}
+ group by c.oid, n.nspname, c.relname, c.relkind
+ ),
+ table_grants as (
+ select
+ id,
+ schema_name,
+ name,
+ kind,
+ case
+ -- 1. Strict Granted: All 3 roles possess ALL 4 privileges
+ when (
+ anon_select and anon_insert and anon_update and anon_delete and
+ auth_select and auth_insert and auth_update and auth_delete and
+ srv_select and srv_insert and srv_update and srv_delete
+ ) then 'granted'
+
+ -- 2. Strict Revoked: NO role possesses ANY privilege
+ when not (
+ anon_select or anon_insert or anon_update or anon_delete or
+ auth_select or auth_insert or auth_update or auth_delete or
+ srv_select or srv_insert or srv_update or srv_delete
+ ) then 'revoked'
+
+ -- 3. Custom: Anything in between
+ else 'custom'
+ end as status
+ from table_privileges
+ )
+ `
+}
+
+export function getExposedTablesSql({
+ search,
+ offset,
+ limit,
+}: {
+ search?: string
+ offset: number
+ limit: number
+}) {
+ return /* SQL */ `
+ with ${getTableGrantsCTEs({ search })}
+ select
+ (select count(*)::int from table_grants) as total_count,
+ coalesce(
+ (
+ select jsonb_agg(
+ jsonb_build_object(
+ 'id', tg.id,
+ 'schema', tg.schema_name,
+ 'name', tg.name,
+ 'status', tg.status
+ )
+ )
+ from (
+ select *
+ from table_grants
+ order by schema_name, name
+ offset ${offset}
+ limit ${limit}
+ ) tg
+ ),
+ '[]'::jsonb
+ ) as tables;
+ `
+}
+
+export function getExposedTableCountsSql({ selectedSchemas }: { selectedSchemas: string[] }) {
+ const schemasList =
+ selectedSchemas.length > 0 ? selectedSchemas.map((s) => `'${s}'`).join(', ') : "''"
+
+ return /* SQL */ `
+ with ${getTableGrantsCTEs()}
+ select
+ count(*)::int as total_count,
+ (count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count
+ from table_grants
+ `
+}
+
+export function getExposedSchemasSql() {
+ return /* SQL */ `
+ select coalesce(
+ (
+ select jsonb_agg(distinct schema_name order by schema_name)
+ from (
+ select n.nspname as schema_name
+ from pg_class c
+ join pg_namespace n on n.oid = c.relnamespace
+ left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl on true
+ where c.relkind in ('r', 'p', 'v', 'm', 'f')
+ and n.nspname not in (${IGNORED_SCHEMAS_LIST})
+ group by c.oid, n.nspname
+ having
+ bool_or(
+ pg_catalog.pg_get_userbyid(acl.grantee) = 'anon'
+ and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
+ )
+ and bool_or(
+ pg_catalog.pg_get_userbyid(acl.grantee) = 'authenticated'
+ and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
+ )
+ and bool_or(
+ pg_catalog.pg_get_userbyid(acl.grantee) = 'service_role'
+ and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
+ )
+ ) t
+ ),
+ '[]'::jsonb
+ ) as schemas;
+ `
+}
diff --git a/apps/studio/data/privileges/update-exposed-tables-mutation.ts b/apps/studio/data/privileges/update-exposed-tables-mutation.ts
new file mode 100644
index 0000000000000..47b576d0209d7
--- /dev/null
+++ b/apps/studio/data/privileges/update-exposed-tables-mutation.ts
@@ -0,0 +1,88 @@
+import { useMutation } from '@tanstack/react-query'
+import { executeSql } from 'data/sql/execute-sql-query'
+import { toast } from 'sonner'
+import type { UseCustomMutationOptions } from 'types'
+
+import type { ConnectionVars } from '../common.types'
+import { getExposedSchemasSql } from './privileges.sql'
+
+export type UpdateExposedTablesVariables = ConnectionVars & {
+ tableIdsToAdd: number[]
+ tableIdsToRemove: number[]
+}
+
+const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => {
+ if (oids.length === 0) return ''
+
+ const privilegeClause =
+ action === 'grant'
+ ? 'grant select, insert, update, delete on table %I.%I to anon, authenticated, service_role'
+ : 'revoke all on table %I.%I from anon, authenticated, service_role'
+
+ return /* SQL */ `
+ do $$
+ declare
+ relname name;
+ nspname name;
+ begin
+ for nspname, relname in
+ select n.nspname, c.relname
+ from pg_class c
+ join pg_namespace n on n.oid = c.relnamespace
+ where c.oid in (${oids.join(', ')})
+ loop
+ execute format('${privilegeClause}', relname, nspname);
+ end loop;
+ end $$;
+ `
+}
+
+export async function updateExposedTables({
+ projectRef,
+ connectionString,
+ tableIdsToAdd,
+ tableIdsToRemove,
+}: UpdateExposedTablesVariables): Promise {
+ if (!projectRef) throw new Error('projectRef is required')
+
+ const sqlParts: string[] = []
+
+ if (tableIdsToAdd.length > 0) {
+ sqlParts.push(buildTablePrivilegesSql(tableIdsToAdd, 'grant'))
+ }
+
+ if (tableIdsToRemove.length > 0) {
+ sqlParts.push(buildTablePrivilegesSql(tableIdsToRemove, 'revoke'))
+ }
+
+ sqlParts.push(getExposedSchemasSql())
+
+ const { result } = await executeSql({
+ projectRef,
+ connectionString,
+ sql: sqlParts.join('\n'),
+ queryKey: ['update-exposed-tables'],
+ })
+
+ return (result[0] as { schemas: string[] }).schemas
+}
+
+type UpdateExposedTablesData = Awaited>
+
+export const useUpdateExposedTablesMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseCustomMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ return useMutation({
+ mutationFn: (vars: UpdateExposedTablesVariables) => updateExposedTables(vars),
+ onError(error: Error) {
+ toast.error(`Failed to update table access: ${error.message}`)
+ },
+ ...(onError ? { onError } : {}),
+ ...options,
+ })
+}
diff --git a/e2e/studio/features/api-access-toggle.spec.ts b/e2e/studio/features/api-access-toggle.spec.ts
index 85269eb6c2f47..a61d38463be2d 100644
--- a/e2e/studio/features/api-access-toggle.spec.ts
+++ b/e2e/studio/features/api-access-toggle.spec.ts
@@ -58,11 +58,10 @@ async function verifyTablePrivileges(
/**
* Locates the API access toggle switch for Data API Access.
- * The switch is labeled by the nearby "Data API Access" text.
+ * Only present when creating or duplicating a table (not when editing).
*/
-function getApiAccessToggle(page: Page) {
+function getApiAccessSwitch(page: Page) {
const sidePanel = page.getByTestId('table-editor-side-panel')
- // The switch is near the "Data API Access" label - get the section first, then find the switch
const dataApiSection = sidePanel
.locator('div')
.filter({ hasText: 'Data API Access' })
@@ -71,23 +70,12 @@ function getApiAccessToggle(page: Page) {
}
/**
- * Locates the settings button for granular privilege settings.
+ * Locates the "Manage access" link shown when editing an existing table.
+ * Links out to the API settings page.
*/
-function getPrivilegeSettingsButton(page: Page) {
+function getManageAccessLink(page: Page) {
const sidePanel = page.getByTestId('table-editor-side-panel')
- return sidePanel.getByRole('button', { name: 'Configure API privileges' })
-}
-
-/**
- * Gets the privilege selector combobox for a specific role in the privileges popover.
- * The popover must already be open.
- */
-function getRolePrivilegeSelector(page: Page, roleLabel: 'Anonymous (anon)' | 'Authenticated') {
- // The popover is a dialog with structure: paragraph (role label) followed by combobox
- // We find the paragraph with the role text, then get the adjacent combobox
- const popoverContent = page.locator('[data-radix-popper-content-wrapper]')
- // Get the paragraph containing the role label, then navigate to the sibling combobox
- return popoverContent.getByText(roleLabel, { exact: true }).locator('..').getByRole('combobox')
+ return sidePanel.getByRole('link', { name: 'Manage access' })
}
test.describe('API Access Toggle', () => {
@@ -107,8 +95,8 @@ test.describe('API Access Toggle', () => {
// Fill in table name
await page.getByTestId('table-name-input').fill(tableName)
- // Find and click the API access toggle to turn it off
- const toggle = getApiAccessToggle(page)
+ // Verify the toggle is checked by default
+ const toggle = getApiAccessSwitch(page)
await expect(toggle).toBeChecked()
// Create the table
@@ -155,8 +143,8 @@ test.describe('API Access Toggle', () => {
// Fill in table name
await page.getByTestId('table-name-input').fill(tableName)
- // Find and click the API access toggle to turn it off
- const toggle = getApiAccessToggle(page)
+ // Toggle API access off
+ const toggle = getApiAccessSwitch(page)
await expect(toggle).toBeChecked()
await toggle.click()
await expect(toggle, 'Toggle should be unchecked after clicking').not.toBeChecked()
@@ -195,7 +183,7 @@ test.describe('API Access Toggle', () => {
})
})
- test('shows API access toggle when editing an existing table', async ({ page, ref }) => {
+ test('shows Manage access link when editing an existing table', async ({ page, ref }) => {
const tableName = `${TABLE_NAME_PREFIX}_edit`
// Create a table first
@@ -207,138 +195,40 @@ test.describe('API Access Toggle', () => {
await page.getByRole('button', { name: 'Save' }).click()
await createPromise
- // Wait for success toast which indicates all operations are complete
await expect(
page.getByText(`Table ${tableName} is good to go!`),
'Success toast should appear after table creation'
).toBeVisible({ timeout: 15000 })
- // Dismiss toast to prevent it from blocking subsequent interactions
await dismissToastsIfAny(page)
-
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
+ await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible()
- // Verify table was created
- await expect(
- page.getByRole('button', { name: `View ${tableName}`, exact: true }),
- 'Table should be visible after creation'
- ).toBeVisible()
-
- // Verify default full privileges were granted
- await verifyTablePrivileges('public', tableName, {
- anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
- authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
- })
-
- // Navigate back to table editor
- let loadPromise = waitForTableToLoad(page, ref)
+ // Navigate back and open the edit panel
+ const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
- // Click on the table to view it
const navigationPromise = page.waitForURL(/\/editor\/\d+\?schema=public$/)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await navigationPromise
- // Open edit table dialog via context menu
await openTableContextMenu(page, tableName)
await page.getByRole('menuitem', { name: 'Edit table' }).click()
-
- // Verify the side panel is open
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
- // Verify Data API Access section is visible
+ // Data API Access section is visible
await expect(
page.getByText('Data API Access'),
'Data API Access label should be visible in edit mode'
).toBeVisible()
- // Verify the toggle is present
- const toggle = getApiAccessToggle(page)
- await expect(toggle, 'API Access toggle should be visible in edit mode').toBeVisible()
- })
-
- test('creates table with partial privileges and verifies correct grants', async ({
- page,
- ref,
- }) => {
- const tableName = `${TABLE_NAME_PREFIX}_partial_grants`
-
- // Open new table dialog
- await page.getByRole('button', { name: 'New table', exact: true }).click()
- await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
-
- // Fill in table name
- await page.getByTestId('table-name-input').fill(tableName)
-
- // Open the privilege settings popover
- const settingsButton = getPrivilegeSettingsButton(page)
- await settingsButton.click()
-
- await expect(page.getByText('Adjust API privileges per role')).toBeVisible()
-
- // Modify anon privileges - leave only SELECT
- const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)')
- await anonSelector.click()
-
- // Click DELETE to toggle it off
- await page.getByRole('option', { name: 'DELETE' }).click()
- // Click UPDATE to toggle it off
- await page.getByRole('option', { name: 'UPDATE' }).click()
- await page.getByRole('option', { name: 'INSERT' }).click()
-
- // Close the dropdown by clicking the combobox again
- await anonSelector.click()
-
- // Wait for dropdown to close
- await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 })
-
- // Modify authenticated privileges - remove DELETE and UPDATE (leave SELECT + INSERT)
- const authSelector = getRolePrivilegeSelector(page, 'Authenticated')
- await authSelector.click()
-
- // Remove all except SELECT
- await page.getByRole('option', { name: 'DELETE' }).click()
- await page.getByRole('option', { name: 'UPDATE' }).click()
-
- // Close the dropdown by clicking the combobox again
- await authSelector.click()
-
- // Wait for dropdown to close
- await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 })
-
- // Close the popover by pressing Escape
- await page.keyboard.press('Escape')
-
- // Create the table
- const createTablePromise = createApiResponseWaiter(
- page,
- 'pg-meta',
- ref,
- 'query?key=table-create'
- )
- await page.getByRole('button', { name: 'Save' }).click()
- await createTablePromise
-
- // Wait for success toast which indicates all operations (including privilege updates) are complete
- await expect(
- page.getByText(`Table ${tableName} is good to go!`),
- 'Success toast should appear after table creation'
- ).toBeVisible({ timeout: 15000 })
-
- await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
-
- // Verify table was created
+ // In edit mode the panel shows a "Manage access" link instead of a toggle switch
+ const manageAccessLink = getManageAccessLink(page)
await expect(
- page.getByRole('button', { name: `View ${tableName}`, exact: true }),
- 'Table should be visible after creation'
+ manageAccessLink,
+ 'Manage access link should be visible in edit mode'
).toBeVisible()
-
- // Verify partial grants - anon: SELECT; authenticated: SELECT, INSERT
- await verifyTablePrivileges('public', tableName, {
- anon: ['SELECT'],
- authenticated: ['SELECT', 'INSERT'],
- })
})
test('preserves API grants when editing non-privilege table properties', async ({
@@ -347,78 +237,54 @@ test.describe('API Access Toggle', () => {
}) => {
const tableName = `${TABLE_NAME_PREFIX}_preserve_grants`
- // Step 1: Create a table with partial privileges (only SELECT and INSERT for anon)
+ // Step 1: Create a table with API access on (default — full grants)
await page.getByRole('button', { name: 'New table', exact: true }).click()
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
-
await page.getByTestId('table-name-input').fill(tableName)
- // Open privilege settings and set partial privileges
- const settingsButton = getPrivilegeSettingsButton(page)
- await settingsButton.click()
- await expect(page.getByText('Adjust API privileges per role')).toBeVisible()
-
- // Modify anon privileges - keep only SELECT and INSERT
- const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)')
- await anonSelector.click()
- await page.getByRole('option', { name: 'DELETE' }).click()
- await page.getByRole('option', { name: 'UPDATE' }).click()
- await anonSelector.click()
- await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 })
-
- // Keep authenticated with full privileges
- await page.keyboard.press('Escape') // Close popover
+ // Verify toggle is on by default
+ const toggle = getApiAccessSwitch(page)
+ await expect(toggle).toBeChecked()
- // Create the table
let createPromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-create')
await page.getByRole('button', { name: 'Save' }).click()
await createPromise
- // Wait for success toast which indicates all operations (including privilege updates) are complete
await expect(
page.getByText(`Table ${tableName} is good to go!`),
'Success toast should appear after table creation'
).toBeVisible({ timeout: 15000 })
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
+ await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible()
- await expect(
- page.getByRole('button', { name: `View ${tableName}`, exact: true }),
- 'Table should be created'
- ).toBeVisible()
-
- // Verify initial privileges before edit
+ // Verify full privileges were granted
await verifyTablePrivileges('public', tableName, {
- anon: ['SELECT', 'INSERT'],
+ anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
})
- // Navigate back to table editor
+ // Step 2: Navigate back and edit only the description
let loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
- // Step 2: Edit the table's description (without touching privileges)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
await openTableContextMenu(page, tableName)
await page.getByRole('menuitem', { name: 'Edit table' }).click()
-
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
- // Add a description without modifying privileges
const descriptionInput = page
.getByTestId('table-editor-side-panel')
.getByPlaceholder('Optional')
await descriptionInput.fill('Test description for grant preservation')
- // Save the changes
const updatePromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-update')
await page.getByRole('button', { name: 'Save' }).click()
await updatePromise
- // Wait for success toast which indicates all operations are complete
await expect(
page.getByText(`Successfully updated ${tableName}!`),
'Success toast should appear after table update'
@@ -426,18 +292,17 @@ test.describe('API Access Toggle', () => {
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
- // Step 3: Verify the privileges remain unchanged after edit
+ // Step 3: Verify the full privileges are unchanged after the description edit
await verifyTablePrivileges('public', tableName, {
- anon: ['SELECT', 'INSERT'],
+ anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
})
- // Navigate back to table editor for cleanup
+ // Clean up
loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
- // Clean up
await deleteTable(page, ref, tableName)
})
})
From 95283fafb56bcfc881cae32cad39971be89199de Mon Sep 17 00:00:00 2001
From: Kanishk Dudeja
Date: Fri, 6 Mar 2026 18:54:47 +0530
Subject: [PATCH 07/12] fix(billing): remove duplicate tax ID entry for New
Zealand (#43482)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR removes a duplicate `nz_gst` entry from the tax ID types list.
**Testing**
---
.../BillingSettings/BillingCustomerData/TaxID.constants.ts | 7 -------
1 file changed, 7 deletions(-)
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts
index b34098174b306..de562f4824a08 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts
@@ -721,13 +721,6 @@ export const TAX_IDS: TaxId[] = [
placeholder: 'MK1234567890123',
countryIso2: 'MK',
},
- {
- name: 'nz_gst',
- type: 'nz_gst',
- country: 'New Zealand',
- placeholder: '123456789',
- countryIso2: 'NZ',
- },
{
name: 'NO VAT',
type: 'no_vat',
From a25e116c505fbfa40aa0c65d8247627f1bfae0b4 Mon Sep 17 00:00:00 2001
From: Jeremias Menichelli
Date: Fri, 6 Mar 2026 14:25:55 +0100
Subject: [PATCH 08/12] chore(Docs): Run format on docs folder to update files
(#43463)
From 98c34582b8c9c576b92ca6c5bb1e9937f7288428 Mon Sep 17 00:00:00 2001
From: hallidayo <22655069+Hallidayo@users.noreply.github.com>
Date: Fri, 6 Mar 2026 13:59:07 +0000
Subject: [PATCH 09/12] feat: add description to schema visualizer (#43406)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
Studio > Database > Schema Visualizer
## What is the current behavior?
If a table has a description value then the value does not show on the
visualizer
## What is the new behavior?
## Additional context
Closes #36072
---
.../Database/Schemas/SchemaTableNode.tsx | 43 ++++++++++++-------
.../Database/Schemas/Schemas.utils.ts | 4 ++
2 files changed, 31 insertions(+), 16 deletions(-)
diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx
index effd0546cdc23..4881bfdca67ac 100644
--- a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx
+++ b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx
@@ -1,9 +1,8 @@
import { buildTableEditorUrl } from 'components/grid/SupabaseGrid.utils'
-import { DiamondIcon, ExternalLink, Fingerprint, Hash, Key, Table2 } from 'lucide-react'
+import { DiamondIcon, ExternalLink, Fingerprint, Hash, InfoIcon, Key, Table2 } from 'lucide-react'
import Link from 'next/link'
import { Handle, NodeProps } from 'reactflow'
-
-import { Button, cn } from 'ui'
+import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
// ReactFlow is scaling everything by the factor of 2
export const TABLE_NODE_WIDTH = 320
@@ -15,6 +14,7 @@ export type TableNodeData = {
name: string
ref?: string
isForeign: boolean
+ description: string
columns: {
id: string
isPrimary: boolean
@@ -67,19 +67,30 @@ export const TableNode = ({
{data.name}
- {!placeholder && (
-
-
-
-
-
- )}
+
+ {data.description && (
+
+
+
+
+ {data.description}
+
+ )}
+
+ {!placeholder && (
+
+
+
+
+
+ )}
+
{data.columns.map((column) => (
diff --git a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts
index 0dc70a1f953cb..c8af180fed61c 100644
--- a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts
+++ b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts
@@ -2,10 +2,12 @@ import dagre from '@dagrejs/dagre'
import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta'
import { uniqBy } from 'lodash'
import { Edge, Node, Position } from 'reactflow'
+
import 'reactflow/dist/style.css'
import { LOCAL_STORAGE_KEYS } from 'common'
import { tryParseJson } from 'lib/helpers'
+
import { TABLE_NODE_ROW_HEIGHT, TABLE_NODE_WIDTH, TableNodeData } from './SchemaTableNode'
const NODE_SEP = 25
@@ -41,6 +43,7 @@ export async function getGraphDataFromTables(
ref,
id: table.id,
name: table.name,
+ description: table.comment ?? '',
schema: table.schema,
isForeign: false,
columns,
@@ -78,6 +81,7 @@ export async function getGraphDataFromTables(
ref: ref!,
schema: rel.target_table_schema,
name: targetId,
+ description: '',
isForeign: true,
columns: [],
}
From ebec20f5428c0b6bf5f547d83c98b6e300cc7687 Mon Sep 17 00:00:00 2001
From: Ignacio Dobronich
Date: Fri, 6 Mar 2026 11:17:57 -0300
Subject: [PATCH 10/12] chore: prevention of used leaked passwords entitlement
(#43410)
### Changes
- Replaces the isPaid plan-based check on the "Prevent use of leaked
passwords" (PASSWORD_HIBP_ENABLED) setting with a proper entitlement
check using the `password_hibp` entitlement key
- Adds a new `useHasEntitlementAccess` hook that returns a reusable
checker function for any entitlement key, backed by the same cached
entitlements query
### Testing
- Head to `/project/_/auth/providers?provider=Email` with an Org on the
Free Plan
- Assert that the "Prevent use of leaked passwords" toggle is disabled.
- Head to `/project/_/auth/providers?provider=Email` with an Org on the
Pro Plan
- Assert that the "Prevent use of leaked passwords" toggle is enabled
and can be toggled and saved.
---
.../AuthProvidersForm.types.ts | 4 +++-
.../Auth/AuthProvidersForm/ProviderForm.tsx | 18 +++++++--------
.../Auth/AuthProvidersFormValidation.tsx | 2 +-
.../studio/hooks/misc/useCheckEntitlements.ts | 22 ++++++++++++++++++-
4 files changed, 34 insertions(+), 12 deletions(-)
diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts b/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts
index af4bcc95c1988..32667230f548f 100644
--- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts
+++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts
@@ -1,3 +1,5 @@
+import type { FeatureKey } from 'data/entitlements/entitlements-query'
+
export interface Enum {
label: string
value: string
@@ -22,7 +24,7 @@ export interface Provider {
descriptionOptional?: string
units?: string
isSecret?: boolean
- isPaid?: boolean
+ entitlementKey?: FeatureKey
link?: string
}
}
diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx
index 594b16b7d37b6..160a08f3cd20e 100644
--- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx
+++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx
@@ -7,6 +7,7 @@ import { ResourceItem } from 'components/ui/Resource/ResourceItem'
import type { components } from 'data/api'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useHasEntitlementAccess } from 'hooks/misc/useCheckEntitlements'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { BASE_PATH } from 'lib/constants'
import { Check } from 'lucide-react'
@@ -67,7 +68,7 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps)
)
}
- const isFreePlan = organization?.plan.id === 'free'
+ const hasEntitlementAccess = useHasEntitlementAccess()
const INITIAL_VALUES = (() => {
const initialValues: { [x: string]: string | boolean } = {}
@@ -201,18 +202,17 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps)
)
}
+ const { entitlementKey } = provider.properties[x]
+ const hasAccess =
+ entitlementKey == null || hasEntitlementAccess(entitlementKey)
const properties = {
...provider.properties[x],
- description:
- provider.properties[x].isPaid && isFreePlan
- ? `${description} Only available on [Pro plan](/org/${organization.slug}/billing?panel=subscriptionPlan) and above.`
- : description,
+ description: hasAccess
+ ? description
+ : `${description} Only available on [Pro plan](/org/${organization?.slug}/billing?panel=subscriptionPlan) and above.`,
}
- const isDisabledDueToPlan = properties.isPaid && isFreePlan
const shouldDisable =
- properties.type === 'boolean'
- ? isDisabledDueToPlan && !values[x]
- : isDisabledDueToPlan
+ properties.type === 'boolean' ? !hasAccess && !values[x] : !hasAccess
return (
+ IS_PLATFORM
+ ? entitlementsData?.entitlements.find((e) => e.feature.key === key)?.hasAccess ?? false
+ : true,
+ [entitlementsData]
+ )
+}
+
export function useCheckEntitlements(
featureKey: FeatureKey,
organizationSlug?: string,
From f0b44ae087028151f1121053c5e4de2ac8f9e9e0 Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Fri, 6 Mar 2026 22:39:04 +0800
Subject: [PATCH 11/12] Adjust header + project list for wildcard routes
(#43387)
## Context
Small adjustments to wildcard routes
- Header logo to be consistent with other layouts
- Before:
- After:
- Project wildcard route list view to be consistent with org/[slug] page
- project list shouldn't take full height if there's no need to, scroll
behaviour should be on the page
- Before:
- After:
Co-authored-by: Ali Waseem
---
.../Home/ProjectList/EmptyStates.tsx | 19 +++++------------
apps/studio/pages/org/[slug]/index.tsx | 2 +-
.../pages/project/_/[[...routeSlug]].tsx | 21 +++++++++++--------
3 files changed, 18 insertions(+), 24 deletions(-)
diff --git a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx
index 60f4c422d2bc0..16a4c9e8df3bb 100644
--- a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx
+++ b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx
@@ -1,9 +1,7 @@
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { BoxPlus } from 'icons'
import { Plus } from 'lucide-react'
import Link from 'next/link'
-
-import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
-import { BASE_PATH } from 'lib/constants'
import {
Button,
Card,
@@ -16,21 +14,14 @@ import {
TableRow,
} from 'ui'
import { EmptyStatePresentational } from 'ui-patterns'
+
import { ShimmeringCard } from './ShimmeringCard'
+import { HomeIcon } from '@/components/layouts/ProjectLayout/LayoutHeader/HomeIcon'
export const Header = () => {
return (
-
-
-
-
-
-
+
+
)
}
diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx
index b223258fe7175..c0493aa977396 100644
--- a/apps/studio/pages/org/[slug]/index.tsx
+++ b/apps/studio/pages/org/[slug]/index.tsx
@@ -19,7 +19,7 @@ const ProjectsPage: NextPageWithLayout = () => {
return (
-
+
{disableAccessMfa ? (
{
-
- {organizations.length > 0 && (
-
-
+
+
+
+ {isLoadingOrganizations ? (
+
+ ) : (
@@ -103,11 +106,11 @@ const GenericProjectPage: NextPage = () => {
))}
-
-
-
- )}
-
+ )}
+
+
+
+
{isLoadingOrganizations ? (
) : isErrorOrganizations ? (
From efa40ea0e4947947589588fe1f843d2621f260e1 Mon Sep 17 00:00:00 2001
From: Marouane Souda <61951643+marsou001@users.noreply.github.com>
Date: Fri, 6 Mar 2026 15:38:49 +0000
Subject: [PATCH 12/12] fix(studio): contrast ratio of JSON cell viewer colors
in light mode (#40691)
JSON column viewer looks fine on dark mode, but barely visible on light
mode, especially when the value is of string type.
There are no colors that can satisfy the 4.5:1 contrast ratio (minimum
requirement for web and digital accessibility) on both white and black
background, so I had to pick different classes for ight mode (dark mode
is fine, I checked the colors against the dark background and the
contrast is good).
Even though the new colors may not look that contrasting to each other,
I prioritized accessibility, and made sure they all complied with the
4.5:1 contrast ratio.
Before:
After:
Fixes #40633
---------
Co-authored-by: Ali Waseem
---
.../RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx
index 32ccb94f0e5d9..7e28b3f2eec82 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx
@@ -63,10 +63,12 @@ const DrilldownPane = ({ pane, jsonData, activeKey, onSelectKey = noop }: Drilld
))}
{keysWithoutChildren.map((key: string) => (
-
{key}:
+
{key}:
{isNull(jsonData[key])