From be9d42f09774e7abe7aab08270b684391a63a972 Mon Sep 17 00:00:00 2001 From: JohnBra <36050523+JohnBra@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:36:27 +0100 Subject: [PATCH 01/83] fix: append semicolons to sql statements in migration view (#32814) --- .../components/interfaces/Database/Migrations/Migrations.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index abfbd2ca145..d573f9a32ba 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -210,7 +210,10 @@ const Migrations = () => { isReadOnly id={selectedMigration?.version ?? ''} language="pgsql" - defaultValue={selectedMigration?.statements?.join('\n')} + defaultValue={ + selectedMigration?.statements?.join(';\n') + + (selectedMigration?.statements?.length ? ';' : '') + } /> From a4258e851a52de9dd0254a96f9bc9120a9696ea5 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 25 Mar 2026 09:41:27 -0600 Subject: [PATCH 02/83] fix: copy cell for record peak portal on forgien relationship (#44144) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES/NO ## What kind of change does this PR introduce? fixes: #37454 The context menu on the FK row is a lie, its just exposing the cell underneath. Ideally this should have its own context menu, right now we only support copy operations. --------- Co-authored-by: Joshen Lim --- .../formatter/CellContextMenuWrapper.tsx | 41 +++++++++++ .../formatter/ForeignKeyFormatter.tsx | 4 + .../formatter/ReferenceRecordPeek.tsx | 40 +++++++--- .../components/grid/components/grid/Grid.tsx | 2 +- .../grid/components/grid/RowRenderer.tsx | 10 ++- e2e/studio/features/table-editor.spec.ts | 73 ++++++++++++++++++- 6 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 apps/studio/components/grid/components/formatter/CellContextMenuWrapper.tsx diff --git a/apps/studio/components/grid/components/formatter/CellContextMenuWrapper.tsx b/apps/studio/components/grid/components/formatter/CellContextMenuWrapper.tsx new file mode 100644 index 00000000000..b465ef79f93 --- /dev/null +++ b/apps/studio/components/grid/components/formatter/CellContextMenuWrapper.tsx @@ -0,0 +1,41 @@ +import { ContextMenuContent } from '@ui/components/shadcn/ui/context-menu' +import { Copy } from 'lucide-react' +import { PropsWithChildren } from 'react' +import { toast } from 'sonner' +import { + ContextMenu_Shadcn_, + ContextMenuItem_Shadcn_, + ContextMenuTrigger_Shadcn_, + copyToClipboard, +} from 'ui' + +import { formatClipboardValue } from '../../utils/common' + +/** + * [Joshen] RowRenderer should be using this so that we can deprecate `react-contextify` + * We can probably extend this for all the other actions like edit, delete, etc + */ +export const CellContextMenuWrapper = ({ + value, + children, +}: PropsWithChildren<{ value: string | number | boolean | object | null }>) => { + const onCopyCellContent = () => { + const text = formatClipboardValue(value) + copyToClipboard(text) + toast.success('Copied cell value to clipboard') + } + + return ( + + + {children} + + + + + Copy cell + + + + ) +} diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index bfa8f2cab28..ef26a4d64ee 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -78,6 +78,7 @@ export const ForeignKeyFormatter = (props: Props) => { } onClick={(e) => e.stopPropagation()} tooltip={{ content: { side: 'bottom', text: 'View referencing record' } }} @@ -90,6 +91,9 @@ export const ForeignKeyFormatter = (props: Props) => { e.preventDefault() e.stopPropagation() }} + onContextMenu={(e) => { + e.stopPropagation() + }} > } export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPeekProps) => { @@ -47,6 +49,9 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee { placeholderData: keepPreviousData } ) + const rows = useMemo(() => data?.rows ?? [], [data?.rows]) + const selectedCellRef = useRef<{ idx: number; rowIdx: number } | null>(null) + const primaryKeys = useMemo(() => table.primary_keys.map((x) => x.name), [table.primary_keys]) const columns = useMemo(() => { @@ -54,14 +59,14 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee const columnDefaultWidth = getColumnDefaultWidth({ dataType: column.data_type, format: column.format, - } as any) + } as Pick as SupaColumn) const columnWidthBasedOnName = (column.name.length + column.format.length) * ESTIMATED_CHARACTER_PIXEL_WIDTH const columnWidth = columnDefaultWidth < columnWidthBasedOnName ? columnWidthBasedOnName : columnDefaultWidth const isPrimaryKey = primaryKeys.includes(column.name) - const res: Column = { + const res: Column = { key: column.name, name: column.name, resizable: false, @@ -84,11 +89,17 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee {column.format} ), - renderCell: isBinaryColumn(column.data_type) - ? BinaryFormatter - : isJsonColumn(column.data_type) && !isArrayColumn(column.data_type) - ? JsonFormatter - : DefaultFormatter, + renderCell: (props) => ( + + {isBinaryColumn(column.data_type) ? ( + + ) : isJsonColumn(column.data_type) && !isArrayColumn(column.data_type) ? ( + + ) : ( + + )} + + ), } return res }) @@ -106,7 +117,14 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee + rowIdx: number + row: SupaRow + }) => { + selectedCellRef.current = { idx: args.column.idx, rowIdx: args.rowIdx } + }} onCellDoubleClick={(_, e) => { e.preventDefault() e.stopPropagation() diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 048b0c8ccae..049a775d00e 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -23,7 +23,7 @@ import type { GridProps, SupaRow } from '../../types' import { isPendingAddRow, isPendingDeleteRow } from '../../types' import { useOnRowsChange } from './Grid.utils' import { GridError } from './GridError' -import RowRenderer from './RowRenderer' +import { RowRenderer } from './RowRenderer' import { ResponseError } from '@/types' const rowKeyGetter = (row: SupaRow) => { diff --git a/apps/studio/components/grid/components/grid/RowRenderer.tsx b/apps/studio/components/grid/components/grid/RowRenderer.tsx index 05e082f7254..fed375a8d78 100644 --- a/apps/studio/components/grid/components/grid/RowRenderer.tsx +++ b/apps/studio/components/grid/components/grid/RowRenderer.tsx @@ -1,11 +1,13 @@ +import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants' +import { SupaRow } from 'components/grid/types' import type { Key } from 'react' import { TriggerEvent, useContextMenu } from 'react-contexify' import { RenderRowProps, Row } from 'react-data-grid' -import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants' -import { SupaRow } from 'components/grid/types' - -export default function RowRenderer(key: Key, props: RenderRowProps) { +/** + * TODO: Refactor to use CellContextMenuWrapper instead of react-contexify + */ +export function RowRenderer(key: Key, props: RenderRowProps) { const { show: showContextMenu } = useContextMenu() function displayMenu(e: TriggerEvent) { diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index b6f7de847ff..c5e461bce13 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -1355,10 +1355,10 @@ testRunner('table editor', () => { last_name text )` ) - await query( - `INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`, - ['Alice', 'Smith'] - ) + await query(`INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`, [ + 'Alice', + 'Smith', + ]) }, async () => { await dropTable(tableName) @@ -1521,4 +1521,69 @@ testRunner('table editor', () => { exact: true, }) }) + + test('copying cell content from referencing record peek copies the correct value', async ({ + page, + ref, + }) => { + const targetTable = 'pw_fk_peek_target' + const sourceTable = 'pw_fk_peek_source' + + await using _ = await withSetupCleanup( + async () => { + await query(` + CREATE TABLE IF NOT EXISTS ${targetTable} ( + id bigint generated by default as identity primary key, + name text + ) + `) + await query(`ALTER TABLE public.${targetTable} ENABLE ROW LEVEL SECURITY`) + await query(`INSERT INTO ${targetTable} (name) VALUES ('target_value')`) + + await query(` + CREATE TABLE IF NOT EXISTS ${sourceTable} ( + id bigint generated by default as identity primary key, + label text, + target_id bigint references ${targetTable}(id) + ) + `) + await query(`ALTER TABLE public.${sourceTable} ENABLE ROW LEVEL SECURITY`) + await query(`INSERT INTO ${sourceTable} (label, target_id) VALUES ('source_label', 1)`) + }, + async () => { + await dropTable(sourceTable) + await dropTable(targetTable) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await page.getByRole('button', { name: `View ${sourceTable}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await expect(page.getByRole('grid')).toBeVisible() + await expect(page.getByRole('gridcell', { name: 'source_label' })).toBeVisible() + + await page.getByRole('button', { name: 'View referencing record' }).click() + + const popover = page.getByText(`Referencing record from public.${targetTable}`) + await expect(popover).toBeVisible() + + const popoverContent = page.locator('[data-radix-popper-content-wrapper]') + await expect(popoverContent.getByRole('gridcell', { name: 'target_value' })).toBeVisible() + + // Right-click on the target_value cell inside the popover to open the peek context menu. + // Before the fix, this would trigger the main grid's context menu via React portal + // event bubbling, and "Copy cell" would copy the wrong value from the main grid. + await popoverContent.getByRole('gridcell', { name: 'target_value' }).click({ button: 'right' }) + + // The peek grid's own context menu should appear with "Copy cell" + await page.getByRole('menuitem', { name: 'Copy cell' }).click() + + // Verify the correct referenced value was copied (not the main grid's FK value "1") + await expectClipboardValue({ + page, + value: 'target_value', + exact: true, + }) + }) }) From 63b80b5e920b5f385184bcd43c1f38e17b659e14 Mon Sep 17 00:00:00 2001 From: Ahmad Yasser Date: Wed, 25 Mar 2026 11:44:19 -0400 Subject: [PATCH 03/83] fix(docs): initialize FormBuilder in constructor in Angular tutorial (#42600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What kind of change does this PR introduce? Bug fix — corrects a TypeScript initialization error in the Angular user management tutorial example. ### What is the current behavior? In the Angular tutorial's `AuthComponent`, `signInForm` is declared with a non-null assertion (`signInForm!: FormGroup`) and initialized inside `ngOnInit()`. This causes a TypeScript strict-mode error because `formBuilder` is used before the constructor runs. Developers following the tutorial encounter: > Property 'formBuilder' is used before being initialized. ### What is the new behavior? `signInForm` is now initialized in the constructor, matching the pattern already used by `AccountComponent` in the same example project. The non-null assertion is removed since the property is properly assigned during construction. ### Changes **`examples/user-management/angular-user-management/src/app/auth/auth.component.ts`** - Moved `signInForm` initialization from `ngOnInit()` into the constructor - Removed non-null assertion operator (`!`) from `signInForm` declaration - Reordered property declarations for consistency (`loading` before `signInForm`) ### Additional context The sibling `AccountComponent` already follows the correct pattern — initializing `updateProfileForm` inside the constructor (line 35). This PR aligns `AuthComponent` with that established convention. Closes #34392 ## Summary by CodeRabbit * **New Features** * Added a loading indicator state to the authentication form. * **Refactor** * Restructured form initialization logic for improved component setup. --- .../src/app/auth/auth.component.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/user-management/angular-user-management/src/app/auth/auth.component.ts b/examples/user-management/angular-user-management/src/app/auth/auth.component.ts index 8ff36808947..203feddda8a 100644 --- a/examples/user-management/angular-user-management/src/app/auth/auth.component.ts +++ b/examples/user-management/angular-user-management/src/app/auth/auth.component.ts @@ -9,14 +9,13 @@ import { SupabaseService } from '../supabase.service' standalone: false, }) export class AuthComponent { - signInForm!: FormGroup + loading = false + signInForm: FormGroup + constructor( private readonly supabase: SupabaseService, private readonly formBuilder: FormBuilder - ) {} - - loading = false - ngOnInit() { + ) { this.signInForm = this.formBuilder.group({ email: '', }) From 16ec2c09c5835e5df9da2e752a632120cd048060 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:12:11 +0100 Subject: [PATCH 04/83] fix: cannot create nor edit foreign keys from schema visualiser (#44190) ## Problem Users cannot create nor edit foreign keys when editing a table or a column from the schema visualiser ## Solution The foreign key edition component is always reading the current table from the URL parameters. However, in the context of the schema visualiser, the parameter does not exist. Allows to override it from the side panel editor context ## How to test - Create two tables - Go to the schema visualiser and edit one of the table - Click the _Add foreign key relation_ and add a foreign key to the other table - Edit one of the foreign key column - You should be able to edit the relation --- .../SidePanelEditor/ColumnEditor/ColumnEditor.tsx | 1 + .../SidePanelEditor/ColumnEditor/ColumnForeignKey.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index 563580f5475..3426c247772 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -364,6 +364,7 @@ export const ColumnEditor = ({ > void @@ -20,6 +21,7 @@ interface ColumnForeignKeyProps { } const ColumnForeignKey = ({ + tableId, column, relations, closePanel, @@ -29,7 +31,6 @@ const ColumnForeignKey = ({ const { id: _id } = useParams() const [open, setOpen] = useState(false) const [selectedFk, setSelectedFk] = useState() - const { data: project } = useSelectedProjectQuery() const { data } = useForeignKeyConstraintsQuery({ projectRef: project?.ref, @@ -41,7 +42,7 @@ const ColumnForeignKey = ({ const { data: table } = useTableEditorQuery({ projectRef: project?.ref, connectionString: project?.connectionString, - id, + id: tableId ?? id, }) const formattedColumnsForFkSelector = (table?.columns ?? []).map((c) => { return { From 9da28685ec442aa4a687d972b5d2a140096b034c Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 25 Mar 2026 14:19:27 -0600 Subject: [PATCH 05/83] fix: remove SMS validation of fields for Twilio Verify (#44198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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? Investigation by claude, validated! - Before (Formik): The old form used `
` Formik does not unregister hidden fields — all fields from initialValues stay in form state with their initial values, so hidden required fields still pass validation because they retain their default values (e.g., the OTP expiry/length numbers from the config). - After (react-hook-form): The new form uses useForm({ shouldUnregister: true }). This explicitly removes fields from form state when their components unmount. When Twilio Verify is selected, the three hidden fields are unmounted, their values become undefined, and yup's unconditional .required() fails silently. this bug was introduced today by PR #44095, the Formik-to-react-hook-form migration. --- .../Auth/AuthProvidersFormValidation.tsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx index 2feadc15831..173081f8a6b 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx @@ -194,9 +194,25 @@ export const getPhoneProviderValidationSchema = (config: ProjectAuthConfigData) }), // Phone SMS - SMS_OTP_EXP: number().min(0, 'Must be more than 0').required('This is required'), - SMS_OTP_LENGTH: number().min(6, 'Must be 6 or more in length').required('This is required'), - SMS_TEMPLATE: string().required('SMS template is required.'), + SMS_OTP_EXP: number() + .min(0, 'Must be more than 0') + .when('SMS_PROVIDER', { + is: (val: string) => val !== 'twilio_verify', + then: (schema) => schema.required('This is required'), + otherwise: (schema) => schema, + }), + SMS_OTP_LENGTH: number() + .min(6, 'Must be 6 or more in length') + .when('SMS_PROVIDER', { + is: (val: string) => val !== 'twilio_verify', + then: (schema) => schema.required('This is required'), + otherwise: (schema) => schema, + }), + SMS_TEMPLATE: string().when('SMS_PROVIDER', { + is: (val: string) => val !== 'twilio_verify', + then: (schema) => schema.required('SMS template is required.'), + otherwise: (schema) => schema, + }), SMS_TEST_OTP: string() .matches( /^\s*([0-9]{1,15}=[0-9]+)(\s*,\s*[0-9]{1,15}=[0-9]+)*\s*$/g, From 10e9cdd1f4db0bcf5b797fd7ec79e6b3cd0a9bac Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:29:23 +1100 Subject: [PATCH 06/83] chore(studio): webhooks UI cleanup (#43797) ## What kind of change does this PR introduce? UI updates to platform webhooks. ## What is the new behavior? - `Webhooks` icon on empty state - Better text wrapping/overflow handling on table - Clearer endpoint deletion dialog | After | | --- | | Webhooks Toolshed
Supabase-A3AF1CE1-B489-450F-9FD7-AE1B1BC0C999 | | Webhooks Toolshed
Supabase-00EF8F02-A3F2-477C-ADDB-68996D6C16E3 | | Webhooks Toolshed
Supabase-902AC0EC-D0D2-471A-B241-C90FA8F50883 | --- .../Webhooks/PlatformWebhooks.mock.ts | 2 +- .../Webhooks/PlatformWebhooksEndpointList.tsx | 13 ++++++--- .../Webhooks/PlatformWebhooksHeader.tsx | 13 +-------- .../Webhooks/PlatformWebhooksPage.tsx | 28 ++++++++++++------- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts index e08791bbef6..06cea016399 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts @@ -30,7 +30,7 @@ export const PLATFORM_WEBHOOKS_MOCK_DATA: Record

Endpoints

+
onSearchChange(event.target.value)} /> + @@ -109,6 +111,7 @@ export const PlatformWebhooksEndpointList = ({ {filteredEndpoints.length === 0 ? ( @@ -171,15 +174,17 @@ export const PlatformWebhooksEndpointList = ({

{displayName}

{hasName && ( -

{endpoint.url}

+

+ {endpoint.url} +

)}
{formatEventCount(endpoint.eventTypes)} - + -

+

by {endpoint.createdBy}

diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx index 1e1d08101d9..30acfbc864b 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx @@ -74,18 +74,7 @@ export const PlatformWebhooksHeader = ({ {headerDescription} - {hasSelectedEndpoint ? ( - endpointActions - ) : ( - <> - - - - )} + {hasSelectedEndpoint ? endpointActions : } diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx index 513f2340c7c..388cfe7f814 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx @@ -128,9 +128,18 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage () => endpoints.find((endpoint) => endpoint.id === endpointIdPendingDelete) ?? null, [endpoints, endpointIdPendingDelete] ) + const endpointPendingDeleteHasName = endpointPendingDelete + ? endpointPendingDelete.name.trim().length > 0 + : false const endpointPendingDeleteDisplayName = endpointPendingDelete ? getWebhookEndpointDisplayName(endpointPendingDelete) : '' + let deleteEndpointDescription = 'This action cannot be undone.' + if (endpointPendingDelete) { + deleteEndpointDescription = endpointPendingDeleteHasName + ? `Deleting “${endpointPendingDeleteDisplayName}” stops all deliveries to the URL below. This can’t be undone.` + : 'Deleting this endpoint stops all deliveries to the URL below. This can’t be undone.' + } useEffect(() => { if (!platformWebhooksEnabled) { @@ -233,7 +242,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage setDeliverySearch('') } setEndpointIdPendingDelete(null) - toast.success(`Deleted endpoint "${endpointPendingDeleteDisplayName}"`) + toast.success('Endpoint deleted') } const handleUpsertEndpoint = (values: EndpointFormValues) => { @@ -333,14 +342,14 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage className="gap-x-2" onClick={() => setShowRegenerateSecretConfirm(true)} > - + Regenerate secret setEndpointIdPendingDelete(selectedEndpoint.id)} > - + Delete endpoint @@ -420,14 +429,13 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage Delete endpoint - - {endpointPendingDelete - ? endpointPendingDelete.name.trim().length > 0 - ? `This will delete endpoint "${endpointPendingDeleteDisplayName}" with URL ${endpointPendingDelete.url}. This action cannot be undone.` - : `This will delete endpoint ${endpointPendingDelete.url}. This action cannot be undone.` - : 'This action cannot be undone.'} - + {deleteEndpointDescription} + {endpointPendingDelete && ( +
+              {endpointPendingDelete.url}
+            
+ )} Cancel From 64c37ca72b409d00c64fa85cf4e3372833083f80 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:48:15 +1100 Subject: [PATCH 07/83] chore(studio): database tables UI improvements (#44163) ## What kind of change does this PR introduce? UI improvements ## What is the current behavior? - The database tables list and columns list use inconsistent page shells and table primitives - The child columns page has weaker information hierarchy and row actions than the parent tables page - Responsive column priority on the tables list does not reflect the most important data on smaller breakpoints - Table actions and counts are harder to scan than they should be ## What is the new behavior? - Both pages now use `PageLayout` with matching large-width content containers - `ColumnList` now uses the latest `ui` Table primitives instead of the legacy cleaned-up-later table - Both pages now show totals in a table footer - `ColumnList` now uses a tiny filter input, case-insensitive filtering, inline descriptions under the name, and a primary `Edit` button with overflow actions - `TableList` now has improved responsive column priority: - smallest breakpoint keeps `Rows` - `Columns` appears from `sm` - `Size` appears from `lg` - `Realtime Enabled` appears from `2xl` - `TableList` now uses `View columns` as the CTA, removes the ambiguous icon from that CTA, restores the entity icon from `sm` upwards only, and tightens the name column on the smallest breakpoint only - Boolean icon columns are right-aligned consistently, with the same Realtime icon tones applied to both `Realtime Enabled` and `Nullable` - The columns detail page now uses breadcrumbs for navigation back to Tables instead of an inline back button | Before | After | | --- | --- | | Tables Database Mallet Toolshed
Supabase-0E0E3DE0-4EA1-407F-88D4-B85664D26D8E | Tables Database Mallet Toolshed
Supabase-C8FC339C-E9DA-4ADB-8458-C7EFF55F2AEC | | Tables Database Mallet Toolshed
Supabase-FE9196A0-BEAF-4BA5-8A2C-06F934A62C38 | Tables Database Mallet Toolshed
Supabase-36E93C1E-7943-4C98-8119-CAF48E2FE5BA | --------- Co-authored-by: Joshen Lim --- .../interfaces/Database/Tables/ColumnList.tsx | 291 +++++++++++------- .../interfaces/Database/Tables/TableList.tsx | 136 ++++---- .../project/[ref]/database/tables/[id].tsx | 50 +-- .../project/[ref]/database/tables/index.tsx | 58 ++-- e2e/studio/features/database.spec.ts | 96 +++--- e2e/studio/utils/wait-for-response.ts | 18 +- 6 files changed, 355 insertions(+), 294 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index 8a836b701cc..622161c0f39 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -1,12 +1,6 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { noop } from 'lodash' -import { Check, ChevronLeft, Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react' -import Link from 'next/link' -import { useState } from 'react' - import { PostgresColumn } from '@supabase/postgres-meta' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { NoSearchResults } from 'components/ui/NoSearchResults' @@ -15,18 +9,30 @@ import { isTableLike } from 'data/table-editor/table-editor-types' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' +import { noop } from 'lodash' +import { Check, Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react' +import { useState } from 'react' import { Button, + Card, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Input, + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, Tooltip, TooltipContent, TooltipTrigger, } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' interface ColumnListProps { @@ -40,7 +46,7 @@ export const ColumnList = ({ onEditColumn = noop, onDeleteColumn = noop, }: ColumnListProps) => { - const { id: _id, ref } = useParams() + const { id: _id } = useParams() const id = _id ? Number(_id) : undefined const { data: project } = useSelectedProjectQuery() @@ -62,7 +68,9 @@ export const ColumnList = ({ const columns = (filterString.length === 0 ? (selectedTable?.columns ?? []) - : selectedTable?.columns?.filter((column) => column.name.includes(filterString))) ?? [] + : selectedTable?.columns?.filter((column) => + column.name.toLowerCase().includes(filterString.toLowerCase()) + )) ?? [] const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedTable?.schema ?? '' }) const { can: canUpdateColumns } = useAsyncCheckPermissions( @@ -72,16 +80,13 @@ export const ColumnList = ({ return (
-
-
- +
+
setFilterString(e.target.value)} + onChange={(e) => setFilterString(e.target.value)} icon={} />
@@ -108,115 +113,163 @@ export const ColumnList = ({ )} - {isLoading && } + + {isLoading ? ( +
+ +
+ ) : ( + + + + + Name + + + Data Type + + + Format + + + Nullable + + + + + + {isError && ( + + + + + + )} - {isError && ( - - )} + {isSuccess && columns.length === 0 && filterString.length > 0 && ( + + + setFilterString('')} + /> + + + )} - {isSuccess && ( - <> - {columns.length === 0 ? ( - setFilterString('')} - /> - ) : ( -
-
Name, - - Description - , - Data Type, - Format, - - Nullable - , - , - ]} - body={columns.map((x) => ( - - -

{x.name}

-
- - {x.comment !== null ? ( -

{x.comment}

+ {isSuccess && columns.length === 0 && filterString.length === 0 && ( + + +

No columns created yet

+

+ There are no columns in "{selectedTable?.schema}.{selectedTable?.name}" +

+
+
+ )} + + {isSuccess && + columns.length > 0 && + columns.map((column) => ( + + +
+

{column.name}

+ {column.comment !== null ? ( + + {column.comment} + + ) : null} +
+
+ + {column.data_type} + + + {column.format} + + + {column.is_nullable ? ( +
+ +
) : ( -

No description

+
+ +
)} -
- - {x.data_type} - - - {x.format} - - - {x.is_nullable ? ( - - ) : ( - - )} - - + + {!isSchemaLocked && isTableEntity && ( - - -
+ )} +
) } diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 298047e4084..a8bf7d7106e 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -1,24 +1,5 @@ import type { PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { noop } from 'lodash' -import { - Check, - Columns, - Copy, - Edit, - Eye, - Filter, - MoreVertical, - Plus, - Search, - Trash, - X, -} from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { parseAsString, useQueryState } from 'nuqs' -import { useState } from 'react' - import { useParams } from 'common' import { buildTableEditorUrl } from 'components/grid/SupabaseGrid.utils' import AlertError from 'components/ui/AlertError' @@ -37,6 +18,12 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' +import { noop } from 'lodash' +import { Check, Copy, Edit, Eye, Filter, MoreVertical, Plus, Search, Trash, X } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { parseAsString, useQueryState } from 'nuqs' +import { useState } from 'react' import { Button, Card, @@ -47,12 +34,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, Label_Shadcn_, + Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, - Popover_Shadcn_, Table, TableBody, TableCell, + TableFooter, TableHead, TableHeader, TableRow, @@ -62,6 +50,7 @@ import { } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import { formatAllEntities } from './Tables.utils' @@ -318,17 +307,19 @@ export const TableList = ({ - Name - - Description + + Name - + + Columns + + Rows (Estimated) - + Size (Estimated) - + Realtime Enabled @@ -387,10 +378,6 @@ export const TableList = ({ - {/* [Alaister]: EntityTypeIcon supports PARTITIONED_TABLE, but formatAllEntities - doesn't distinguish between tables and partitioned tables yet. - Once the endpoint/formatAllEntities is updated to include partitioned tables, - EntityTypeIcon will automatically style them correctly. */} @@ -398,66 +385,70 @@ export const TableList = ({ - - {/* only show tooltips if required, to reduce noise */} - {x.name.length > 20 ? ( - - -

{x.name}

-
+ +
+ {/* only show tooltips if required, to reduce noise */} + {x.name.length > 20 ? ( + + +

{x.name}

+
- {x.name} -
+ {x.name} + + ) : ( +

{x.name}

+ )} + {x.comment !== null ? ( + + {x.comment} + + ) : null} +
+
+ +

+ {x.columns.length.toLocaleString()} +

+
+ + {x.rows !== undefined ? ( +

{x.rows.toLocaleString()}

) : ( -

{x.name}

+

–

)}
- - {x.comment !== null ? ( - - {x.comment} - - ) : ( -

No description

- )} -
- - {x.rows !== undefined ? x.rows.toLocaleString() : '-'} - - + {x.size !== undefined ? ( - {x.size} +

{x.size}

) : ( - '-' +

–

)}
- + {(realtimePublication?.tables ?? []).find( (table) => table.id === x.id ) ? (
- +
) : (
- +
)}
- @@ -554,6 +545,13 @@ export const TableList = ({ ))} + + + + {entities.length} {entities.length === 1 ? 'table' : 'tables'} + + +
diff --git a/apps/studio/pages/project/[ref]/database/tables/[id].tsx b/apps/studio/pages/project/[ref]/database/tables/[id].tsx index 94b4a029eda..e64808aa785 100644 --- a/apps/studio/pages/project/[ref]/database/tables/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/tables/[id].tsx @@ -4,21 +4,21 @@ import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/Del import { SidePanelEditor } from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import { FormHeader } from 'components/ui/Forms/FormHeader' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { ChevronRight } from 'lucide-react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' import type { NextPageWithLayout } from 'types' +import { PageContainer } from 'ui-patterns/PageContainer' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' const DatabaseTables: NextPageWithLayout = () => { const snap = useTableEditorStateSnapshot() - const { id: _id } = useParams() + const { id: _id, ref } = useParams() const id = _id ? Number(_id) : undefined const { data: project } = useSelectedProjectQuery() @@ -30,26 +30,28 @@ const DatabaseTables: NextPageWithLayout = () => { return ( <> - - -
-
- - - {isLoading ? ( - - ) : ( - - )} -
- -
-
-
+ : (selectedTable?.name ?? '')} + breadcrumbs={[ + { + label: 'Tables', + href: `/project/${ref}/database/tables`, + }, + ]} + size="large" + > + + + + + + + + {project?.ref !== undefined && selectedTable !== undefined && isTableLike(selectedTable) && ( { @@ -26,34 +21,29 @@ const DatabaseTables: NextPageWithLayout = () => { return ( <> - - - - Database Tables - - - - - - - { - setSelectedTableToEdit(postgresTableToEntity(table)) - snap.onEditTable() - }} - onDeleteTable={(table) => { - setSelectedTableToEdit(postgresTableToEntity(table)) - snap.onDeleteTable() - }} - onDuplicateTable={(table) => { - setSelectedTableToEdit(postgresTableToEntity(table)) - snap.onDuplicateTable() - }} - /> - - - + + + + + { + setSelectedTableToEdit(postgresTableToEntity(table)) + snap.onEditTable() + }} + onDeleteTable={(table) => { + setSelectedTableToEdit(postgresTableToEntity(table)) + snap.onDeleteTable() + }} + onDuplicateTable={(table) => { + setSelectedTableToEdit(postgresTableToEntity(table)) + snap.onDuplicateTable() + }} + /> + + + + {projectRef !== undefined && selectedTableToEdit !== undefined && diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index ace3323d0d0..d8dfcf2085a 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -89,12 +89,14 @@ test.describe('Database', () => { // validates table and column exists await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible() // test we can edit the column - await page.getByText(`${databaseTableName} actions`).click() - - await page.getByText(`${databaseTableName} actions`).click() - await expect(page.getByRole('menuitem', { name: 'Edit table' })).toBeVisible() - await page.getByRole('menuitem', { name: 'Edit table' }).click({ force: true }) - await expect(page.getByRole('menuitem', { name: 'Edit table' })).not.toBeVisible() + const tableActionsButton = page.getByRole('button', { + name: `${databaseTableName} actions`, + }) + await tableActionsButton.click() + const editTableMenuItem = page.getByRole('menuitem', { name: 'Edit table' }) + await expect(editTableMenuItem).toBeVisible() + await editTableMenuItem.press('Enter') + await expect(editTableMenuItem).not.toBeVisible() const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() await expect(dialog.getByText('timestamptz')).toBeVisible() @@ -104,10 +106,10 @@ test.describe('Database', () => { await expect(page.getByRole('dialog')).not.toBeVisible() // test the schema view has been refreshed - await page.getByText(`${databaseTableName} actions`).click() - await expect(page.getByRole('menuitem', { name: 'Edit table' })).toBeVisible() - await page.getByRole('menuitem', { name: 'Edit table' }).click() - await expect(page.getByRole('menuitem', { name: 'Edit table' })).not.toBeVisible() + await tableActionsButton.click() + await expect(editTableMenuItem).toBeVisible() + await editTableMenuItem.press('Enter') + await expect(editTableMenuItem).not.toBeVisible() await expect(page.getByRole('dialog')).toBeVisible() // FIXME: For some reason, the dialog is not stable and rerenders, sometimes preventing the description to be filled await page.waitForTimeout(500) @@ -115,15 +117,19 @@ test.describe('Database', () => { await page.getByRole('button', { name: 'Cancel' }).click() await expect(page.getByRole('dialog')).not.toBeVisible() - await page.getByText(`${databaseTableName} actions`).click() - await expect(page.getByRole('menuitem', { name: 'Copy name' })).toBeVisible() - await page.getByRole('menuitem', { name: 'Copy name' }).click() - await expect(page.getByRole('menuitem', { name: 'Copy name' })).not.toBeVisible() + await tableActionsButton.click() + const copyTableNameMenuItem = page.getByRole('menuitem', { name: 'Copy name' }) + await expect(copyTableNameMenuItem).toBeVisible() + await copyTableNameMenuItem.press('Enter') + await expect(copyTableNameMenuItem).not.toBeVisible() await expectClipboardValue({ page, value: databaseTableName, exact: true }) - await page.getByText(`${databaseTableName} actions`).click() - await expect(page.getByRole('menuitem', { name: 'View in Table Editor' })).toBeVisible() - await page.getByRole('menuitem', { name: 'View in Table Editor' }).click() + await tableActionsButton.click() + const viewInTableEditorMenuItem = page.getByRole('menuitem', { + name: 'View in Table Editor', + }) + await expect(viewInTableEditorMenuItem).toBeVisible() + await viewInTableEditorMenuItem.press('Enter') await page.waitForURL(/.*\/editor\/\d+/) await expect(page.getByRole('tab', { name: databaseTableName })).toBeVisible() }) @@ -152,11 +158,13 @@ test.describe('Database', () => { await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible() await expect(page.getByText(databaseColumnName, { exact: true })).toBeVisible() // test we can edit the column - await page - .getByText(`${databaseTableName} ${databaseColumnName} actions`) - .click({ force: true }) - await expect(page.getByRole('menuitem', { name: 'Edit column' })).toBeVisible() - await page.getByRole('menuitem', { name: 'Edit column' }).click() + const columnActionsButton = page.getByRole('button', { + name: `${databaseTableName} ${databaseColumnName} actions`, + }) + await columnActionsButton.click() + const editColumnMenuItem = page.getByRole('menuitem', { name: 'Edit column' }) + await expect(editColumnMenuItem).toBeVisible() + await editColumnMenuItem.press('Enter') await page.getByLabel('Description').fill('Bazinga') await page.getByRole('button', { name: 'Save' }).click() await expect( @@ -165,20 +173,17 @@ test.describe('Database', () => { await expect(page.getByRole('dialog')).not.toBeVisible() // test the schema view has been refreshed - await page - .getByText(`${databaseTableName} ${databaseColumnName} actions`) - .click({ force: true }) - await expect(page.getByRole('menuitem', { name: 'Edit column' })).toBeVisible() - await page.getByRole('menuitem', { name: 'Edit column' }).click() + await columnActionsButton.click() + await expect(editColumnMenuItem).toBeVisible() + await editColumnMenuItem.press('Enter') await expect(page.getByLabel('Description')).toHaveValue('Bazinga') await page.getByRole('button', { name: 'Cancel' }).click() await expect(page.getByRole('dialog')).not.toBeVisible() - await page - .getByText(`${databaseTableName} ${databaseColumnName} actions`) - .click({ force: true }) - await expect(page.getByRole('menuitem', { name: 'Copy name' })).toBeVisible() - await page.getByRole('menuitem', { name: 'Copy name' }).click() + await columnActionsButton.click() + const copyColumnNameMenuItem = page.getByRole('menuitem', { name: 'Copy name' }) + await expect(copyColumnNameMenuItem).toBeVisible() + await copyColumnNameMenuItem.press('Enter') await expectClipboardValue({ page, value: databaseColumnName, exact: true }) }) }) @@ -208,11 +213,13 @@ test.describe('Database', () => { await expect(page.getByRole('button', { name: 'New table' })).toBeVisible() // validates database name is present and has accurate number of columns - const tableRow = page.getByRole('row', { - name: `${databaseTableName} No description`, - }) + const tableRow = page + .getByRole('row') + .filter({ has: page.getByText(databaseTableName, { exact: true }) }) + .first() await expect(tableRow).toContainText(databaseTableName) - await expect(tableRow).toContainText('3 columns') + await expect(tableRow.getByRole('cell').filter({ hasText: /^3$/ }).first()).toBeVisible() + await expect(tableRow.getByRole('link', { name: 'View columns' })).toBeVisible() // change schema -> auth await page.getByTestId('schema-selector').click() @@ -364,14 +371,20 @@ test.describe('Database', () => { } ) + const databaseWait = createApiResponseWaiter( + page, + 'pg-meta', + ref, + 'tables?include_columns=true&included_schemas=public' + ) await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/tables?schema=public`)) // Wait for database tables to be populated - await waitForDatabaseToLoad(page, ref) + await databaseWait // navigate to table columns const databaseRow = page.getByRole('row', { name: databaseTableName }) - await databaseRow.getByRole('link', { name: '3 columns' }).click() + await databaseRow.getByRole('link', { name: 'View columns' }).click() await page.waitForURL(/.*\/database\/tables\/\d+/) // validate and display everything correctly @@ -407,8 +420,7 @@ test.describe('Database', () => { await expect(columnDatabase2Row).toContainText('numeric') // update table column - await columnDatabase2Row.getByRole('button').click() - await page.getByRole('button', { name: 'Edit column' }).click() + await columnDatabase2Row.getByRole('button', { name: 'Edit' }).click() await page.getByLabel('name').fill(databaseColumnName3) const columnUpdateWait = createApiResponseWaiter( page, @@ -430,8 +442,8 @@ test.describe('Database', () => { // delete table column const columnDatabase3Row = page.getByRole('row', { name: databaseColumnName3 }) - await columnDatabase3Row.getByRole('button').click() - await page.getByRole('button', { name: 'Delete column' }).click() + await columnDatabase3Row.getByRole('button').last().click() + await page.getByRole('menuitem', { name: 'Delete column' }).click() await page.getByRole('checkbox', { name: 'Drop column with cascade?' }).check() const columnDeleteWait = createApiResponseWaiter( page, diff --git a/e2e/studio/utils/wait-for-response.ts b/e2e/studio/utils/wait-for-response.ts index c40feb5566c..0a0ab48ad4e 100644 --- a/e2e/studio/utils/wait-for-response.ts +++ b/e2e/studio/utils/wait-for-response.ts @@ -24,19 +24,25 @@ function buildUrlMatcher(basePath: string, ref: string, action: string, method?: // Normalize inputs and build a tolerant matcher that works across environments const trimmedBasePath = basePath.replace(/^\/+|\/+$/g, '') const refAlternatives = [ref, 'default'] + const [actionPath, actionQuery] = action.split('?') + const trimmedActionPath = actionPath.replace(/^\/+/, '') + const expectedSearchParams = new URLSearchParams(actionQuery ?? '') return (response: any) => { - const url = response.url() + const url = new URL(response.url()) const requestMethod = response.request().method() // Must include base path and one of the ref alternatives - const hasBasePath = url.includes(`${trimmedBasePath}/`) - const hasRef = refAlternatives.some((r) => url.includes(`/${r}/`)) + const hasBasePath = url.pathname.includes(`/${trimmedBasePath}/`) + const hasRef = refAlternatives.some((r) => url.pathname.includes(`/${r}/`)) - // Action match should be tolerant to extra query params ordering - const hasAction = url.includes(action) + const hasActionPath = + trimmedActionPath.length === 0 || url.pathname.includes(`/${trimmedActionPath}`) + const hasExpectedSearchParams = [...expectedSearchParams.entries()].every(([key, value]) => + url.searchParams.getAll(key).some((actualValue) => actualValue.includes(value)) + ) - const urlMatches = hasBasePath && hasRef && hasAction + const urlMatches = hasBasePath && hasRef && hasActionPath && hasExpectedSearchParams if (method) return urlMatches && requestMethod === method return urlMatches } From 87c61d8de32edcfcf1d76b3fcec164122294aa14 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Thu, 26 Mar 2026 00:34:44 -0300 Subject: [PATCH 08/83] fix: hide tax id banner for billing partner orgs (#44195) ### Changes - (shouldFetch): `!org.billing_partner` - prevents the unnecessary API call to fetch the customer profile for partner orgs. - (early return guard): `org.billing_partner` - returns null (hides the banner) when the org has a billing partner. --- apps/studio/components/layouts/AppLayout/TaxIdBanner.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/studio/components/layouts/AppLayout/TaxIdBanner.tsx b/apps/studio/components/layouts/AppLayout/TaxIdBanner.tsx index 1db4820692b..b6ec0897eaf 100644 --- a/apps/studio/components/layouts/AppLayout/TaxIdBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/TaxIdBanner.tsx @@ -23,6 +23,7 @@ export const TaxIdBanner = () => { const shouldFetch = Boolean( !!slug && org?.plan?.id !== 'free' && + !org?.billing_partner && isDismissLoaded && !isDismissed && !!org?.organization_missing_tax_id @@ -39,6 +40,7 @@ export const TaxIdBanner = () => { router.pathname.includes('sign-in') || !org || org.plan?.id === 'free' || + org.billing_partner || !isDismissLoaded || isDismissed || !org.organization_missing_tax_id || From ba5eedcefaa5f79886e4be813bb0e3c5065342bb Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Thu, 26 Mar 2026 05:52:19 +0000 Subject: [PATCH 09/83] Added Navigating Regional Network Blocks blog post (#44203) --- ...-26-navigating-regional-network-blocks.mdx | 118 ++++++++++++++++++ ...2026-05-04-figma-make-supabase-webinar.mdx | 41 ------ apps/www/lib/authors.json | 8 ++ apps/www/public/rss.xml | 9 +- 4 files changed, 134 insertions(+), 42 deletions(-) create mode 100644 apps/www/_blog/2026-03-26-navigating-regional-network-blocks.mdx delete mode 100644 apps/www/_events/2026-05-04-figma-make-supabase-webinar.mdx diff --git a/apps/www/_blog/2026-03-26-navigating-regional-network-blocks.mdx b/apps/www/_blog/2026-03-26-navigating-regional-network-blocks.mdx new file mode 100644 index 00000000000..49b4582ad53 --- /dev/null +++ b/apps/www/_blog/2026-03-26-navigating-regional-network-blocks.mdx @@ -0,0 +1,118 @@ +--- +title: 'Navigating Regional Network Blocks' +description: 'A look at recent ISP and government-directed blocks of Supabase domains in three regions—what triggered them, how we worked with authorities and customers to restore access, and what multi-tenant platforms can do to prepare.' +author: sara_read +imgSocial: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=announcement&layout=horizontal©=Navigating+Regional+Network+Blocks&icon=supabase.svg' +imgThumb: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=ruler&layout=icon-only©=Regional+network+blocks&icon=supabase.svg' +categories: + - product +tags: + - platform + - reliability +date: '2026-03-26' +toc_depth: 2 +--- + +If you provide infrastructure that hosts applications for developers, you will eventually run into network blocks. ISPs, national firewalls, and automated abuse systems can block IP ranges or domains with little warning. When you operate a multi-tenant platform, a single abusive workload can affect thousands of unrelated developers. + +For infrastructure providers, this creates a difficult balance: you need to keep the platform open and flexible for developers while preventing behavior that can trigger regional blocks. + +At Supabase, we’ve encountered this several times ourselves and in this post we share lessons from three real incidents, what caused them, and the steps we took to resolve and prevent them. In each case, local ISPs or government authorities had blocked access to `supabase.co` domains. + +The goal is to help other infrastructure providers understand how these blocks happen and how to respond when they do. + +## Mitigating blocks with the Public Suffix List + +Multi-tenant platforms often host applications on subdomains like `project.example.com`. Without additional safeguards, browsers treat all of these as part of the same site, which can create security and reputation risks across tenants. + +One mitigation is to add your hosting domain to the [**Public Suffix List**](https://github.com/publicsuffix/list), a community-maintained project hosted by the Mozilla Foundation. + +Originally, Mozilla created this list so browsers could correctly enforce cookie boundaries across complex domain structures (e.g., co.uk). Over time it became the de facto standard used across the internet for determining domain ownership boundaries. + +Adding your platform to this list is as simple as creating a Pull Request. You can find our example [here](https://github.com/publicsuffix/list/pull/1363). However, this is only a mitigation. Even though we submitted our suffix as far back as 2021, it hasn’t completely prevented network blocks in the past few years. + +So why do these blocks happen at all? + +## Why do countries and ISPs block domains? + +Governments and ISPs around the world use domain blocking as a regulatory tool for a wide range of reasons. The most common include: + +- **Copyright and broadcasting rights enforcement.** Authorities may direct ISPs to block platforms hosting content that violates local licensing agreements, such as unauthorized streaming. +- **Data protection and privacy violations.** Platforms that expose or mishandle the personal data of citizens can be blocked while regulators investigate or seek compliance. +- **National security and political control.** Some governments restrict access to services they view as threats to social stability or national security. +- **Anti-fraud and cybercrime enforcement.** Blocking is sometimes used as a rapid-response tool to shut down platforms linked to scams, phishing, or other criminal activity. + +Blocking typically occurs at one of two layers. **DNS-level blocking** works by intercepting the domain name lookup process — when your device asks "where is supabase.co?", the ISP's DNS server returns no answer or a false one, making the domain appear unreachable. **IP blocking** goes deeper, dropping IP packets, which has the consequence of TCP connections timing out. + +Both methods are blunt instruments. Because multi-tenant platforms like Supabase host thousands of independent projects under a single domain and a common set of IP addresses, a block targeting one bad actor can — and does — block every other project on the platform. Regulators often have no easy way to remove a single project; blocking the domain is the path of least resistance. + +Cloudflare, our CDN provider and a key piece of internet infrastructure, is explicitly unable to intervene in government-directed blocking situations. When a government issues a blocking order, it falls on the platform operator — us — to engage with the relevant authority and work toward a resolution. + +## Three recent examples of regional network blocks + +In the last several months, Supabase handled three regional network blocks. Here is the background on what happened in each case. + +### UAE — September 2025 + +Starting September 2, 2025, users in the UAE on the Etisalat and Du networks were unable to reach Supabase Projects. The UAE Telecommunications Regulatory Authority (TDRA) directed ISPs to block `*.supabase.co` because a single project hosted on our platform was being used to distribute unauthorized streaming content in violation of UAE broadcasting rights laws. + +Supabase was not given advance notice. We had not received any formal abuse notification prior to the block going into effect. + +We worked quickly to establish communication with the TDRA — assisted by a customer who helped broker the introduction — and we removed the offending project from our platform. Once we demonstrated compliance, TDRA lifted the restrictions. Etisalat was restored within days; Du followed shortly after. The block lasted approximately 18 days. + +### Yemen — February 2026 + +Beginning February 2, 2026, users in Yemen on Yemen Mobile, Sabafon, Y-Telecom, and Spacetel lost access to Supabase Projects. Unlike the UAE, this block operated at the transport layer rather than at DNS — meaning TCP connections were being dropped before they could be established. No formal reason was ever communicated to us by YemenNet, the ISP issuing the block. + +We opened a support ticket with Cloudflare, reached out to YemenNet directly via email, and worked to gather diagnostic data from affected customers. Despite these efforts, we received no response from the ISP through official channels. The block was ultimately resolved when a customer physically visited a YemenNet service center and requested the block be lifted. Access was restored on February 10, 2026 — approximately 8 days after the block began. + +### India — February–March 2026 + +On February 24, 2026 Supabase experienced a temporary access restriction affecting the [supabase.co](https://supabase.com) domain for certain internet service providers in India. The restriction was imposed by the Ministry of Electronics and Information Technology (MeitY) under Section 69A of the Information Technology Act in connection with concerns relating to a third-party misuse of a Supabase-hosted project. + +Supabase had previously received a notice from Indian authorities regarding that activity and responded immediately by disabling the relevant project and taking appropriate enforcement measures under our policies. Our review confirmed that the issue was limited to this single instance of platform misuse and did not involve any vulnerability or systemic issue with Supabase services. + +Following constructive engagement with MeitY and the submission of additional technical information, the Government of India rescinded the blocking order and access to Supabase services in India was restored on March 3, 2026. The block lasted approximately 8 days. + +Supabase appreciates the cooperation of the Indian authorities in resolving this matter. We have also established clearer and direct communication channels with the relevant agencies to ensure that any future concerns can be addressed quickly and precisely without disruption to developers and users. + +## Workarounds we provided + +During each incident, we worked to minimize disruption for affected users: + +- **UAE:** We launched a proxy domain which restored access for PostgREST, Realtime, and Edge Functions, though auth callbacks and storage object URLs remained affected due to hardcoded domain references. +- **Yemen:** The transport-layer nature of the block limited what we could offer. VPN usage was the most reliable workaround available. +- **India:** We recommended VPNs, alternative DNS providers, and [custom domains](https://supabase.com/docs/guides/platform/custom-domains) as interim options while we worked toward a resolution. + +In all three cases, VPN connections continued to work throughout the incidents. This is consistent with how DNS and transport-layer blocks generally work — they restrict access within a jurisdiction's network infrastructure, but encrypted tunnels routed through external servers bypass them entirely. + +## What these incidents have in common + +All three blocks share a pattern worth understanding: + +1. A specific project on our platform violated local law or regulation — in two of the three cases, we had already taken the project down before authorities acted. Supabase takes legal compliance very seriously, and actively surveys and audits our platform to discover and eliminate violations of our use policies quickly. +2. We had no existing relationship with the relevant authorities, which slowed our ability to engage and resolve the situation quickly. +3. Customer relationships proved decisive — in the UAE, a customer introduced us to the TDRA; in Yemen, a customer resolved the issue in person with the ISP; in India, members of our team with local connections led the legal and government engagement. +4. Cloudflare, our CDN provider, is unable to intervene in government-directed blocking situations and advised that we engage the relevant authorities directly. + +This pattern reflects a broader challenge for global developer platforms: regulatory enforcement is local, but the internet is not. When a government acts, it acts within its own jurisdiction — and the platform operator must meet it there. + +## What steps can infrastructure providers take? + +These incidents have made clear that operating a global developer platform means navigating a complex and sometimes unpredictable regulatory environment. Several steps that any provider can take include: + +**Faster communication with regulators.** Work to establish direct, proactive relationships with telecommunications regulators and government agencies in key regions before incidents occur. Ensure it is easy for regulators and agencies to find contact emails to report abusive behavior and potential blocks. The goal is to be a known, compliant platform with clear channels for authorities to reach you, so that a blocking order is never the first contact. + +**Better abuse response pipelines.** Improve handling of abuse response and communicate takedown actions so that when actions are taken on a project, relevant authorities are informed promptly, with acknowledgement from those parties confirmed as part of the process. + +**Expanded workaround capabilities.** Evaluate technical options, including alternative domain infrastructure, to reduce the impact of regional blocks on users when they do occur. + +## A note to our users in affected regions + +We know how disruptive these incidents were. Developers and businesses depend on Supabase to run critical applications, and we take that responsibility seriously. We're sorry for the downtime and the uncertainty that came with it. + +To every customer who helped us navigate these situations — including those who made introductions, provided diagnostic data, and in one case personally walked into an ISP office on our behalf — THANK YOU! Your support made a real difference. + +If you ever encounter access issues in your region, please reach out to [support.supabase.com](https://support.supabase.com). + +For details on each incident, you can find our status page history at [status.supabase.com](https://status.supabase.com). diff --git a/apps/www/_events/2026-05-04-figma-make-supabase-webinar.mdx b/apps/www/_events/2026-05-04-figma-make-supabase-webinar.mdx deleted file mode 100644 index 71c66cfab11..00000000000 --- a/apps/www/_events/2026-05-04-figma-make-supabase-webinar.mdx +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: 'Fully Operational: Building Production-Ready Apps with Figma Make + Supabase' -meta_title: 'Fully Operational: Building Production-Ready Apps with Figma Make + Supabase' -subtitle: >- - Go from working prototype to production-ready application with Figma Make and Supabase -description: >- - Learn how to add authentication, row-level security, and persistent data to AI-generated apps. A live build from design to deployed, production-grade app. -meta_description: >- - Learn how to add authentication, row-level security, and persistent data to AI-generated apps. A live build from design to deployed, production-grade app. -type: webinar -onDemand: true -date: '2026-05-04T16:00:00.000-07:00' -timezone: America/Los_Angeles -duration: 45 mins -categories: - - webinar -main_cta: - { - url: 'https://attendee.gotowebinar.com/register/1588965180448847961', - target: '_blank', - label: 'Register now', - } -speakers: 'gerardo_estaba,michel_ferreira' -partners: 'figma' ---- - -Vibe coding gets you from zero to prototype fast. But what happens when you want real users? Most AI-generated apps hit a wall the moment you need authentication, persistent data, or row-level security. - -In this 45-minute session, engineers from Supabase and Figma walk through what it takes to go from a working prototype to a production-ready application. You'll see how to pair Figma Make's design-to-code workflow with Supabase's backend to ship apps that are secure, scalable, and ready for real traffic. - -## What you'll learn - -- Where vibe-coded apps typically break on the way to production - -- How to add authentication and row-level security without rewriting your app - -- How Figma Make and Supabase work together end to end - -- A live build: from design to deployed, production-grade app - -Join us live to participate in the Q&A. Can't make it? We'll send you a link to the recording. diff --git a/apps/www/lib/authors.json b/apps/www/lib/authors.json index c38350986ef..7ab0f5c3f6b 100644 --- a/apps/www/lib/authors.json +++ b/apps/www/lib/authors.json @@ -606,6 +606,14 @@ "author_url": "https://github.com/saxonf", "author_image_url": "https://github.com/saxonf.png" }, + { + "author_id": "sara_read", + "author": "Sara Read", + "username": "supa-sara", + "position": "Incident Manager", + "author_url": "https://github.com/supa-sara", + "author_image_url": "https://github.com/supa-sara.png" + }, { "author_id": "gregpapas", "author": "Greg Papas", diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml index 18f8f9cccd0..457f56aefba 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -4,9 +4,16 @@ https://supabase.com Latest news from Supabase en - Thu, 05 Mar 2026 00:00:00 -0700 + Thu, 26 Mar 2026 00:00:00 -0700 + https://supabase.com/blog/navigating-regional-network-blocks + Navigating Regional Network Blocks + https://supabase.com/blog/navigating-regional-network-blocks + A look at recent ISP and government-directed blocks of Supabase domains in three regions—what triggered them, how we worked with authorities and customers to restore access, and what multi-tenant platforms can do to prepare. + Thu, 26 Mar 2026 00:00:00 -0700 + + https://supabase.com/blog/log-drains-now-available-on-pro Log Drains: Now available on Pro https://supabase.com/blog/log-drains-now-available-on-pro From 0c5f64fcba697286aff262f6f134d69c6a36e2c0 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Thu, 26 Mar 2026 02:35:54 -0400 Subject: [PATCH 10/83] feat(assistant): upgrade default models to gpt-5.4-nano and gpt-5.3-codex (#44107) Replaces `gpt-5-mini` and `gpt-5` with `gpt-5.4-nano` and `gpt-5.3-codex` respectively. Clients with stale model IDs in IndexedDB will gracefully reset to the new defaults. While we can technically keep the existing models around, we've [opted](https://supabase.slack.com/archives/C051L8U2EJF/p1774283070517609?thread_ts=1773771991.871669&cid=C051L8U2EJF) to replace them w/ the newer models for simplicity. Basic completion endpoints use `'none'` reasoning level for optimal speed. Rationale for these models is they provide they best balance of intelligence/speed and cost. GPT-5.4-nano is less expensive (0.8x price), faster, and smarter than GPT-5-mini. GPT-5.4-mini would be even smarter but is 3x the price. GPT-5.3-Codex is ~1.4x the price of GPT-5, while GPT-5.4 would be 2x price, but 5.3-Codex is still a big intelligence boost from GPT-5. See [eval comparison](https://www.braintrust.dev/app/supabase.io/p/Assistant/experiments/mattrossman%2Fai-509-v2-upgrade-assistant-models-beyond-gpt-5-family-1774468619?c=master-1774458837&diff=between_experiments), scores are relatively stable and conciseness naturally improves on gpt-5.4-nano. Other change: - Fixed an eval test case to clarify that https://supabase.help is also a correct URL for submitting support ticket, which was unfairly scored as incorrect [here](https://www.braintrust.dev/app/supabase.io/p/Assistant/trace?object_type=experiment&object_id=5244cccd-23b2-4f79-9dd2-287f1b40ebad&r=bac9b903-8bde-4c21-99dd-e0ed141c4f9e&s=f248fbf5-75bf-4aab-be0a-87a4298e6d11) I sanity checked the Assistant, natural language filters, and SQL Editor completions on staging preview. References: - https://openai.com/index/introducing-gpt-5-4-mini-and-nano/ - https://openai.com/index/introducing-gpt-5-3-codex/ - https://developers.openai.com/api/docs/pricing Closes AI-509 --- apps/studio/evals/dataset.ts | 3 +- apps/studio/lib/ai/model.test.ts | 18 +++++------ apps/studio/lib/ai/model.utils.test.ts | 40 +++++++++++++----------- apps/studio/lib/ai/model.utils.ts | 30 +++++++++--------- apps/studio/state/ai-assistant-state.tsx | 11 +++++-- 5 files changed, 55 insertions(+), 47 deletions(-) diff --git a/apps/studio/evals/dataset.ts b/apps/studio/evals/dataset.ts index 65cc8081ad5..84da4f65264 100644 --- a/apps/studio/evals/dataset.ts +++ b/apps/studio/evals/dataset.ts @@ -109,7 +109,8 @@ export const dataset: AssistantEvalCase[] = [ { input: { prompt: 'Where can I go to create a support ticket?' }, expected: { - correctAnswer: 'https://supabase.com/dashboard/support/new', + correctAnswer: + 'https://supabase.com/dashboard/support/new (or https://supabase.help which redirects there)', }, metadata: { category: ['general_help'], diff --git a/apps/studio/lib/ai/model.test.ts b/apps/studio/lib/ai/model.test.ts index e8671bcf553..64f76f95e74 100644 --- a/apps/studio/lib/ai/model.test.ts +++ b/apps/studio/lib/ai/model.test.ts @@ -51,11 +51,11 @@ describe('getModel', () => { const { modelParams, promptProviderOptions } = await getModel({ provider: 'openai', - modelEntry: openaiModelEntry({ id: 'gpt-5-mini' }), + modelEntry: openaiModelEntry({ id: 'gpt-5.4-nano' }), }) expect(modelParams?.model).toEqual('openai-model') - expect(openai).toHaveBeenCalledWith('gpt-5-mini') + expect(openai).toHaveBeenCalledWith('gpt-5.4-nano') expect(promptProviderOptions).toBeUndefined() }) @@ -64,24 +64,24 @@ describe('getModel', () => { const { error } = await getModel({ provider: 'openai', - modelEntry: openaiModelEntry({ id: 'gpt-5-mini' }), + modelEntry: openaiModelEntry({ id: 'gpt-5.4-nano' }), }) expect(error).toEqual(new Error('OPENAI_API_KEY not available')) }) - it('returns openai gpt-5 when hasAccessToAdvanceModel and not throttled', async () => { + it('returns openai gpt-5.3-codex when hasAccessToAdvanceModel and not throttled', async () => { vi.stubEnv('OPENAI_API_KEY', 'test-key') vi.stubEnv('IS_THROTTLED', 'false') const { modelParams, error } = await getModel({ provider: 'openai', - modelEntry: openaiModelEntry({ id: 'gpt-5', reasoningEffort: 'minimal' }), + modelEntry: openaiModelEntry({ id: 'gpt-5.3-codex', reasoningEffort: 'low' }), }) expect(error).toBeUndefined() expect(modelParams?.model).toEqual('openai-model') - expect(openai).toHaveBeenCalledWith('gpt-5') - expect(modelParams?.providerOptions?.openai?.reasoningEffort).toBe('minimal') + expect(openai).toHaveBeenCalledWith('gpt-5.3-codex') + expect(modelParams?.providerOptions?.openai?.reasoningEffort).toBe('low') }) it('applies reasoningEffort from DEFAULT_COMPLETION_MODEL', async () => { @@ -93,7 +93,7 @@ describe('getModel', () => { }) expect(error).toBeUndefined() - expect(openai).toHaveBeenCalledWith('gpt-5-mini') - expect(modelParams?.providerOptions?.openai?.reasoningEffort).toBe('minimal') + expect(openai).toHaveBeenCalledWith('gpt-5.4-nano') + expect(modelParams?.providerOptions?.openai?.reasoningEffort).toBe('none') }) }) diff --git a/apps/studio/lib/ai/model.utils.test.ts b/apps/studio/lib/ai/model.utils.test.ts index 2de1e5dadf2..80fe423295a 100644 --- a/apps/studio/lib/ai/model.utils.test.ts +++ b/apps/studio/lib/ai/model.utils.test.ts @@ -25,7 +25,7 @@ describe('model.utils', () => { it('should return correct default for openai provider', () => { const result = getDefaultModelForProvider('openai') - expect(result).toBe('gpt-5-mini') + expect(result).toBe('gpt-5.4-nano') }) it('should return undefined for unknown provider', () => { @@ -47,8 +47,8 @@ describe('model.utils', () => { it('should have openai provider with models', () => { expect(PROVIDERS.openai).toBeDefined() expect(PROVIDERS.openai.models).toBeDefined() - expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5') - expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5-mini') + expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5.3-codex') + expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5.4-nano') }) it('should have exactly one default model per provider', () => { @@ -111,48 +111,50 @@ describe('model.utils', () => { }) it('defaults should satisfy unions', () => { - expect(DEFAULT_ASSISTANT_BASE_MODEL_ID).toBe('gpt-5-mini') - expect(DEFAULT_ASSISTANT_ADVANCE_MODEL_ID).toBe('gpt-5') + expect(DEFAULT_ASSISTANT_BASE_MODEL_ID).toBe('gpt-5.4-nano') + expect(DEFAULT_ASSISTANT_ADVANCE_MODEL_ID).toBe('gpt-5.3-codex') expect(defaultAssistantModelId(false)).toBe(DEFAULT_ASSISTANT_BASE_MODEL_ID) expect(defaultAssistantModelId(true)).toBe(DEFAULT_ASSISTANT_ADVANCE_MODEL_ID) }) it('isAssistantBaseModelId / isAdvanceOnlyModelId', () => { - expect(isAssistantBaseModelId('gpt-5-mini')).toBe(true) - expect(isAssistantBaseModelId('gpt-5')).toBe(false) - expect(isAdvanceOnlyModelId('gpt-5')).toBe(true) - expect(isAdvanceOnlyModelId('gpt-5-mini')).toBe(false) + expect(isAssistantBaseModelId('gpt-5.4-nano')).toBe(true) + expect(isAssistantBaseModelId('gpt-5.3-codex')).toBe(false) + expect(isAdvanceOnlyModelId('gpt-5.3-codex')).toBe(true) + expect(isAdvanceOnlyModelId('gpt-5.4-nano')).toBe(false) }) it('isKnownAssistantModelId', () => { - expect(isKnownAssistantModelId('gpt-5-mini')).toBe(true) - expect(isKnownAssistantModelId('gpt-5')).toBe(true) + expect(isKnownAssistantModelId('gpt-5.4-nano')).toBe(true) + expect(isKnownAssistantModelId('gpt-5.3-codex')).toBe(true) + expect(isKnownAssistantModelId('gpt-5')).toBe(false) + expect(isKnownAssistantModelId('gpt-5-mini')).toBe(false) expect(isKnownAssistantModelId('unknown')).toBe(false) }) it('getAssistantModelEntry returns config for known ids', () => { - expect(getAssistantModelEntry('gpt-5-mini').reasoningEffort).toBe('minimal') - expect(getAssistantModelEntry('gpt-5').reasoningEffort).toBe('minimal') - expect(getAssistantModelEntry('gpt-5-mini')).toEqual( - ASSISTANT_MODELS.find((m) => m.id === 'gpt-5-mini') + expect(getAssistantModelEntry('gpt-5.4-nano').reasoningEffort).toBe('low') + expect(getAssistantModelEntry('gpt-5.3-codex').reasoningEffort).toBe('low') + expect(getAssistantModelEntry('gpt-5.4-nano')).toEqual( + ASSISTANT_MODELS.find((m) => m.id === 'gpt-5.4-nano') ) }) - it('DEFAULT_COMPLETION_MODEL is gpt-5-mini with minimal reasoning effort', () => { + it('DEFAULT_COMPLETION_MODEL is gpt-5.4-nano with no reasoning effort', () => { expect(DEFAULT_COMPLETION_MODEL.id).toBe(DEFAULT_ASSISTANT_BASE_MODEL_ID) - expect(DEFAULT_COMPLETION_MODEL.reasoningEffort).toBe('minimal') + expect(DEFAULT_COMPLETION_MODEL.reasoningEffort).toBe('none') }) it('openaiModelEntry enforces valid reasoning effort at compile time', () => { // Valid: supported effort level const withEffort = openaiModelEntry({ - id: 'gpt-5-mini', + id: 'gpt-5.4-nano', reasoningEffort: 'low', }) expect(withEffort.reasoningEffort).toBe('low') // Valid: no effort - const withoutEffort = openaiModelEntry({ id: 'gpt-5-mini' }) + const withoutEffort = openaiModelEntry({ id: 'gpt-5.4-nano' }) expect(withoutEffort.reasoningEffort).toBeUndefined() }) }) diff --git a/apps/studio/lib/ai/model.utils.ts b/apps/studio/lib/ai/model.utils.ts index 15dc0f8fabc..b558a1687b1 100644 --- a/apps/studio/lib/ai/model.utils.ts +++ b/apps/studio/lib/ai/model.utils.ts @@ -2,17 +2,17 @@ export type ProviderName = 'bedrock' | 'openai' export type BedrockModel = 'anthropic.claude-3-7-sonnet-20250219-v1:0' | 'openai.gpt-oss-120b-1:0' -export type OpenAIModelId = 'gpt-5' | 'gpt-5-mini' +export type OpenAIModelId = 'gpt-5.4-nano' | 'gpt-5.3-codex' // Source: https://developers.openai.com/api/docs/guides/reasoning + per-model pages export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' // Per-model reasoning effort compatibility. -// When adding a model, verify supported levels in the community matrix and add an entry: -// https://community.openai.com/t/request-for-compatibility-matrix-reasoning-effort-sampling-parameters-across-gpt-5-series/1371738/2 +// Sources: https://developers.openai.com/api/docs/models/gpt-5.4-nano +// https://developers.openai.com/api/docs/models/gpt-5.3-codex type ModelReasoningSupport = { - 'gpt-5': 'minimal' | 'low' | 'medium' | 'high' - 'gpt-5-mini': 'minimal' | 'low' | 'medium' | 'high' + 'gpt-5.4-nano': 'none' | 'low' | 'medium' | 'high' | 'xhigh' + 'gpt-5.3-codex': 'low' | 'medium' | 'high' | 'xhigh' } type ReasoningEffortFor = ModelId extends keyof ModelReasoningSupport @@ -47,22 +47,22 @@ export type OpenAIModelEntry = ReturnType /** Default model entry for simple completion endpoints where latency is more important than reasoning. */ export const DEFAULT_COMPLETION_MODEL = openaiModelEntry({ - id: 'gpt-5-mini', - reasoningEffort: 'minimal', + id: 'gpt-5.4-nano', + reasoningEffort: 'none', }) // Single source of truth for all Assistant chat model variants and their reasoning levels. // Models with requiresAdvanceModelEntitlement false are available to all users; true requires the assistant.advance_model entitlement. export const ASSISTANT_MODELS = [ openaiModelEntry({ - id: 'gpt-5-mini', + id: 'gpt-5.4-nano', requiresAdvanceModelEntitlement: false, - reasoningEffort: 'minimal', + reasoningEffort: 'low', }), openaiModelEntry({ - id: 'gpt-5', + id: 'gpt-5.3-codex', requiresAdvanceModelEntitlement: true, - reasoningEffort: 'minimal', + reasoningEffort: 'low', }), ] as const @@ -77,9 +77,9 @@ const ASSISTANT_MODELS_MAP = Object.fromEntries(ASSISTANT_MODELS.map((m) => [m.i (typeof ASSISTANT_MODELS)[number] > -export const DEFAULT_ASSISTANT_BASE_MODEL_ID = 'gpt-5-mini' satisfies AssistantBaseModelId +export const DEFAULT_ASSISTANT_BASE_MODEL_ID = 'gpt-5.4-nano' satisfies AssistantBaseModelId -export const DEFAULT_ASSISTANT_ADVANCE_MODEL_ID = 'gpt-5' satisfies AssistantModelId +export const DEFAULT_ASSISTANT_ADVANCE_MODEL_ID = 'gpt-5.3-codex' satisfies AssistantModelId export function defaultAssistantModelId(hasAccessToAdvanceModel: boolean): AssistantModelId { return hasAccessToAdvanceModel @@ -148,8 +148,8 @@ export const PROVIDERS: ProviderRegistry = { }, openai: { models: { - 'gpt-5': { default: false }, - 'gpt-5-mini': { default: true }, + 'gpt-5.3-codex': { default: false }, + 'gpt-5.4-nano': { default: true }, }, providerOptions: { openai: { diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index 6cb44ef7197..5ac866e6065 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -8,6 +8,7 @@ import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' import { constructHeaders } from 'data/fetchers' import { prepareMessagesForAPI } from 'lib/ai/message-utils' +import { isKnownAssistantModelId } from 'lib/ai/model.utils' import type { AssistantModelId } from 'lib/ai/model.utils' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' @@ -46,7 +47,7 @@ type AiAssistantData = { tables: { schema: string; name: string }[] chats: Record activeChatId?: string - model: AssistantModel + model?: AssistantModel context: AiAssistantContext } @@ -65,7 +66,7 @@ const INITIAL_AI_ASSISTANT: AiAssistantData = { tables: [], chats: {}, activeChatId: undefined, - model: 'gpt-5', + model: undefined, context: {}, } @@ -487,7 +488,11 @@ export const createAiAssistantState = (): AiAssistantState => { loadPersistedState: (persistedState: StoredAiAssistantState) => { state.chats = persistedState.chats state.activeChatId = persistedState.activeChatId - state.model = persistedState.model ?? INITIAL_AI_ASSISTANT.model + const storedModel = persistedState.model + state.model = + storedModel && isKnownAssistantModelId(storedModel) + ? storedModel + : INITIAL_AI_ASSISTANT.model // Ensure an active chat exists after loading if (!state.activeChat) { From 3debd400a62250d19d065b0c499054d8c8c2318a Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 26 Mar 2026 14:36:21 +0800 Subject: [PATCH 11/83] Refactor ColumnEditor to use latest UI components + fix some UI oddities (#44214) ## Context Refactors the Table Editor's `ColumnEditor` to use the latest UI components, and fix some UI oddities along the way ## Bug fixes - Fix header text vertical alignment - Before: image - After: image - Fix closing a toast would close the panel as well - Can verify by creating a new column, then hitting save without entering anything. Will trigger some error toasts and closing them will close the panel too - Fix tooltips on "is nullable" and "is unique" showing up irregardless if "is primary key" is toggled on or off --- .../ColumnEditor/ColumnEditor.tsx | 524 ++++++++++-------- .../ColumnEditor/HeaderTitle.tsx | 2 +- .../TableEditor/HeaderTitle.tsx | 12 +- 3 files changed, 304 insertions(+), 234 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index 3426c247772..75c8d13f774 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -18,17 +18,25 @@ import { isEmpty, noop } from 'lodash' import { ExternalLink, Plus } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' -import type { Dictionary } from 'types' import { Button, - Checkbox, - Input, + Checkbox_Shadcn_, + cn, + DialogSectionSeparator, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, SidePanel, - Toggle, + Switch, Tooltip, TooltipContent, TooltipTrigger, } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ActionBar } from '../ActionBar' import type { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types' @@ -67,7 +75,7 @@ export interface ColumnEditorProps { existingForeignKeyRelations: ForeignKeyConstraint[] createMore?: boolean }, - resolve: any + resolve: () => void ) => void updateEditorDirty: () => void } @@ -83,7 +91,7 @@ export const ColumnEditor = ({ const { ref } = useParams() const { data: project } = useSelectedProjectQuery() - const [errors, setErrors] = useState>({}) + const [errors, setErrors] = useState<{ [key: string]: string }>({}) const [columnFields, setColumnFields] = useState() const [fkRelations, setFkRelations] = useState([]) const [createMore, setCreateMore] = useState(false) @@ -135,6 +143,7 @@ export const ColumnEditor = ({ setColumnFields(columnFields) setFkRelations(formatForeignKeys(foreignKeys)) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]) if (!columnFields) return null @@ -195,7 +204,7 @@ export const ColumnEditor = ({ existingForeignKeyRelations: foreignKeys, createMore, } - saveChanges(payload, isNewRecord, configuration, (err?: any) => { + saveChanges(payload, isNewRecord, configuration, (err?: string) => { resolve() if (!err && createMore && isNewRecord) { const freshColumnFields = generateColumnField({ @@ -214,234 +223,295 @@ export const ColumnEditor = ({ } return ( - void) => onSaveChanges(resolve)} - // @ts-ignore - header={} - onCancel={closePanel} - customFooter={ - void) => onSaveChanges(resolve)} - visible={visible} - > - {isNewRecord && ( -
- setCreateMore(!createMore)} - /> - -
- )} -
- } - > - General}> - - onUpdateField({ name: event.target.value })} - /> - onUpdateField({ comment: event.target.value })} - /> - - - - - - -
+ onUpdateField({ name: event.target.value })} + /> + {errors.name &&

{errors.name}

} + + + onUpdateField({ comment: event.target.value })} + /> + + + + + + + + + +
+ } + > + Data Type + } > - Data Type - - } - > - - onUpdateField({ format, defaultValue: null })} - /> - {columnFields.foreignKey === undefined && ( -
- {columnFields.format.includes('int') && ( -
- { - const isIdentity = !columnFields.isIdentity - const isArray = isIdentity ? false : columnFields.isArray - onUpdateField({ isIdentity, isArray }) - }} - /> + + onUpdateField({ format, defaultValue: null })} + /> + {columnFields.foreignKey === undefined && ( +
+ {columnFields.format.includes('int') && ( + + { + const isIdentity = !columnFields.isIdentity + const isArray = isIdentity ? false : columnFields.isArray + onUpdateField({ isIdentity, isArray }) + }} + /> + + )} + {!columnFields.isPrimaryKey && ( + + { + const isArray = !columnFields.isArray + const isIdentity = isArray ? false : columnFields.isIdentity + onUpdateField({ isArray, isIdentity }) + }} + /> + + )}
)} - {!columnFields.isPrimaryKey && ( -
- { - const isArray = !columnFields.isArray - const isIdentity = isArray ? false : columnFields.isIdentity - onUpdateField({ isArray, isIdentity }) - }} - /> -
- )} -
- )} - - - + + + - + - Foreign Keys} - > - - Foreign Keys} + > + + { + if (format[0] === '_') { + onUpdateField({ format: format.slice(1), isArray: true, isIdentity: false }) + } else { + onUpdateField({ format }) + } + }} + onUpdateFkRelations={setFkRelations} + /> + + + + Constraints} + > + + + + onUpdateField({ + isPrimaryKey: !columnFields?.isPrimaryKey, + isUnique: false, + isNullable: false, + }) + } + /> + + + + + + + onUpdateField({ isNullable: !columnFields.isNullable }) + } + /> + + + {columnFields.isPrimaryKey && ( + + Column is a primary key and hence cannot be NULL + + )} + + + + + + onUpdateField({ isUnique: !columnFields.isUnique })} + /> + + + {columnFields.isPrimaryKey && ( + + Column is a primary key and hence already unique + + )} + + + + onUpdateField({ check: event.target.value })} + className="[&_input]:font-mono" + /> + + + + + + + { - if (format[0] === '_') { - onUpdateField({ format: format.slice(1), isArray: true, isIdentity: false }) - } else { - onUpdateField({ format }) - } - }} - onUpdateFkRelations={setFkRelations} - /> - - - - Constraints} - > - - - onUpdateField({ - isPrimaryKey: !columnFields?.isPrimaryKey, - isUnique: false, - isNullable: false, - }) - } - /> - - - -
- onUpdateField({ isNullable: !columnFields.isNullable })} - /> + applyFunction={(resolve: () => void) => onSaveChanges(resolve)} + visible={visible} + > + {isNewRecord && ( +
+ setCreateMore(!createMore)} /> +
- - - Column is a primary key and hence cannot be NULL - - - - - -
- onUpdateField({ isUnique: !columnFields.isUnique })} - /> -
-
- - Column is a primary key and hence already unique - -
- - onUpdateField({ check: event.target.value })} - className="[&_input]:font-mono" - /> - - - + )} + + + + ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx index f7631bd5214..5bd0f76fd4c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx @@ -2,7 +2,7 @@ import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' interface Props { table: PostgresTable - column: PostgresColumn + column?: PostgresColumn } export const HeaderTitle = ({ table, column }: Props) => { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/HeaderTitle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/HeaderTitle.tsx index c3c56c2063e..1c22fb3cdce 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/HeaderTitle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/HeaderTitle.tsx @@ -7,21 +7,21 @@ interface HeaderTitleProps { export const HeaderTitle = ({ schema, table, isDuplicating }: HeaderTitleProps) => { if (!table) { return ( - <> + Create a new table under {schema} - + ) } if (isDuplicating) { return ( - <> + Duplicate table {table?.name} - + ) } return ( - <> + Update table {table?.name} - + ) } From 2b419fcb1a645937db0192521a1aedaca89a76d8 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 26 Mar 2026 00:39:37 -0600 Subject: [PATCH 12/83] fix: revert changes when the value is the same as old (#44196) ## 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? Revert changes when the value is the same as the old value --------- Co-authored-by: Joshen Lim --- .../utils/queueConflictResolution.test.ts | 114 ++++++++++++++++++ .../grid/utils/queueConflictResolution.ts | 22 +++- .../features/queue-table-operations.spec.ts | 47 ++++++++ 3 files changed, 179 insertions(+), 4 deletions(-) diff --git a/apps/studio/components/grid/utils/queueConflictResolution.test.ts b/apps/studio/components/grid/utils/queueConflictResolution.test.ts index 7df3199c4bc..77ddc7ca7e2 100644 --- a/apps/studio/components/grid/utils/queueConflictResolution.test.ts +++ b/apps/studio/components/grid/utils/queueConflictResolution.test.ts @@ -491,6 +491,120 @@ describe('upsertOperation', () => { expect((updated.payload as any).newValue).toBe('final value') }) + test('should remove operation when value is reverted to original', () => { + const existingOp: QueuedOperation = { + id: 'edit_cell_content:1:name:id:1', + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + timestamp: Date.now() - 1000, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'original', + newValue: 'changed', + table: mockTable, + }, + } + + const otherOp: QueuedOperation = { + id: 'edit_cell_content:1:email:id:1', + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + timestamp: Date.now(), + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'email', + oldValue: 'old@test.com', + newValue: 'new@test.com', + table: mockTable, + }, + } + + const operations = [existingOp, otherOp] + const newOperation: NewEditCellContentOperation = { + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'changed', + newValue: 'original', + table: mockTable, + }, + } + + const result = upsertOperation(operations, newOperation) + + expect(result.operations).toHaveLength(1) + expect(result.operations[0]).toEqual(otherOp) + }) + + test('should remove operation when number oldValue matches string newValue', () => { + const existingOp: QueuedOperation = { + id: 'edit_cell_content:1:age:id:1', + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + timestamp: Date.now() - 1000, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'age', + oldValue: 42, + newValue: 'changed', + table: mockTable, + }, + } + + const operations = [existingOp] + const newOperation: NewEditCellContentOperation = { + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'age', + oldValue: 'changed', + newValue: '42', + table: mockTable, + }, + } + + const result = upsertOperation(operations, newOperation) + + expect(result.operations).toHaveLength(0) + }) + + test('should remove operation when string oldValue matches JSON object newValue', () => { + const existingOp: QueuedOperation = { + id: 'edit_cell_content:1:metadata:id:1', + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + timestamp: Date.now() - 1000, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'metadata', + oldValue: '{"key":"value","count":3}', + newValue: 'changed', + table: mockTable, + }, + } + + const operations = [existingOp] + const newOperation: NewEditCellContentOperation = { + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'metadata', + oldValue: 'changed', + newValue: { key: 'value', count: 3 }, + table: mockTable, + }, + } + + const result = upsertOperation(operations, newOperation) + + expect(result.operations).toHaveLength(0) + }) + test('should update existing DELETE_ROW operation', () => { const existingOp: QueuedOperation = { id: 'delete_row:1:id:1', diff --git a/apps/studio/components/grid/utils/queueConflictResolution.ts b/apps/studio/components/grid/utils/queueConflictResolution.ts index 4b96f38c40e..a0d0a1a6305 100644 --- a/apps/studio/components/grid/utils/queueConflictResolution.ts +++ b/apps/studio/components/grid/utils/queueConflictResolution.ts @@ -1,13 +1,16 @@ +import { isEqual } from 'lodash' + import { isPendingAddRow } from '../types' import { generateTableChangeKey, rowMatchesIdentifiers } from './queueOperationUtils' +import { tryParseJson } from '@/lib/helpers' import { - type NewDeleteRowOperation, - type NewEditCellContentOperation, + isDeleteRowOperation, + isEditCellContentOperation, NewQueuedOperation, QueuedOperation, QueuedOperationType, - isDeleteRowOperation, - isEditCellContentOperation, + type NewDeleteRowOperation, + type NewEditCellContentOperation, } from '@/state/table-editor-operation-queue.types' export type DeleteConflictResult = @@ -165,6 +168,17 @@ export function upsertOperation( existingOp.type === QueuedOperationType.EDIT_CELL_CONTENT ) { queuedOperation.payload.oldValue = existingOp.payload.oldValue + + const { oldValue, newValue } = queuedOperation.payload + // [Joshen] These comparisons by data type are because of how the table editor renders the values + if ( + (typeof oldValue === 'number' && Number(oldValue) === Number(newValue)) || + (typeof newValue === 'object' && isEqual(tryParseJson(oldValue), newValue)) || + oldValue === newValue + ) { + updatedOperations.splice(existingOpIndex, 1) + return { operations: updatedOperations } + } } updatedOperations[existingOpIndex] = queuedOperation diff --git a/e2e/studio/features/queue-table-operations.spec.ts b/e2e/studio/features/queue-table-operations.spec.ts index d100e23c752..bbc357bd4f6 100644 --- a/e2e/studio/features/queue-table-operations.spec.ts +++ b/e2e/studio/features/queue-table-operations.spec.ts @@ -786,6 +786,53 @@ test.describe('Queue Table Operations', () => { await expect(page.getByRole('gridcell', { name: 'only in table 2' })).not.toBeVisible() }) + test('reverted cell edit clears the pending change', async ({ page, ref }) => { + const tableName = `${tableNamePrefix}_revert_edit` + const columnName = 'name' + + await using _ = await withSetupCleanup( + async () => { + await createTable(tableName, columnName, [{ name: 'original value' }]) + }, + async () => { + await dropTable(tableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await enableQueueOperations(page) + await page.reload() + await waitForTableToLoad(page, ref) + + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await expect(page.getByRole('gridcell', { name: 'original value' })).toBeVisible() + + // Edit the cell to a different value + const cell = page.getByRole('gridcell', { name: 'original value' }) + await cell.dblclick() + + const editor = page.getByRole('textbox', { name: /Editor content/ }) + await expect(editor).toBeVisible() + await editor.fill('changed value') + await page.keyboard.press('Enter') + + await expect(page.getByText('1 pending change')).toBeVisible() + + // Edit the cell back to the original value + const changedCell = page.getByRole('gridcell', { name: 'changed value' }) + await changedCell.dblclick() + + const editor2 = page.getByRole('textbox', { name: /Editor content/ }) + await expect(editor2).toBeVisible() + await editor2.fill('original value') + await page.keyboard.press('Enter') + + // The pending change should be cleared since the value was reverted + await expect(page.getByText('pending change')).not.toBeVisible() + }) + test('editing multiple columns via side panel queues all changes', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_multi_col` From 55852a0eb1ec0e5a9aaeb96f2963a33171a11ecb Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Thu, 26 Mar 2026 08:01:12 +0100 Subject: [PATCH 13/83] chore: pin all actions (#42068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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? chore ## Additional context We will require all actions to be pinned going forward ## Summary by CodeRabbit * **Chores** * Updated CI/CD workflows to use pinned action versions for improved reliability and consistency. ✏️ Tip: You can customize this high-level summary in your review settings. --- .github/workflows/autofix_linters.yml | 2 +- .github/workflows/braintrust-evals.yml | 2 +- .github/workflows/docs-lint-v2.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/autofix_linters.yml b/.github/workflows/autofix_linters.yml index f86ce2e573f..93e62ecb14e 100644 --- a/.github/workflows/autofix_linters.yml +++ b/.github/workflows/autofix_linters.yml @@ -22,7 +22,7 @@ jobs: - name: Generate token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.GH_AUTOFIX_APP_ID }} private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }} diff --git a/.github/workflows/braintrust-evals.yml b/.github/workflows/braintrust-evals.yml index 46b7b7c9276..cf3e7ee8fa2 100644 --- a/.github/workflows/braintrust-evals.yml +++ b/.github/workflows/braintrust-evals.yml @@ -52,7 +52,7 @@ jobs: run: cd apps/studio && pnpm evals:setup - name: Run Evals - uses: braintrustdata/eval-action@v1 + uses: braintrustdata/eval-action@c0dd75b29984a0cc63a827d6e8da2f23f2752be4 # v1.0.16 with: api_key: ${{ secrets.BRAINTRUST_API_KEY }} runtime: node diff --git a/.github/workflows/docs-lint-v2.yml b/.github/workflows/docs-lint-v2.yml index 2d1315d9a8e..da3c07edba2 100644 --- a/.github/workflows/docs-lint-v2.yml +++ b/.github/workflows/docs-lint-v2.yml @@ -46,7 +46,7 @@ jobs: - name: cache cargo id: cache-cargo if: steps.filter.outputs.docs == 'true' - uses: actions/cache@v4 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5 with: path: | ~/.cargo/bin/ From fe08059cb7617d3a57ffbfc0b414b4c18d3df404 Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:54:46 +0100 Subject: [PATCH 14/83] chore(self-hosted): remove backticks in headers in self-hosted how-to (#44184) --- apps/docs/content/guides/self-hosting/docker.mdx | 2 +- .../guides/self-hosting/self-hosted-auth-keys.mdx | 6 +++--- .../guides/self-hosting/self-hosted-functions.mdx | 6 +++--- .../guides/self-hosting/self-hosted-oauth.mdx | 12 +++++++----- .../guides/self-hosting/self-hosted-phone-mfa.mdx | 8 +++++--- .../guides/self-hosting/self-hosted-proxy-https.mdx | 2 +- 6 files changed, 20 insertions(+), 16 deletions(-) diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index a196650d77b..6e9b5bacf22 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -446,7 +446,7 @@ See the [Configure Phone Login & MFA](/docs/guides/self-hosting/self-hosted-phon Configuring the Supabase AI Assistant is optional. By adding **your own** `OPENAI_API_KEY` to `.env` you can enable AI services, which help with writing SQL queries, statements, and policies. -#### Setting database's `log_min_messages` +#### Setting log_min_messages in Postgres By default, the database's `log_min_messages` configuration is set to `fatal` in [docker-compose.yml](https://github.com/supabase/supabase/blob/df8729a82b1847e2989c14ede27965612761d503/docker/docker-compose.yml#L466) to prevent redundant logs generated by Realtime. You can configure `log_min_messages` using any of the Postgres [Severity Levels](https://www.postgresql.org/docs/current/runtime-config-logging.html#RUNTIME-CONFIG-SEVERITY-LEVELS). diff --git a/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx b/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx index 1cbdd4d4199..21e7c60e03f 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx @@ -131,7 +131,7 @@ When `JWT_KEYS` is set, Auth will start signing new user session JWTs with the n -## Rotating `sb_` API keys +## Rotating the new API keys If your new API keys are compromised or you want to rotate them periodically, you can regenerate `sb_publishable` and `sb_secret` without touching the asymmetric key pair: @@ -175,9 +175,9 @@ Regenerating asymmetric keys invalidates all ES256 user sessions. Plan a mainten Below are a few notes on the details of the new authentication architecture. -### What `supabase-js` sends +### What client SDK sends -Every request includes two headers: +Every request via `supabase-js` includes two headers: - `apikey` - the API key (`sb_` or legacy JWT) - `Authorization` - when unauthenticated, the client SDK copies the API key here (`Bearer sb_publishable_xxx` or `Bearer eyJ...`). When authenticated, this contains the user session JWT minted by Auth. diff --git a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx index 02eaefaa097..06db096061c 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx @@ -24,7 +24,7 @@ This returns `"Hello from Edge Functions!"`. ## Create a new function -### Step 1: Create a new directory with an `index.ts` file in `volumes/functions/`: +### Step 1: Add a new function directory and the function code ``` mkdir -p volumes/functions/my-function && @@ -44,13 +44,13 @@ Deno.serve(async (req: Request) => { }) ``` -### Step 2: Restart the functions service to pick up the new function: +### Step 2: Restart the functions service to pick up the new function ```bash docker compose restart functions --no-deps ``` -### Step 3: Invoke your function: +### Step 3: Invoke your function ```bash curl -X POST http://:8000/functions/v1/my-function \ diff --git a/apps/docs/content/guides/self-hosting/self-hosted-oauth.mdx b/apps/docs/content/guides/self-hosting/self-hosted-oauth.mdx index 4297d06127c..eb457106ea4 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-oauth.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-oauth.mdx @@ -56,7 +56,7 @@ The default `.env.example` and `docker-compose.yml` include commented-out placeh 2. Set the **authorized redirect URL**, e.g., `https:///auth/v1/callback` 3. Copy the **client ID** and **client secret** into your `.env` file. -### Step 2: Configure variables in the `.env` file +### Step 2: Configure environment variables Uncomment the lines for your provider in `.env` and add your client ID and secret, e.g., for Google: @@ -66,7 +66,9 @@ GOOGLE_CLIENT_ID=your-client-id GOOGLE_SECRET=your-client-secret ``` -### Step 3: Enable the matching lines in `docker-compose.yml` +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} + +### Step 3: Enable the matching lines in Docker Compose configuration Uncomment the corresponding `GOTRUE_EXTERNAL_` lines in the `auth` service's `environment`: @@ -400,12 +402,12 @@ For detailed client-side integration, see [Social Login](/docs/guides/auth/socia ## Troubleshooting -### "Provider not enabled" or provider shows `false` in `/auth/v1/settings` +### "Provider not enabled" or provider seen as false in settings - Check that `GOTRUE_EXTERNAL_*_ENABLED` is set to `true` in `docker-compose.yml` - Verify the `.env` variable is not empty, e.g., check with `docker compose exec auth env | grep GOOGLE` -### Variables added to `.env` but provider still not working +### Variables added to the environment but provider still not working Configuration variables from `.env` are **not** automatically available inside the container unless there's a matching passthrough definition in `docker-compose.yml`. Check, e.g., for: @@ -415,7 +417,7 @@ GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED} Run `docker compose exec auth env | grep GOTRUE_EXTERNAL` to verify the variables are reaching the container. -### `SITE_URL` or redirect URL errors after login +### Site URL or redirect URL errors after login After a successful OAuth login, the Auth service redirects to `SITE_URL` or a URL from `ADDITIONAL_REDIRECT_URLS`. Ensure: diff --git a/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx b/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx index 30d427e471f..69c214fdec6 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx @@ -23,7 +23,7 @@ The default `.env.example` and `docker-compose.yml` include commented-out SMS pr To enable SMS delivery: -### Step 1: Uncomment and configure the settings in `.env` +### Step 1: Uncomment and configure the environment variables ``` SMS_PROVIDER=twilio @@ -38,7 +38,9 @@ SMS_TWILIO_AUTH_TOKEN=your-auth-token SMS_TWILIO_MESSAGE_SERVICE_SID=your-message-service-sid ``` -### Step 2: Uncomment the matching lines in `docker-compose.yml` +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} + +### Step 2: Uncomment the matching lines in Docker Compose configuration Uncomment the `GOTRUE_SMS_*` lines in the `auth` service's `environment` block: @@ -200,7 +202,7 @@ Common causes: - Provider credentials are wrong - Phone number format is wrong (use E.164 format: `+1234567890`) -### Variables are configured in `.env` but not working +### Variables added to the environment but not working Configuration variables from `.env` are **not** automatically available inside the container unless there's a matching passthrough definition in `docker-compose.yml`. Check, e.g., for: diff --git a/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx b/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx index cb040b48ab4..048e7b4e40d 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx @@ -147,7 +147,7 @@ kong: KONG_SSL_CERT_KEY: /home/kong/server.key ``` -### Step 3: Update configuration variables in `.env` +### Step 3: Update configuration variables Edit your `.env` file to use HTTPS with the Kong HTTPS port: From 3980bb162d22c90f678f4abd7e414a7c0fc8ad6d Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:55:32 +1100 Subject: [PATCH 15/83] feat(studio): improve database column list scanning (#44208) ## What kind of change does this PR introduce? UI improvements. Resolves DEPR-419. ## What is the current behavior? - `ColumnList` splits type information across separate `Data Type` and `Format` columns, which makes scanning less efficient - The list only surfaces a limited subset of column metadata up front, so key structural information like primary keys, foreign keys, uniqueness, and identity is not easy to scan - Constraint and nullability affordances are not aligned with the visual language already used in the Table Editor and Schema Visualiser ## What is the new behavior? - `ColumnList` now shows a leading type affordance icon, while keeping the exact Postgres type visible in the `Type` column - The standalone `Data Type` column is removed, and `format` is now the primary textual type shown in the table - The `Constraints` column now surfaces explicit icon-plus-label tokens for: - `Primary` - `Foreign key` - `Unique` - `Identity` - `Nullable` / `Non-nullable` - Constraint tokens use a purpose-built segmented style based on `ComputeBadge`, including a green primary variant for primary keys - Nullability now matches Schema Visualiser semantics exactly: outlined diamond for nullable, filled diamond for non-nullable - Existing search, descriptions, row actions, and permission gating are preserved --------- Co-authored-by: Joshen Lim --- .../interfaces/Database/Tables/ColumnList.tsx | 320 +++++++++++++----- .../Database/Tables/ColumnList.utils.test.ts | 66 ++++ .../Database/Tables/ColumnList.utils.ts | 78 +++++ .../Database/Tables/ConstraintToken.tsx | 44 +++ .../interfaces/Database/Tables/TableList.tsx | 44 ++- 5 files changed, 436 insertions(+), 116 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Tables/ColumnList.utils.test.ts create mode 100644 apps/studio/components/interfaces/Database/Tables/ColumnList.utils.ts create mode 100644 apps/studio/components/interfaces/Database/Tables/ConstraintToken.tsx diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index 622161c0f39..4848ecb2c6b 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -3,6 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { NoSearchResults } from 'components/ui/NoSearchResults' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' @@ -10,14 +11,29 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { noop } from 'lodash' -import { Check, Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react' -import { useState } from 'react' +import { + Braces, + Calendar, + DiamondIcon, + Fingerprint, + Hash, + Key, + Link as LinkIcon, + ListPlus, + MoreVertical, + Plus, + Search, + ToggleRight, + Trash, + Type, +} from 'lucide-react' +import { useState, type ReactNode } from 'react' import { Button, Card, + cn, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuTrigger, Table, TableBody, @@ -34,6 +50,51 @@ import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' +import { + getColumnTypeAffordance, + getForeignKeyColumnNames, + getPrimaryKeyColumnNames, + getUniqueIndexColumnNames, +} from './ColumnList.utils' +import { ConstraintToken } from './ConstraintToken' + +const getColumnTypeAffordancePresentation = (column: PostgresColumn) => { + const { kind, label } = getColumnTypeAffordance(column.format) + const iconClassName = 'text-foreground-muted' + + switch (kind) { + case 'number': + return { + icon: , + label, + } + case 'time': + return { + icon: , + label, + } + case 'text': + return { + icon: , + label, + } + case 'json': + return { + icon: , + label, + } + case 'bool': + return { + icon: , + label, + } + default: + return { + icon: , + label, + } + } +} interface ColumnListProps { onAddColumn: () => void @@ -64,6 +125,10 @@ export const ColumnList = ({ const [filterString, setFilterString] = useState('') const isTableEntity = isTableLike(selectedTable) + const tableConstraintSource = isTableEntity ? selectedTable : undefined + const primaryKeyColumns = getPrimaryKeyColumnNames(tableConstraintSource) + const foreignKeyColumns = getForeignKeyColumnNames(tableConstraintSource) + const uniqueIndexColumns = getUniqueIndexColumnNames(tableConstraintSource) const columns = (filterString.length === 0 @@ -77,6 +142,9 @@ export const ColumnList = ({ PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns' ) + const deleteColumnTooltipText = !canUpdateColumns + ? 'Additional permissions required to delete column' + : undefined return (
@@ -122,25 +190,23 @@ export const ColumnList = ({ - + + + Name - Data Type + Type - Format - - - Nullable + Constraints + {isError && ( @@ -177,86 +243,156 @@ export const ColumnList = ({ )} {isSuccess && - columns.length > 0 && - columns.map((column) => ( - - -
-

{column.name}

- {column.comment !== null ? ( - - {column.comment} - - ) : null} -
-
- - {column.data_type} - - - {column.format} - - - {column.is_nullable ? ( -
- + columns.map((column) => { + const { icon: TypeIcon, label: typeLabel } = + getColumnTypeAffordancePresentation(column) + + const constraintTokens = [ + primaryKeyColumns.has(column.name) ? ( + } + label="Primary" + variant="primary" + /> + ) : null, + foreignKeyColumns.has(column.name) ? ( + + } + label="Foreign key" + /> + ) : null, + column.is_unique || uniqueIndexColumns.has(column.name) ? ( + + } + label="Unique" + /> + ) : null, + column.is_identity ? ( + + } + label="Identity" + /> + ) : null, + + } + label={column.is_nullable ? 'Nullable' : 'Non-nullable'} + variant="secondary" + />, + ].filter(Boolean) + + return ( + + + + +
{TypeIcon}
+
+ +
+ {column.data_type} + {column.format !== column.data_type && ( + + {column.format} + + )} +
+
+
+
+ +
+

{column.name}

+ {column.comment !== null ? ( + + {column.comment} + + ) : null}
- ) : ( -
- -
- )} -
- - {!isSchemaLocked && isTableEntity && ( -
- onEditColumn(column)} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateColumns - ? 'Additional permissions required to edit column' - : undefined, - }, - }} - > - Edit - - - -
- )} -
-
- ))} + + +

{column.format}

+
+ +
{constraintTokens}
+
+ + {!isSchemaLocked && isTableEntity && ( +
+ onEditColumn(column)} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateColumns + ? 'Additional permissions required to edit column' + : undefined, + }, + }} + > + Edit + + + +
+ )} +
+ + ) + })} {isSuccess && ( diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.test.ts b/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.test.ts new file mode 100644 index 00000000000..5167108f7f3 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { + getColumnTypeAffordance, + getForeignKeyColumnNames, + getPrimaryKeyColumnNames, + getUniqueIndexColumnNames, +} from './ColumnList.utils' + +describe('ColumnList.utils', () => { + it('normalises quoted array formats before resolving the affordance kind', () => { + expect(getColumnTypeAffordance('"uuid"[]')).toEqual({ + kind: 'text', + label: 'Text', + }) + }) + + it('maps recognised Postgres formats to their affordance labels', () => { + expect(getColumnTypeAffordance('timestamptz')).toEqual({ + kind: 'time', + label: 'Date / time', + }) + expect(getColumnTypeAffordance('jsonb')).toEqual({ + kind: 'json', + label: 'JSON', + }) + }) + + it('falls back to the other affordance for unrecognised formats', () => { + expect(getColumnTypeAffordance('citext')).toEqual({ + kind: 'other', + label: 'Other', + }) + }) + + it('derives only source-table foreign key column names', () => { + const table = { + schema: 'public', + name: 'orders', + primary_keys: [{ name: 'id' }], + relationships: [ + { + source_schema: 'public', + source_table_name: 'orders', + source_column_name: 'customer_id', + target_table_schema: 'public', + target_table_name: 'customers', + target_column_name: 'id', + }, + { + source_schema: 'public', + source_table_name: 'customers', + source_column_name: 'account_id', + target_table_schema: 'public', + target_table_name: 'accounts', + target_column_name: 'id', + }, + ], + unique_indexes: [{ columns: ['reference'] }, { columns: ['customer_id', 'reference'] }], + } as const + + expect([...getPrimaryKeyColumnNames(table)]).toEqual(['id']) + expect([...getForeignKeyColumnNames(table)]).toEqual(['customer_id']) + expect([...getUniqueIndexColumnNames(table)]).toEqual(['reference']) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.ts b/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.ts new file mode 100644 index 00000000000..bfd03662aa5 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.utils.ts @@ -0,0 +1,78 @@ +import { POSTGRES_DATA_TYPE_OPTIONS } from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants' + +type TableConstraintSource = { + schema: string + name: string + primary_keys: ReadonlyArray<{ name: string }> + relationships: ReadonlyArray<{ + source_schema: string + source_table_name: string + source_column_name: string + }> + unique_indexes?: ReadonlyArray<{ columns: ReadonlyArray }> +} + +export type ColumnAffordanceKind = 'number' | 'time' | 'text' | 'json' | 'bool' | 'other' + +export interface ColumnTypeAffordance { + kind: ColumnAffordanceKind + label: string +} + +const COLUMN_AFFORDANCE_LABELS: Record = { + number: 'Numeric', + time: 'Date / time', + text: 'Text', + json: 'JSON', + bool: 'Boolean', + other: 'Other', +} + +const normalizeColumnFormat = (format: string) => format.replaceAll('"', '').replace(/\[\]$/, '') + +export function getColumnTypeAffordance(format: string): ColumnTypeAffordance { + const normalizedFormat = normalizeColumnFormat(format) + const optionType = POSTGRES_DATA_TYPE_OPTIONS.find( + (option) => option.name === normalizedFormat + )?.type + + switch (optionType) { + case 'number': + case 'time': + case 'text': + case 'json': + case 'bool': + return { kind: optionType, label: COLUMN_AFFORDANCE_LABELS[optionType] } + default: + return { kind: 'other', label: COLUMN_AFFORDANCE_LABELS.other } + } +} + +export function getPrimaryKeyColumnNames(table?: TableConstraintSource) { + return new Set(table?.primary_keys.map((primaryKey) => primaryKey.name) ?? []) +} + +export function getForeignKeyColumnNames(table?: TableConstraintSource) { + if (!table) { + return new Set() + } + + const { schema, name, relationships } = table + + return new Set( + relationships + .filter( + (relationship) => + relationship.source_schema === schema && relationship.source_table_name === name + ) + .map((relationship) => relationship.source_column_name) + ) +} + +export function getUniqueIndexColumnNames(table?: TableConstraintSource) { + return new Set( + table?.unique_indexes + ?.filter((uniqueIndex) => uniqueIndex.columns.length === 1) + .flatMap((uniqueIndex) => uniqueIndex.columns) ?? [] + ) +} diff --git a/apps/studio/components/interfaces/Database/Tables/ConstraintToken.tsx b/apps/studio/components/interfaces/Database/Tables/ConstraintToken.tsx new file mode 100644 index 00000000000..6bcb0db01b1 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Tables/ConstraintToken.tsx @@ -0,0 +1,44 @@ +import { ReactNode } from 'react' +import { cn } from 'ui' + +interface ConstraintTokenProps { + icon?: ReactNode + label: string + variant?: 'default' | 'secondary' | 'primary' +} + +const constraintTokenClassName = cn( + 'inline-flex items-center justify-center rounded-md text-center font-mono uppercase whitespace-nowrap font-medium', + 'tracking-[0.06em] text-[11px] leading-[1.1] px-[5.5px] h-[21px]', + 'transition-all border' +) + +export const ConstraintToken = ({ icon, label, variant = 'default' }: ConstraintTokenProps) => { + const tokenToneClassName = + variant === 'primary' + ? 'bg-brand bg-opacity-10 text-brand-600 border-brand-500' + : variant === 'default' + ? 'bg-surface-75 text-foreground-light border-strong' + : 'bg-surface-75 bg-opacity-50 text-foreground-light border-strong' + + return ( +
+ {icon && ( + + {icon} + + )} + + {label} + +
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index a8bf7d7106e..8e9e0b69d60 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -306,22 +306,14 @@ export const TableList = ({
- + Name - - Columns - - - Rows (Estimated) - - - Size (Estimated) - - - Realtime Enabled - + Columns + Rows (Estimated) + Size (Estimated) + Realtime @@ -375,10 +367,12 @@ export const TableList = ({ {entities.length > 0 && entities.map((x) => ( - + - - + +
+ +
{formatTooltipText(x.type)} @@ -412,35 +406,37 @@ export const TableList = ({ ) : null}
- +

{x.columns.length.toLocaleString()}

- + {x.rows !== undefined ? (

{x.rows.toLocaleString()}

) : (

–

)}
- + {x.size !== undefined ? (

{x.size}

) : (

–

)}
- + {(realtimePublication?.tables ?? []).find( (table) => table.id === x.id ) ? ( -
- +
+ +

Enabled

) : ( -
- +
+ +

Disabled

)} From 1f733dc1d4226b787f0ddfdcdd11a269f177df54 Mon Sep 17 00:00:00 2001 From: Inder Singh <85822513+singh-inder@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:51:16 +0530 Subject: [PATCH 16/83] docs(self-host): custom email templates (#42832) --- .../NavigationMenu.constants.ts | 1 + .../self-hosting/custom-email-templates.mdx | 175 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 apps/docs/content/guides/self-hosting/custom-email-templates.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 6dd2fadc00c..4f7b1408e8a 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2862,6 +2862,7 @@ export const self_hosting: NavMenuConstant = { }, { name: 'Configure S3 Storage', url: '/guides/self-hosting/self-hosted-s3' }, { name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' }, + { name: 'Custom Email Templates', url: '/guides/self-hosting/custom-email-templates' }, { name: 'Configure Social Login (OAuth)', url: '/guides/self-hosting/self-hosted-oauth' }, { name: 'Configure Phone Login & MFA', url: '/guides/self-hosting/self-hosted-phone-mfa' }, { name: 'Enable MCP server', url: '/guides/self-hosting/enable-mcp' }, diff --git a/apps/docs/content/guides/self-hosting/custom-email-templates.mdx b/apps/docs/content/guides/self-hosting/custom-email-templates.mdx new file mode 100644 index 00000000000..9135f12b86e --- /dev/null +++ b/apps/docs/content/guides/self-hosting/custom-email-templates.mdx @@ -0,0 +1,175 @@ +--- +title: 'Custom Email Templates' +description: 'Configure custom email templates with self-hosted Supabase instance' +subtitle: 'Configure custom email templates with self-hosted Supabase instance' +--- + +When running a self-hosted Supabase instance, you can fully customize emails sent by Supabase Auth. + +## Overview + +Supabase Auth does not read email templates from mounted Docker volumes. Instead, it expects each template to be available at a URL that returns a valid HTML template. + +This URL: + +- Does not need to be public +- Must be reachable from `auth` service +- Must return a valid Golang HTML template + +To provide templates to Supabase Auth, you need a service that serves static HTML files. This can be any server of your choice. You can even use `kong` service which is included with the default Supabase docker configuration. The only requirement is that the `auth` service must be able to reach it via a HTTP GET request. + +This guide uses [Caddy](https://github.com/caddyserver/caddy) for serving templates. + + + +If Supabase Auth cannot fetch the template or if the fetched template is invalid, it falls back to the default template. + + + +## Authentication email templates + +Authentication email templates can be configured using the following environment variables: + +- `GOTRUE_MAILER_TEMPLATES_`: Provide a custom template URL. Falls back to the default template if not set. +- `GOTRUE_MAILER_SUBJECTS_`: Customize the email subject. Falls back to the default subject if not set. + +| Auth flow | Sent | +| ------------------ | -------------------------------------------------------------------- | +| `CONFIRMATION` | When a user signs up and needs to verify their email address | +| `RECOVERY` | When a user requests a password reset | +| `MAGIC_LINK` | When a user requests a magic link for password-less authentication | +| `INVITE` | When a user is invited to join your application via email invitation | +| `EMAIL_CHANGE` | When a user requests to change their email address | +| `REAUTHENTICATION` | When a user needs to re-authenticate for sensitive operations | + +For example: + +```sh +GOTRUE_MAILER_TEMPLATES_MAGIC_LINK='' +GOTRUE_MAILER_SUBJECTS_MAGIC_LINK='' +``` + +### Example + +Below is an example configuration for setting up a custom invite template. + +### Step 1: Create a templates directory + +Create a `templates` directory inside the existing `volumes` directory and add your email templates to it. + +Your directory structure should look like this: + +``` +volumes/ + templates/ + invite.html +``` + +### Step 2: Update `docker-compose.yml` + +Update the `auth` service to depend on `templates-server`, and pass the email template environment variables. Then add a `templates-server` service to serve the templates from `./volumes/templates`. + +```yml +services: + auth: + depends_on: + db: + condition: service_healthy + templates-server: # 👈 new dependency + condition: service_started + environment: + GOTRUE_MAILER_TEMPLATES_INVITE: 'http://templates-server/invite.html' + GOTRUE_MAILER_SUBJECTS_INVITE: 'You have been invited' + + templates-server: + image: caddy:2-alpine + command: ['caddy', 'file-server', '-r', '/templates', '--listen', ':80'] + volumes: + - ./volumes/templates:/templates +``` + +#### What this configuration does + +- Adds a `templates-server`service that runs alongside the Supabase services in the same docker network. +- Serves your custom email template files from the `./volumes/templates` directory. +- Keeps the templates-server private to the Docker network (no published ports), so it is not accessible from outside. +- Allows the `auth` service to fetch templates via `http://templates-server/