From 88dca021d6b5201b3561a37f452a4f91598a8311 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 21 Jul 2025 12:16:06 +0800 Subject: [PATCH 01/38] Webhooks automatically append authorization header if edge function with verify JWT enabled is selected (#36759) * Webhooks automatically append authorization header if edge function with verify JWT enabled is selected * Fix when selecting http request to remove auth header as well * fixy fix --- .../Database/Hooks/EditHookPanel.tsx | 252 ++--------------- .../Database/Hooks/FormContents.tsx | 260 ++++++++++++++++++ .../Database/Hooks/HTTPRequestFields.tsx | 23 +- .../Database/Hooks/HooksList/HooksList.tsx | 6 +- .../Integrations/Webhooks/ListTab.tsx | 3 +- 5 files changed, 296 insertions(+), 248 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Hooks/FormContents.tsx diff --git a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx index 73f739cd8f8..f809e700ad6 100644 --- a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx @@ -1,25 +1,18 @@ -import type { PostgresTable, PostgresTrigger } from '@supabase/postgres-meta' -import Image from 'next/legacy/image' -import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react' +import { PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers' +import type { PostgresTrigger } from '@supabase/postgres-meta' +import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation' import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-transaction-mutation' -import { - EdgeFunctionsResponse, - useEdgeFunctionsQuery, -} from 'data/edge-functions/edge-functions-query' import { getTableEditor } from 'data/table-editor/table-editor-query' import { useTablesQuery } from 'data/tables/tables-query' import { isValidHttpUrl, uuidv4 } from 'lib/helpers' -import { Button, Checkbox, Form, Input, Listbox, Radio, SidePanel } from 'ui' +import { Button, Form, SidePanel } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import HTTPRequestFields from './HTTPRequestFields' -import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants' -import { PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers' +import { FormContents } from './FormContents' export interface EditHookPanelProps { visible: boolean @@ -29,7 +22,19 @@ export interface EditHookPanelProps { export type HTTPArgument = { id: string; name: string; value: string } -const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) => { +export const isEdgeFunction = ({ + ref, + restUrlTld, + url, +}: { + ref?: string + restUrlTld?: string + url: string +}) => + url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) || + url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`) + +export const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) => { const { ref } = useParams() const submitRef = useRef(null) const [isEdited, setIsEdited] = useState(false) @@ -49,7 +54,6 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) = projectRef: project?.ref, connectionString: project?.connectionString, }) - const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref }) const [isSubmitting, setIsSubmitting] = useState(false) const { mutate: createDatabaseTrigger } = useDatabaseTriggerCreateMutation({ onSuccess: (res) => { @@ -81,16 +85,12 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) = const restUrl = project?.restUrl const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' - const isEdgeFunction = (url: string) => - url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) || - url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`) - const initialValues = { name: selectedHook?.name ?? '', table_id: selectedHook?.table_id ?? '', http_url: selectedHook?.function_args?.[0] ?? '', http_method: selectedHook?.function_args?.[1] ?? 'POST', - function_type: isEdgeFunction(selectedHook?.function_args?.[0] ?? '') + function_type: isEdgeFunction({ ref, restUrlTld, url: selectedHook?.function_args?.[0] ?? '' }) ? 'supabase_function' : 'http_request', timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000), @@ -104,7 +104,7 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) = if (selectedHook !== undefined) { setEvents(selectedHook.events) - const [url, method, headers, parameters] = selectedHook.function_args + const [_, __, headers, parameters] = selectedHook.function_args let parsedParameters: Record = {} @@ -314,10 +314,6 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) = values={values} resetForm={resetForm} errors={errors} - projectRef={ref} - restUrlTld={restUrlTld} - functions={functions} - isEdgeFunction={isEdgeFunction} tables={tables} events={events} eventsError={eventsError} @@ -352,211 +348,3 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) = ) } - -export default EditHookPanel - -interface FormContentsProps { - values: any - resetForm: any - errors: any - projectRef?: string - restUrlTld?: string - selectedHook?: PostgresTrigger - functions: EdgeFunctionsResponse[] | undefined - isEdgeFunction: (url: string) => boolean - tables: PostgresTable[] - events: string[] - eventsError?: string - onUpdateSelectedEvents: (event: string) => void - httpHeaders: HTTPArgument[] - httpParameters: HTTPArgument[] - setHttpHeaders: (arr: HTTPArgument[]) => void - setHttpParameters: (arr: HTTPArgument[]) => void - submitRef: MutableRefObject -} - -const FormContents = ({ - values, - resetForm, - errors, - projectRef, - restUrlTld, - selectedHook, - functions, - isEdgeFunction, - tables, - events, - eventsError, - onUpdateSelectedEvents, - httpHeaders, - httpParameters, - setHttpHeaders, - setHttpParameters, - submitRef, -}: FormContentsProps) => { - useEffect(() => { - if (values.function_type === 'http_request') { - if (selectedHook !== undefined) { - const [url, method] = selectedHook.function_args - const updatedValues = { ...values, http_url: url, http_method: method } - resetForm({ values: updatedValues, initialValues: updatedValues }) - } else { - const updatedValues = { ...values, http_url: '' } - resetForm({ values: updatedValues, initialValues: updatedValues }) - } - } else if (values.function_type === 'supabase_function') { - const fnSlug = (functions ?? [])[0]?.slug - const defaultFunctionUrl = `https://${projectRef}.supabase.${restUrlTld}/functions/v1/${fnSlug}` - const updatedValues = { - ...values, - http_url: isEdgeFunction(values.http_url) ? values.http_url : defaultFunctionUrl, - } - resetForm({ values: updatedValues, initialValues: updatedValues }) - } - }, [values.function_type]) - - return ( -
- General}> - - - - - - - Select which table and events will trigger your webhook -

- } - > - Conditions to fire webhook - - } - > - - - - --- - - {tables.map((table) => ( - -
-

{table.schema}

-

{table.name}

-
-
- ))} -
- - {HOOK_EVENTS.map((event) => ( - onUpdateSelectedEvents(event.value)} - /> - ))} - -
-
- - Webhook configuration - } - > - - - {AVAILABLE_WEBHOOK_TYPES.map((webhook) => ( - - -
-
-

{webhook.label}

-
-

{webhook.description}

-
-
- } - /> - ))} - - - - - - { - if (header) setHttpHeaders(httpHeaders.concat(header)) - else setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' })) - }} - onUpdateHeader={(idx, property, value) => - setHttpHeaders( - httpHeaders.map((header, i) => { - if (idx === i) return { ...header, [property]: value } - else return header - }) - ) - } - onRemoveHeader={(idx) => setHttpHeaders(httpHeaders.filter((_, i) => idx !== i))} - onAddParameter={() => - setHttpParameters(httpParameters.concat({ id: uuidv4(), name: '', value: '' })) - } - onUpdateParameter={(idx, property, value) => - setHttpParameters( - httpParameters.map((param, i) => { - if (idx === i) return { ...param, [property]: value } - else return param - }) - ) - } - onRemoveParameter={(idx) => setHttpParameters(httpParameters.filter((_, i) => idx !== i))} - /> - - - - - - ) -} - export const ObjectsToBeDroppedWarning = ({ objectsToBeDropped, }: { From 4bd28eecb8b4f3c0b5a5c32dfc1dfa2976e1e1fc Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Mon, 21 Jul 2025 10:39:46 +0200 Subject: [PATCH 04/38] fix(api): set x-pg-application-name for dashboard (#37048) chore(api): set x-pg-application-name for dashboard --- .../database-extensions-query.ts | 2 + .../database-policies-query.ts | 6 +- .../database-publications-query.ts | 2 + .../database-triggers-query.ts | 6 +- .../enumerated-types-query.ts | 6 +- apps/studio/data/fetchers.ts | 7 +- .../foreign-tables/foreign-tables-query.ts | 6 +- .../materialized-views-query.ts | 6 +- .../privileges/column-privileges-query.ts | 6 +- apps/studio/data/sql/execute-sql-query.ts | 8 +- apps/studio/data/tables/tables-query.ts | 6 +- apps/studio/data/views/views-query.ts | 6 +- packages/api-types/types/platform.d.ts | 76 ++++++++++++++++++- packages/pg-meta/src/constants.ts | 1 + 14 files changed, 132 insertions(+), 12 deletions(-) diff --git a/apps/studio/data/database-extensions/database-extensions-query.ts b/apps/studio/data/database-extensions/database-extensions-query.ts index e9eb3ae42a5..76133e1dc17 100644 --- a/apps/studio/data/database-extensions/database-extensions-query.ts +++ b/apps/studio/data/database-extensions/database-extensions-query.ts @@ -5,6 +5,7 @@ import { databaseExtensionsKeys } from './keys' import { components } from 'api-types' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type DatabaseExtension = components['schemas']['PostgresExtension'] @@ -27,6 +28,7 @@ export async function getDatabaseExtensions( params: { header: { 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, }, path: { ref: projectRef, diff --git a/apps/studio/data/database-policies/database-policies-query.ts b/apps/studio/data/database-policies/database-policies-query.ts index a2602491f46..291a263526e 100644 --- a/apps/studio/data/database-policies/database-policies-query.ts +++ b/apps/studio/data/database-policies/database-policies-query.ts @@ -5,6 +5,7 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' import type { ResponseError } from 'types' import { databasePoliciesKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type DatabasePoliciesVariables = { projectRef?: string @@ -24,7 +25,10 @@ export async function getDatabasePolicies( const { data, error } = await get('/platform/pg-meta/{ref}/policies', { params: { - header: { 'x-connection-encrypted': connectionString! }, + header: { + 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, + }, path: { ref: projectRef }, query: { included_schemas: schema || '', diff --git a/apps/studio/data/database-publications/database-publications-query.ts b/apps/studio/data/database-publications/database-publications-query.ts index f8e20b269f7..616efce3692 100644 --- a/apps/studio/data/database-publications/database-publications-query.ts +++ b/apps/studio/data/database-publications/database-publications-query.ts @@ -2,6 +2,7 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { databasePublicationsKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type DatabasePublicationsVariables = { projectRef?: string @@ -21,6 +22,7 @@ export async function getDatabasePublications( params: { header: { 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, }, path: { ref: projectRef, diff --git a/apps/studio/data/database-triggers/database-triggers-query.ts b/apps/studio/data/database-triggers/database-triggers-query.ts index 997d077c469..faa78e23fc2 100644 --- a/apps/studio/data/database-triggers/database-triggers-query.ts +++ b/apps/studio/data/database-triggers/database-triggers-query.ts @@ -2,6 +2,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { databaseTriggerKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type DatabaseTriggersVariables = { projectRef?: string @@ -19,7 +20,10 @@ export async function getDatabaseTriggers( const { data, error } = await get('/platform/pg-meta/{ref}/triggers', { params: { - header: { 'x-connection-encrypted': connectionString! }, + header: { + 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, + }, path: { ref: projectRef }, query: undefined as any, }, diff --git a/apps/studio/data/enumerated-types/enumerated-types-query.ts b/apps/studio/data/enumerated-types/enumerated-types-query.ts index e7b541f0f77..ed61ae2291d 100644 --- a/apps/studio/data/enumerated-types/enumerated-types-query.ts +++ b/apps/studio/data/enumerated-types/enumerated-types-query.ts @@ -4,6 +4,7 @@ import type { components } from 'data/api' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { enumeratedTypesKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type EnumeratedTypesVariables = { projectRef?: string @@ -23,7 +24,10 @@ export async function getEnumeratedTypes( const { data, error } = await get('/platform/pg-meta/{ref}/types', { params: { - header: { 'x-connection-encrypted': connectionString! }, + header: { + 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, + }, path: { ref: projectRef }, }, headers: Object.fromEntries(headers), diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index 7b8bb0ef7de..4cafb546dee 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -6,8 +6,8 @@ import { API_URL } from 'lib/constants' import { getAccessToken } from 'lib/gotrue' import { uuidv4 } from 'lib/helpers' import { ResponseError } from 'types' -// generated from openapi-typescript -import type { paths } from './api' +import type { paths } from './api' // generated from openapi-typescript +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' const DEFAULT_HEADERS = { Accept: 'application/json' } @@ -73,6 +73,9 @@ function pgMetaGuard(request: Request) { retryAfterHeader ? parseInt(retryAfterHeader) : undefined ) } + if (!request.headers.get('x-pg-application-name')) { + request.headers.set('x-pg-application-name', DEFAULT_PLATFORM_APPLICATION_NAME) + } } return request } diff --git a/apps/studio/data/foreign-tables/foreign-tables-query.ts b/apps/studio/data/foreign-tables/foreign-tables-query.ts index 3c2f3157122..bffbb857aaa 100644 --- a/apps/studio/data/foreign-tables/foreign-tables-query.ts +++ b/apps/studio/data/foreign-tables/foreign-tables-query.ts @@ -4,6 +4,7 @@ import { PostgresView } from '@supabase/postgres-meta' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { foreignTableKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type ForeignTablesVariables = { projectRef?: string @@ -22,7 +23,10 @@ export async function getForeignTables( const { data, error } = await get('/platform/pg-meta/{ref}/foreign-tables', { params: { - header: { 'x-connection-encrypted': connectionString! }, + header: { + 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, + }, path: { ref: projectRef }, query: { included_schemas: schema || '', diff --git a/apps/studio/data/materialized-views/materialized-views-query.ts b/apps/studio/data/materialized-views/materialized-views-query.ts index a8a7eab23dd..052a3dc3377 100644 --- a/apps/studio/data/materialized-views/materialized-views-query.ts +++ b/apps/studio/data/materialized-views/materialized-views-query.ts @@ -4,6 +4,7 @@ import { PostgresMaterializedView } from '@supabase/postgres-meta' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { materializedViewKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type MaterializedViewsVariables = { projectRef?: string @@ -22,7 +23,10 @@ export async function getMaterializedViews( const { data, error } = await get('/platform/pg-meta/{ref}/materialized-views', { params: { - header: { 'x-connection-encrypted': connectionString! }, + header: { + 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, + }, path: { ref: projectRef }, query: { included_schemas: schema || '', diff --git a/apps/studio/data/privileges/column-privileges-query.ts b/apps/studio/data/privileges/column-privileges-query.ts index 01bda4ce1f3..249a7c028ff 100644 --- a/apps/studio/data/privileges/column-privileges-query.ts +++ b/apps/studio/data/privileges/column-privileges-query.ts @@ -4,6 +4,7 @@ import type { components } from 'data/api' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { privilegeKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type ColumnPrivilegesVariables = { projectRef?: string @@ -25,7 +26,10 @@ export async function getColumnPrivileges( params: { path: { ref: projectRef }, // this is needed to satisfy the typescript, but it doesn't pass the actual header - header: { 'x-connection-encrypted': connectionString! }, + header: { + 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, + }, }, signal, headers, diff --git a/apps/studio/data/sql/execute-sql-query.ts b/apps/studio/data/sql/execute-sql-query.ts index a7fe145fc9e..65fe32a565b 100644 --- a/apps/studio/data/sql/execute-sql-query.ts +++ b/apps/studio/data/sql/execute-sql-query.ts @@ -9,6 +9,7 @@ import { } from 'lib/role-impersonation' import type { ResponseError } from 'types' import { sqlKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type ExecuteSqlVariables = { projectRef?: string @@ -73,7 +74,12 @@ export async function executeSql( const result = await post('/platform/pg-meta/{ref}/query', { signal, params: { - header: { 'x-connection-encrypted': connectionString ?? '' }, + header: { + 'x-connection-encrypted': connectionString ?? '', + 'x-pg-application-name': isStatementTimeoutDisabled + ? 'supabase/dashboard-query-editor' + : DEFAULT_PLATFORM_APPLICATION_NAME, + }, path: { ref: projectRef }, // @ts-expect-error: This is just a client side thing to identify queries better query: { diff --git a/apps/studio/data/tables/tables-query.ts b/apps/studio/data/tables/tables-query.ts index 02ce5a5c625..b7da827a5bb 100644 --- a/apps/studio/data/tables/tables-query.ts +++ b/apps/studio/data/tables/tables-query.ts @@ -6,6 +6,7 @@ import { useCallback } from 'react' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { tableKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type TablesVariables = { projectRef?: string @@ -45,7 +46,10 @@ export async function getTables( const { data, error } = await get('/platform/pg-meta/{ref}/tables', { params: { - header: { 'x-connection-encrypted': connectionString! }, + header: { + 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, + }, path: { ref: projectRef }, query: queryParams as any, }, diff --git a/apps/studio/data/views/views-query.ts b/apps/studio/data/views/views-query.ts index 4903c28323f..884dda527e9 100644 --- a/apps/studio/data/views/views-query.ts +++ b/apps/studio/data/views/views-query.ts @@ -4,6 +4,7 @@ import { PostgresView } from '@supabase/postgres-meta' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { viewKeys } from './keys' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type ViewsVariables = { projectRef?: string @@ -22,7 +23,10 @@ export async function getViews( const { data, error } = await get('/platform/pg-meta/{ref}/views', { params: { - header: { 'x-connection-encrypted': connectionString! }, + header: { + 'x-connection-encrypted': connectionString!, + 'x-pg-application-name': DEFAULT_PLATFORM_APPLICATION_NAME, + }, path: { ref: projectRef }, query: { included_schemas: schema || '', diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 090d53fbbd8..00a3b169b1a 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -7835,7 +7835,6 @@ export interface components { organization_id: number region: string status: string - subscription_id: string } UpcomingInvoice: { amount_projected?: number @@ -11308,6 +11307,12 @@ export interface operations { 'application/json': components['schemas']['CustomerResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve the Billing customer */ 500: { headers: { @@ -11422,6 +11427,12 @@ export interface operations { } content?: never } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to get daily organization stats */ 500: { headers: { @@ -11453,6 +11464,12 @@ export interface operations { } content?: never } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to get daily organization stats for compute */ 500: { headers: { @@ -11893,6 +11910,12 @@ export interface operations { 'application/json': components['schemas']['MemberWithFreeProjectLimit'][] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve organization members who have reached their free project limit */ 500: { headers: { @@ -12541,6 +12564,12 @@ export interface operations { 'application/json': components['schemas']['OrgUsageResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to get usage stats */ 500: { headers: { @@ -12606,6 +12635,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -12643,6 +12673,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -12684,6 +12715,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -12728,6 +12760,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -12765,6 +12798,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -12810,6 +12844,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -12849,6 +12884,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -12890,6 +12926,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -12927,6 +12964,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -12970,6 +13008,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13012,6 +13051,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13052,6 +13092,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13089,6 +13130,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13132,6 +13174,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13171,6 +13214,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13220,6 +13264,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13260,6 +13305,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13297,6 +13343,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13340,6 +13387,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13379,6 +13427,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13420,6 +13469,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13457,6 +13507,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13500,6 +13551,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13539,6 +13591,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13580,6 +13633,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13683,6 +13737,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13720,6 +13775,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13763,6 +13819,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13802,6 +13859,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13851,6 +13909,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13888,6 +13947,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13933,6 +13993,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -13972,6 +14033,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -14016,6 +14078,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -14053,6 +14116,7 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -14096,6 +14160,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -14135,6 +14200,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -14179,6 +14245,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -14224,6 +14291,7 @@ export interface operations { } header: { 'x-connection-encrypted': string + 'x-pg-application-name': string } path: { /** @description Project ref */ @@ -16858,6 +16926,12 @@ export interface operations { } content?: never } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to get daily project stats */ 500: { headers: { diff --git a/packages/pg-meta/src/constants.ts b/packages/pg-meta/src/constants.ts index c28ea770cc0..bb9513080a9 100644 --- a/packages/pg-meta/src/constants.ts +++ b/packages/pg-meta/src/constants.ts @@ -1 +1,2 @@ export const DEFAULT_SYSTEM_SCHEMAS = ['information_schema', 'pg_catalog', 'pg_toast'] +export const DEFAULT_PLATFORM_APPLICATION_NAME = 'supabase/dashboard' From dbb413beebcb07532d5d0ebbf4fbd837652293b4 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 21 Jul 2025 16:59:17 +0800 Subject: [PATCH 05/38] Chore/deprecate lib common fetch 03 (#36529) * Deprecate use of getWithTimeout, refactor BuildingState and RestoringState to use RQ * Refactor profile-create-mutation to use data/fetchers, and edge-function-status-query to use fetch * Shift post from lib/common/fetch, refactor bucket-object-download-mutation * Address feedback * Minor fix * Refactor post calls from lib/common/fetch in auth pages to data/fetchers * Add missing POST users endpoint + small fix when deleting user via context menu * Remove all use of any imports from lib/common/fetch * Clean up remaining usage of lib/common/fetch * Fix fetchHeadWithTimeout * simplify handleFetchError * allow handleFetchError to accept unknown * non-breaking change * small fixes * fix query path --------- Co-authored-by: Alaister Young --- apps/studio/data/fetchers.ts | 80 ++++++++++++- apps/studio/lib/api/apiWrapper.ts | 19 ++- apps/studio/lib/common/fetch/base.ts | 112 ------------------ apps/studio/lib/common/fetch/delete.ts | 30 ----- apps/studio/lib/common/fetch/get.ts | 27 ----- apps/studio/lib/common/fetch/head.ts | 56 --------- apps/studio/lib/common/fetch/index.ts | 16 --- apps/studio/lib/common/fetch/patch.ts | 30 ----- apps/studio/lib/common/fetch/post.ts | 32 ----- apps/studio/lib/common/fetch/put.ts | 31 ----- apps/studio/lib/integration-utils.ts | 2 +- apps/studio/lib/pingPostgrest.test.ts | 18 +-- apps/studio/lib/pingPostgrest.ts | 4 +- .../pg-meta/[ref]/column-privileges.ts | 44 +------ .../api/platform/pg-meta/[ref]/columns.ts | 80 ------------- .../api/platform/pg-meta/[ref]/extensions.ts | 44 ++----- .../platform/pg-meta/[ref]/foreign-tables.ts | 27 ++--- .../pg-meta/[ref]/materialized-views.ts | 27 ++--- .../api/platform/pg-meta/[ref]/policies.ts | 18 +-- .../platform/pg-meta/[ref]/publications.ts | 64 ++-------- .../platform/pg-meta/[ref]/query/format.ts | 32 ----- .../platform/pg-meta/[ref]/query/validate.ts | 32 ----- .../api/platform/pg-meta/[ref]/tables.ts | 75 ++---------- .../api/platform/pg-meta/[ref]/triggers.ts | 64 ++-------- .../pages/api/platform/pg-meta/[ref]/types.ts | 14 ++- .../pages/api/platform/pg-meta/[ref]/views.ts | 27 ++--- .../api/platform/projects/[ref]/run-lints.ts | 8 +- .../api/v1/projects/[ref]/types/typescript.ts | 16 +-- apps/studio/vitest.config.ts | 7 +- 29 files changed, 205 insertions(+), 831 deletions(-) delete mode 100644 apps/studio/lib/common/fetch/base.ts delete mode 100644 apps/studio/lib/common/fetch/delete.ts delete mode 100644 apps/studio/lib/common/fetch/get.ts delete mode 100644 apps/studio/lib/common/fetch/head.ts delete mode 100644 apps/studio/lib/common/fetch/index.ts delete mode 100644 apps/studio/lib/common/fetch/patch.ts delete mode 100644 apps/studio/lib/common/fetch/post.ts delete mode 100644 apps/studio/lib/common/fetch/put.ts delete mode 100644 apps/studio/pages/api/platform/pg-meta/[ref]/columns.ts delete mode 100644 apps/studio/pages/api/platform/pg-meta/[ref]/query/format.ts delete mode 100644 apps/studio/pages/api/platform/pg-meta/[ref]/query/validate.ts diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index 4cafb546dee..99e010f7c86 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -176,7 +176,7 @@ export const handleError = ( throw new ResponseError(undefined) } -// [Joshen] The methods below are brought over from lib/common/fetchers because we still need them +// [Joshen] The methods below are brought over from lib/common/fetch because we still need them // primarily for our own endpoints in the dashboard repo. So consolidating all the fetch methods into here. async function handleFetchResponse(response: Response): Promise { @@ -196,6 +196,21 @@ async function handleFetchResponse(response: Response): Promise( + response: Response, + headers: string[] +): Promise { + try { + const res = {} as any + headers.forEach((header: string) => { + res[header] = response.headers.get(header) + }) + return res + } catch (e) { + return handleError(response) as T | ResponseError + } +} + async function handleFetchError(response: unknown): Promise { let resJson: any = {} @@ -229,6 +244,34 @@ async function handleFetchError(response: unknown): Promise { return error } +/** + * To be used only for dashboard API endpoints. Use `fetch` directly if calling a non dashboard API endpoint + */ +export async function fetchGet( + url: string, + options?: { [prop: string]: any } +): Promise { + try { + const { headers: otherHeaders, abortSignal, ...otherOptions } = options ?? {} + const headers = await constructHeaders({ + 'Content-Type': 'application/json', + ...DEFAULT_HEADERS, + ...otherHeaders, + }) + const response = await fetch(url, { + headers, + method: 'GET', + referrerPolicy: 'no-referrer-when-downgrade', + ...otherOptions, + signal: abortSignal, + }) + if (!response.ok) return handleFetchError(response) + return handleFetchResponse(response) + } catch (error) { + return handleFetchError(error) + } +} + /** * To be used only for dashboard API endpoints. Use `fetch` directly if calling a non dashboard API endpoint * @@ -247,10 +290,10 @@ export async function fetchPost( ...otherHeaders, }) const response = await fetch(url, { + headers, method: 'POST', body: JSON.stringify(data), referrerPolicy: 'no-referrer-when-downgrade', - headers, ...otherOptions, signal: abortSignal, }) @@ -260,3 +303,36 @@ export async function fetchPost( return handleFetchError(error) } } + +/** + * To be used only for dashboard API endpoints. Use `fetch` directly if calling a non dashboard API endpoint + */ +export async function fetchHeadWithTimeout( + url: string, + headersToRetrieve: string[], + options?: { [prop: string]: any } +): Promise { + try { + const timeout = options?.timeout ?? 60000 + + const { headers: otherHeaders, abortSignal, ...otherOptions } = options ?? {} + const headers = await constructHeaders({ + 'Content-Type': 'application/json', + ...DEFAULT_HEADERS, + ...otherHeaders, + }) + + const response = await fetch(url, { + method: 'HEAD', + referrerPolicy: 'no-referrer-when-downgrade', + headers, + ...otherOptions, + signal: AbortSignal.timeout(timeout), + }) + + if (!response.ok) return handleFetchError(response) + return handleFetchHeadResponse(response, headersToRetrieve) + } catch (error) { + return handleFetchError(error) + } +} diff --git a/apps/studio/lib/api/apiWrapper.ts b/apps/studio/lib/api/apiWrapper.ts index 89369151fb5..daa5cd6d141 100644 --- a/apps/studio/lib/api/apiWrapper.ts +++ b/apps/studio/lib/api/apiWrapper.ts @@ -1,8 +1,25 @@ -import { isResponseOk } from 'lib/common/fetch' import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next' + +import { ResponseError, ResponseFailure } from 'types' import { IS_PLATFORM } from '../constants' import { apiAuthenticate } from './apiAuthenticate' +export function isResponseOk(response: T | ResponseFailure | undefined): response is T { + if (response === undefined || response === null) { + return false + } + + if (response instanceof ResponseError) { + return false + } + + if (typeof response === 'object' && 'error' in response && Boolean(response.error)) { + return false + } + + return true +} + // Purpose of this apiWrapper is to function like a global catchall for ANY errors // It's a safety net as the API service should never drop, nor fail diff --git a/apps/studio/lib/common/fetch/base.ts b/apps/studio/lib/common/fetch/base.ts deleted file mode 100644 index 3f000da9971..00000000000 --- a/apps/studio/lib/common/fetch/base.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { getAccessToken } from 'lib/gotrue' -import { isUndefined } from 'lodash' -import type { SupaResponse } from 'types/base' - -/* @deprecated Use handleError from data/fetchers instead */ -export function handleError(e: any, requestId: string): SupaResponse { - const message = e?.message ? `An error has occurred: ${e.message}` : 'An error has occurred' - const error = { code: 500, message, requestId } - return { error } as unknown as SupaResponse -} - -export async function handleResponse( - response: Response, - requestId: string -): Promise> { - const contentType = response.headers.get('Content-Type') - if (contentType === 'application/octet-stream') return response as any - try { - const resTxt = await response.text() - try { - // try to parse response text as json - return JSON.parse(resTxt) - } catch (err) { - // return as text plain - return resTxt as any - } - } catch (e) { - return handleError(response, requestId) as SupaResponse - } -} - -export async function handleHeadResponse( - response: Response, - requestId: string, - headers: string[] -): Promise> { - try { - const res = {} as any - headers.forEach((header: string) => { - res[header] = response.headers.get(header) - }) - return res - } catch (e) { - return handleError(response, requestId) as SupaResponse - } -} - -export async function handleResponseError( - response: Response, - requestId: string -): Promise> { - let resJson: { [prop: string]: any } - - const resTxt = await response.text() - try { - resJson = JSON.parse(resTxt) - } catch (_) { - resJson = {} - } - - if (resJson.error && typeof resJson.error === 'string') { - if (resJson.error_description) { - const error = { - code: response.status, - message: resJson.error, - description: resJson.error_description, - requestId, - } - return { error } as unknown as SupaResponse - } else { - const error = { code: response.status, message: resJson.error, requestId } - return { error } as unknown as SupaResponse - } - } else if (resJson.message) { - const error = { code: response.status, message: resJson.message, requestId } - return { error } as unknown as SupaResponse - } else if (resJson.msg) { - const error = { code: response.status, message: resJson.msg, requestId } - return { error } as unknown as SupaResponse - } else if (resJson.error && resJson.error.message) { - return { error: { code: response.status, ...resJson.error } } as unknown as SupaResponse - } else { - const message = resTxt ?? `An error has occurred: ${response.status}` - const error = { code: response.status, message, requestId } - return { error } as unknown as SupaResponse - } -} - -export async function constructHeaders(requestId: string, optionHeaders?: { [prop: string]: any }) { - let headers: { [prop: string]: any } = { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-Request-Id': requestId, - ...optionHeaders, - } - - const hasAuthHeader = !isUndefined(optionHeaders) && 'Authorization' in optionHeaders - if (!hasAuthHeader) { - const accessToken = await getAccessToken() - if (accessToken) headers.Authorization = `Bearer ${accessToken}` - } - - return headers -} - -export function isResponseOk(response: SupaResponse | undefined): response is T { - return ( - response !== undefined && - response !== null && - !(typeof response === 'object' && 'error' in response && Boolean(response.error)) - ) -} diff --git a/apps/studio/lib/common/fetch/delete.ts b/apps/studio/lib/common/fetch/delete.ts deleted file mode 100644 index b8da5a117dc..00000000000 --- a/apps/studio/lib/common/fetch/delete.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { handleError, handleResponse, handleResponseError, constructHeaders } from './base' -import { uuidv4 } from '../../helpers' -import type { SupaResponse } from 'types/base' - -/** - * @deprecated please use del method from data/fetchers instead - */ -export async function delete_( - url: string, - data?: { [prop: string]: any }, - options?: { [prop: string]: any } -): Promise> { - const requestId = uuidv4() - try { - const { headers: optionHeaders, ...otherOptions } = options ?? {} - const headers = await constructHeaders(requestId, optionHeaders) - const response = await fetch(url, { - method: 'DELETE', - body: JSON.stringify(data), - referrerPolicy: 'no-referrer-when-downgrade', - headers, - ...otherOptions, - }) - - if (!response.ok) return handleResponseError(response, requestId) - return handleResponse(response, requestId) - } catch (error) { - return handleError(error, requestId) - } -} diff --git a/apps/studio/lib/common/fetch/get.ts b/apps/studio/lib/common/fetch/get.ts deleted file mode 100644 index b1f912934c9..00000000000 --- a/apps/studio/lib/common/fetch/get.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SupaResponse } from 'types/base' -import { uuidv4 } from '../../helpers' -import { constructHeaders, handleError, handleResponse, handleResponseError } from './base' - -/** - * @deprecated Please use get method from data/fetchers instead, unless making requests to an external URL - */ -export async function get( - url: string, - options?: { [prop: string]: any } -): Promise> { - const requestId = uuidv4() - try { - const { headers: optionHeaders, ...otherOptions } = options ?? {} - const headers = await constructHeaders(requestId, optionHeaders) - const response = await fetch(url, { - method: 'GET', - referrerPolicy: 'no-referrer-when-downgrade', - headers, - ...otherOptions, - }) - if (!response.ok) return handleResponseError(response, requestId) - return handleResponse(response, requestId) - } catch (error) { - return handleError(error, requestId) - } -} diff --git a/apps/studio/lib/common/fetch/head.ts b/apps/studio/lib/common/fetch/head.ts deleted file mode 100644 index 97d3e514c62..00000000000 --- a/apps/studio/lib/common/fetch/head.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { handleError, handleHeadResponse, handleResponseError, constructHeaders } from './base' -import { uuidv4 } from '../../helpers' -import type { SupaResponse } from 'types/base' - -/** - * @deprecated please use head method from data/fetchers instead - */ -export async function head( - url: string, - headersToRetrieve: string[], - options?: { [prop: string]: any } -): Promise> { - const requestId = uuidv4() - try { - const { headers: optionHeaders, ...otherOptions } = options ?? {} - const headers = await constructHeaders(requestId, optionHeaders) - const response = await fetch(url, { - method: 'HEAD', - referrerPolicy: 'no-referrer-when-downgrade', - headers, - ...otherOptions, - }) - if (!response.ok) return handleResponseError(response, requestId) - return handleHeadResponse(response, requestId, headersToRetrieve) - } catch (error) { - return handleError(error, requestId) - } -} - -export async function headWithTimeout( - url: string, - headersToRetrieve: string[], - options?: { [prop: string]: any } -): Promise> { - const requestId = uuidv4() - try { - const timeout = options?.timeout ?? 60000 - const controller = new AbortController() - const id = setTimeout(() => controller.abort(), timeout) - const { headers: optionHeaders, ...otherOptions } = options ?? {} - const headers = await constructHeaders(requestId, optionHeaders) - const response = await fetch(url, { - method: 'HEAD', - referrerPolicy: 'no-referrer-when-downgrade', - headers, - ...otherOptions, - signal: controller.signal, - }) - clearTimeout(id) - - if (!response.ok) return handleResponseError(response, requestId) - return handleHeadResponse(response, requestId, headersToRetrieve) - } catch (error) { - return handleError(error, requestId) - } -} diff --git a/apps/studio/lib/common/fetch/index.ts b/apps/studio/lib/common/fetch/index.ts deleted file mode 100644 index f476c3c02f4..00000000000 --- a/apps/studio/lib/common/fetch/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Even though this is a barrel file, the exports are all very similar - -export { - constructHeaders, - handleError, - handleHeadResponse, - handleResponse, - handleResponseError, - isResponseOk, -} from './base' -export { delete_ } from './delete' -export { get } from './get' -export { head, headWithTimeout } from './head' -export { patch } from './patch' -export { post } from './post' -export { put } from './put' diff --git a/apps/studio/lib/common/fetch/patch.ts b/apps/studio/lib/common/fetch/patch.ts deleted file mode 100644 index f8b3bb47d4a..00000000000 --- a/apps/studio/lib/common/fetch/patch.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { constructHeaders, handleError, handleResponse, handleResponseError } from './base' -import { uuidv4 } from '../../helpers' -import type { SupaResponse } from 'types/base' - -/** - * @deprecated please use patch method from data/fetchers instead - */ -export async function patch( - url: string, - data: { [prop: string]: any }, - options?: { [prop: string]: any } -): Promise> { - const requestId = uuidv4() - try { - const { headers: optionHeaders, ...otherOptions } = options ?? {} - const headers = await constructHeaders(requestId, optionHeaders) - const response = await fetch(url, { - method: 'PATCH', - body: JSON.stringify(data), - referrerPolicy: 'no-referrer-when-downgrade', - headers, - ...otherOptions, - }) - - if (!response.ok) return handleResponseError(response, requestId) - return handleResponse(response, requestId) - } catch (error) { - return handleError(error, requestId) - } -} diff --git a/apps/studio/lib/common/fetch/post.ts b/apps/studio/lib/common/fetch/post.ts deleted file mode 100644 index 9eea5771411..00000000000 --- a/apps/studio/lib/common/fetch/post.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { uuidv4 } from 'lib/helpers' -import type { SupaResponse } from 'types/base' -import { constructHeaders, handleError, handleResponse, handleResponseError } from './base' - -/** - * @deprecated Please use post method from data/fetchers instead - * - * Exception for bucket-object-download-mutation as openapi-fetch doesn't support octet-stream responses - */ -export async function post( - url: string, - data: { [prop: string]: any }, - options?: { [prop: string]: any } -): Promise> { - const requestId = uuidv4() - try { - const { headers: optionHeaders, abortSignal, ...otherOptions } = options ?? {} - const headers = await constructHeaders(requestId, optionHeaders) - const response = await fetch(url, { - method: 'POST', - body: JSON.stringify(data), - referrerPolicy: 'no-referrer-when-downgrade', - headers, - ...otherOptions, - signal: abortSignal, - }) - if (!response.ok) return handleResponseError(response, requestId) - return handleResponse(response, requestId) - } catch (error) { - return handleError(error, requestId) - } -} diff --git a/apps/studio/lib/common/fetch/put.ts b/apps/studio/lib/common/fetch/put.ts deleted file mode 100644 index a2439e23d87..00000000000 --- a/apps/studio/lib/common/fetch/put.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { constructHeaders, handleError, handleResponse, handleResponseError } from './base' -import { uuidv4 } from '../../helpers' -import type { SupaResponse } from 'types/base' - -/** - * @deprecated please use post method from data/fetchers instead - */ -export async function put( - url: string, - data: { [prop: string]: any }, - options?: { [prop: string]: any } -): Promise> { - const requestId = uuidv4() - try { - const { headers: optionHeaders, ...otherOptions } = options ?? {} - const headers = await constructHeaders(requestId, optionHeaders) - const response = await fetch(url, { - method: 'PUT', - body: JSON.stringify(data), - credentials: 'include', - referrerPolicy: 'no-referrer-when-downgrade', - headers, - ...otherOptions, - }) - - if (!response.ok) return handleResponseError(response, requestId) - return handleResponse(response, requestId) - } catch (error) { - return handleError(error, requestId) - } -} diff --git a/apps/studio/lib/integration-utils.ts b/apps/studio/lib/integration-utils.ts index 44f176fb3bd..2f845488e73 100644 --- a/apps/studio/lib/integration-utils.ts +++ b/apps/studio/lib/integration-utils.ts @@ -1,7 +1,7 @@ import { fetchHandler } from 'data/fetchers' import type { Integration } from 'data/integrations/integrations.types' import { ResponseError, type SupaResponse } from 'types' -import { isResponseOk } from './common/fetch' +import { isResponseOk } from './api/apiWrapper' async function fetchGitHub(url: string, responseJson = true): Promise> { const response = await fetchHandler(url) diff --git a/apps/studio/lib/pingPostgrest.test.ts b/apps/studio/lib/pingPostgrest.test.ts index 79a2e216ade..fce1127c5c9 100644 --- a/apps/studio/lib/pingPostgrest.test.ts +++ b/apps/studio/lib/pingPostgrest.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as fetchModule from 'data/fetchers' +import { beforeEach, describe, expect, it, vi } from 'vitest' import pingPostgrest from './pingPostgrest' -import * as fetchModule from './common/fetch' vi.mock('./constants', () => ({ API_URL: 'https://api.example.com' })) @@ -9,14 +9,14 @@ describe('pingPostgrest', () => { vi.restoreAllMocks() }) - it('returns true if headWithTimeout returns no error', async () => { - vi.spyOn(fetchModule, 'headWithTimeout').mockResolvedValue({ error: undefined }) + it('returns true if fetchHeadWithTimeout returns no error', async () => { + vi.spyOn(fetchModule, 'fetchHeadWithTimeout').mockResolvedValue({ error: undefined }) const result = await pingPostgrest('my-project') expect(result).toBe(true) }) - it('returns false if headWithTimeout returns an error', async () => { - vi.spyOn(fetchModule, 'headWithTimeout').mockResolvedValue({ error: { message: 'fail' } }) + it('returns false if fetchHeadWithTimeout returns an error', async () => { + vi.spyOn(fetchModule, 'fetchHeadWithTimeout').mockResolvedValue({ error: { message: 'fail' } }) const result = await pingPostgrest('my-project') expect(result).toBe(false) }) @@ -26,8 +26,10 @@ describe('pingPostgrest', () => { expect(result).toBe(false) }) - it('passes timeout option to headWithTimeout', async () => { - const spy = vi.spyOn(fetchModule, 'headWithTimeout').mockResolvedValue({ error: undefined }) + it('passes timeout option to fetchHeadWithTimeout', async () => { + const spy = vi + .spyOn(fetchModule, 'fetchHeadWithTimeout') + .mockResolvedValue({ error: undefined }) await pingPostgrest('my-project', { timeout: 1234 }) expect(spy).toHaveBeenCalledWith( 'https://api.example.com/projects/my-project/api/rest', diff --git a/apps/studio/lib/pingPostgrest.ts b/apps/studio/lib/pingPostgrest.ts index 2dbad44f109..208be243a18 100644 --- a/apps/studio/lib/pingPostgrest.ts +++ b/apps/studio/lib/pingPostgrest.ts @@ -1,4 +1,4 @@ -import { headWithTimeout } from './common/fetch' +import { fetchHeadWithTimeout } from 'data/fetchers' import { API_URL } from './constants' const DEFAULT_TIMEOUT_MILLISECONDS = 2000 @@ -33,7 +33,7 @@ export default pingPostgrest * @return true if there's no error else false */ async function pingOpenApi(ref: string, timeout?: number) { - const { error } = await headWithTimeout(`${API_URL}/projects/${ref}/api/rest`, [], { + const { error } = await fetchHeadWithTimeout(`${API_URL}/projects/${ref}/api/rest`, [], { timeout: timeout ?? DEFAULT_TIMEOUT_MILLISECONDS, }) return error === undefined diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/column-privileges.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/column-privileges.ts index 15306ddcc94..e8b288d0210 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/column-privileges.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/column-privileges.ts @@ -1,9 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next' +import { fetchGet } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' -import { delete_, get, post } from 'lib/common/fetch' -import { PG_META_URL } from 'lib/constants' import { getPgMetaRedirectUrl } from './tables' export default (req: NextApiRequest, res: NextApiResponse) => @@ -14,10 +13,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { switch (method) { case 'GET': return handleGetAll(req, res) - case 'POST': - return handlePost(req, res) - case 'DELETE': - return handleDelete(req, res) default: res.setHeader('Allow', ['GET']) res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) @@ -26,39 +21,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - let response = await get(getPgMetaRedirectUrl(req, 'column-privileges'), { - headers, - }) - if (response.error) { - return res.status(400).json({ error: response.error }) - } - return res.status(200).json(response) -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = req.body - const response = await post(`${PG_META_URL}/column-privileges`, payload, { - headers, - }) + const response = await fetchGet(getPgMetaRedirectUrl(req, 'column-privileges'), { headers }) if (response.error) { - console.error('Role POST:', response.error) - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - - return res.status(200).json(response) -} - -const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = req.body - const response = await delete_(`${PG_META_URL}/column-privileges`, payload, { headers }) - - if (response.error) { - console.error('Role DELETE:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/columns.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/columns.ts deleted file mode 100644 index 7b131a24d0d..00000000000 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/columns.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next' -import apiWrapper from 'lib/api/apiWrapper' -import { constructHeaders, toSnakeCase } from 'lib/api/apiHelpers' -import { PG_META_URL } from 'lib/constants' -import { delete_, get, patch, post } from 'lib/common/fetch' -import { getPgMetaRedirectUrl } from './tables' - -export default (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { method } = req - - switch (method) { - case 'GET': - return handleGetAll(req, res) - case 'POST': - return handlePost(req, res) - case 'PATCH': - return handlePatch(req, res) - case 'DELETE': - return handleDelete(req, res) - default: - res.setHeader('Allow', ['GET', 'POST', 'PATCH', 'DELETE']) - res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) - } -} - -const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - let response = await get(getPgMetaRedirectUrl(req, 'columns'), { - headers, - }) - if (response.error) { - return res.status(400).json({ error: response.error }) - } - return res.status(200).json(response) -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = toSnakeCase(req.body) - const response = await post(`${PG_META_URL}/columns`, payload, { - headers, - }) - - if (response.error) { - console.error('Column POST:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) -} - -const handlePatch = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = toSnakeCase(req.body) - const response = await patch(`${PG_META_URL}/columns/${req.query.id}`, payload, { - headers, - }) - - if (response.error) { - console.error('Column PATCH:', response) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) -} - -const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const response = await delete_(`${PG_META_URL}/columns/${req.query.id}`, {}, { headers }) - - if (response.error) { - console.error('Column DELETE:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) -} diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/extensions.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/extensions.ts index bc060035c38..51862a2e976 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/extensions.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/extensions.ts @@ -1,9 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next' +import { fetchGet } from 'data/fetchers' +import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' import { PG_META_URL } from 'lib/constants' -import { get, post, delete_ } from 'lib/common/fetch' -import { constructHeaders, toSnakeCase } from 'lib/api/apiHelpers' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler, { withAuth: true }) @@ -14,10 +14,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { switch (method) { case 'GET': return handleGetAll(req, res) - case 'POST': - return handlePost(req, res) - case 'DELETE': - return handleDelete(req, res) default: res.setHeader('Allow', ['GET']) res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) @@ -26,38 +22,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - let response = await get(`${PG_META_URL}/extensions`, { - headers, - }) - if (response.error) { - return res.status(400).json(response.error) - } - return res.status(200).json(response) -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = toSnakeCase(req.body) - const response = await post(`${PG_META_URL}/extensions`, payload, { - headers, - }) + const response = await fetchGet(`${PG_META_URL}/extensions`, { headers }) if (response.error) { - console.error('Extensions POST:', response.error) - return res.status(400).json(response.error) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - - return res.status(200).json(response) -} - -const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const response = await delete_(`${PG_META_URL}/extensions/${req.query.id}`, {}, { headers }) - - if (response.error) { - console.error('Extensions DELETE:', response.error) - return res.status(400).json(response.error) - } - - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/foreign-tables.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/foreign-tables.ts index e2459fb0dba..a919ca09509 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/foreign-tables.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/foreign-tables.ts @@ -1,9 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next' -import apiWrapper from 'lib/api/apiWrapper' -import { get } from 'lib/common/fetch' +import { fetchGet } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' -import { PG_META_URL } from 'lib/constants' +import apiWrapper from 'lib/api/apiWrapper' import { getPgMetaRedirectUrl } from './tables' export default (req: NextApiRequest, res: NextApiResponse) => @@ -14,7 +13,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { switch (method) { case 'GET': - if (req.query.id) return handleGetOne(req, res) return handleGetAll(req, res) default: res.setHeader('Allow', ['GET']) @@ -24,23 +22,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - let response = await get(getPgMetaRedirectUrl(req, 'foreign-tables'), { - headers, - }) - if (response.error) { - return res.status(400).json({ error: response.error }) - } + const response = await fetchGet(getPgMetaRedirectUrl(req, 'foreign-tables'), { headers }) - return res.status(200).json(response) -} - -const handleGetOne = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - let response = await get(`${PG_META_URL}/foreign-tables/${req.query.id}`, { - headers, - }) if (response.error) { - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/materialized-views.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/materialized-views.ts index d0ecfac3512..72a35c9622a 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/materialized-views.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/materialized-views.ts @@ -1,9 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next' -import apiWrapper from 'lib/api/apiWrapper' -import { get } from 'lib/common/fetch' +import { fetchGet } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' -import { PG_META_URL } from 'lib/constants' +import apiWrapper from 'lib/api/apiWrapper' import { getPgMetaRedirectUrl } from './tables' export default (req: NextApiRequest, res: NextApiResponse) => @@ -14,7 +13,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { switch (method) { case 'GET': - if (req.query.id) return handleGetOne(req, res) return handleGetAll(req, res) default: res.setHeader('Allow', ['GET']) @@ -24,23 +22,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - let response = await get(getPgMetaRedirectUrl(req, 'materialized-views'), { - headers, - }) - if (response.error) { - return res.status(400).json({ error: response.error }) - } + const response = await fetchGet(getPgMetaRedirectUrl(req, 'materialized-views'), { headers }) - return res.status(200).json(response) -} - -const handleGetOne = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - let response = await get(`${PG_META_URL}/materialized-views/${req.query.id}`, { - headers, - }) if (response.error) { - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/policies.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/policies.ts index 51ded58c9d3..775b7cf36e2 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/policies.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/policies.ts @@ -1,8 +1,9 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import { fetchGet } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' -import { get } from 'lib/common/fetch' import { PG_META_URL } from 'lib/constants' -import { NextApiRequest, NextApiResponse } from 'next' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler, { withAuth: true }) @@ -14,18 +15,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { case 'GET': return handleGetAll(req, res) default: - res.setHeader('Allow', ['GET', 'POST', 'PATCH', 'DELETE']) + res.setHeader('Allow', ['GET']) res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) } } const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - let response = await get(`${PG_META_URL}/policies`, { - headers, - }) + const response = await fetchGet(`${PG_META_URL}/policies`, { headers }) + if (response.error) { - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/publications.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/publications.ts index 7f3a2bd1ac0..fdb191442f7 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/publications.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/publications.ts @@ -1,8 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next' + +import { fetchGet } from 'data/fetchers' +import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' -import { constructHeaders, toSnakeCase } from 'lib/api/apiHelpers' import { PG_META_URL } from 'lib/constants' -import { delete_, get, patch, post } from 'lib/common/fetch' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler, { withAuth: true }) @@ -13,67 +14,20 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { switch (method) { case 'GET': return handleGetAll(req, res) - case 'POST': - return handlePost(req, res) - case 'PATCH': - return handlePatch(req, res) - case 'DELETE': - return handleDelete(req, res) default: - res.setHeader('Allow', ['GET', 'POST', 'PATCH', 'DELETE']) + res.setHeader('Allow', ['GET']) res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) } } const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - let response = await get(`${PG_META_URL}/publications`, { - headers, - }) - if (response.error) { - return res.status(400).json({ error: response.error }) - } - return res.status(200).json(response) -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = toSnakeCase(req.body) - const response = await post(`${PG_META_URL}/publications`, payload, { - headers, - }) + const response = await fetchGet(`${PG_META_URL}/publications`, { headers }) if (response.error) { - console.error('Publication POST:', response.error) - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - - return res.status(200).json(response) -} - -const handlePatch = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = toSnakeCase(req.body) - const response = await patch(`${PG_META_URL}/publications/${req.query.id}`, payload, { - headers, - }) - - if (response.error) { - console.error('Publication PATCH:', response) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) -} - -const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const response = await delete_(`${PG_META_URL}/publications/${req.query.id}`, {}, { headers }) - - if (response.error) { - console.error('Publication DELETE:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/query/format.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/query/format.ts deleted file mode 100644 index 92a3cfe6ff8..00000000000 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/query/format.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next' -import apiWrapper from 'lib/api/apiWrapper' -import { constructHeaders } from 'lib/api/apiHelpers' -import { PG_META_URL } from 'lib/constants' -import { post } from 'lib/common/fetch' - -export default (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) - } -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const { query } = req.body - const headers = constructHeaders(req.headers) - const response = await post(`${PG_META_URL}/query/format`, { query }, { headers }) - - if (response.error) { - return res.status(400).json({ error: response.error }) - } else { - return res.status(200).json(response) - } -} diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/query/validate.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/query/validate.ts deleted file mode 100644 index 4279f479ce5..00000000000 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/query/validate.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next' -import apiWrapper from 'lib/api/apiWrapper' -import { constructHeaders } from 'lib/api/apiHelpers' -import { PG_META_URL } from 'lib/constants' -import { post } from 'lib/common/fetch' - -export default (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) - } -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const { query } = req.body - const headers = constructHeaders(req.headers) - const response = await post(`${PG_META_URL}/query/parse`, { query }, { headers }) - - if (response.error) { - return res.status(400).json({ valid: false, error: response.error }) - } else { - return res.status(200).json({ valid: true }) - } -} diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/tables.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/tables.ts index d4db8c40067..2c9ecbd1bdd 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/tables.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/tables.ts @@ -1,8 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next' -import { PG_META_URL } from 'lib/constants' -import apiWrapper from 'lib/api/apiWrapper' + +import { fetchGet } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' -import { delete_, get, patch, post } from 'lib/common/fetch' +import apiWrapper from 'lib/api/apiWrapper' +import { PG_META_URL } from 'lib/constants' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler, { withAuth: true }) @@ -12,16 +13,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { switch (method) { case 'GET': - if (req.query.id) return handleGetOne(req, res) return handleGetAll(req, res) - case 'POST': - return handlePost(req, res) - case 'PATCH': - return handlePatch(req, res) - case 'DELETE': - return handleDelete(req, res) default: - res.setHeader('Allow', ['GET', 'POST', 'PATCH', 'DELETE']) + res.setHeader('Allow', ['GET']) res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) } } @@ -53,61 +47,12 @@ export function getPgMetaRedirectUrl(req: NextApiRequest, endpoint: string) { const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) + const response = await fetchGet(getPgMetaRedirectUrl(req, 'tables'), { headers }) - const response = await get(getPgMetaRedirectUrl(req, 'tables'), { headers }) if (response.error) { - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - - return res.status(200).json(response) -} - -const handleGetOne = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - let response = await get(`${PG_META_URL}/tables/${req.query.id}`, { - headers, - }) - if (response.error) { - return res.status(400).json({ error: response.error }) - } - return res.status(200).json(response) -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = req.body - const response = await post(`${PG_META_URL}/tables`, payload, { - headers, - }) - if (response.error) { - console.error('Table POST:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) -} - -const handlePatch = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = req.body - const response = await patch(`${PG_META_URL}/tables/${req.query.id}`, payload, { - headers, - }) - if (response.error) { - console.error('Table PATCH:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) -} - -const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const response = await delete_(`${PG_META_URL}/tables/${req.query.id}`, {}, { headers }) - if (response.error) { - console.error('Table DELETE:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/triggers.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/triggers.ts index ba71fcfb2bc..fe4c1308a27 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/triggers.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/triggers.ts @@ -1,7 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next' + +import { fetchGet } from 'data/fetchers' +import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' -import { constructHeaders, toSnakeCase } from 'lib/api/apiHelpers' -import { delete_, get, patch, post } from 'lib/common/fetch' import { PG_META_URL } from 'lib/constants' export default (req: NextApiRequest, res: NextApiResponse) => @@ -13,67 +14,20 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { switch (method) { case 'GET': return handleGetAll(req, res) - case 'POST': - return handlePost(req, res) - case 'PATCH': - return handlePatch(req, res) - case 'DELETE': - return handleDelete(req, res) default: - res.setHeader('Allow', ['GET', 'POST', 'PATCH', 'DELETE']) + res.setHeader('Allow', ['GET']) res.status(405).json({ error: { message: `Method ${method} Not Allowed` } }) } } const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - let response = await get(`${PG_META_URL}/triggers`, { - headers, - }) - if (response.error) { - return res.status(400).json({ error: response.error }) - } - return res.status(200).json(response) -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = toSnakeCase(req.body) - const response = await post(`${PG_META_URL}/triggers`, payload, { - headers, - }) + const response = await fetchGet(`${PG_META_URL}/triggers`, { headers }) if (response.error) { - console.error('Trigger POST:', response.error) - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - - return res.status(200).json(response) -} - -const handlePatch = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const payload = toSnakeCase(req.body) - const response = await patch(`${PG_META_URL}/triggers/${req.query.id}`, payload, { - headers, - }) - - if (response.error) { - console.error('Trigger PATCH:', response) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) -} - -const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - const response = await delete_(`${PG_META_URL}/triggers/${req.query.id}`, {}, { headers }) - - if (response.error) { - console.error('Trigger DELETE:', response.error) - return res.status(400).json({ error: response.error }) - } - - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/types.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/types.ts index 8adc18b77fc..8293590f629 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/types.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/types.ts @@ -1,8 +1,9 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import { fetchGet } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' -import { get } from 'lib/common/fetch' import { PG_META_URL } from 'lib/constants' -import { NextApiRequest, NextApiResponse } from 'next' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler, { withAuth: true }) @@ -21,11 +22,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - const response = await get(`${PG_META_URL}/types`, { headers }) + const response = await fetchGet(`${PG_META_URL}/types`, { headers }) if (response.error) { - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/pg-meta/[ref]/views.ts b/apps/studio/pages/api/platform/pg-meta/[ref]/views.ts index 636dd93af9c..755ff6cf42c 100644 --- a/apps/studio/pages/api/platform/pg-meta/[ref]/views.ts +++ b/apps/studio/pages/api/platform/pg-meta/[ref]/views.ts @@ -1,9 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next' -import apiWrapper from 'lib/api/apiWrapper' -import { get } from 'lib/common/fetch' +import { fetchGet } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' -import { PG_META_URL } from 'lib/constants' +import apiWrapper from 'lib/api/apiWrapper' import { getPgMetaRedirectUrl } from './tables' export default (req: NextApiRequest, res: NextApiResponse) => @@ -14,7 +13,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { switch (method) { case 'GET': - if (req.query.id) return handleGetOne(req, res) return handleGetAll(req, res) default: res.setHeader('Allow', ['GET']) @@ -24,23 +22,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - let response = await get(getPgMetaRedirectUrl(req, 'views'), { - headers, - }) - if (response.error) { - return res.status(400).json({ error: response.error }) - } + const response = await fetchGet(getPgMetaRedirectUrl(req, 'views'), { headers }) - return res.status(200).json(response) -} - -const handleGetOne = async (req: NextApiRequest, res: NextApiResponse) => { - const headers = constructHeaders(req.headers) - let response = await get(`${PG_META_URL}/views/${req.query.id}`, { - headers, - }) if (response.error) { - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - return res.status(200).json(response) } diff --git a/apps/studio/pages/api/platform/projects/[ref]/run-lints.ts b/apps/studio/pages/api/platform/projects/[ref]/run-lints.ts index fff0ccbc5e8..e58f7b326f7 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/run-lints.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/run-lints.ts @@ -1,9 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next' import { paths } from 'api-types' +import { fetchPost } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' import apiWrapper from 'lib/api/apiWrapper' -import { post } from 'lib/common/fetch' import { PG_META_URL } from 'lib/constants' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) @@ -25,7 +25,11 @@ type ResponseData = const handleGet = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - const response = await post(`${PG_META_URL}/query`, { query: enrichQuery(LINT_SQL) }, { headers }) + const response = await fetchPost( + `${PG_META_URL}/query`, + { query: enrichQuery(LINT_SQL) }, + { headers } + ) if (response.error) { return res.status(400).json(response.error) } else { diff --git a/apps/studio/pages/api/v1/projects/[ref]/types/typescript.ts b/apps/studio/pages/api/v1/projects/[ref]/types/typescript.ts index fa62b486eb3..904ea6598be 100644 --- a/apps/studio/pages/api/v1/projects/[ref]/types/typescript.ts +++ b/apps/studio/pages/api/v1/projects/[ref]/types/typescript.ts @@ -1,8 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next' -import { PG_META_URL } from 'lib/constants' -import apiWrapper from 'lib/api/apiWrapper' + +import { fetchGet } from 'data/fetchers' import { constructHeaders } from 'lib/api/apiHelpers' -import { get } from 'lib/common/fetch' +import apiWrapper from 'lib/api/apiWrapper' +import { PG_META_URL } from 'lib/constants' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler, { withAuth: true }) @@ -40,14 +41,15 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { const headers = constructHeaders(req.headers) - const response = await get( + const response = await fetchGet( `${PG_META_URL}/generators/typescript?included_schema=${includedSchema}&excluded_schemas=${excludedSchema}`, { headers } ) if (response.error) { - return res.status(400).json({ error: response.error }) + const { code, message } = response.error + return res.status(code).json({ message }) + } else { + return res.status(200).json(response) } - - return res.status(200).json({ types: response }) } diff --git a/apps/studio/vitest.config.ts b/apps/studio/vitest.config.ts index f2a848d5581..9ed6851ec8e 100644 --- a/apps/studio/vitest.config.ts +++ b/apps/studio/vitest.config.ts @@ -34,12 +34,7 @@ export default defineConfig({ reporters: [['default']], coverage: { reporter: ['lcov'], - exclude: [ - '**/*.test.ts', - '**/*.test.tsx', - // 👇 Excluded because it will be deprecated. - 'lib/common/fetch/**', - ], + exclude: ['**/*.test.ts', '**/*.test.tsx'], include: ['lib/**/*.ts'], }, }, From 63028c60f50cb0deffcb1e026c080cf90b985093 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 21 Jul 2025 17:56:02 +0800 Subject: [PATCH 06/38] fix: disable usercentrics in staging (#37347) --- packages/common/consent-state.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/common/consent-state.ts b/packages/common/consent-state.ts index 8575a76c883..23e1109dfbd 100644 --- a/packages/common/consent-state.ts +++ b/packages/common/consent-state.ts @@ -61,10 +61,13 @@ export const consentState = proxy({ async function initUserCentrics() { if (process.env.NODE_ENV === 'test' || !IS_PLATFORM) return - // [Alaister] For local development, we accept all consent by default. - // If you need to test usercentrics locally, comment out the + // [Alaister] For local development and staging, we accept all consent by default. + // If you need to test usercentrics in these environments, comment out this // NEXT_PUBLIC_ENVIRONMENT check and add an ngrok domain to usercentrics - if (process.env.NEXT_PUBLIC_ENVIRONMENT === 'local') { + if ( + process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + ) { consentState.hasConsented = true return } From 745bebb06738f52fb82fad30796f280e24973c55 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Mon, 21 Jul 2025 11:28:54 -0300 Subject: [PATCH 07/38] chore: support redirect query params on signup (#37272) --- .../interfaces/SignIn/SignUpForm.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx index 52a117d6e6b..bd6a7772c41 100644 --- a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx @@ -4,10 +4,12 @@ import { parseAsString, useQueryStates } from 'nuqs' import { useRef, useState } from 'react' import { toast } from 'sonner' import * as yup from 'yup' +import { useRouter } from 'next/router' import { useSignUpMutation } from 'data/misc/signup-mutation' import { BASE_PATH } from 'lib/constants' import { passwordSchema } from 'lib/schemas' +import { buildPathWithParams } from 'lib/gotrue' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -28,6 +30,7 @@ const SignUpForm = () => { const [isSubmitted, setIsSubmitted] = useState(false) const [passwordHidden, setPasswordHidden] = useState(true) const [captchaToken, setCaptchaToken] = useState(null) + const router = useRouter() const [searchParams] = useQueryStates({ auth_id: parseAsString.withDefault(''), @@ -59,9 +62,19 @@ const SignUpForm = () => { ? location.origin : process.env.NEXT_PUBLIC_SITE_URL }${BASE_PATH}` - const redirectTo = isInsideOAuthFlow - ? `${redirectUrlBase}/authorize?auth_id=${searchParams.auth_id}${searchParams.token && `&token=${searchParams.token}`}` - : `${redirectUrlBase}/sign-in` + + let redirectTo: string + + if (isInsideOAuthFlow) { + redirectTo = `${redirectUrlBase}/authorize?auth_id=${searchParams.auth_id}${searchParams.token && `&token=${searchParams.token}`}` + } else { + // Use getRedirectToPath to handle redirect_to parameter and other query params + const { returnTo } = router.query + const basePath = returnTo || '/sign-in' + const fullPath = buildPathWithParams(basePath as string) + const fullRedirectUrl = `${redirectUrlBase}${fullPath}` + redirectTo = fullRedirectUrl + } signup({ email, From 0d0bccb97241ef9213bb50aa57658e978f5b3a61 Mon Sep 17 00:00:00 2001 From: Levon Date: Mon, 21 Jul 2025 14:15:14 -0400 Subject: [PATCH 08/38] Update pgadmin.mdx (#37355) Update instructions for tutorial --- apps/docs/content/guides/database/pgadmin.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/database/pgadmin.mdx b/apps/docs/content/guides/database/pgadmin.mdx index a7917fb205b..8a3dfbc6f4a 100644 --- a/apps/docs/content/guides/database/pgadmin.mdx +++ b/apps/docs/content/guides/database/pgadmin.mdx @@ -56,7 +56,7 @@ hideToc: true - Add the connection info. Go to your [`Database Settings`](https://supabase.com/dashboard/project/_/settings/database). Make sure `Use connection pooling` is enabled. Switch the connection mode to `Session` and copy your connection parameters. Fill in your Database password that you made when creating your project (It can be reset in Database Settings above if you don't have it). + Add the connection info. Click the "Connect" button at the top of the page to open the connect Modal. Scroll down to "session pooler", click "view parameters" to toggle the parameters menu open and copy your connection parameters. Fill in your Database password that you made when creating your project (It can be reset in Database Settings above if you don't have it). From e23607b099486215f13a8300d212616aceedc909 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Tue, 22 Jul 2025 02:28:46 +0800 Subject: [PATCH 09/38] chore: add bucket creation tracking (#37213) --- .../interfaces/Storage/CreateBucketModal.tsx | 9 ++++++++ packages/common/telemetry-constants.ts | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index 1d5f0fa8236..b9b58052e7b 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -18,6 +18,8 @@ import { import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation' import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import { Alert_Shadcn_, @@ -74,6 +76,8 @@ export type CreateBucketForm = z.infer const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { const { ref } = useParams() + const org = useSelectedOrganization() + const { mutate: sendEvent } = useSendEventMutation() const router = useRouter() const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation() @@ -134,6 +138,11 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { file_size_limit: fileSizeLimit, allowed_mime_types: allowedMimeTypes, }) + sendEvent({ + action: 'storage_bucket_created', + properties: { bucketType: values.type }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) if (values.type === 'ANALYTICS' && icebergWrapperExtensionState === 'installed') { await createIcebergWrapper({ bucketName: values.name }) diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 2886475adef..9417d17b7b4 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1363,6 +1363,27 @@ export interface ForeignDataWrapperCreatedEvent { } } +/** + * Triggered when a new storage bucket is created in a project. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/storage/buckets + */ +export interface StorageBucketCreatedEvent { + action: 'storage_bucket_created' + properties: { + /** + * The type of the bucket created. E.g. standard or analytics iceberg. + */ + bucketType?: string + } + groups: { + project: string + organization: string + } +} + /** * @hidden */ @@ -1443,3 +1464,4 @@ export type TelemetryEvent = | AiAssistantInSupportFormClickedEvent | OrganizationMfaEnforcementUpdated | ForeignDataWrapperCreatedEvent + | StorageBucketCreatedEvent From 9523272dbf18849a5b4a5b3ca672aaacfd736238 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Jul 2025 15:37:48 +0800 Subject: [PATCH 10/38] Chore/add instructions for psql export table editor (#37249) * Midway * midway * update warning * Nit * Nittt * Add comment --- .../grid/components/header/ExportDialog.tsx | 129 ++++++++++++++++++ .../grid/components/header/Header.tsx | 118 +++++++++------- .../TableGridEditor/TableGridEditor.tsx | 10 -- data.sql | 0 4 files changed, 195 insertions(+), 62 deletions(-) create mode 100644 apps/studio/components/grid/components/header/ExportDialog.tsx create mode 100644 data.sql diff --git a/apps/studio/components/grid/components/header/ExportDialog.tsx b/apps/studio/components/grid/components/header/ExportDialog.tsx new file mode 100644 index 00000000000..83d6f275d74 --- /dev/null +++ b/apps/studio/components/grid/components/header/ExportDialog.tsx @@ -0,0 +1,129 @@ +import { useParams } from 'common' +import { getConnectionStrings } from 'components/interfaces/Connect/DatabaseSettings.utils' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { pluckObjectFields } from 'lib/helpers' +import { useState } from 'react' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import { + Button, + cn, + CodeBlock, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Tabs_Shadcn_, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' + +interface ExportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => { + const { ref: projectRef } = useParams() + const snap = useTableEditorTableStateSnapshot() + const [selectedTab, setSelectedTab] = useState('csv') + + const { data: databases } = useReadReplicasQuery({ projectRef }) + const primaryDatabase = (databases ?? []).find((db) => db.identifier === projectRef) + const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] + const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } + + const connectionInfo = pluckObjectFields(primaryDatabase || emptyState, DB_FIELDS) + const { db_host, db_port, db_user, db_name } = connectionInfo + + const connectionStrings = getConnectionStrings({ + connectionInfo, + metadata: { projectRef }, + // [Joshen] We don't need any pooler details for this context, we only want direct + poolingInfo: { connectionString: '', db_host: '', db_name: '', db_port: 0, db_user: '' }, + }) + + const outputName = `${snap.table.name}_rows` + + const csvExportCommand = ` +${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${snap.table.schema}"."${snap.table.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv +`.trim() + + const sqlExportCommand = ` +pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${snap.table.schema}.${snap.table.name}" --data-only --column-inserts > ${outputName}.sql + `.trim() + + return ( + + + + Export table data via CLI + + + + + +

+ We highly recommend using {selectedTab === 'csv' ? 'psql' : 'pg_dump'} to + export your table data, in particular if your table is relatively large. This can be + done via the following command that you can run in your terminal: +

+ + + + As CSV + As SQL + + + + + + + + + +

+ You will be prompted for your database password, and the output file{' '} + + {outputName}.{selectedTab} + {' '} + will be saved in the current directory that your terminal is in. +

+ + {selectedTab === 'sql' && ( + +

+ If you run into a server version mismatch error, you will need to update{' '} + pg_dump before running the command. +

+
+ )} +
+ + + +
+
+ ) +} diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 55847f98d3e..67d4d2886e8 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -35,6 +35,7 @@ import { Separator, SonnerProgress, } from 'ui' +import { ExportDialog } from './ExportDialog' import { FilterPopover } from './filter/FilterPopover' import { SortPopover } from './sort/SortPopover' // [Joshen] CSV exports require this guard as a fail-safe if the table is @@ -230,6 +231,7 @@ const RowHeader = () => { const { sorts } = useTableSort() const [isExporting, setIsExporting] = useState(false) + const [showExportModal, setShowExportModal] = useState(false) const { data } = useTableRowsQuery({ projectRef: project?.ref, @@ -441,61 +443,73 @@ const RowHeader = () => { }) return ( -
- {snap.editable && ( - } - onClick={onRowsDelete} - disabled={snap.allRowsSelected && isImpersonatingRole} - tooltip={{ - content: { - side: 'bottom', - text: - snap.allRowsSelected && isImpersonatingRole - ? 'Table truncation is not supported when impersonating a role' - : undefined, - }, - }} - > - {snap.allRowsSelected - ? `Delete all rows in table` - : snap.selectedRows.size > 1 - ? `Delete ${snap.selectedRows.size} rows` - : `Delete ${snap.selectedRows.size} row`} - - )} - - - - - - - Export to CSV - - Export to SQL - - + {snap.allRowsSelected + ? `Delete all rows in table` + : snap.selectedRows.size > 1 + ? `Delete ${snap.selectedRows.size} rows` + : `Delete ${snap.selectedRows.size} row`} + + )} + + + + + + Export as CSV + Export as SQL + {/* [Joshen] Should make this available for all cases, but that'll involve updating + the Dialog's SQL output to be dynamic based on any filters applied */} + {snap.allRowsSelected && ( + setShowExportModal(true)}> +
+

Export via CLI

+

Recommended for large tables

+
+
+ )} +
+
- {!snap.allRowsSelected && totalRows > allRows.length && ( - <> -
- -
- - - )} -
+ {!snap.allRowsSelected && totalRows > allRows.length && ( + <> +
+ +
+ + + )} + + + setShowExportModal(false)} /> + ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index a29a158fc05..fe294b36169 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -12,10 +12,7 @@ import { isTableLike, isView, } from 'data/table-editor/table-editor-types' -import { useGetTables } from 'data/tables/tables-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useAppStateSnapshot } from 'state/app-state' @@ -37,12 +34,10 @@ export const TableGridEditor = ({ selectedTable, }: TableGridEditorProps) => { const router = useRouter() - const project = useSelectedProject() const appSnap = useAppStateSnapshot() const { ref: projectRef, id } = useParams() const tabs = useTabsStateSnapshot() - const { selectedSchema } = useQuerySchemaState() useLoadTableEditorStateFromLocalStorageIntoUrl({ projectRef, @@ -57,11 +52,6 @@ export const TableGridEditor = ({ const tabId = !!id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined const openTabs = tabs.openTabs.filter((x) => !x.startsWith('sql')) - const getTables = useGetTables({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const onClearDashboardHistory = useCallback(() => { if (projectRef) appSnap.setDashboardHistory(projectRef, 'editor', undefined) }, [appSnap, projectRef]) diff --git a/data.sql b/data.sql new file mode 100644 index 00000000000..e69de29bb2d From e70242f822130455f0eb4becf3e9ced82c31ef9f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Jul 2025 16:05:16 +0800 Subject: [PATCH 11/38] Chore/clean up assistant mcp feature flags (#36772) * Clean up usage of newOrgAiOptIn and useBedrockAssistant feature flags * Remove all OpenAI endpoints * Fix for self-hosted * Default isLimited to false * Update PG meta tests * Fix unit tests for model * Revert pg meta tests * Fix test --------- Co-authored-by: Alaister Young --- .../CronJobs/CronJobScheduleSection.tsx | 4 +- .../GeneralSettings/AIOptInLevelSelector.tsx | 49 +-- .../GeneralSettings/DataPrivacyForm.tsx | 4 +- .../GeneralSettings/OptInToOpenAIToggle.tsx | 101 ++---- .../interfaces/SQLEditor/RenameQueryModal.tsx | 8 +- .../interfaces/SQLEditor/SQLEditor.tsx | 10 +- .../ui/AIAssistantPanel/AIAssistant.tsx | 22 +- .../ui/AIAssistantPanel/AIOptInModal.tsx | 4 +- .../components/ui/EditorPanel/EditorPanel.tsx | 9 +- apps/studio/data/ai/sql-cron-mutation.ts | 7 +- apps/studio/data/ai/sql-title-mutation.ts | 7 +- apps/studio/hooks/forms/useAIOptInForm.ts | 107 +++---- apps/studio/hooks/misc/useOrgOptedIntoAi.ts | 4 +- apps/studio/lib/ai/model.test.ts | 1 + apps/studio/lib/ai/model.ts | 4 +- apps/studio/middleware.ts | 8 - .../pages/api/ai/edge-function/complete.ts | 301 ------------------ apps/studio/pages/api/ai/sql/complete.ts | 161 ---------- apps/studio/pages/api/ai/sql/cron.ts | 60 ---- apps/studio/pages/api/ai/sql/generate-v3.ts | 176 ---------- apps/studio/pages/api/ai/sql/generate-v4.ts | 61 ++-- apps/studio/pages/api/ai/sql/title.ts | 58 ---- .../[ref]/functions/[functionSlug]/code.tsx | 8 +- .../pages/project/[ref]/functions/new.tsx | 9 +- packages/pg-meta/test/query/query.test.ts | 6 +- 25 files changed, 137 insertions(+), 1052 deletions(-) delete mode 100644 apps/studio/pages/api/ai/edge-function/complete.ts delete mode 100644 apps/studio/pages/api/ai/sql/complete.ts delete mode 100644 apps/studio/pages/api/ai/sql/cron.ts delete mode 100644 apps/studio/pages/api/ai/sql/generate-v3.ts delete mode 100644 apps/studio/pages/api/ai/sql/title.ts diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx index 09ad555434f..813d94eecf9 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx @@ -6,7 +6,6 @@ import { useDebounce } from 'use-debounce' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useSqlCronGenerateMutation } from 'data/ai/sql-cron-mutation' import { useCronTimezoneQuery } from 'data/database-cron-jobs/database-cron-timezone-query' -import { useFlag } from 'hooks/ui/useFlag' import { Accordion_Shadcn_, AccordionContent_Shadcn_, @@ -36,7 +35,6 @@ interface CronJobScheduleSectionProps { export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => { const { project } = useProjectContext() - const useBedrockAssistant = useFlag('useBedrockAssistant') const [inputValue, setInputValue] = useState('') const [debouncedValue] = useDebounce(inputValue, 750) const [useNaturalLanguage, setUseNaturalLanguage] = useState(false) @@ -67,7 +65,7 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul useEffect(() => { if (useNaturalLanguage && debouncedValue) { - generateCronSyntax({ prompt: debouncedValue, useBedrockAssistant }) + generateCronSyntax({ prompt: debouncedValue }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedValue, useNaturalLanguage]) diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx index 7d890a0fb09..5744f9e177b 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx @@ -2,9 +2,7 @@ import { ReactNode } from 'react' import { Control } from 'react-hook-form' import { AIOptInFormValues } from 'hooks/forms/useAIOptInForm' -import { useFlag } from 'hooks/ui/useFlag' import { FormField_Shadcn_, RadioGroup_Shadcn_, RadioGroupItem_Shadcn_ } from 'ui' -import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { OptInToOpenAIToggle } from './OptInToOpenAIToggle' @@ -15,21 +13,6 @@ interface AIOptInLevelSelectorProps { layout?: 'horizontal' | 'vertical' | 'flex-row-reverse' } -const AI_OPT_IN_LEVELS_OLD = [ - { - value: 'disabled', - title: 'Disabled', - description: - 'You do not consent to sharing any database information with Supabase AI and understand that responses will be generic and not tailored to your database', - }, - { - value: 'schema', - title: 'Send anonymous metadata', - description: - 'You consent to sending anonymous data to Supabase AI, which can improve the answers it shows you.', - }, -] - const AI_OPT_IN_LEVELS = [ { value: 'disabled', @@ -63,35 +46,19 @@ export const AIOptInLevelSelector = ({ label, layout = 'vertical', }: AIOptInLevelSelectorProps) => { - const newOrgAiOptIn = useFlag('newOrgAiOptIn') - const useBedrockAssistant = useFlag('useBedrockAssistant') - - const optInLevels = useBedrockAssistant ? AI_OPT_IN_LEVELS : AI_OPT_IN_LEVELS_OLD - return ( - {useBedrockAssistant && ( - <> - {!newOrgAiOptIn && ( - - )} -

- Supabase AI can provide more relevant answers if you choose to share different - levels of data. This feature is powered by Amazon Bedrock which does not store or - log your prompts and completions, nor does it use them to train AWS models or - distribute them to third parties. This is an organization-wide setting, so please - select the level of data you are comfortable sharing. -

- - )} +

+ Supabase AI can provide more relevant answers if you choose to share different levels of + data. This feature is powered by Amazon Bedrock which does not store or log your prompts + and completions, nor does it use them to train AWS models or distribute them to third + parties. This is an organization-wide setting, so please select the level of data you + are comfortable sharing. +

} @@ -107,7 +74,7 @@ export const AIOptInLevelSelector = ({ disabled={disabled} className="space-y-2 mb-6" > - {optInLevels.map((item) => ( + {AI_OPT_IN_LEVELS.map((item) => (
{ - const newOrgAiOptIn = useFlag('newOrgAiOptIn') const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm() const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') @@ -28,7 +26,7 @@ export const DataPrivacyForm = () => { diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx index becd7b76488..33197145ab3 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx @@ -1,5 +1,4 @@ import { InlineLink } from 'components/ui/InlineLink' -import { useFlag } from 'hooks/ui/useFlag' import { Button, @@ -12,8 +11,6 @@ import { } from 'ui' export const OptInToOpenAIToggle = () => { - const useBedrockAssistant = useFlag('useBedrockAssistant') - return ( @@ -29,81 +26,37 @@ export const OptInToOpenAIToggle = () => { padding="small" className="flex flex-col gap-y-4 text-sm text-foreground-light" > - {useBedrockAssistant ? ( - <> -

- Supabase AI utilizes Amazon Bedrock ("Bedrock"), a service designed with a strong - focus on data privacy and security. -

+

+ Supabase AI utilizes Amazon Bedrock ("Bedrock"), a service designed with a strong focus + on data privacy and security. +

-

- Amazon Bedrock does not store or log your prompts and completions. This data is not - used to train any AWS models and is not distributed to third parties or model - providers. Model providers do not have access to Amazon Bedrock logs or customer - prompts and completions. -

+

+ Amazon Bedrock does not store or log your prompts and completions. This data is not used + to train any AWS models and is not distributed to third parties or model providers. + Model providers do not have access to Amazon Bedrock logs or customer prompts and + completions. +

-

- By default, no information is shared with Bedrock unless you explicitly provide - consent. With your permission, Supabase may share customer-generated prompts, - database schema, database data, and project logs with Bedrock. This information is - used solely to generate responses to your queries and is not retained by Bedrock or - used to train their foundation models. -

+

+ By default, no information is shared with Bedrock unless you explicitly provide consent. + With your permission, Supabase may share customer-generated prompts, database schema, + database data, and project logs with Bedrock. This information is used solely to + generate responses to your queries and is not retained by Bedrock or used to train their + foundation models. +

-

- If you are a HIPAA Covered Entity, please note that Bedrock is HIPAA eligible, and - Supabase has a Business Associate Agreement in place covering this use. -

+

+ If you are a HIPAA Covered Entity, please note that Bedrock is HIPAA eligible, and + Supabase has a Business Associate Agreement in place covering this use. +

-

- For more detailed information about how we collect and use your data, see our{' '} - Privacy Policy. You can - choose which types of information you consent to share by selecting from the options - in the AI settings. -

- - ) : ( - <> -

- Supabase AI is a chatbot support tool powered by OpenAI. Supabase will share the - query you submit and information about the databases you manage through Supabase - with OpenAI, L.L.C. and its affiliates in order to provide the Supabase AI tool. -

- -

- OpenAI will only access information about the structure of your databases, such as - table names, column and row headings. OpenAI will not access the contents of the - database itself. -

- -

- OpenAI uses this information to generate responses to your query, and does not - retain or use the information to train its algorithms or otherwise improve its - products and services. -

- -

- If you have your own individual account on Supabase, we will use any personal - information collected through [Supabase AI] to provide you with the [Supabase AI] - tool. If you are in the UK, EEA or Switzerland, the processing of this personal - information is necessary for the performance of a contract between you and us. -

- -

- Supabase collects information about the queries you submit through Supabase AI and - the responses you receive to assess the performance of the Supabase AI tool and - improve our services. If you are in the UK, EEA or Switzerland, the processing is - necessary for our legitimate interests, namely informing our product development and - improvement. -

- -

- For more information about how we use personal information, please see our{' '} - privacy policy. -

- - )} +

+ For more detailed information about how we collect and use your data, see our{' '} + Privacy Policy. You can + choose which types of information you consent to share by selecting from the options in + the AI settings. +

diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index 6b16ccc1aa3..f06192a711d 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -13,7 +13,6 @@ import { Snippet } from 'data/content/sql-folders-query' import type { SqlSnippet } from 'data/content/sql-snippets-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { AiIconAnimation, Button, Form, Input, Modal } from 'ui' @@ -34,7 +33,6 @@ const RenameQueryModal = ({ }: RenameQueryModalProps) => { const { ref } = useParams() const organization = useSelectedOrganization() - const useBedrockAssistant = useFlag('useBedrockAssistant') const snapV2 = useSqlEditorV2StateSnapshot() const tabsSnap = useTabsStateSnapshot() @@ -66,13 +64,11 @@ const RenameQueryModal = ({ const generateTitle = async () => { if ('content' in snippet && isSQLSnippet) { - titleSql({ sql: snippet.content.sql, useBedrockAssistant }) + titleSql({ sql: snippet.content.sql }) } else { try { const { content } = await getContentById({ projectRef: ref, id: snippet.id }) - if ('sql' in content) { - titleSql({ sql: content.sql, useBedrockAssistant }) - } + if ('sql' in content) titleSql({ sql: content.sql }) } catch (error) { toast.error('Unable to generate title based on query contents') } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index c8f472df446..0efecee1d43 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -23,7 +23,6 @@ import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { formatSql } from 'lib/formatSql' import { detectOS, uuidv4 } from 'lib/helpers' @@ -81,7 +80,6 @@ export const SQLEditor = () => { const os = detectOS() const router = useRouter() const { ref, id: urlId } = useParams() - const useBedrockAssistant = useFlag('useBedrockAssistant') const { profile } = useProfile() const project = useSelectedProject() @@ -209,7 +207,7 @@ export const SQLEditor = () => { const setAiTitle = useCallback( async (id: string, sql: string) => { try { - const { title: name } = await generateSqlTitle({ sql, useBedrockAssistant }) + const { title: name } = await generateSqlTitle({ sql }) snapV2.renameSnippet({ id, name }) const tabId = createTabId('sql', { id }) tabs.updateTab(tabId, { label: name }) @@ -217,7 +215,7 @@ export const SQLEditor = () => { // [Joshen] No error handler required as this happens in the background and not necessary to ping the user } }, - [generateSqlTitle, useBedrockAssistant, snapV2] + [generateSqlTitle, snapV2] ) const prettifyQuery = useCallback(async () => { @@ -461,9 +459,7 @@ export const SQLEditor = () => { completion, isLoading: isCompletionLoading, } = useCompletion({ - api: useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/complete-v2` - : `${BASE_PATH}/api/ai/sql/complete`, + api: `${BASE_PATH}/api/ai/sql/complete-v2`, body: { projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 3b6d8e320e0..cd853d9d143 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -1,7 +1,7 @@ import type { Message as MessageType } from '@ai-sdk/react' import { useChat } from '@ai-sdk/react' import { AnimatePresence, motion } from 'framer-motion' -import { ArrowDown, FileText, Info, RefreshCw, Settings, X } from 'lucide-react' +import { ArrowDown, Info, RefreshCw, Settings, X } from 'lucide-react' import { useRouter } from 'next/router' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -19,21 +19,21 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import uuidv4 from 'lib/uuid' +import type { AssistantMessageType } from 'state/ai-assistant-state' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { AiIconAnimation, Button, cn } from 'ui' import { Admonition, GenericSkeletonLoader } from 'ui-patterns' import { ButtonTooltip } from '../ButtonTooltip' import { ErrorBoundary } from '../ErrorBoundary' +import { type SqlSnippet } from './AIAssistant.types' import { onErrorChat } from './AIAssistant.utils' import { AIAssistantChatSelector } from './AIAssistantChatSelector' import { AIOnboarding } from './AIOnboarding' import { AIOptInModal } from './AIOptInModal' import { AssistantChatForm } from './AssistantChatForm' -import { type SqlSnippet } from './AIAssistant.types' import { Message } from './Message' import { useAutoScroll } from './hooks' -import type { AssistantMessageType } from 'state/ai-assistant-state' const MemoizedMessage = memo( ({ @@ -80,9 +80,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const { ref, id: entityId } = useParams() const searchParams = useSearchParamsShallow() - const newOrgAiOptIn = useFlag('newOrgAiOptIn') const disablePrompts = useFlag('disableAssistantPrompts') - const useBedrockAssistant = useFlag('useBedrockAssistant') const { snippets } = useSqlEditorV2StateSnapshot() const snap = useAiAssistantStateSnapshot() @@ -98,8 +96,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const showMetadataWarning = IS_PLATFORM && !!selectedOrganization && - ((!useBedrockAssistant && aiOptInLevel === 'disabled') || - (useBedrockAssistant && (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema'))) + (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema') // Add a ref to store the last user message const lastUserMessageRef = useRef(null) @@ -154,9 +151,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { reload, } = useChat({ id: snap.activeChatId, - api: useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/generate-v4` - : `${BASE_PATH}/api/ai/sql/generate-v3`, + api: `${BASE_PATH}/api/ai/sql/generate-v4`, maxSteps: 5, // [Alaister] typecast is needed here because valtio returns readonly arrays // and useChat expects a mutable array @@ -196,9 +191,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { schema: currentSchema, table: currentTable?.name, chatName: currentChat, - includeSchemaMetadata: !useBedrockAssistant - ? !IS_PLATFORM || aiOptInLevel !== 'disabled' - : undefined, orgSlug: selectedOrganization?.slug, }) }, @@ -392,7 +384,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { { : 'Limited metadata is shared to the Assistant' } description={ - newOrgAiOptIn && !updatedOptInSinceMCP + !updatedOptInSinceMCP ? 'You may now opt-in to share schema metadata and even logs for better results' : isHipaaProjectDisallowed ? 'Your organization has the HIPAA addon and will not send project metadata with your prompts for projects marked as HIPAA.' diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx index 2c5df30d838..1b09490417d 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx @@ -4,7 +4,6 @@ import { useEffect } from 'react' import { AIOptInLevelSelector } from 'components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector' import { useAIOptInForm } from 'hooks/forms/useAIOptInForm' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useFlag } from 'hooks/ui/useFlag' import { Button, cn, @@ -24,7 +23,6 @@ interface AIOptInModalProps { } export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => { - const newOrgAiOptIn = useFlag('newOrgAiOptIn') const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm(onCancel) const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') @@ -54,7 +52,7 @@ export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => { diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index ee70fab8c8f..0694fca9bdd 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -14,7 +14,6 @@ import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' @@ -57,7 +56,6 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { const snapV2 = useSqlEditorV2StateSnapshot() const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation() const { includeSchemaMetadata } = useOrgAiOptInLevel() - const useBedrockAssistant = useFlag('useBedrockAssistant') const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState() @@ -239,7 +237,6 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { setIsSaving(true) const { title: name } = await generateSqlTitle({ sql: currentValue, - useBedrockAssistant, }) const snippet = createSqlSnippetSkeletonV2({ id: uuidv4(), @@ -280,11 +277,7 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { language="pgsql" value={currentValue} onChange={handleChange} - aiEndpoint={ - useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/complete-v2` - : `${BASE_PATH}/api/ai/sql/complete` - } + aiEndpoint={`${BASE_PATH}/api/ai/sql/complete-v2`} aiMetadata={{ projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/data/ai/sql-cron-mutation.ts b/apps/studio/data/ai/sql-cron-mutation.ts index 8257a8a9993..057dfe826ab 100644 --- a/apps/studio/data/ai/sql-cron-mutation.ts +++ b/apps/studio/data/ai/sql-cron-mutation.ts @@ -9,13 +9,10 @@ export type SqlCronGenerateResponse = string export type SqlCronGenerateVariables = { prompt: string - useBedrockAssistant?: boolean } -export async function generateSqlCron({ prompt, useBedrockAssistant }: SqlCronGenerateVariables) { - const url = useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/cron-v2` - : `${BASE_PATH}/api/ai/sql/cron` +export async function generateSqlCron({ prompt }: SqlCronGenerateVariables) { + const url = `${BASE_PATH}/api/ai/sql/cron-v2` const headers = await constructHeaders({ 'Content-Type': 'application/json' }) const response = await fetchHandler(url, { diff --git a/apps/studio/data/ai/sql-title-mutation.ts b/apps/studio/data/ai/sql-title-mutation.ts index ba28a87eeb7..45147665d94 100644 --- a/apps/studio/data/ai/sql-title-mutation.ts +++ b/apps/studio/data/ai/sql-title-mutation.ts @@ -12,13 +12,10 @@ export type SqlTitleGenerateResponse = { export type SqlTitleGenerateVariables = { sql: string - useBedrockAssistant?: boolean } -export async function generateSqlTitle({ sql, useBedrockAssistant }: SqlTitleGenerateVariables) { - const url = useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/title-v2` - : `${BASE_PATH}/api/ai/sql/title` +export async function generateSqlTitle({ sql }: SqlTitleGenerateVariables) { + const url = `${BASE_PATH}/api/ai/sql/title-v2` const headers = await constructHeaders({ 'Content-Type': 'application/json' }) const response = await fetchHandler(url, { diff --git a/apps/studio/hooks/forms/useAIOptInForm.ts b/apps/studio/hooks/forms/useAIOptInForm.ts index a0c293e93ae..d81da03076f 100644 --- a/apps/studio/hooks/forms/useAIOptInForm.ts +++ b/apps/studio/hooks/forms/useAIOptInForm.ts @@ -12,7 +12,6 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { OPT_IN_TAGS } from 'lib/constants' import type { ResponseError } from 'types' @@ -34,18 +33,11 @@ export const useAIOptInForm = (onSuccessCallback?: () => void) => { const selectedOrganization = useSelectedOrganization() const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const useBedrockAssistant = useFlag('useBedrockAssistant') - const [_, setUpdatedOptInSinceMCP] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN, false ) - // [Joshen] This is to prevent users from changing their opt in levels until the migration - // to clean up the existing opt in tags are completed. Once toggled on, users can then change their - // opt in levels again and we can clean this feature flag up - const newOrgAiOptIn = useFlag('newOrgAiOptIn') - const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() const form = useForm({ @@ -65,73 +57,52 @@ export const useAIOptInForm = (onSuccessCallback?: () => void) => { } const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - if (!useBedrockAssistant) { - const updatedOptInTags = values.aiOptInLevel === 'schema' ? [OPT_IN_TAGS.AI_SQL] : [] + let updatedOptInTags = existingOptInTags.filter( + (tag: string) => + tag !== OPT_IN_TAGS.AI_SQL && + tag !== (OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') && + tag !== (OPT_IN_TAGS.AI_LOG ?? 'AI_LOG') + ) - updateOrganization( - { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - invalidateOrganizationsQuery(queryClient) - toast.success('Successfully updated AI opt-in settings') - setUpdatedOptInSinceMCP(true) - onSuccessCallback?.() // Call optional callback on success - }, - onError: (error: ResponseError) => { - toast.error(`Failed to update settings: ${error.message}`) - }, - } - ) - } else { - let updatedOptInTags = existingOptInTags.filter( - (tag: string) => - tag !== OPT_IN_TAGS.AI_SQL && - tag !== (OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') && - tag !== (OPT_IN_TAGS.AI_LOG ?? 'AI_LOG') - ) - - if ( - values.aiOptInLevel === 'schema' || - values.aiOptInLevel === 'schema_and_log' || - values.aiOptInLevel === 'schema_and_log_and_data' - ) { - updatedOptInTags.push(OPT_IN_TAGS.AI_SQL) - } - if ( - values.aiOptInLevel === 'schema_and_log' || - values.aiOptInLevel === 'schema_and_log_and_data' - ) { - updatedOptInTags.push(OPT_IN_TAGS.AI_LOG) - } - if (values.aiOptInLevel === 'schema_and_log_and_data') { - updatedOptInTags.push(OPT_IN_TAGS.AI_DATA) - } - - updatedOptInTags = [...new Set(updatedOptInTags)] - - updateOrganization( - { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - invalidateOrganizationsQuery(queryClient) - toast.success('Successfully updated AI opt-in settings') - setUpdatedOptInSinceMCP(true) - onSuccessCallback?.() // Call optional callback on success - }, - onError: (error: ResponseError) => { - toast.error(`Failed to update settings: ${error.message}`) - }, - } - ) + if ( + values.aiOptInLevel === 'schema' || + values.aiOptInLevel === 'schema_and_log' || + values.aiOptInLevel === 'schema_and_log_and_data' + ) { + updatedOptInTags.push(OPT_IN_TAGS.AI_SQL) } + if ( + values.aiOptInLevel === 'schema_and_log' || + values.aiOptInLevel === 'schema_and_log_and_data' + ) { + updatedOptInTags.push(OPT_IN_TAGS.AI_LOG) + } + if (values.aiOptInLevel === 'schema_and_log_and_data') { + updatedOptInTags.push(OPT_IN_TAGS.AI_DATA) + } + + updatedOptInTags = [...new Set(updatedOptInTags)] + + updateOrganization( + { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + invalidateOrganizationsQuery(queryClient) + toast.success('Successfully updated AI opt-in settings') + setUpdatedOptInSinceMCP(true) + onSuccessCallback?.() // Call optional callback on success + }, + onError: (error: ResponseError) => { + toast.error(`Failed to update settings: ${error.message}`) + }, + } + ) } return { form, onSubmit, isUpdating, - currentOptInLevel: !newOrgAiOptIn - ? 'disabled' - : getAiOptInLevel(selectedOrganization?.opt_in_tags), + currentOptInLevel: getAiOptInLevel(selectedOrganization?.opt_in_tags), } } diff --git a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts index eca1a2d618b..d60a69b4d8b 100644 --- a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts +++ b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts @@ -5,7 +5,6 @@ import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' export const aiOptInLevelSchema = z.enum([ @@ -54,12 +53,11 @@ export function useOrgAiOptInLevel(): { } { const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() - const newOrgAiOptIn = useFlag('newOrgAiOptIn') // [Joshen] Default to disabled until migration to clean up existing opt in tags are completed // Once toggled on, then we can default to their set opt in level and clean up feature flag const optInTags = selectedOrganization?.opt_in_tags - const level = !newOrgAiOptIn ? 'disabled' : getAiOptInLevel(optInTags) + const level = getAiOptInLevel(optInTags) const isOptedIntoAI = level !== 'disabled' const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) diff --git a/apps/studio/lib/ai/model.test.ts b/apps/studio/lib/ai/model.test.ts index 3c76e678193..0f4f75eca3d 100644 --- a/apps/studio/lib/ai/model.test.ts +++ b/apps/studio/lib/ai/model.test.ts @@ -18,6 +18,7 @@ describe('getModel', () => { beforeEach(() => { vi.resetAllMocks() + vi.stubEnv('AWS_BEDROCK_PROFILE', 'test') }) afterEach(() => { diff --git a/apps/studio/lib/ai/model.ts b/apps/studio/lib/ai/model.ts index b6afbf2d141..57698f2aaeb 100644 --- a/apps/studio/lib/ai/model.ts +++ b/apps/studio/lib/ai/model.ts @@ -32,9 +32,11 @@ export const ModelErrorMessage = */ export async function getModel(routingKey?: string, isLimited?: boolean): Promise { const hasAwsCredentials = await checkAwsCredentials() + + const hasAwsBedrockprofile = !!process.env.AWS_BEDROCK_PROFILE const hasOpenAIKey = !!process.env.OPENAI_API_KEY - if (hasAwsCredentials) { + if (hasAwsBedrockprofile && hasAwsCredentials) { const bedrockModel = IS_THROTTLED || isLimited ? BEDROCK_NORMAL_MODEL : BEDROCK_PRO_MODEL const bedrock = createRoutedBedrock(routingKey) diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index 34d405e60ed..912c8ccb14a 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -7,19 +7,11 @@ export const config = { // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ - // These are using OpenAI, can be removed once Bedrock is default - '/ai/sql/generate-v3', - '/ai/sql/complete', - '/ai/sql/cron', - '/ai/sql/title', - // These are using Bedrock '/ai/sql/generate-v4', '/ai/sql/complete-v2', '/ai/sql/cron-v2', '/ai/sql/title-v2', '/ai/edge-function/complete-v2', - // Others - '/ai/edge-function/complete', '/ai/onboarding/design', '/ai/feedback/classify', '/get-ip-address', diff --git a/apps/studio/pages/api/ai/edge-function/complete.ts b/apps/studio/pages/api/ai/edge-function/complete.ts deleted file mode 100644 index 4d6096da5f9..00000000000 --- a/apps/studio/pages/api/ai/edge-function/complete.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { openai } from '@ai-sdk/openai' -import pgMeta from '@supabase/pg-meta' -import { streamText } from 'ai' -import { IS_PLATFORM } from 'common' -import { executeSql } from 'data/sql/execute-sql-query' -import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' -import { NextApiRequest, NextApiResponse } from 'next' -import { getTools } from '../sql/tools' - -export const maxDuration = 30 -const openAiKey = process.env.OPENAI_API_KEY -const pgMetaSchemasList = pgMeta.schemas.list() - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return new Response( - JSON.stringify({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - return new Response( - JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, - } - ) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper - -async function handlePost(req: NextApiRequest, res: NextApiResponse) { - try { - const { completionMetadata, projectRef, connectionString, includeSchemaMetadata } = req.body - const { textBeforeCursor, textAfterCursor, language, prompt, selection } = completionMetadata - - if (!projectRef) { - return res.status(400).json({ - error: 'Missing project_ref in request body', - }) - } - - const authorization = req.headers.authorization - - const { result: schemas } = includeSchemaMetadata - ? await executeSql( - { - projectRef, - connectionString, - sql: pgMetaSchemasList.sql, - }, - undefined, - { - 'Content-Type': 'application/json', - ...(authorization && { Authorization: authorization }), - }, - IS_PLATFORM ? undefined : queryPgMetaSelfHosted - ) - : { result: [] } - - const result = await streamText({ - model: openai('gpt-4o-mini-2024-07-18'), - maxSteps: 5, - tools: getTools({ projectRef, connectionString, authorization, includeSchemaMetadata }), - system: ` - # Writing Supabase Edge Functions - - You're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices: - - ## Guidelines - - 1. Try to use Web APIs and Deno's core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws) - 2. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either \`npm:\` or \`jsr:\`. For example, \`@supabase/supabase-js\` should be written as \`npm:@supabase/supabase-js\`. - 3. For external imports, always define a version. For example, \`npm:@express\` should be written as \`npm:express@4.18.2\`. - 4. For external dependencies, importing via \`npm:\` and \`jsr:\` is preferred. Minimize the use of imports from @\`deno.land/x\` , \`esm.sh\` and @\`unpkg.com\` . If you have a package from one of those CDNs, you can replace the CDN hostname with \`npm:\` specifier. - 5. You can also use Node built-in APIs. You will need to import them using \`node:\` specifier. For example, to import Node process: \`import process from "node:process"\`. Use Node APIs when you find gaps in Deno APIs. - 6. Do NOT use \`import { serve } from "https://deno.land/std@0.168.0/http/server.ts"\`. Instead use the built-in \`Deno.serve\`. - 7. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them: - * SUPABASE_URL - * SUPABASE_ANON_KEY - * SUPABASE_SERVICE_ROLE_KEY - * SUPABASE_DB_URL - 8. To set other environment variables the user can go to project settings then edge functions to set them - 9. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with \`/function-name\` so they are routed correctly. - 10. File write operations are ONLY permitted on \`/tmp\` directory. You can use either Deno or Node File APIs. - 11. Use \`EdgeRuntime.waitUntil(promise)\` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context. - - ## Example Templates - - ### Simple Hello World Function - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - interface reqPayload { - name: string; - } - - console.info('server started'); - - Deno.serve(async (req: Request) => { - const { name }: reqPayload = await req.json(); - const data = { - message: \`Hello \${name} from foo!\`, - }; - - return new Response( - JSON.stringify(data), - { headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' }} - ); - }); - \`\`\` - - ### Example Function using Node built-in API - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - import { randomBytes } from "node:crypto"; - import { createServer } from "node:http"; - import process from "node:process"; - - const generateRandomString = (length) => { - const buffer = randomBytes(length); - return buffer.toString('hex'); - }; - - const randomString = generateRandomString(10); - console.log(randomString); - - const server = createServer((req, res) => { - const message = \`Hello\`; - res.end(message); - }); - - server.listen(9999); - \`\`\` - - ### Using npm packages in Functions - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - import express from "npm:express@4.18.2"; - - const app = express(); - - app.get(/(.*)/, (req, res) => { - res.send("Welcome to Supabase"); - }); - - app.listen(8000); - \`\`\` - - ### Generate embeddings using built-in @Supabase.ai API - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - const model = new Supabase.ai.Session('gte-small'); - - Deno.serve(async (req: Request) => { - const params = new URL(req.url).searchParams; - const input = params.get('text'); - const output = await model.run(input, { mean_pool: true, normalize: true }); - return new Response( - JSON.stringify(output), - { - headers: { - 'Content-Type': 'application/json', - 'Connection': 'keep-alive', - }, - }, - ); - }); - \`\`\` - - ## Integrating with Supabase Auth - - \`\`\`edge - // Setup type definitions for built-in Supabase Runtime APIs - import "jsr:@supabase/functions-js/edge-runtime.d.ts"; - import { createClient } from \\'jsr:@supabase/supabase-js@2\\' - import { corsHeaders } from \\'../_shared/cors.ts\\' - - console.log(\`Function "select-from-table-with-auth-rls" up and running!\`) - - Deno.serve(async (req: Request) => { - // This is needed if you\\'re planning to invoke your function from a browser. - if (req.method === \\'OPTIONS\\') { - return new Response(\\'ok\\', { headers: corsHeaders }) - } - - try { - // Create a Supabase client with the Auth context of the logged in user. - const supabaseClient = createClient( - // Supabase API URL - env var exported by default. - Deno.env.get('SUPABASE_URL')!, - // Supabase API ANON KEY - env var exported by default. - Deno.env.get('SUPABASE_ANON_KEY')!, - // Create client with Auth context of the user that called the function. - // This way your row-level-security (RLS) policies are applied. - { - global: { - headers: { Authorization: req.headers.get(\\'Authorization\\')! }, - }, - } - ) - - // First get the token from the Authorization header - const token = req.headers.get(\\'Authorization\\').replace(\\'Bearer \\', \\'\\') - - // Now we can get the session or user object - const { - data: { user }, - } = await supabaseClient.auth.getUser(token) - - // And we can run queries in the context of our authenticated user - const { data, error } = await supabaseClient.from(\\'users\\').select(\\'*\\') - if (error) throw error - - return new Response(JSON.stringify({ user, data }), { - headers: { ...corsHeaders, \\'Content-Type\\': \\'application/json\\' }, - status: 200, - }) - } catch (error) { - return new Response(JSON.stringify({ error: error.message }), { - headers: { ...corsHeaders, \\'Content-Type\\': \\'application/json\\' }, - status: 400, - }) - } - }) - - // To invoke: - // curl -i --location --request POST \\'http://localhost:54321/functions/v1/select-from-table-with-auth-rls\\' \\ - // --header \\'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs\\' \\ - // --header \\'Content-Type: application/json\\' \\ - // --data \\'{"name":"Functions"}\\' - \`\`\` - - Database Integration: - - Use the getSchema tool to understand the database structure when needed - - Reference existing tables and schemas to ensure edge functions work with the user's data model - - Use proper types that match the database schema - - When accessing the database: - - Use RLS policies appropriately for security - - Handle database errors gracefully - - Use efficient queries and proper indexing - - Consider rate limiting for resource-intensive operations - - Use connection pooling when appropriate - - Implement proper error handling for database operations - - # For all your abilities, follow these instructions: - - First look at the list of provided schemas and if needed, get more information about a schema to understand the data model you're working with - - If the edge function needs to interact with user data, check both the public and auth schemas to understand the authentication setup - - Here are the existing database schema names you can retrieve: ${schemas} - `, - messages: [ - { - role: 'user', - content: `You are helping me write TypeScript/JavaScript code for an edge function. - Here is the context: - ${textBeforeCursor}${selection}${textAfterCursor} - - Instructions: - 1. Only modify the selected text based on this prompt: ${prompt} - 2. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed. - 3. Do not wrap in code blocks or markdown - 4. You can respond with one word or multiple words - 5. Ensure the modified text flows naturally within the current line - 6. Avoid duplicating variable declarations, imports, or function definitions when considering the full code - 7. If there is no surrounding context (before or after), make sure your response is a complete valid Deno Edge Function including imports. - - Modify the selected text now:`, - }, - ], - }) - - return result.pipeDataStreamToResponse(res) - } catch (error) { - console.error('Completion error:', error) - return res.status(500).json({ - error: 'Failed to generate completion', - }) - } -} diff --git a/apps/studio/pages/api/ai/sql/complete.ts b/apps/studio/pages/api/ai/sql/complete.ts deleted file mode 100644 index 569831095e5..00000000000 --- a/apps/studio/pages/api/ai/sql/complete.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { openai } from '@ai-sdk/openai' -import pgMeta from '@supabase/pg-meta' -import { streamText } from 'ai' -import { IS_PLATFORM } from 'common' -import { executeSql } from 'data/sql/execute-sql-query' -import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' -import { NextApiRequest, NextApiResponse } from 'next' -import { getTools } from '../sql/tools' - -export const maxDuration = 30 -const openAiKey = process.env.OPENAI_API_KEY -const pgMetaSchemasList = pgMeta.schemas.list() - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return new Response( - JSON.stringify({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } - - if (req.method !== 'POST') { - return new Response( - JSON.stringify({ data: null, error: { message: `Method ${req.method} Not Allowed` } }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, - } - ) - } - - try { - const { completionMetadata, projectRef, connectionString, includeSchemaMetadata } = req.body - const { textBeforeCursor, textAfterCursor, language, prompt, selection } = completionMetadata - - if (!projectRef) { - return res.status(400).json({ - error: 'Missing project_ref in request body', - }) - } - - const authorization = req.headers.authorization - - const { result: schemas } = includeSchemaMetadata - ? await executeSql( - { - projectRef, - connectionString, - sql: pgMetaSchemasList.sql, - }, - undefined, - { - 'Content-Type': 'application/json', - ...(authorization && { Authorization: authorization }), - }, - IS_PLATFORM ? undefined : queryPgMetaSelfHosted - ) - : { result: [] } - - const result = await streamText({ - model: openai('gpt-4o-mini-2024-07-18'), - maxSteps: 5, - tools: getTools({ projectRef, connectionString, authorization, includeSchemaMetadata }), - system: ` - You are a Supabase Postgres expert who can do the following things. - - # You generate and debug SQL - The generated SQL (must be valid SQL), and must adhere to the following: - - Always retrieve public schema information first - - Always use double apostrophe in SQL strings (eg. 'Night''s watch') - - Always use semicolons - - Use vector(384) data type for any embedding/vector related query - - When debugging, retrieve sql schema details to ensure sql is correct - - In Supabase, the auth schema already has a users table which is used to store users. It is common practice to create a profiles table in the public schema that links to auth.users to store user information instead. You don't need to create a new users table. - - Never suggest creating a view to retrieve information from the users table of the auth schema. This is against our best practices. - - When generating tables, do the following: - - Ensure that all tables always have a primary key - - Ensure that all tables have RLS enabled. Inform the user that they will need to create RLS policies before being able to read or write to the table over Supabase APIs. - - For primary keys, always use "id bigint primary key generated always as identity" (not serial) - - Prefer creating foreign key references in the create statement - - Prefer 'text' over 'varchar' - - Prefer 'timestamp with time zone' over 'date' - - If a foreign key is created, include a statement to create a database index on the columns involved in the foreign key. This is a best practice as it is common to use foreign key columns in join conditions when querying the database, hence the index will improve the performance of those joins and reduce database resource consumption. - - If the table is a foreign table, create it in a new schema called private. If the private schema doesnt exist, create it. - - Explain to the user that we create it outside of the public schema as it can present a security risk as foreign tables views cannot be configured to respect RLS policies. Provide this link for users to view more details about the security risk: https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0017_foreign_table_in_api - - When generating views, do the following: - - All views should include 'with (security_invoker=on)' clause in the SQL statement for creating views. - - Place the 'with (security_invoker=on)' immediately after the CREATE VIEW statement, before AS - - If the view is a materialized view, create it in a new schema called private. If the private schema doesnt exist, create it. - - Explain to the user that we create it outside of the public schema as it can present a security risk as materialized views cannot be configured to respect RLS policies of the underlying tables they are built upon, nor can they be secured with RLS directly. Provide this link for users to view more details about the security risk: https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0016_materialized_view_in_api - - Feel free to suggest corrections for suspected typos. - - # You write row level security policies. - - Your purpose is to generate a policy with the constraints given by the user using the getRlsKnowledge tool. - - First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema. - - Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool . - - Then write new policies or update existing policies based on the prompt - - When asked to suggest policies, either alter existing policies or add new ones to the public schema. - - When writing policies that use a function from the auth schema, ensure that the calls are wrapped with parentheses e.g select auth.uid() should be written as (select auth.uid()) instead - - # You write database functions - Your purpose is to generate a database function with the constraints given by the user. The output may also include a database trigger - if the function returns a type of trigger. When generating functions, do the following: - - If the function returns a trigger type, ensure that it uses security definer, otherwise default to security invoker. Include this in the create functions SQL statement. - - Ensure to set the search_path configuration parameter as '', include this in the create functions SQL statement. - - Default to create or replace whenever possible for updating an existing function, otherwise use the alter function statement - Please make sure that all queries are valid Postgres SQL queries - - # For all your abilities, follow these instructions: - - First look at the list of provided schemas and if needed, get more information about a schema. You will almost always need to retrieve information about the public schema before answering a question. - - If the question is about users or involves creating a users table, also retrieve the auth schema. - - Here are the existing database schema names you can retrieve: ${schemas} - `, - messages: [ - { - role: 'user', - content: `You are helping me edit some pgsql code. - Here is the context: - ${textBeforeCursor}${selection}${textAfterCursor} - - Instructions: - 1. Only modify the selected text based on this prompt: ${prompt} - 2. Get schema tables information using the getSchemaTables tool - 3. Get existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool - 4. Write new policies or update existing policies based on the prompt - 5. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed. - 6. Do not wrap in code blocks or markdown - 7. You can respond with one word or multiple words - 8. Ensure the modified text flows naturally within the current line - 6. Avoid duplicating SQL keywords (SELECT, FROM, WHERE, etc) when considering the full statement - 7. If there is no surrounding context (before or after), make sure your response is a complete valid SQL statement that can be run and resolves the prompt. - - Modify the selected text now:`, - }, - ], - }) - - return result.pipeDataStreamToResponse(res) - } catch (error) { - console.error('Completion error:', error) - return res.status(500).json({ - error: 'Failed to generate completion', - }) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper diff --git a/apps/studio/pages/api/ai/sql/cron.ts b/apps/studio/pages/api/ai/sql/cron.ts deleted file mode 100644 index 9e67862507d..00000000000 --- a/apps/studio/pages/api/ai/sql/cron.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ContextLengthError } from 'ai-commands' -import { generateCron } from 'ai-commands/edge' -import apiWrapper from 'lib/api/apiWrapper' -import { NextApiRequest, NextApiResponse } from 'next' -import OpenAI from 'openai' - -const openAiKey = process.env.OPENAI_API_KEY -const openai = new OpenAI({ apiKey: openAiKey }) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return res.status(500).json({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) - } -} - -export async function handlePost(req: NextApiRequest, res: NextApiResponse) { - const { - body: { prompt }, - } = req - - try { - const result = await generateCron(openai, prompt) - - return res.json(result) - } catch (error) { - if (error instanceof Error) { - console.error(`AI cron generation failed: ${error.message}`) - - if (error instanceof ContextLengthError) { - return res.status(400).json({ - error: - 'Your cron prompt is too large for Supabase AI to ingest. Try splitting it into smaller prompts.', - }) - } - } else { - console.error(`Unknown error: ${error}`) - } - - return res.status(500).json({ - error: 'There was an unknown error generating the cron syntax. Please try again.', - }) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper diff --git a/apps/studio/pages/api/ai/sql/generate-v3.ts b/apps/studio/pages/api/ai/sql/generate-v3.ts deleted file mode 100644 index 81b1302fb0a..00000000000 --- a/apps/studio/pages/api/ai/sql/generate-v3.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { openai } from '@ai-sdk/openai' -import pgMeta from '@supabase/pg-meta' -import { streamText } from 'ai' -import { NextApiRequest, NextApiResponse } from 'next' - -import { IS_PLATFORM } from 'common' -import { executeSql } from 'data/sql/execute-sql-query' -import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' -import { getTools } from './tools' - -export const maxDuration = 30 -const openAiKey = process.env.OPENAI_API_KEY -const pgMetaSchemasList = pgMeta.schemas.list() - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return res.status(500).json({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper - -async function handlePost(req: NextApiRequest, res: NextApiResponse) { - const { messages, projectRef, connectionString, includeSchemaMetadata, schema, table } = - typeof req.body === 'string' ? JSON.parse(req.body) : req.body - - if (!projectRef) { - return res.status(400).json({ - error: 'Missing project_ref in query parameters', - }) - } - - const cookie = req.headers.cookie - const authorization = req.headers.authorization - - try { - const { result: schemas } = includeSchemaMetadata - ? await executeSql( - { - projectRef, - connectionString, - sql: pgMetaSchemasList.sql, - }, - undefined, - { - 'Content-Type': 'application/json', - ...(cookie && { cookie }), - ...(authorization && { Authorization: authorization }), - }, - IS_PLATFORM ? undefined : queryPgMetaSelfHosted - ) - : { result: [] } - - const result = await streamText({ - model: openai('gpt-4o-mini'), - maxSteps: 5, - system: ` - You are a Supabase Postgres expert who can do the following things. - - # You generate and debug SQL - The generated SQL (must be valid SQL), and must adhere to the following: - - Always use double apostrophe in SQL strings (eg. 'Night''s watch') - - Always use semicolons - - Output as markdown - - Always include code snippets if available - - If a code snippet is SQL, the first line of the snippet should always be -- props: {"id": "id", "title": "Query title", "runQuery": "false", "isChart": "true", "xAxis": "columnOrAlias", "yAxis": "columnOrAlias"} - - Only include one line of comment props per markdown snippet, even if the snippet has multiple queries - - Only set chart to true if the query makes sense as a chart. xAxis and yAxis need to be columns or aliases returned by the query. - - Set the id to a random uuidv4 value - - Only set runQuery to true if the query has no risk of writing data and is not a debugging request. Set it to false if there are any values that need to be replaced with real data. - - Explain what the snippet does in a sentence or two before showing it - - Use vector(384) data type for any embedding/vector related query - - When debugging, retrieve sql schema details to ensure sql is correct - - In Supabase, the auth schema already has a users table which is used to store users. It is common practice to create a profiles table in the public schema that links to auth.users to store user information instead. You don't need to create a new users table. - - Never suggest creating a view to retrieve information from the users table of the auth schema. This is against our best practices. - - When generating tables, do the following: - - Ensure that all tables always have a primary key - - Ensure that all tables have RLS enabled. Inform the user that they will need to create RLS policies before being able to read or write to the table over Supabase APIs. - - For primary keys, always use "id bigint primary key generated always as identity" (not serial) - - Prefer creating foreign key references in the create statement - - Prefer 'text' over 'varchar' - - Prefer 'timestamp with time zone' over 'date' - - If a foreign key is created, include a statement to create a database index on the columns involved in the foreign key. This is a best practice as it is common to use foreign key columns in join conditions when querying the database, hence the index will improve the performance of those joins and reduce database resource consumption. - - If the table is a foreign table, create it in a new schema called private. If the private schema doesnt exist, create it. - - Explain to the user that we create it outside of the public schema as it can present a security risk as foreign tables views cannot be configured to respect RLS policies. Provide this link for users to view more details about the security risk: https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0017_foreign_table_in_api - - When generating views, do the following: - - All views should include 'with (security_invoker=on)' clause in the SQL statement for creating views (only views though - do not do this for tables) - - Place the 'with (security_invoker=on)' immediately after the CREATE VIEW statement, before AS - - If the view is a materialized view, create it in a new schema called private. If the private schema doesnt exist, create it. - - Explain to the user that we create it outside of the public schema as it can present a security risk as materialized views cannot be configured to respect RLS policies of the underlying tables they are built upon, nor can they be secured with RLS directly. Provide this link for users to view more details about the security risk: https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0016_materialized_view_in_api - - When installing database extensions, do the following: - - Never install extensions in the public schema - - Extensions should be installed in the extensions schema, or a dedicated schema - - Feel free to suggest corrections for suspected typos. - - # You write row level security policies. - - Your purpose is to generate a policy with the constraints given by the user. - - First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema. - - Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool . - - Then write new policies or update existing policies based on the prompt - - When asked to suggest policies, either alter existing policies or add new ones to the public schema. - - When writing policies that use a function from the auth schema, ensure that the calls are wrapped with parentheses e.g select auth.uid() should be written as (select auth.uid()) instead - - # You write database functions - Your purpose is to generate a database function with the constraints given by the user. The output may also include a database trigger - if the function returns a type of trigger. When generating functions, do the following: - - If the function returns a trigger type, ensure that it uses security definer, otherwise default to security invoker. Include this in the create functions SQL statement. - - Ensure to set the search_path configuration parameter as '', include this in the create functions SQL statement. - - Default to create or replace whenever possible for updating an existing function, otherwise use the alter function statement - Please make sure that all queries are valid Postgres SQL queries - - # You write edge functions - Your purpose is to generate entire edge functions with the constraints given by the user. - - First, always use the getEdgeFunctionKnowledge tool to get knowledge about how to write edge functions for Supabase - - When writing edge functions, always ensure that they are written in TypeScript and Deno JavaScript runtime. - - When writing edge functions, write complete code so the user doesn't need to replace any placeholders. - - When writing edge functions, always ensure that they are written in a way that is compatible with the database schema. - - When suggesting edge functions, follow the guidelines in getEdgeFunctionKnowledge tool. Always create personalised edge functions based on the database schema - - When outputting edge functions, always include a props comment in the first line of the code block: - -- props: {"name": "function-name", "title": "Human readable title"} - - The function name in the props must be URL-friendly (use hyphens instead of spaces or underscores) - - Always wrap the edge function code in a markdown code block with the language set to 'edge' - - The props comment must be the first line inside the code block, followed by the actual function code - - # You convert sql to supabase-js client code - Use the convertSqlToSupabaseJs tool to convert select sql to supabase-js client code. Only provide js code snippets if explicitly asked. If conversion isn't supported, build a postgres function instead and suggest using supabase-js to call it via "const { data, error } = await supabase.rpc('echo', { say: '👋'})" - - # For all your abilities, follow these instructions: - - First look at the list of provided schemas and if needed, get more information about a schema. You will almost always need to retrieve information about the public schema before answering a question. - - If the question is about users or involves creating a users table, also retrieve the auth schema. - - If it a query is a destructive query e.g. table drop, ask for confirmation before writing the query. The user will still have to run the query once you create it - - - Here are the existing database schema names you can retrieve: ${schemas} - - ${schema !== undefined && includeSchemaMetadata ? `The user is currently looking at the ${schema} schema.` : ''} - ${table !== undefined && includeSchemaMetadata ? `The user is currently looking at the ${table} table.` : ''} - `, - messages, - tools: getTools({ - projectRef, - connectionString, - cookie, - authorization, - includeSchemaMetadata, - }), - }) - - // write the data stream to the response - // Note: this is sent as a single response, not a stream - result.pipeDataStreamToResponse(res) - } catch (error: any) { - return res.status(500).json({ message: error.message }) - } -} diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index b53d8d4749d..30dabd313cf 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -8,12 +8,12 @@ import { IS_PLATFORM } from 'common' import { getOrganizations } from 'data/organizations/organizations-query' import { getProjects } from 'data/projects/projects-query' import { executeSql } from 'data/sql/execute-sql-query' -import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' +import { AiOptInLevel, getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { getModel } from 'lib/ai/model' -import apiWrapper from 'lib/api/apiWrapper' -import { queryPgMetaSelfHosted } from 'lib/self-hosted' import { createSupabaseMCPClient } from 'lib/ai/supabase-mcp' import { filterToolsByOptInLevel, toolSetValidationSchema } from 'lib/ai/tool-filter' +import apiWrapper from 'lib/api/apiWrapper' +import { queryPgMetaSelfHosted } from 'lib/self-hosted' import { getTools } from './tools' export const maxDuration = 120 @@ -78,35 +78,40 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { return msg }) - // Get organizations and compute opt in level server-side - const [organizations, projects] = await Promise.all([ - getOrganizations({ - headers: { - 'Content-Type': 'application/json', - ...(authorization && { Authorization: authorization }), - }, - }), - getProjects({ - headers: { - 'Content-Type': 'application/json', - ...(authorization && { Authorization: authorization }), - }, - }), - ]) + let aiOptInLevel: AiOptInLevel = 'schema' + let isLimited = false - const selectedOrg = organizations.find((org) => org.slug === orgSlug) - const selectedProject = projects.find( - (project) => project.ref === projectRef || project.preview_branch_refs.includes(projectRef) - ) + if (IS_PLATFORM) { + // Get organizations and compute opt in level server-side + const [organizations, projects] = await Promise.all([ + getOrganizations({ + headers: { + 'Content-Type': 'application/json', + ...(authorization && { Authorization: authorization }), + }, + }), + getProjects({ + headers: { + 'Content-Type': 'application/json', + ...(authorization && { Authorization: authorization }), + }, + }), + ]) - // If the project is not in the organization specific by the org slug, return an error - if (selectedProject?.organization_slug !== selectedOrg?.slug) { - return res.status(400).json({ error: 'Project and organization do not match' }) + const selectedOrg = organizations.find((org) => org.slug === orgSlug) + const selectedProject = projects.find( + (project) => project.ref === projectRef || project.preview_branch_refs.includes(projectRef) + ) + + // If the project is not in the organization specific by the org slug, return an error + if (selectedProject?.organization_slug !== selectedOrg?.slug) { + return res.status(400).json({ error: 'Project and organization do not match' }) + } + + aiOptInLevel = getAiOptInLevel(selectedOrg?.opt_in_tags) + isLimited = selectedOrg?.plan.id === 'free' } - const aiOptInLevel = getAiOptInLevel(selectedOrg?.opt_in_tags) - const isLimited = selectedOrg?.plan.id === 'free' - const { model, error: modelError } = await getModel(projectRef, isLimited) // use project ref as routing key if (modelError) { diff --git a/apps/studio/pages/api/ai/sql/title.ts b/apps/studio/pages/api/ai/sql/title.ts deleted file mode 100644 index 83d6f4e20d7..00000000000 --- a/apps/studio/pages/api/ai/sql/title.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ContextLengthError, titleSql } from 'ai-commands' -import apiWrapper from 'lib/api/apiWrapper' -import { NextApiRequest, NextApiResponse } from 'next' -import { OpenAI } from 'openai' - -const openAiKey = process.env.OPENAI_API_KEY -const openai = new OpenAI({ apiKey: openAiKey }) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return res.status(500).json({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) - } -} - -export async function handlePost(req: NextApiRequest, res: NextApiResponse) { - const { - body: { sql }, - } = req - - try { - const result = await titleSql(openai, sql) - return res.json(result) - } catch (error) { - if (error instanceof Error) { - console.error(`AI title generation failed: ${error.message}`) - - if (error instanceof ContextLengthError) { - return res.status(400).json({ - error: - 'Your SQL query is too large for Supabase AI to ingest. Try splitting it into smaller queries.', - }) - } - } else { - console.log(`Unknown error: ${error}`) - } - - return res.status(500).json({ - error: 'There was an unknown error generating the snippet title. Please try again.', - }) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => - apiWrapper(req, res, handler, { withAuth: true }) - -export default wrapper diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx index 2daf2f50323..cd3a64bb5a7 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx @@ -18,7 +18,6 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { LogoLoader } from 'ui' @@ -26,7 +25,6 @@ const CodePage = () => { const { ref, functionSlug } = useParams() const project = useSelectedProject() const { includeSchemaMetadata } = useOrgAiOptInLevel() - const useBedrockAssistant = useFlag('useBedrockAssistant') const { mutate: sendEvent } = useSendEventMutation() const org = useSelectedOrganization() @@ -221,11 +219,7 @@ const CodePage = () => { { const { mutate: sendEvent } = useSendEventMutation() const org = useSelectedOrganization() - const useBedrockAssistant = useFlag('useBedrockAssistant') - const [files, setFiles] = useState< { id: number; name: string; content: string; selected?: boolean }[] >([ @@ -338,11 +335,7 @@ const NewFunctionPage = () => { { test('from() should create a QueryAction with the correct table', () => { From 31b8fbe487eb9228d046942a0615edd53bdd732c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Jul 2025 16:22:03 +0800 Subject: [PATCH 12/38] Deprecate getAPIKeys from project settings v2, use new api keys endpoint instead (#37300) * Deprecate getAPIKeys from project settings v2, use new api keys endpoint instead * use getPreferredKeys where appropriate * Prevent usage of secret key for storage and realtime inspector * Add dashboard API api-keys endpoint * Simplify * Disable edge functions test if legacy api keys are disabled * Revert * Fix graphiql * Remove all usage of api keys from project settings, except DisplayApiSettings * Update * Fix * Small fix for an undefined upload url. * Fix the storage state initialization when the resumable url changes. --------- Co-authored-by: Ivan Vasilov --- .../interfaces/App/CommandMenu/ApiKeys.tsx | 52 +++++++++++++---- .../components/interfaces/Connect/Connect.tsx | 21 ++++--- .../Database/Hooks/FormContents.tsx | 4 +- .../Database/Hooks/HTTPRequestFields.tsx | 58 +++++++++++-------- .../interfaces/Docs/Authentication.tsx | 6 +- .../EdgeFunctionDetails.constants.ts | 2 +- .../EdgeFunctionDetails.tsx | 8 ++- .../EdgeFunctionTesterSheet.tsx | 6 +- .../Functions/TerminalInstructions.tsx | 15 ++--- .../Home/NewProjectPanel/APIKeys.tsx | 31 ++++------ .../CronJobs/HttpHeaderFieldsSection.tsx | 18 +++--- .../Integrations/GraphQL/GraphiQLTab.tsx | 8 +-- .../ProjectAPIDocs/Content/Introduction.tsx | 9 +-- .../ProjectAPIDocs/ProjectAPIDocs.tsx | 9 ++- .../Inspector/RealtimeTokensPopover/index.tsx | 35 ++++++++--- .../RoleImpersonationPopover.tsx | 19 ++++-- .../EdgeFunctionDetailsLayout.tsx | 2 +- .../SimpleConfigurationDetails.tsx | 9 ++- .../ui/ProjectSettings/DisplayApiSettings.tsx | 3 +- apps/studio/data/api-keys/api-keys-query.ts | 26 +++++++-- apps/studio/data/api-keys/keys.ts | 3 +- .../data/config/project-settings-v2-query.ts | 10 ---- .../iceberg-wrapper-create-mutation.ts | 9 ++- .../pages/api/v1/projects/[ref]/api-keys.ts | 50 ++++++++++++++++ apps/studio/state/storage-explorer.tsx | 37 ++++++++++-- 25 files changed, 314 insertions(+), 136 deletions(-) create mode 100644 apps/studio/pages/api/v1/projects/[ref]/api-keys.ts diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx index 7010e1a8c82..10c0e5df990 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx @@ -1,8 +1,8 @@ import { Key } from 'lucide-react' import { useMemo } from 'react' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Badge, copyToClipboard } from 'ui' import type { ICommand } from 'ui-patterns/CommandMenu' import { @@ -21,14 +21,11 @@ export function useApiKeysCommands() { const setIsOpen = useSetCommandMenuOpen() const setPage = useSetPage() - const project = useSelectedProject() + const { data: project } = useSelectedProjectQuery() const ref = project?.ref || '_' - const { data: settings } = useProjectSettingsV2Query( - { projectRef: project?.ref }, - { enabled: !!project } - ) - const { anonKey, serviceKey } = getAPIKeys(settings) + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref, reveal: true }) + const { anonKey, serviceKey, publishableKey, allSecretKeys } = getKeys(apiKeys) const commands = useMemo( () => @@ -42,9 +39,10 @@ export function useApiKeysCommands() { setIsOpen(false) }, badge: () => ( - + Project: {project?.name} Public + {anonKey.type} ), icon: () => , @@ -58,13 +56,47 @@ export function useApiKeysCommands() { setIsOpen(false) }, badge: () => ( - + Project: {project?.name} Secret + {serviceKey.type} ), icon: () => , }, + project && + publishableKey && { + id: 'publishable-key', + name: `Copy publishable key`, + action: () => { + copyToClipboard(publishableKey.api_key ?? '') + setIsOpen(false) + }, + badge: () => ( + + Project: {project?.name} + {publishableKey.type} + + ), + icon: () => , + }, + ...(project && allSecretKeys + ? allSecretKeys.map((key) => ({ + id: key.id, + name: `Copy secret key (${key.name})`, + action: () => { + copyToClipboard(key.api_key ?? '') + setIsOpen(false) + }, + badge: () => ( + + Project: {project?.name} + {key.type} + + ), + icon: () => , + })) + : []), !(anonKey || serviceKey) && { id: 'api-keys-project-settings', name: 'See API keys in Project Settings', diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx index bc1bfbeb6ac..3a68ee63758 100644 --- a/apps/studio/components/interfaces/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Connect/Connect.tsx @@ -7,8 +7,8 @@ import { useMemo, useState } from 'react' import { DatabaseConnectionString } from 'components/interfaces/Connect/DatabaseConnectionString' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import Panel from 'components/ui/Panel' -import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' @@ -138,21 +138,28 @@ export const Connect = () => { return [] } - const { anonKey } = canReadAPIKeys ? getAPIKeys(settings) : { anonKey: null } - const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: false }) + const { data: apiKeys } = useAPIKeysQuery({ projectRef }) + const { anonKey, publishableKey } = canReadAPIKeys + ? getKeys(apiKeys) + : { anonKey: null, publishableKey: null } const projectKeys = useMemo(() => { const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint ?? '' const apiHost = canReadAPIKeys ? `${protocol}://${endpoint ?? '-'}` : '' - const apiUrl = canReadAPIKeys ? apiHost : null return { apiUrl: apiHost ?? null, anonKey: anonKey?.api_key ?? null, - publishableKey: apiKeys?.find(({ type }) => type === 'publishable')?.api_key ?? null, + publishableKey: publishableKey?.api_key ?? null, } - }, [apiKeys, anonKey, canReadAPIKeys, settings]) + }, [ + settings?.app_config?.protocol, + settings?.app_config?.endpoint, + canReadAPIKeys, + anonKey?.api_key, + publishableKey?.api_key, + ]) const filePath = getContentFilePath({ connectionObject, diff --git a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx index 8841de7b993..a1a9dbe3239 100644 --- a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx @@ -227,8 +227,8 @@ export const FormContents = ({ errors={errors} httpHeaders={httpHeaders} httpParameters={httpParameters} - onAddHeader={(header?: any) => { - if (header) setHttpHeaders(httpHeaders.concat(header)) + onAddHeaders={(headers?: any[]) => { + if (headers) setHttpHeaders(httpHeaders.concat(headers)) else setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' })) }} onUpdateHeader={(idx, property, value) => diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx index e77c36658d2..ec8cf9e4d75 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx @@ -5,7 +5,7 @@ import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' import { uuidv4 } from 'lib/helpers' import { @@ -27,7 +27,7 @@ interface HTTPRequestFieldsProps { errors: any httpHeaders: HTTPArgument[] httpParameters: HTTPArgument[] - onAddHeader: (header?: any) => void + onAddHeaders: (headers?: any[]) => void onUpdateHeader: (idx: number, property: string, value: string) => void onRemoveHeader: (idx: number) => void onAddParameter: () => void @@ -40,21 +40,22 @@ const HTTPRequestFields = ({ errors, httpHeaders = [], httpParameters = [], - onAddHeader, + onAddHeaders, onUpdateHeader, onRemoveHeader, onAddParameter, onUpdateParameter, onRemoveParameter, }: HTTPRequestFieldsProps) => { - const { project: selectedProject } = useProjectContext() const { ref } = useParams() - const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) + const { project: selectedProject } = useProjectContext() + const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref }) + const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref, reveal: true }) const edgeFunctions = functions ?? [] - const { serviceKey } = getAPIKeys(settings) - const apiKey = serviceKey?.api_key ?? '[YOUR API KEY]' + const { serviceKey, secretKey } = getKeys(apiKeys) + const apiKey = secretKey?.api_key ?? serviceKey?.api_key ?? '[YOUR API KEY]' return ( <> @@ -159,30 +160,37 @@ const HTTPRequestFields = ({ size="tiny" icon={} className={cn(type === 'supabase_function' && 'rounded-r-none px-3')} - onClick={onAddHeader} + onClick={() => onAddHeaders()} > Add a new header {type === 'supabase_function' && ( - + + ) => { const router = useRouter() - const { functionSlug, ref } = useParams() const org = useSelectedOrganization() + const { functionSlug, ref } = useParams() const { mutate: sendEvent } = useSendEventMutation() const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx index feccb8aa02f..d08489cf2e0 100644 --- a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx @@ -1,6 +1,7 @@ import Link from '@ui/components/Typography/Link' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldSectionDescription, ScaffoldSectionTitle } from 'components/layouts/Scaffold' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Card } from 'ui' import { getCatalogURI, getConnectionURL } from '../StorageSettings/StorageSettings.utils' @@ -19,12 +20,14 @@ const wrapperMeta = { export const SimpleConfigurationDetails = ({ bucketName }: { bucketName: string }) => { const { project } = useProjectContext() + + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref }) const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref }) const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint - const serviceApiKey = - (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? - 'SUPABASE_CLIENT_SERVICE_KEY' + + const { serviceKey } = getKeys(apiKeys) + const serviceApiKey = serviceKey?.api_key ?? 'SUPABASE_CLIENT_SERVICE_KEY' const values: Record = { vault_token: serviceApiKey, diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 18aaf4c8c5e..4072f6f2caa 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -10,10 +10,10 @@ import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useFlag } from 'hooks/ui/useFlag' +import Link from 'next/link' import { Input } from 'ui' import { getLastUsedAPIKeys, useLastUsedAPIKeysLogQuery } from './DisplayApiSettings.utils' import { ToggleLegacyApiKeysPanel } from './ToggleLegacyApiKeys' -import Link from 'next/link' export const DisplayApiSettings = ({ showTitle = true, @@ -49,6 +49,7 @@ export const DisplayApiSettings = ({ const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated + const apiKeys = useMemo(() => settings?.service_api_keys ?? [], [settings]) // api keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 diff --git a/apps/studio/data/api-keys/api-keys-query.ts b/apps/studio/data/api-keys/api-keys-query.ts index a78f3c031d1..24911001188 100644 --- a/apps/studio/data/api-keys/api-keys-query.ts +++ b/apps/studio/data/api-keys/api-keys-query.ts @@ -2,7 +2,6 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' -import { IS_PLATFORM } from 'lib/constants' import { apiKeysKeys } from './keys' type LegacyKeys = { @@ -46,9 +45,11 @@ type PublishableKeys = { interface APIKeysVariables { projectRef?: string - reveal: boolean + reveal?: boolean } +type APIKey = LegacyKeys | SecretKeys | PublishableKeys + async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: AbortSignal) { if (!projectRef) throw new Error('projectRef is required') @@ -62,20 +63,33 @@ async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: Abo } // [Jonny]: Overriding the types here since some stuff is not actually nullable or optional - return data as unknown as (LegacyKeys | SecretKeys | PublishableKeys)[] + return data as unknown as APIKey[] } export type APIKeysData = Awaited> export const useAPIKeysQuery = ( - { projectRef, reveal }: APIKeysVariables, + { projectRef, reveal = false }: APIKeysVariables, { enabled, ...options }: UseQueryOptions = {} ) => useQuery( - apiKeysKeys.list(projectRef), + apiKeysKeys.list(projectRef, reveal), ({ signal }) => getAPIKeys({ projectRef, reveal }, signal), { - enabled: IS_PLATFORM && enabled && !!projectRef, + enabled: enabled && !!projectRef, ...options, } ) + +export const getKeys = (apiKeys: APIKey[] = []) => { + const anonKey = apiKeys.find((x) => x.name === 'anon') + const serviceKey = apiKeys.find((x) => x.name === 'service_role') + + // [Joshen] For now I just want 1 of each, I don't need all + const publishableKey = apiKeys.find((x) => x.type === 'publishable') + const secretKey = apiKeys.find((x) => x.type === 'secret') + + const allSecretKeys = apiKeys.filter((x) => x.type === 'secret') + + return { anonKey, serviceKey, publishableKey, secretKey, allSecretKeys } +} diff --git a/apps/studio/data/api-keys/keys.ts b/apps/studio/data/api-keys/keys.ts index 84d9c41d1d1..222a51b0b1c 100644 --- a/apps/studio/data/api-keys/keys.ts +++ b/apps/studio/data/api-keys/keys.ts @@ -1,5 +1,6 @@ export const apiKeysKeys = { - list: (projectRef?: string) => ['projects', projectRef, 'api-keys'] as const, + list: (projectRef?: string, reveal?: boolean) => + ['projects', projectRef, 'api-keys', reveal] as const, single: (projectRef?: string, id?: string) => ['projects', projectRef, 'api-keys', id] as const, status: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'legacy'] as const, } diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts index 9b8d65e071d..1827d14be5d 100644 --- a/apps/studio/data/config/project-settings-v2-query.ts +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -62,13 +62,3 @@ export const useProjectSettingsV2Query = ( } ) } - -/** - * @deprecated Use api-keys-query instead! - */ -export const getAPIKeys = (settings?: ProjectSettings) => { - const anonKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon') - const serviceKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'service_role') - - return { anonKey, serviceKey } -} diff --git a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts index e82f77ca537..95cac1e501b 100644 --- a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts +++ b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts @@ -9,6 +9,7 @@ import { getCatalogURI, getConnectionURL, } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { FDWCreateVariables, useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' @@ -18,13 +19,15 @@ import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation' export const useIcebergWrapperCreateMutation = () => { const { project } = useProjectContext() + + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref }) + const { serviceKey } = getKeys(apiKeys) + const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref }) const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint - const serviceApiKey = - (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? - 'SUPABASE_CLIENT_SERVICE_KEY' + const serviceApiKey = serviceKey?.api_key ?? 'SUPABASE_CLIENT_SERVICE_KEY' const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === 'iceberg_wrapper') diff --git a/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts b/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts new file mode 100644 index 00000000000..8a11aa695d6 --- /dev/null +++ b/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts @@ -0,0 +1,50 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import { components } from 'api-types' +import apiWrapper from 'lib/api/apiWrapper' + +type ProjectAppConfig = components['schemas']['ProjectSettingsResponse']['app_config'] & { + protocol?: string +} +export type ProjectSettings = components['schemas']['ProjectSettingsResponse'] & { + app_config?: ProjectAppConfig +} + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGetAll(req, res) + default: + res.setHeader('Allow', ['GET']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { + const response = [ + { + name: 'anon', + api_key: process.env.SUPABASE_ANON_KEY ?? '', + id: 'anon', + type: 'legacy', + hash: '', + prefix: '', + description: 'Legacy anon API key', + }, + { + name: 'service_role', + api_key: process.env.SUPABASE_SERVICE_KEY ?? '', + id: 'service_role', + type: 'legacy', + hash: '', + prefix: '', + description: 'Legacy service_role API key', + }, + ] + + return res.status(200).json(response) +} diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 4150e63eb4a..373423eb11b 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -31,8 +31,9 @@ import { } from 'components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.utils' import { convertFromBytes } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils' import { InlineLink } from 'components/ui/InlineLink' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { configKeys } from 'data/config/keys' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { ProjectStorageConfigResponse } from 'data/config/project-storage-config-query' import { getQueryClient } from 'data/query-client' import { deleteBucketObject } from 'data/storage/bucket-object-delete-mutation' @@ -917,6 +918,20 @@ function createStorageExplorerState({ columnIndex: number isDrop?: boolean }) => { + if (!state.serviceKey) { + toast( +

+ Uploading files to Storage through the dashboard is currently unavailable with the new + API keys. Please re-enable{' '} + + legacy JWT keys + {' '} + if you'd like to upload files to Storage through the dashboard. +

+ ) + return + } + const queryClient = getQueryClient() const storageConfiguration = queryClient .getQueryCache() @@ -1115,6 +1130,7 @@ function createStorageExplorerState({ headers: { authorization: `Bearer ${state.serviceKey}`, 'x-source': 'supabase-dashboard', + ...(state.serviceKey.includes('secret') ? { apikey: state.serviceKey } : {}), }, uploadDataDuringCreation: uploadDataDuringCreation, removeFingerprintOnSuccess: true, @@ -1722,11 +1738,12 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild const [state, setState] = useState(() => createStorageExplorerState(DEFAULT_STATE_CONFIG)) const stateRef = useLatest(state) + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref, reveal: true }) const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref }) - const { serviceKey } = getAPIKeys(settings) + + const { serviceKey } = getKeys(apiKeys) const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint - const resumableUploadUrl = `${IS_PLATFORM ? 'https' : protocol}://${endpoint}/storage/v1/upload/resumable` // [Joshen] JFYI opting with the useEffect here as the storage explorer state was being loaded // before the project details were ready, hence the store kept returning project ref as undefined @@ -1736,9 +1753,17 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild useEffect(() => { const snap = snapshot(stateRef.current) const hasDataReady = !!project?.ref - const isDifferentProject = snap.projectRef !== project?.ref + const resumableUploadUrl = `${IS_PLATFORM ? 'https' : protocol}://${endpoint}/storage/v1/upload/resumable` - if (!isPaused && hasDataReady && isDifferentProject && serviceKey) { + const isDifferentProject = snap.projectRef !== project?.ref + const isDifferentResumableUploadUrl = snap.resumableUploadUrl !== resumableUploadUrl + + if ( + !isPaused && + hasDataReady && + (isDifferentProject || isDifferentResumableUploadUrl) && + serviceKey + ) { const clientEndpoint = `${IS_PLATFORM ? 'https' : protocol}://${endpoint}` const supabaseClient = createClient(clientEndpoint, serviceKey.api_key, { auth: { @@ -1764,7 +1789,7 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild }) ) } - }, [project?.ref, stateRef, serviceKey, isPaused]) + }, [project?.ref, stateRef, serviceKey, isPaused, protocol, endpoint]) return ( From b80e798ecd88ef9185860f35b333395bc7187060 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Jul 2025 16:32:46 +0800 Subject: [PATCH 13/38] Table editor cast column type to text if filtering with like based filter (#37341) * Table editor cast column type to text if filtering with like based filter * Update pg-meta tests * Fix test cases for ~~ in advanced-query.test * Fix tests in table-row-query.test --- packages/pg-meta/src/query/Query.utils.ts | 13 +++++++++++-- packages/pg-meta/test/query/advanced-query.test.ts | 8 ++++---- packages/pg-meta/test/query/query.test.ts | 4 +++- packages/pg-meta/test/query/table-row-query.test.ts | 8 ++++---- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/pg-meta/src/query/Query.utils.ts b/packages/pg-meta/src/query/Query.utils.ts index ee39000b24d..47e2bb9f78f 100644 --- a/packages/pg-meta/src/query/Query.utils.ts +++ b/packages/pg-meta/src/query/Query.utils.ts @@ -1,5 +1,5 @@ -import { ident, literal, format } from '../pg-format' -import type { Filter, QueryPagination, QueryTable, Sort, Dictionary } from './types' +import { format, ident, literal } from '../pg-format' +import type { Dictionary, Filter, QueryPagination, QueryTable, Sort } from './types' export function countQuery( table: QueryTable, @@ -171,6 +171,11 @@ function applyFilters(query: string, filters: Filter[]) { return inFilterSql(filter) case 'is': return isFilterSql(filter) + case '~~': + case '~~*': + case '!~~': + case '!~~*': + return castColumnToText(filter) default: return `${ident(filter.column)} ${filter.operator} ${filterLiteral(filter.value)}` } @@ -203,6 +208,10 @@ function isFilterSql(filter: Filter) { } } +function castColumnToText(filter: Filter) { + return `${ident(filter.column)}::text ${filter.operator} ${filterLiteral(filter.value)}` +} + function filterLiteral(value: any) { if (typeof value === 'string') { if (value?.startsWith('ARRAY[') && value?.endsWith(']')) { diff --git a/packages/pg-meta/test/query/advanced-query.test.ts b/packages/pg-meta/test/query/advanced-query.test.ts index 0af6aac201c..3abaf6dad79 100644 --- a/packages/pg-meta/test/query/advanced-query.test.ts +++ b/packages/pg-meta/test/query/advanced-query.test.ts @@ -1,6 +1,6 @@ -import { expect, test, describe, afterAll } from 'vitest' +import { afterAll, describe, expect, test } from 'vitest' import { Query } from '../../src/query/Query' -import { createTestDatabase, cleanupRoot } from '../db/utils' +import { cleanupRoot, createTestDatabase } from '../db/utils' type TestDb = Awaited> @@ -343,7 +343,7 @@ describe('Advanced Query Tests', () => { .toSql() expect(sql).toMatchInlineSnapshot( - `"select id, name from public.normal_table where id > 10 and name ~~ '%John%' order by normal_table.name asc nulls last limit 10 offset 0;"` + `"select id, name from public.normal_table where id > 10 and name::text ~~ '%John%' order by normal_table.name asc nulls last limit 10 offset 0;"` ) const result = await validateSql(db, sql) @@ -444,7 +444,7 @@ describe('Advanced Query Tests', () => { .toSql() expect(sql).toMatchInlineSnapshot( - '"select count(*) from public.normal_table where name ~~ \'%John%\';"' + '"select count(*) from public.normal_table where name::text ~~ \'%John%\';"' ) const result = await validateSql(db, sql) diff --git a/packages/pg-meta/test/query/query.test.ts b/packages/pg-meta/test/query/query.test.ts index f149435977f..0302f41efc6 100644 --- a/packages/pg-meta/test/query/query.test.ts +++ b/packages/pg-meta/test/query/query.test.ts @@ -515,7 +515,9 @@ describe('End-to-end query chaining', () => { .filter('name', '~~', '%John%') .toSql() - expect(sql).toBe("select id, name, email from public.users where id > 10 and name ~~ '%John%';") + expect(sql).toBe( + "select id, name, email from public.users where id > 10 and name::text ~~ '%John%';" + ) }) test('should correctly build a select query with match criteria', () => { diff --git a/packages/pg-meta/test/query/table-row-query.test.ts b/packages/pg-meta/test/query/table-row-query.test.ts index c72c98546c2..62645f9012a 100644 --- a/packages/pg-meta/test/query/table-row-query.test.ts +++ b/packages/pg-meta/test/query/table-row-query.test.ts @@ -1,8 +1,8 @@ -import { expect, test, describe, afterAll, beforeAll } from 'vitest' -import { createTestDatabase, cleanupRoot } from '../db/utils' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' import pgMeta from '../../src/index' +import { Filter, Sort } from '../../src/query' import { getDefaultOrderByColumns, getTableRowsSql } from '../../src/query/table-row-query' -import { Sort, Filter } from '../../src/query' +import { cleanupRoot, createTestDatabase } from '../db/utils' beforeAll(async () => { // Any global setup if needed @@ -1098,7 +1098,7 @@ describe('Table Row Query', () => { // Verify SQL generation with snapshot expect(sql).toMatchInlineSnapshot( ` - "with _base_query as (select * from public.test_sql_filter where name ~~ 'Test%' and category = 'A' order by test_sql_filter.id asc nulls last limit 10 offset 0) + "with _base_query as (select * from public.test_sql_filter where name::text ~~ 'Test%' and category = 'A' order by test_sql_filter.id asc nulls last limit 10 offset 0) select id,case when octet_length(name::text) > 10240 then left(name::text, 10240) || '...' From 4d9788c4d3373f9484664c0c825e2f286805014a Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:50:23 +0100 Subject: [PATCH 14/38] chore: add kemal to humans.txt (#37372) This adds myself to the humans.txt as part of the onboarding tasks. Co-authored-by: kemal --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index fdd236e51a0..c4c3855972b 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -65,6 +65,7 @@ Kang Ming Tay Karan S Karlo Ison Katerina Skroumpelou +Kemal Y Kevin Brolly Kevin Grüneberg Lakshan Perera From 16bb5187c971890f1e6174eb5affa0662cc603d9 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Tue, 22 Jul 2025 08:25:36 -0300 Subject: [PATCH 15/38] docs: credit top ups pay outstanding invoices (#37255) --- apps/docs/content/guides/platform/credits.mdx | 2 ++ .../interfaces/Organization/BillingSettings/CreditTopUp.tsx | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/guides/platform/credits.mdx b/apps/docs/content/guides/platform/credits.mdx index 04ced3dae40..a71d1574de0 100644 --- a/apps/docs/content/guides/platform/credits.mdx +++ b/apps/docs/content/guides/platform/credits.mdx @@ -29,6 +29,8 @@ As an example, if you start a Pro Plan subscription on January 1 and downgrade t You can top up credits at any time, with a maximum of per top-up. These credits do not expire and are non-refundable. +If you have any outstanding invoices, we’ll automatically use your credits to pay them off. Any remaining credits will be applied to future invoices. + You may want to consider this option to avoid issues with recurring payments, gain more control over how often your credit card is charged, and potentially make things easier for your accounting department. diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index b33f047760d..341df597737 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -227,8 +227,8 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { Top Up Credits On successful payment, an invoice will be issued and you'll be granted credits. - Credits will be applied to future invoices only and are not refundable. The topped up - credits do not expire. + Credits will be applied to outstanding and future invoices and are not refundable. The + topped up credits do not expire. From 008cca006d6ddbdd468ec5947d12434db45c0c0e Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Jul 2025 19:47:07 +0800 Subject: [PATCH 16/38] Refactor custom domain verification (#37349) * Refactor custom domain verification * Add success toast when cancelling custom domain * Update toast success for other delete custom domain calls * Revert unnecessary change * Update copy * Update copy --- .../CustomDomainActivate.tsx | 8 ++- .../CustomDomainConfig/CustomDomainConfig.tsx | 27 +++++----- .../CustomDomainConfig/CustomDomainDelete.tsx | 4 +- .../CustomDomainConfig/CustomDomainVerify.tsx | 50 +++++++++++-------- 4 files changed, 54 insertions(+), 35 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx index c471cd49438..ef87d4d509d 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx @@ -30,7 +30,13 @@ const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivate }, } ) - const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation() + const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation({ + onSuccess: () => { + toast.success( + 'Custom domain setup cancelled successfully. It may take a few seconds before your custom domain is fully removed, so you may need to refresh your browser.' + ) + }, + }) const endpoint = settings?.app_config?.endpoint diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index 6a7e80b13f0..aa2391f660b 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -27,10 +27,10 @@ const CustomDomainConfig = () => { const hasCustomDomainAddon = !!addons?.selected_addons.find((x) => x.type === 'custom_domain') const { + data: customDomainData, isLoading: isCustomDomainsLoading, isError, isSuccess, - data, } = useCustomDomainsQuery( { projectRef: ref }, { @@ -45,6 +45,8 @@ const CustomDomainConfig = () => { } ) + const { status, customDomain } = customDomainData || {} + return (
@@ -94,24 +96,25 @@ const CustomDomainConfig = () => {
- ) : data?.status === '0_no_hostname_configured' ? ( + ) : status === '0_no_hostname_configured' ? ( ) : ( {isSuccess && (
- {(data.status === '1_not_started' || - data.status === '2_initiated' || - data.status === '3_challenge_verified') && ( - + {(status === '1_not_started' || + status === '2_initiated' || + status === '3_challenge_verified') && } + + {customDomainData.status === '4_origin_setup_completed' && ( + )} - {data.status === '4_origin_setup_completed' && ( - - )} - - {data.status === '5_services_reconfigured' && ( - + {customDomainData.status === '5_services_reconfigured' && ( + )}
)} diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainDelete.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainDelete.tsx index a029d8a1817..4ec3c264442 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainDelete.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainDelete.tsx @@ -18,7 +18,9 @@ const CustomDomainDelete = ({ projectRef, customDomain }: CustomDomainDeleteProp const [isDeleteConfirmModalVisible, setIsDeleteConfirmModalVisible] = useState(false) const { mutate: deleteCustomDomain } = useCustomDomainDeleteMutation({ onSuccess: () => { - toast.success(`Successfully deleted custom domain`) + toast.success( + `Successfully deleted custom domain. It may take a few seconds before your custom domain is fully removed, hence you may need to refresh your browser.` + ) setIsDeleteConfirmModalVisible(false) }, }) diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx index 9d97d93a157..2e83375d731 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx @@ -1,13 +1,14 @@ import { AlertCircle, HelpCircle, RefreshCw } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' +import { toast } from 'sonner' import { useParams } from 'common' import { DocsButton } from 'components/ui/DocsButton' import Panel from 'components/ui/Panel' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation' -import type { CustomDomainResponse } from 'data/custom-domains/custom-domains-query' +import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useCustomDomainReverifyMutation } from 'data/custom-domains/custom-domains-reverify-mutation' import { useInterval } from 'hooks/misc/useInterval' import { @@ -20,16 +21,17 @@ import { import DNSRecord from './DNSRecord' import { DNSTableHeaders } from './DNSTableHeaders' -export type CustomDomainVerifyProps = { - customDomain: CustomDomainResponse -} - -const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { +const CustomDomainVerify = () => { const { ref: projectRef } = useParams() const [isNotVerifiedYet, setIsNotVerifiedYet] = useState(false) const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) + const customDomain = customDomainData?.customDomain + const isSSLCertificateDeploying = + customDomain?.ssl.status !== undefined && customDomain.ssl.txt_name === undefined + const { mutate: reverifyCustomDomain, isLoading: isReverifyLoading } = useCustomDomainReverifyMutation({ onSuccess: (res) => { @@ -37,13 +39,19 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { }, }) - const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation() + const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation({ + onSuccess: () => { + toast.success( + 'Custom domain setup cancelled successfully. It may take a few seconds before your custom domain is fully removed, so you may need to refresh your browser.' + ) + }, + }) - const hasCAAErrors = customDomain.ssl.validation_errors?.reduce( + const hasCAAErrors = customDomain?.ssl.validation_errors?.reduce( (acc, error) => acc || error.message.includes('caa_error'), false ) - const isValidating = (customDomain.ssl.txt_name ?? '') === '' + const isValidating = (customDomain?.ssl.txt_name ?? '') === '' const onReverifyCustomDomain = () => { if (!projectRef) return console.error('Project ref is required') @@ -53,7 +61,7 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { useInterval( onReverifyCustomDomain, // Poll every 5 seconds if the SSL certificate is being deployed - customDomain.ssl.status !== undefined && customDomain.ssl.txt_name === undefined ? 5000 : false + isSSLCertificateDeploying && !isDeleting ? 5000 : false ) const onCancelCustomDomain = async () => { @@ -67,7 +75,7 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => {

Configure TXT verification for your custom domain{' '} - {customDomain.hostname} + {customDomain?.hostname}

Set the following TXT record(s) in your DNS provider, then click verify to confirm your @@ -102,7 +110,7 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { here @@ -131,13 +139,13 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { Please add a CAA record allowing "digicert.com" to issue certificates for{' '} - {customDomain.hostname}. For example:{' '} + {customDomain?.hostname}. For example:{' '} 0 issue "digicert.com" )} - {customDomain.ssl.status === 'validation_timed_out' ? ( + {customDomain?.ssl.status === 'validation_timed_out' ? ( Validation timed out @@ -147,27 +155,27 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { ) : (

- + - {customDomain.verification_errors?.includes( + {customDomain?.verification_errors?.includes( 'custom hostname does not CNAME to this zone.' ) && ( )} - {!isValidating && customDomain.ssl.status === 'pending_validation' && ( + {!isValidating && customDomain?.ssl.status === 'pending_validation' && ( )} - {customDomain.ssl.status === 'pending_deployment' && ( + {customDomain?.ssl.status === 'pending_deployment' && (

From f9e330f3ca3db7bfa92d0825cbd5f3438eb1e586 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Tue, 22 Jul 2025 20:51:08 +0800 Subject: [PATCH 17/38] Chore/track branching 2 (#37350) * add review branch w ai * track create branch * track update push branch event * track create merge request * track create and close merge request * track merge attempts and results * add delete branch event * combine handle update * fix lint --- .../BranchManagement/CreateBranchModal.tsx | 15 + .../BranchManagement/OutOfDateNotice.tsx | 37 +- .../BranchManagement/ReviewWithAI.tsx | 13 + .../LayoutHeader/MergeRequestButton.tsx | 16 + .../pages/project/[ref]/branches/index.tsx | 15 + .../project/[ref]/branches/merge-requests.tsx | 43 ++ apps/studio/pages/project/[ref]/merge.tsx | 70 +++ packages/common/telemetry-constants.ts | 433 +++++++++--------- 8 files changed, 428 insertions(+), 214 deletions(-) diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 93c82251740..a23bf75eb81 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -22,6 +22,7 @@ import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-ch import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import { projectKeys } from 'data/projects/keys' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' @@ -83,12 +84,26 @@ export const CreateBranchModal = () => { onError: () => {}, }) + const { mutate: sendEvent } = useSendEventMutation() + const { mutate: createBranch, isLoading: isCreating } = useBranchCreateMutation({ onSuccess: async (data) => { toast.success(`Successfully created preview branch "${data.name}"`) if (projectRef) { await Promise.all([queryClient.invalidateQueries(projectKeys.detail(projectRef))]) } + sendEvent({ + action: 'branch_create_button_clicked', + properties: { + branchType: data.persistent ? 'persistent' : 'preview', + gitlessBranching, + }, + groups: { + project: ref ?? 'Unknown', + organization: selectedOrg?.slug ?? 'Unknown', + }, + }) + setShowCreateBranchModal(false) router.push(`/project/${data.project_ref}`) }, diff --git a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx index 73444da1e93..c90b7f99166 100644 --- a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx +++ b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx @@ -13,6 +13,9 @@ import { } from 'ui' import { GitBranchIcon } from 'lucide-react' import { Admonition } from 'ui-patterns' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' interface OutOfDateNoticeProps { isBranchOutOfDateMigrations: boolean @@ -41,6 +44,12 @@ export const OutOfDateNotice = ({ }: OutOfDateNoticeProps) => { const [isDialogOpen, setIsDialogOpen] = useState(false) const hasOutdatedMigrations = isBranchOutOfDateMigrations && missingMigrationsCount > 0 + const selectedOrg = useSelectedOrganization() + const project = useSelectedProject() + const { mutate: sendEvent } = useSendEventMutation() + + const isBranch = project?.parent_project_ref !== undefined + const parentProjectRef = isBranch ? project?.parent_project_ref : project?.ref const getTitle = () => { if (hasOutdatedMigrations && (hasMissingFunctions || hasOutOfDateFunctions)) { @@ -57,12 +66,24 @@ export const OutOfDateNotice = ({ return 'Update this branch to get the latest changes from the production branch.' } - const handleUpdateClick = () => { - onPush() - } + const handleUpdate = (shouldCloseDialog = false) => { + if (shouldCloseDialog) { + setIsDialogOpen(false) + } + + // Track branch update + sendEvent({ + action: 'branch_updated', + properties: { + modifiedEdgeFunctions: hasEdgeFunctionModifications, + source: 'out_of_date_notice', + }, + groups: { + project: parentProjectRef ?? 'Unknown', + organization: selectedOrg?.slug ?? 'Unknown', + }, + }) - const handleConfirmUpdate = () => { - setIsDialogOpen(false) onPush() } @@ -98,7 +119,9 @@ export const OutOfDateNotice = ({ Cancel - Update anyway + handleUpdate(true)}> + Update anyway + @@ -106,7 +129,7 @@ export const OutOfDateNotice = ({ + + - - -

-
+
+

Generate token for experimental API

+
+ + +
- setIsOpen(!isOpen)} - header={ -
-
- {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'} -
-
- } + { + if (!open) form.reset() + setVisible(open) + }} > -
- {() => ( - <> - {tokenScope === 'V0' && ( - - -

- These include deleting organizations and projects which cannot be undone. - As such, be very careful when using this API. -

-
- -
- - } - /> -
- )} - - + + + {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'} + + + + + {tokenScope === 'V0' && ( + +

+ These include deleting organizations and projects which cannot be undone. As + such, be very careful when using this API. +

+
+ +
+ + } + /> + )} + + + ( + + + + + + )} /> -
- -
- - -
-
- - )} -
-
+ + + + + + + + + ) } diff --git a/apps/studio/package.json b/apps/studio/package.json index 3085a3d6de7..038669dd947 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -146,6 +146,7 @@ "zxcvbn": "^4.4.2" }, "devDependencies": { + "@faker-js/faker": "^9.9.0", "@graphql-codegen/cli": "5.0.5", "@graphql-typed-document-node/core": "^3.2.0", "@radix-ui/react-use-escape-keydown": "^1.0.3", diff --git a/apps/studio/tests/vitestSetup.ts b/apps/studio/tests/vitestSetup.ts index 00ac0696c07..e32fd66226f 100644 --- a/apps/studio/tests/vitestSetup.ts +++ b/apps/studio/tests/vitestSetup.ts @@ -40,10 +40,11 @@ beforeAll(() => { routerMock.useParser(createDynamicRouteParser(['/projects/[ref]'])) }) -afterAll(() => mswServer.close()) - -afterEach(() => mswServer.resetHandlers()) - afterEach(() => { + mswServer.resetHandlers() cleanup() }) + +afterAll(() => { + mswServer.close() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f2a902f587..a239e511204 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -615,6 +615,9 @@ importers: shiki: specifier: ^3.2.1 version: 3.2.1 + shx: + specifier: ^0.4.0 + version: 0.4.0 simple-git: specifier: ^3.24.0 version: 3.24.0(supports-color@8.1.1) @@ -1009,6 +1012,9 @@ importers: specifier: ^4.4.2 version: 4.4.2 devDependencies: + '@faker-js/faker': + specifier: ^9.9.0 + version: 9.9.0 '@graphql-codegen/cli': specifier: 5.0.5 version: 5.0.5(@parcel/watcher@2.5.1)(@types/node@22.13.14)(encoding@0.1.13)(graphql-sock@1.0.1(graphql@16.11.0))(graphql@16.11.0)(supports-color@8.1.1)(typescript@5.5.2) @@ -2184,7 +2190,7 @@ importers: version: 6.6.3 '@testing-library/react': specifier: ^16.0.0 - version: 16.0.0(@testing-library/dom@10.1.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/lodash': specifier: 4.17.5 version: 4.17.5 @@ -3659,6 +3665,10 @@ packages: resolution: {integrity: sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==} engines: {node: '>=14.0.0', npm: '>=6.0.0'} + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + '@fal-works/esbuild-plugin-global-externals@2.1.2': resolution: {integrity: sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==} @@ -8421,6 +8431,10 @@ packages: resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==} engines: {node: '>=18'} + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + '@testing-library/jest-dom@6.6.3': resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} @@ -11481,6 +11495,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@1.0.0: + resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + engines: {node: '>=6'} + execa@7.2.0: resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} @@ -11969,6 +11987,10 @@ packages: resolution: {integrity: sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==} engines: {node: '>=10'} + get-stream@4.1.0: + resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + engines: {node: '>=6'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -12601,6 +12623,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + interpret@1.4.0: + resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} + engines: {node: '>= 0.10'} + intersection-observer@0.10.0: resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==} @@ -12882,6 +12908,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -14514,6 +14544,10 @@ packages: engines: {node: '>= 4'} hasBin: true + npm-run-path@2.0.2: + resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} + engines: {node: '>=4'} + npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -14776,6 +14810,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -15889,6 +15927,10 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + rechoir@0.6.2: + resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} + engines: {node: '>= 0.10'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -16420,6 +16462,11 @@ packages: shell-quote@1.8.1: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + shelljs@0.9.2: + resolution: {integrity: sha512-S3I64fEiKgTZzKCC46zT/Ib9meqofLrQVbpSswtjFfAVDW+AZ54WTnAM/3/yENoxz/V1Cy6u3kiiEbQ4DNphvw==} + engines: {node: '>=18'} + hasBin: true + shiki@1.6.0: resolution: {integrity: sha512-P31ROeXcVgW/k3Z+vUUErcxoTah7ZRaimctOpzGuqAntqnnSmx1HOsvnbAB8Z2qfXPRhw61yptAzCsuKOhTHwQ==} @@ -16447,6 +16494,11 @@ packages: should@13.2.3: resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} + shx@0.4.0: + resolution: {integrity: sha512-Z0KixSIlGPpijKgcH6oCMCbltPImvaKy0sGH8AkLRXw1KyzpKtaCTizP2xen+hNDqVF4xxgvA0KXSb9o4Q6hnA==} + engines: {node: '>=18'} + hasBin: true + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -16820,6 +16872,10 @@ packages: resolution: {integrity: sha512-p9LsUieSjWNNAxVCXLeilaDlmuUOrDS5/dF9znM1nZc7EGX5+zEFC0bEevsNIaldjlks+2jns5Siz6F9iK6jwA==} engines: {node: '>=0.10.0'} + strip-eof@1.0.0: + resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} + engines: {node: '>=0.10.0'} + strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -20296,7 +20352,7 @@ snapshots: '@contentlayer2/core': 0.5.3(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1) '@contentlayer2/utils': 0.5.3 chokidar: 3.5.3 - fast-glob: 3.3.2 + fast-glob: 3.3.3 gray-matter: 4.0.3 imagescript: 1.3.0 micromatch: 4.0.8 @@ -20751,6 +20807,8 @@ snapshots: '@faker-js/faker@7.6.0': {} + '@faker-js/faker@9.9.0': {} + '@fal-works/esbuild-plugin-global-externals@2.1.2': {} '@fastify/ajv-compiler@3.6.0': @@ -27715,6 +27773,17 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.26.10 + '@types/aria-query': 5.0.2 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + '@testing-library/jest-dom@6.6.3': dependencies: '@adobe/css-tools': 4.4.0 @@ -27735,6 +27804,16 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@testing-library/react@16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.26.10 + '@testing-library/dom': 10.4.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@testing-library/user-event@14.6.1(@testing-library/dom@10.1.0)': dependencies: '@testing-library/dom': 10.1.0 @@ -27748,7 +27827,7 @@ snapshots: '@ts-morph/common@0.19.0': dependencies: - fast-glob: 3.3.2 + fast-glob: 3.3.3 minimatch: 7.4.6 mkdirp: 2.1.6 path-browserify: 1.0.1 @@ -31203,6 +31282,16 @@ snapshots: dependencies: eventsource-parser: 3.0.2 + execa@1.0.0: + dependencies: + cross-spawn: 6.0.6 + get-stream: 4.1.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + execa@7.2.0: dependencies: cross-spawn: 7.0.6 @@ -31782,6 +31871,10 @@ snapshots: get-stdin@8.0.0: {} + get-stream@4.1.0: + dependencies: + pump: 3.0.3 + get-stream@6.0.1: {} get-stream@8.0.1: {} @@ -32710,6 +32803,8 @@ snapshots: internmap@2.0.3: {} + interpret@1.4.0: {} + intersection-observer@0.10.0: {} invariant@2.2.4: @@ -32964,6 +33059,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@1.1.0: {} + is-stream@2.0.1: {} is-stream@3.0.0: {} @@ -35374,6 +35471,10 @@ snapshots: shell-quote: 1.8.1 string.prototype.padend: 3.1.5 + npm-run-path@2.0.2: + dependencies: + path-key: 2.0.1 + npm-run-path@5.3.0: dependencies: path-key: 4.0.0 @@ -35688,6 +35789,8 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-finally@1.0.0: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -36991,6 +37094,10 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.6.11 + rechoir@0.6.2: + dependencies: + resolve: 1.22.10 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -37816,6 +37923,13 @@ snapshots: shell-quote@1.8.1: {} + shelljs@0.9.2: + dependencies: + execa: 1.0.0 + fast-glob: 3.3.3 + interpret: 1.4.0 + rechoir: 0.6.2 + shiki@1.6.0: dependencies: '@shikijs/core': 1.6.0 @@ -37859,6 +37973,11 @@ snapshots: should-type-adaptors: 1.1.0 should-util: 1.0.1 + shx@0.4.0: + dependencies: + minimist: 1.2.8 + shelljs: 0.9.2 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -38292,6 +38411,8 @@ snapshots: strip-color@0.1.0: {} + strip-eof@1.0.0: {} + strip-final-newline@3.0.0: {} strip-indent@3.0.0: From 5f76c85dd191dea92f902c0716a120916658526a Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 23 Jul 2025 17:18:07 +0800 Subject: [PATCH 26/38] chore: cleanup telemetry codeowners (#37396) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3feaae032a1..043d489e617 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,7 @@ /packages/ui/ @supabase/design /packages/shared-data/pricing.ts @roryw10 @supabase/billing /packages/shared-data/plans.ts @roryw10 @supabase/billing -/packages/common/telemetry-constants.ts @4L3k51 @supabase/growth-eng +/packages/common/telemetry-constants.ts @supabase/growth-eng /apps/studio/ @supabase/Dashboard From 7505c2923e595f3b64bc14ab0f0227b2e04c2ea5 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 23 Jul 2025 17:40:19 +0800 Subject: [PATCH 27/38] Update AI model check (#37395) * Update AI model chekc * Add comment * Fix test --- apps/studio/lib/ai/model.test.ts | 2 +- apps/studio/lib/ai/model.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/studio/lib/ai/model.test.ts b/apps/studio/lib/ai/model.test.ts index 0f4f75eca3d..8418ad36893 100644 --- a/apps/studio/lib/ai/model.test.ts +++ b/apps/studio/lib/ai/model.test.ts @@ -18,7 +18,7 @@ describe('getModel', () => { beforeEach(() => { vi.resetAllMocks() - vi.stubEnv('AWS_BEDROCK_PROFILE', 'test') + vi.stubEnv('AWS_BEDROCK_ROLE_ARN', 'test') }) afterEach(() => { diff --git a/apps/studio/lib/ai/model.ts b/apps/studio/lib/ai/model.ts index 57698f2aaeb..79e52cd4e20 100644 --- a/apps/studio/lib/ai/model.ts +++ b/apps/studio/lib/ai/model.ts @@ -33,10 +33,10 @@ export const ModelErrorMessage = export async function getModel(routingKey?: string, isLimited?: boolean): Promise { const hasAwsCredentials = await checkAwsCredentials() - const hasAwsBedrockprofile = !!process.env.AWS_BEDROCK_PROFILE + const hasAwsBedrockRoleArn = !!process.env.AWS_BEDROCK_ROLE_ARN const hasOpenAIKey = !!process.env.OPENAI_API_KEY - if (hasAwsBedrockprofile && hasAwsCredentials) { + if (hasAwsBedrockRoleArn && hasAwsCredentials) { const bedrockModel = IS_THROTTLED || isLimited ? BEDROCK_NORMAL_MODEL : BEDROCK_PRO_MODEL const bedrock = createRoutedBedrock(routingKey) @@ -45,6 +45,7 @@ export async function getModel(routingKey?: string, isLimited?: boolean): Promis } } + // [Joshen] Only for local/self-hosted, hosted should always only use bedrock if (hasOpenAIKey) { return { model: openai(OPENAI_MODEL), From 740eeb850432ef940ec7ac4cfee96ad67ae8879c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Wed, 23 Jul 2025 18:16:10 +0800 Subject: [PATCH 28/38] feat: mandatory address input for paying customers (#37337) - Mandatory address input when adding a new payment method - Removed the global HCaptcha store that wasn't used consistently and would sometimes block payment method changes - Remove the custom billing address data & tax id form on org creation and plan upgrades in favour of Stripe's `AddressElement` - Unify usage of the Stripe payment elements into a single component - Customers can mark "Purchasing as a business" and will then be able to put down a tax id - Adjusted billing address form to have better labels + tax id is filtered down to selected country - Adjusted Stripe Elements styling to use floating labels (otherwise very hard to use with address element) + additional styling changes - New flag to filter out payment methods that do not have an address for org upgrades and credit top ups, this will be enforced a few days after rolling this out - Added Google Maps Places API integration for address auto-completion via Stripe AddressElement - Upgraded Stripe dependencies - Slight adjustments to styling of plan upgrade modal --- .../Payment/AddNewPaymentMethodModal.tsx | 12 +- .../Billing/Payment/AddPaymentMethodForm.tsx | 159 ++++++--- .../Billing/Payment/Payment.utils.ts | 24 +- .../BillingCustomerData.tsx | 17 +- .../BillingCustomerDataExistingOrgDialog.tsx | 215 ------------ .../BillingCustomerDataForm.tsx | 78 +++-- .../BillingCustomerDataNewOrgDialog.tsx | 122 ------- .../BillingCustomerData/TaxID.constants.ts | 5 - .../useBillingCustomerDataForm.ts | 3 +- .../BillingSettings/CreditTopUp.tsx | 53 +-- .../NewPaymentMethodElement.tsx | 328 +++++++++++++++++- .../PaymentMethods/PaymentMethods.tsx | 1 - .../Subscription/EnterpriseCard.tsx | 4 +- .../Subscription/MembersExceedLimitModal.tsx | 4 +- .../Subscription/PaymentMethodSelection.tsx | 202 ++++++----- .../Subscription/PlanUpdateSidePanel.tsx | 10 +- .../SubscriptionPlanUpdateDialog.tsx | 198 +++++------ .../InvoicesSettings/InvoicesSettings.tsx | 8 +- .../Organization/NewOrg/NewOrgForm.tsx | 56 +-- apps/studio/csp.js | 4 + .../organization-create-mutation.ts | 14 +- .../organization-credit-top-up-mutation.ts | 16 +- ...zation-customer-profile-update-mutation.ts | 4 +- apps/studio/data/organizations/types.ts | 4 + .../org-subscription-update-mutation.ts | 10 + .../hooks/misc/useSelectedOrganization.ts | 4 +- apps/studio/package.json | 4 +- apps/studio/pages/_app.tsx | 2 - apps/studio/pages/new/index.tsx | 6 +- apps/studio/stores/hcaptcha-loaded-store.tsx | 31 -- packages/api-types/types/api.d.ts | 3 +- packages/api-types/types/platform.d.ts | 170 ++++++--- pnpm-lock.yaml | 24 +- turbo.json | 1 + 34 files changed, 953 insertions(+), 843 deletions(-) delete mode 100644 apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx delete mode 100644 apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx create mode 100644 apps/studio/data/organizations/types.ts delete mode 100644 apps/studio/stores/hcaptcha-loaded-store.tsx diff --git a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx index d24f02b8036..e4feb1d958f 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx @@ -9,7 +9,6 @@ import { Modal } from 'ui' import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { STRIPE_PUBLIC_KEY } from 'lib/constants' -import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' import AddPaymentMethodForm from './AddPaymentMethodForm' import { getStripeElementsAppearanceOptions } from './Payment.utils' @@ -18,8 +17,6 @@ interface AddNewPaymentMethodModalProps { returnUrl: string onCancel: () => void onConfirm: () => void - showSetDefaultCheckbox?: boolean - autoMarkAsDefaultPaymentMethod?: boolean } const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -29,14 +26,11 @@ const AddNewPaymentMethodModal = ({ returnUrl, onCancel, onConfirm, - showSetDefaultCheckbox, - autoMarkAsDefaultPaymentMethod, }: AddNewPaymentMethodModalProps) => { const { resolvedTheme } = useTheme() const [intent, setIntent] = useState() const selectedOrganization = useSelectedOrganization() - const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) @@ -64,7 +58,7 @@ const AddNewPaymentMethodModal = ({ } const loadPaymentForm = async () => { - if (visible && captchaRef && captchaLoaded) { + if (visible && captchaRef) { let token = captchaToken try { @@ -82,7 +76,7 @@ const AddNewPaymentMethodModal = ({ } loadPaymentForm() - }, [visible, captchaRef, captchaLoaded]) + }, [visible, captchaRef]) const resetCaptcha = () => { setCaptchaToken(null) @@ -142,8 +136,6 @@ const AddNewPaymentMethodModal = ({ returnUrl={returnUrl} onCancel={onLocalCancel} onConfirm={onLocalConfirm} - showSetDefaultCheckbox={showSetDefaultCheckbox} - autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod} /> diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index cb470dcb3e5..0171d9f5610 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -1,49 +1,56 @@ -import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' import { useQueryClient } from '@tanstack/react-query' +import { + NewPaymentMethodElement, + type PaymentMethodElementRef, +} from 'components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement' import { organizationKeys } from 'data/organizations/keys' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation' import { useOrganizationPaymentMethodMarkAsDefaultMutation } from 'data/organizations/organization-payment-method-default-mutation' +import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query' +import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useState } from 'react' +import { isEqual } from 'lodash' +import { useRef, useState } from 'react' import { toast } from 'sonner' import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' interface AddPaymentMethodFormProps { returnUrl: string onCancel: () => void onConfirm: () => void - showSetDefaultCheckbox?: boolean - autoMarkAsDefaultPaymentMethod?: boolean } // Stripe docs recommend to use the new SetupIntent flow over // manually creating and attaching payment methods via the API // Small UX annoyance here, that the page will be refreshed -const AddPaymentMethodForm = ({ - returnUrl, - onCancel, - onConfirm, - showSetDefaultCheckbox = false, - autoMarkAsDefaultPaymentMethod = false, -}: AddPaymentMethodFormProps) => { - const stripe = useStripe() - const elements = useElements() +const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps) => { const selectedOrganization = useSelectedOrganization() + const { data: customerProfile, isLoading: customerProfileLoading } = + useOrganizationCustomerProfileQuery({ + slug: selectedOrganization?.slug, + }) + const [isSaving, setIsSaving] = useState(false) - const [isDefault, setIsDefault] = useState(showSetDefaultCheckbox) + const [isDefaultPaymentMethod, setIsDefaultPaymentMethod] = useState(true) + const [isPrimaryBillingAddress, setIsPrimaryBillingAddress] = useState(true) const queryClient = useQueryClient() const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation() + const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation() + const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation() + const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ + slug: selectedOrganization?.slug, + }) + + const paymentRef = useRef(null) const handleSubmit = async (event: any) => { event.preventDefault() - if (!stripe || !elements) { - console.error('Stripe.js has not loaded') - return - } - setIsSaving(true) if (document !== undefined) { @@ -51,25 +58,20 @@ const AddPaymentMethodForm = ({ document.body.classList.add('!pointer-events-auto') } - const { error, setupIntent } = await stripe.confirmSetup({ - elements, - redirect: 'if_required', - confirmParams: { return_url: returnUrl }, - }) + const result = await paymentRef.current?.confirmSetup() - if (error) { + if (!result) { setIsSaving(false) - toast.error(error?.message ?? ' Failed to save card details') } else { if ( - (isDefault || autoMarkAsDefaultPaymentMethod) && + isDefaultPaymentMethod && selectedOrganization && - typeof setupIntent?.payment_method === 'string' + typeof result.setupIntent?.payment_method === 'string' ) { try { await markAsDefault({ slug: selectedOrganization.slug, - paymentMethodId: setupIntent.payment_method, + paymentMethodId: result.setupIntent.payment_method, }) await queryClient.invalidateQueries( @@ -82,10 +84,10 @@ const AddPaymentMethodForm = ({ if (!prev) return prev return { ...prev, - defaultPaymentMethodId: setupIntent.payment_method, + defaultPaymentMethodId: result.setupIntent.payment_method, data: prev.data.map((pm: any) => ({ ...pm, - is_default: pm.id === setupIntent.payment_method, + is_default: pm.id === result.setupIntent.payment_method, })), } } @@ -101,6 +103,28 @@ const AddPaymentMethodForm = ({ } } + if (isPrimaryBillingAddress) { + try { + if ( + result.address && + (!isEqual(result.address, customerProfile?.address) || + customerProfile?.billing_name !== result.customerName) + ) { + await updateCustomerProfile({ + slug: selectedOrganization?.slug, + billing_name: result.customerName, + address: result.address, + }) + } + + if (result.taxId && !isEqual(result.taxId, taxId)) { + await updateTaxId({ taxId: result.taxId, slug: selectedOrganization?.slug }) + } + } catch (error) { + toast.error('Failed to update billing address') + } + } + setIsSaving(false) onConfirm() } @@ -110,33 +134,64 @@ const AddPaymentMethodForm = ({ } } + if (customerProfileLoading || isCustomerTaxIdLoading) { + return ( + +
+ + + + + + +
+
+ ) + } + return (
- - {showSetDefaultCheckbox && ( -
- { - if (typeof checked === 'boolean') { - setIsDefault(checked) - } - }} - /> - - Save as default payment method - -
- )} + +
+ { + if (typeof checked === 'boolean') { + setIsDefaultPaymentMethod(checked) + } + }} + /> + + Save as default payment method + +
+ +
+ { + if (typeof checked === 'boolean') { + setIsPrimaryBillingAddress(checked) + } + }} + /> + + Use the billing address as my organization's primary address + +
diff --git a/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts b/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts index 51f095f2e3a..ff8b08bbc7d 100644 --- a/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts +++ b/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts @@ -4,6 +4,7 @@ export const getStripeElementsAppearanceOptions = ( resolvedTheme: string | undefined ): Appearance => { return { + labels: 'floating', theme: (resolvedTheme?.includes('dark') ? 'night' : 'flat') as 'night' | 'flat', variables: { fontSizeBase: '14px', @@ -12,18 +13,27 @@ export const getStripeElementsAppearanceOptions = ( : 'hsl(0deg 0% 95.3%)', fontFamily: 'var(--font-custom, Circular, custom-font, Helvetica Neue, Helvetica, Arial, sans-serif)', - spacingUnit: '4px', - borderRadius: '.375rem', - gridRowSpacing: '4px', }, rules: { - '.Label': { - // Hide labels - it is obvious enough what the fields are for - fontSize: '0', - }, '.TermsText': { fontSize: '12px', }, + '.Label--floating': { + fontSize: '14px', + }, + '.Label--resting': { + fontSize: '14px', + color: 'rgb(137, 137, 137)', + }, + '.Input': { + boxShadow: 'none', + height: '34px', + lineHeight: '16px', + padding: '8px 12px', + }, + '.AccordionItem': { + boxShadow: 'none', + }, }, } } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx index 5082e90df99..a137720baa9 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx @@ -20,15 +20,18 @@ import { useAsyncCheckProjectPermissions, useCheckPermissions, } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button, Card, CardFooter, Form_Shadcn_ as Form } from 'ui' -import { BillingCustomerDataForm } from './BillingCustomerDataForm' +import { + BillingCustomerDataForm, + type BillingCustomerDataFormValues, +} from './BillingCustomerDataForm' import { TAX_IDS } from './TaxID.constants' import { useBillingCustomerDataForm } from './useBillingCustomerDataForm' export const BillingCustomerData = () => { const { slug } = useParams() - const selectedOrganization = useSelectedOrganization() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const { isSuccess: isPermissionsLoaded, can: canReadBillingCustomerData } = useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.customer') @@ -51,9 +54,13 @@ export const BillingCustomerData = () => { isSuccess: loadedTaxId, } = useOrganizationTaxIdQuery({ slug }) - const initialCustomerData = useMemo( + const initialCustomerData = useMemo>( () => ({ - ...customerProfile?.address, + city: customerProfile?.address?.city ?? undefined, + country: customerProfile?.address?.country, + line1: customerProfile?.address?.line1, + line2: customerProfile?.address?.line2 ?? undefined, + postal_code: customerProfile?.address?.postal_code ?? undefined, billing_name: customerProfile?.billing_name, tax_id_type: taxId?.type, tax_id_value: taxId?.value, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx deleted file mode 100644 index 9fac673f8fc..00000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Pencil } from 'lucide-react' -import { useMemo, useState } from 'react' -import { toast } from 'sonner' - -import AlertError from 'components/ui/AlertError' -import NoPermission from 'components/ui/NoPermission' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' -import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation' -import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query' -import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogSectionSeparator, - DialogTitle, - Form_Shadcn_ as Form, - Label_Shadcn_ as Label, -} from 'ui' -import { BillingCustomerDataForm } from './BillingCustomerDataForm' -import { TAX_IDS } from './TaxID.constants' -import { useBillingCustomerDataForm } from './useBillingCustomerDataForm' - -export const BillingCustomerDataExistingOrgDialog = () => { - const [open, setOpen] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) - - const { slug } = useSelectedOrganization() ?? {} - - const canReadBillingCustomerData = useCheckPermissions( - PermissionAction.BILLING_READ, - 'stripe.customer' - ) - - const canUpdateBillingCustomerData = useCheckPermissions( - PermissionAction.BILLING_WRITE, - 'stripe.customer' - ) - - const { - data: customerProfile, - error, - isLoading, - isSuccess, - } = useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingCustomerData }) - - const { - data: taxId, - error: errorTaxId, - isLoading: isLoadingTaxId, - isSuccess: isSuccessTaxId, - } = useOrganizationTaxIdQuery({ slug }) - - const initialCustomerData = useMemo( - () => ({ - ...customerProfile?.address, - billing_name: customerProfile?.billing_name, - tax_id_type: taxId?.type, - tax_id_value: taxId?.value, - tax_id_name: taxId - ? TAX_IDS.find( - (option) => option.type === taxId.type && option.countryIso2 === taxId.country - )?.name || '' - : '', - }), - [customerProfile, taxId] - ) - - const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation() - const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation() - - const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({ - initialCustomerData, - onCustomerDataChange: async (data) => { - setIsSubmitting(true) - - try { - await updateCustomerProfile({ - slug, - address: data.address ?? undefined, - billing_name: data.billing_name, - }) - - await updateTaxId({ slug, taxId: data.tax_id }) - - toast.success('Successfully updated billing data') - - setOpen(false) - - setIsSubmitting(false) - } catch (error: any) { - toast.error(`Failed updating billing data: ${error.message}`) - setIsSubmitting(false) - } - }, - }) - - const handleClose = () => { - handleReset() - setOpen(false) - } - - const getAddressSummary = () => { - if (!customerProfile?.address?.line1) return 'Optionally add a billing address' - - const parts = [ - customerProfile?.billing_name, - customerProfile?.address?.line1, - customerProfile?.address?.city, - customerProfile?.address?.state, - customerProfile?.address?.country, - ].filter(Boolean) - - return parts.join(', ') - } - - const isSubmitDisabled = !isDirty || !canUpdateBillingCustomerData || isSubmitting - - return ( - <> -
- - {!canReadBillingCustomerData ? ( - - ) : ( - <> - {(isLoading || isLoadingTaxId) && ( -
- -
- )} - {(error || errorTaxId) && ( - - )} - {isSuccess && isSuccessTaxId && ( -
-

{getAddressSummary()}

- -
- )} - - )} -
- - { - if (!value) setOpen(false) - else setOpen(true) - }} - > - - - Billing Address & Tax Id - - This will be reflected in every upcoming invoice, past invoices are not affected - - - -
- - - - {!canUpdateBillingCustomerData && ( - - You need additional permissions to manage this organization's billing address - - )} -
- - -
-
- - -
-
- - ) -} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx index 7b7a8da1333..98e84f71649 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx @@ -2,7 +2,7 @@ import { Check, ChevronsUpDown, X } from 'lucide-react' import { UseFormReturn } from 'react-hook-form' import { z } from 'zod' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Button, cn, @@ -90,18 +90,24 @@ export const BillingCustomerDataForm = ({ form.setValue('tax_id_value', '', { shouldDirty: true }) } - const { tax_id_name } = form.watch() + const { tax_id_name, country } = form.watch() const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) + const availableTaxIds = useMemo(() => { + return TAX_IDS.filter((taxId) => !country || taxId.countryIso2 === country).sort((a, b) => + a.country.localeCompare(b.country) + ) + }, [country]) + return (
( - + - + @@ -112,9 +118,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="line1" render={({ field }: { field: any }) => ( - + - + @@ -125,9 +131,13 @@ export const BillingCustomerDataForm = ({ control={form.control} name="line2" render={({ field }: { field: any }) => ( - + - + @@ -139,7 +149,7 @@ export const BillingCustomerDataForm = ({ control={form.control} name="country" render={({ field }: { field: any }) => ( - + @@ -207,9 +217,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="postal_code" render={({ field }: { field: any }) => ( - + - + @@ -222,9 +232,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="city" render={({ field }: { field: any }) => ( - + - + @@ -234,9 +244,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="state" render={({ field }: { field: any }) => ( - + - + @@ -281,26 +291,24 @@ export const BillingCustomerDataForm = ({ No tax ID found. - {TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map( - (option) => ( - { - onSelectTaxIdType(option.name) - setShowTaxIDsPopover(false) - }} - > - - {option.country} - {option.name} - - ) - )} + {availableTaxIds.map((option) => ( + { + onSelectTaxIdType(option.name) + setShowTaxIDsPopover(false) + }} + > + + {option.country} - {option.name} + + ))} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx deleted file mode 100644 index dd072469401..00000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { FocusTrap } from '@headlessui/react' -import { Pencil } from 'lucide-react' -import { useState } from 'react' - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogSectionSeparator, - DialogTitle, - DialogTrigger, - Form_Shadcn_ as Form, -} from 'ui' -import { BillingCustomerDataForm } from './BillingCustomerDataForm' -import { FormCustomerData, useBillingCustomerDataForm } from './useBillingCustomerDataForm' - -interface BillingCustomerDataNewOrgDialogProps { - onCustomerDataChange: (data: FormCustomerData) => void -} - -export const BillingCustomerDataNewOrgDialog = ({ - onCustomerDataChange, -}: BillingCustomerDataNewOrgDialogProps) => { - const [open, setOpen] = useState(false) - - const handleDialogClose = () => { - setOpen(false) - } - - const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({ - onCustomerDataChange, - }) - - const handleClose = () => { - handleReset() - handleDialogClose() - } - - const getAddressSummary = () => { - const { line1, city, state, country, billing_name } = form.getValues() - - if (!line1) return 'Optionally add a billing address' - - const parts = [billing_name, line1, city, state, country].filter(Boolean) - - return parts.join(', ') - } - - const isSubmitDisabled = !isDirty - - return ( -
- - { - if (!value) handleDialogClose() - else setOpen(true) - }} - > - - - - - - Billing Address & Tax Id - - - {/* - [Joshen] There's something odd going on with using Dialog and RHF FormField here - where the focus keeps going to the Dialog when tabbing across the input fields, hence the FocusTrap - What's weirder is that once you've cycled the focus through all the inputs at least once, the focus - then no longer goes to the Dialog thereafter - FWIW it's likely happening across the dashboard whereever we're using Dialog and FormField - */} - -
- { - e.preventDefault() - e.stopPropagation() - form.handleSubmit(handleSubmit)(e) - - // Only close the dialog if the form is valid - if (!Object.keys(form.formState.errors).length) { - handleDialogClose() - } - }} - > - - -
- - -
-
- - -
-
-
-
- ) -} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts index dcc09c8e795..290af4077ed 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts @@ -1,8 +1,3 @@ -// List of available Tax IDs as reflected in Stripe's web portal -// This was manually ported over so there may be a chance of mistakes -// Last updated as of 29th March 2022. -// The code may not necessarily match with the name (ref SE_VAT) -// https://stripe.com/docs/api/customer_tax_ids/create export interface TaxId { name: string type: string diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts index aef1e286377..176a3e4f19d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts @@ -6,9 +6,10 @@ import { BillingCustomerDataFormValues, BillingCustomerDataSchema } from './Bill import { TAX_IDS } from './TaxID.constants' import { sanitizeTaxIdValue } from './TaxID.utils' import { components } from 'api-types' +import type { CustomerAddress } from 'data/organizations/types' export type FormCustomerData = { - address: components['schemas']['CustomerResponse']['address'] | undefined + address: CustomerAddress | undefined billing_name: string tax_id: components['schemas']['TaxIdResponse']['tax_id'] | null } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index 341df597737..9141ad9efc6 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -7,7 +7,7 @@ import { useQueryClient } from '@tanstack/react-query' import { AlertCircle, Info } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' @@ -17,7 +17,6 @@ import { useOrganizationCreditTopUpMutation } from 'data/organizations/organizat import { subscriptionKeys } from 'data/subscriptions/keys' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import { STRIPE_PUBLIC_KEY } from 'lib/constants' -import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' import { Alert_Shadcn_, AlertDescription_Shadcn_, @@ -40,6 +39,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import PaymentMethodSelection from './Subscription/PaymentMethodSelection' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' +import type { PaymentMethodElementRef } from './PaymentMethods/NewPaymentMethodElement' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -59,6 +59,9 @@ type CreditTopUpForm = z.infer export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const { resolvedTheme } = useTheme() const queryClient = useQueryClient() + const paymentMethodSelectionRef = useRef<{ + createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] + }>(null) const canTopUpCredits = useCheckPermissions( PermissionAction.BILLING_WRITE, @@ -82,7 +85,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const [topUpModalVisible, setTopUpModalVisible] = useState(false) const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) - const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) @@ -96,7 +98,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { } const initHcaptcha = async () => { - if (topUpModalVisible && captchaRef && captchaLoaded) { + if (topUpModalVisible && captchaRef) { let token = captchaToken try { @@ -116,7 +118,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { useEffect(() => { initHcaptcha() - }, [topUpModalVisible, captchaRef, captchaLoaded]) + }, [topUpModalVisible, captchaRef]) const [paymentIntentSecret, setPaymentIntentSecret] = useState('') const [paymentIntentConfirmation, setPaymentIntentConfirmation] = useState() @@ -126,12 +128,20 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const token = await initHcaptcha() + const paymentMethodResult = await paymentMethodSelectionRef.current?.createPaymentMethod() + if (!paymentMethodResult) { + return + } + await topUpCredits( { slug, amount, - payment_method_id: paymentMethod, + payment_method_id: paymentMethodResult.paymentMethod.id, hcaptchaToken: token, + address: paymentMethodResult.address, + tax_id: paymentMethodResult.taxId ?? undefined, + billing_name: paymentMethodResult.customerName, }, { onSuccess: (data) => { @@ -225,10 +235,21 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { /> Top Up Credits - - On successful payment, an invoice will be issued and you'll be granted credits. - Credits will be applied to outstanding and future invoices and are not refundable. The - topped up credits do not expire. + +

+ On successful payment, an invoice will be issued and you'll be granted credits. + Credits will be applied to outstanding and future invoices and are not refundable. + The topped up credits do not expire. +

+

+ For larger discounted credit packages, please{' '} + + reach out. + +

@@ -252,7 +273,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { name="paymentMethod" render={() => ( form.setValue('paymentMethod', pm)} selectedPaymentMethod={form.getValues('paymentMethod')} readOnly={executingTopUp || paymentConfirmationLoading} @@ -260,16 +281,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { )} /> -

- For larger discounted credit packages, please{' '} - - reach out. - -

- {paymentIntentConfirmation && paymentIntentConfirmation.error && ( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx index 9070143191f..11a41994047 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx @@ -4,26 +4,148 @@ * If Elements is on a higher level, we risk losing all form state in case a payment fails. */ -import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' -import { forwardRef, useImperativeHandle } from 'react' +import { AddressElement, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import { + StripeAddressElementChangeEvent, + StripeAddressElementOptions, + type SetupIntent, +} from '@stripe/stripe-js' +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' import { toast } from 'sonner' +import { + Button, + Checkbox_Shadcn_, + cn, + Command_Shadcn_ as Command, + CommandEmpty_Shadcn_ as CommandEmpty, + CommandGroup_Shadcn_ as CommandGroup, + CommandInput_Shadcn_ as CommandInput, + CommandItem_Shadcn_ as CommandItem, + CommandList_Shadcn_ as CommandList, + FormControl_Shadcn_ as FormControl, + FormField_Shadcn_ as FormField, + FormItem_Shadcn_, + FormMessage_Shadcn_ as FormMessage, + Input_Shadcn_ as Input, + Popover_Shadcn_ as Popover, + PopoverContent_Shadcn_ as PopoverContent, + PopoverTrigger_Shadcn_ as PopoverTrigger, +} from 'ui' +import { TAX_IDS, type TaxId } from '../BillingCustomerData/TaxID.constants' +import { z } from 'zod' +import { useForm } from 'react-hook-form' +import { Form } from '@ui/components/shadcn/ui/form' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { Check, ChevronsUpDown } from 'lucide-react' +import { zodResolver } from '@hookform/resolvers/zod' +import { getURL } from 'lib/helpers' +import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types' +import type { PaymentMethod } from '@stripe/stripe-js' + +export const BillingCustomerDataSchema = z.object({ + tax_id_type: z.string(), + tax_id_value: z.string().min(2, { + message: 'Tax ID needs to be set.', + }), + tax_id_name: z.string(), +}) + +type BillingCustomerDataFormValues = z.infer + +export type PaymentMethodElementRef = { + confirmSetup: () => Promise< + | { + setupIntent: SetupIntent + address: CustomerAddress + customerName: string + taxId: CustomerTaxId | null + } + | undefined + > + createPaymentMethod: () => Promise< + | { + paymentMethod: PaymentMethod + address: CustomerAddress | null + customerName: string | null + taxId: CustomerTaxId | null + } + | undefined + > +} const NewPaymentMethodElement = forwardRef( ( { email, readOnly, + currentAddress, + currentTaxId, + customerName, }: { email?: string | null | undefined readOnly: boolean + currentAddress?: CustomerAddress | null + currentTaxId?: CustomerTaxId | null + customerName?: string | undefined }, ref ) => { const stripe = useStripe() const elements = useElements() - const createPaymentMethod = async () => { + const form = useForm({ + resolver: zodResolver(BillingCustomerDataSchema), + defaultValues: { + tax_id_name: currentTaxId + ? TAX_IDS.find( + (option) => + option.type === currentTaxId.type && option.countryIso2 === currentTaxId.country + )?.name || '' + : '', + tax_id_type: currentTaxId ? currentTaxId.type : '', + tax_id_value: currentTaxId ? currentTaxId.value : '', + }, + }) + + // To avoid rendering the business checkbox prematurely and causing weird layout shifts, we wait until the address element is fully loaded + const [fullyLoaded, setFullyLoaded] = useState(false) + + const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false) + + const onSelectTaxIdType = (name: string) => { + const selectedTaxIdOption = TAX_IDS.find((option) => option.name === name) + if (!selectedTaxIdOption) return + form.setValue('tax_id_type', selectedTaxIdOption.type) + form.setValue('tax_id_value', '') + form.setValue('tax_id_name', name) + } + + const { tax_id_name } = form.watch() + const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) + + const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null) + const [stripeAddress, setStripeAddress] = useState< + StripeAddressElementChangeEvent['value'] | undefined + >(undefined) + + const availableTaxIds = useMemo(() => { + const country = stripeAddress?.address.country || null + + return TAX_IDS.filter((taxId) => country == null || taxId.countryIso2 === country).sort( + (a, b) => a.country.localeCompare(b.country) + ) + }, [stripeAddress]) + + const createPaymentMethod = async (): ReturnType< + PaymentMethodElementRef['createPaymentMethod'] + > => { if (!stripe || !elements) return + await form.trigger() + + if (purchasingAsBusiness && availableTaxIds.length > 0 && !form.getValues('tax_id_value')) { + return + } + await elements.submit() // To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment @@ -34,17 +156,209 @@ const NewPaymentMethodElement = forwardRef( toast.error(error?.message ?? ' Failed to process card details') return } - return paymentMethod + + const addressElement = await elements.getElement('address')!.getValue() + return { + paymentMethod, + address: { + ...addressElement.value.address, + line2: addressElement.value.address.line2 || undefined, + }, + customerName: addressElement.value.name, + taxId: getConfiguredTaxId(), + } + } + + function getConfiguredTaxId(): CustomerTaxId | null { + return purchasingAsBusiness && selectedTaxId + ? { + country: selectedTaxId.countryIso2, + type: selectedTaxId.type, + value: form.getValues('tax_id_value'), + } + : null + } + + const confirmSetup = async (): ReturnType => { + if (!stripe || !elements) return + + await elements.submit() + + const { error, setupIntent } = await stripe.confirmSetup({ + elements, + redirect: 'if_required', + confirmParams: { return_url: `${getURL()}/org/_/billing` }, + }) + + if (error || setupIntent == null) { + toast.error(error?.message ?? ' Failed to process card details') + return + } + + const addressElement = await elements.getElement('address')!.getValue() + return { + setupIntent, + address: { + ...addressElement.value.address, + line2: addressElement.value.address.line2 || undefined, + }, + customerName: addressElement.value.name, + taxId: getConfiguredTaxId(), + } } useImperativeHandle(ref, () => ({ createPaymentMethod, + confirmSetup, })) + const addressOptions: StripeAddressElementOptions = useMemo( + () => ({ + mode: 'billing', + autocomplete: { + apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!, + mode: 'google_maps_api', + }, + display: { name: purchasingAsBusiness ? 'organization' : 'full' }, + defaultValues: { + address: currentAddress ?? undefined, + name: customerName, + }, + }), + [purchasingAsBusiness] + ) + + // Preselect tax id if there is no more than 2 available tax ids (even if there are two options, first one in the list is likely to be it) + useEffect(() => { + if (availableTaxIds.length && stripeAddress?.address.country && !currentTaxId) { + const taxIdOption = availableTaxIds[0] + form.setValue('tax_id_type', taxIdOption.type) + form.setValue('tax_id_value', '') + form.setValue('tax_id_name', taxIdOption.name) + } + }, [availableTaxIds, stripeAddress]) + return ( - +
+

+ Please ensure CVC and postal codes match what is on file for your card. +

+ + + + {fullyLoaded && ( +
+ setPurchasingAsBusiness(!purchasingAsBusiness)} + /> + +
+ )} + + setStripeAddress(evt.value)} + onReady={() => setFullyLoaded(true)} + /> + + {purchasingAsBusiness && availableTaxIds.length > 0 && ( +
+
+ ( + + + + + + + + + + + + No tax ID found. + + {availableTaxIds.map((option) => ( + { + onSelectTaxIdType(option.name) + setShowTaxIDsPopover(false) + }} + > + + {option.country} - {option.name} + + ))} + + + + + + + + )} + /> + + {selectedTaxId && ( + ( + + + + + + + )} + /> + )} +
+
+ )} +
) } ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx index 15b85fa5ecf..93e42b96b51 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx @@ -176,7 +176,6 @@ const PaymentMethods = () => { setShowAddPaymentMethodModal(false) toast.success('Successfully added new payment method') }} - showSetDefaultCheckbox={true} /> { - const selectedOrganization = useSelectedOrganization() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const orgSlug = selectedOrganization?.slug const features = pickFeatures(plan, billingPartner) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx index 44ea19292d2..510d5a1b8c2 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx @@ -1,5 +1,5 @@ import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button, Modal } from 'ui' export interface MembersExceedLimitModalProps { @@ -8,7 +8,7 @@ export interface MembersExceedLimitModalProps { } const MembersExceedLimitModal = ({ visible, onClose }: MembersExceedLimitModalProps) => { - const selectedOrganization = useSelectedOrganization() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const slug = selectedOrganization?.slug const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery( { slug }, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 3754491fbd1..b775173a3c8 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -1,4 +1,3 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { forwardRef, useCallback, @@ -9,26 +8,30 @@ import { useState, } from 'react' import { toast } from 'sonner' - -import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { + useSelectedOrganization, + useSelectedOrganizationQuery, +} from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, STRIPE_PUBLIC_KEY } from 'lib/constants' -import { getURL } from 'lib/helpers' -import { AlertCircle, CreditCard, Loader, Plus } from 'lucide-react' -import { Listbox } from 'ui' +import { Loader, Plus } from 'lucide-react' +import { Checkbox_Shadcn_, Listbox } from 'ui' import HCaptcha from '@hcaptcha/react-hcaptcha' -import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation' import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation' import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' import { useTheme } from 'next-themes' import { Elements } from '@stripe/react-stripe-js' -import { NewPaymentMethodElement } from '../PaymentMethods/NewPaymentMethodElement' +import { + NewPaymentMethodElement, + type PaymentMethodElementRef, +} from '../PaymentMethods/NewPaymentMethodElement' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { useFlag } from 'hooks/ui/useFlag' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query' +import { useParams } from 'common' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -36,7 +39,6 @@ export interface PaymentMethodSelectionProps { selectedPaymentMethod?: string onSelectPaymentMethod: (id: string) => void layout?: 'vertical' | 'horizontal' - createPaymentMethodInline: boolean readOnly: boolean } @@ -45,27 +47,48 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( selectedPaymentMethod, onSelectPaymentMethod, layout = 'vertical', - createPaymentMethodInline = false, readOnly, }: PaymentMethodSelectionProps, ref ) { - const selectedOrganization = useSelectedOrganization() - const slug = selectedOrganization?.slug - const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false) - const captchaLoaded = useIsHCaptchaLoaded() + const { slug } = useParams() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) const [setupIntent, setSetupIntent] = useState(undefined) + const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true) const { resolvedTheme } = useTheme() - const paymentRef = useRef<{ createPaymentMethod: () => Promise }>(null) + const paymentRef = useRef(null) const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState(null) + const { data: customerProfile, isLoading: isCustomerProfileLoading } = + useOrganizationCustomerProfileQuery({ + slug, + }) + const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ slug }) - const { - data: paymentMethods, - isLoading, - refetch: refetchPaymentMethods, - } = useOrganizationPaymentMethodsQuery({ slug }) + const hidePaymentMethodsWithoutAddress = useFlag('hidePaymentMethodsWithoutAddress') + + const { data: allPaymentMethods, isLoading } = useOrganizationPaymentMethodsQuery({ slug }) + + const paymentMethods = useMemo(() => { + if (!allPaymentMethods) + return { + data: [], + defaultPaymentMethodId: null, + } + + const filtered = allPaymentMethods.data.filter( + (pm) => !hidePaymentMethodsWithoutAddress || pm.has_address + ) + return { + data: filtered, + defaultPaymentMethodId: allPaymentMethods.data.some( + (pm) => pm.id === allPaymentMethods.defaultPaymentMethodId + ) + ? allPaymentMethods.defaultPaymentMethodId + : null, + } + }, [allPaymentMethods]) const captchaRefCallback = useCallback((node: any) => { setCaptchaRef(node) @@ -98,7 +121,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( } const loadPaymentForm = async () => { - if (setupNewPaymentMethod && createPaymentMethodInline && captchaRef && captchaLoaded) { + if (setupNewPaymentMethod && captchaRef) { let token = captchaToken try { @@ -116,18 +139,13 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( } loadPaymentForm() - }, [createPaymentMethodInline, captchaRef, captchaLoaded, setupNewPaymentMethod]) + }, [captchaRef, setupNewPaymentMethod]) const resetCaptcha = () => { setCaptchaToken(null) captchaRef?.resetCaptcha() } - const canUpdatePaymentMethods = useCheckPermissions( - PermissionAction.BILLING_WRITE, - 'stripe.payment_methods' - ) - const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo( () => ({ @@ -156,11 +174,27 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( }, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod]) // If createPaymentMethod already exists, use it. Otherwise, define it here. - const createPaymentMethod = async () => { + const createPaymentMethod = async (): ReturnType< + PaymentMethodElementRef['createPaymentMethod'] + > => { if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) { - return paymentRef.current?.createPaymentMethod() + const paymentResult = await paymentRef.current?.createPaymentMethod() + + if (!paymentResult) return paymentResult + + return { + paymentMethod: paymentResult.paymentMethod, + customerName: useAsDefaultBillingAddress ? paymentResult.customerName : null, + address: useAsDefaultBillingAddress ? paymentResult.address : null, + taxId: useAsDefaultBillingAddress ? paymentResult.taxId : null, + } } else { - return { id: selectedPaymentMethod } + return { + paymentMethod: { id: selectedPaymentMethod } as PaymentMethod, + customerName: useAsDefaultBillingAddress ? customerProfile?.billing_name || '' : null, + address: useAsDefaultBillingAddress ? customerProfile?.address ?? null : null, + taxId: useAsDefaultBillingAddress ? taxId ?? null : null, + } } } @@ -197,42 +231,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(

Retrieving payment methods

- ) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? ( -
-
- -

No payment methods

-
- - } - onClick={() => { - if (createPaymentMethodInline) { - setSetupNewPaymentMethod(true) - } else { - setShowAddNewPaymentMethodModal(true) - } - }} - htmlType="button" - tooltip={{ - content: { - side: 'bottom', - text: !canUpdatePaymentMethods ? ( -
- - You need additional permissions to add new payment methods to this - organization - -
- ) : undefined, - }, - }} - > - Add new -
-
) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? ( { - if (createPaymentMethodInline) { - setSetupNewPaymentMethod(true) - } else { - setShowAddNewPaymentMethodModal(true) - } + setSetupNewPaymentMethod(true) }} > @@ -282,17 +276,39 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( ) : null} - {stripePromise && setupIntent && ( - - - + {stripePromise && setupIntent && customerProfile && ( + <> + + + + + {/* If the customer already has a billing address, optionally allow overwriting it - if they have no address, we use that as a default */} + {customerProfile?.address != null && ( +
+ setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)} + /> + +
+ )} + )} - {setupIntentLoading && ( + {(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
@@ -303,26 +319,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
)}
- - setShowAddNewPaymentMethodModal(false)} - autoMarkAsDefaultPaymentMethod={true} - onConfirm={async () => { - setShowAddNewPaymentMethodModal(false) - toast.success('Successfully added new payment method') - const { data: refetchedPaymentMethods } = await refetchPaymentMethods() - if (refetchedPaymentMethods?.data?.length) { - // Preselect the card that was just added - const mostRecentPaymentMethod = refetchedPaymentMethods?.data.reduce( - (prev, current) => (prev.created > current.created ? prev : current), - refetchedPaymentMethods.data[0] - ) - onSelectPaymentMethod(mostRecentPaymentMethod.id) - } - }} - /> ) }) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index a91fa6368eb..e8c1082ff4e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -18,7 +18,10 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import type { OrgPlan } from 'data/subscriptions/types' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { + useSelectedOrganization, + useSelectedOrganizationQuery, +} from 'hooks/misc/useSelectedOrganization' import { formatCurrency } from 'lib/helpers' import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' @@ -26,14 +29,15 @@ import { Button, SidePanel, cn } from 'ui' import DowngradeModal from './DowngradeModal' import { EnterpriseCard } from './EnterpriseCard' import { ExitSurveyModal } from './ExitSurveyModal' +import { useParams } from 'common' import MembersExceedLimitModal from './MembersExceedLimitModal' import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog' import UpgradeSurveyModal from './UpgradeModal' const PlanUpdateSidePanel = () => { const router = useRouter() - const selectedOrganization = useSelectedOrganization() - const slug = selectedOrganization?.slug + const { slug } = useParams() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const { mutate: sendEvent } = useSendEventMutation() const originalPlanRef = useRef() diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 4b180b48862..88892c72e4b 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -13,22 +13,23 @@ import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizatio import { ProjectInfo } from 'data/projects/projects-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' import { SubscriptionTier } from 'data/subscriptions/types' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS, STRIPE_PUBLIC_KEY } from 'lib/constants' import { formatCurrency } from 'lib/helpers' -import { Badge, Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui' +import { Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui' import { Admonition } from 'ui-patterns' import { InfoTooltip } from 'ui-patterns/info-tooltip' -import { BillingCustomerDataExistingOrgDialog } from '../BillingCustomerData/BillingCustomerDataExistingOrgDialog' import PaymentMethodSelection from './PaymentMethodSelection' import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' import { Elements } from '@stripe/react-stripe-js' -import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' +import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js' import { useTheme } from 'next-themes' import { PaymentIntentResult } from '@stripe/stripe-js' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' import { plans as subscriptionsPlans } from 'shared-data/plans' +import type { PaymentMethodElementRef } from '../PaymentMethods/NewPaymentMethodElement' +import { useParams } from 'common' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -76,12 +77,12 @@ export const SubscriptionPlanUpdateDialog = ({ projects, }: Props) => { const { resolvedTheme } = useTheme() - const selectedOrganization = useSelectedOrganization() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) - const paymentMethodSelection = useRef<{ - createPaymentMethod: () => Promise + const paymentMethodSelectionRef = useRef<{ + createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] }>(null) const billingViaPartner = subscription?.billing_via_partner === true @@ -161,18 +162,14 @@ export const SubscriptionPlanUpdateDialog = ({ setPaymentConfirmationLoading(true) - const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod() - if (paymentMethod) { - setSelectedPaymentMethod(paymentMethod.id) + const result = await paymentMethodSelectionRef.current?.createPaymentMethod() + if (result) { + setSelectedPaymentMethod(result.paymentMethod.id) } else { setPaymentConfirmationLoading(false) } - if ( - !paymentMethod && - subscription?.payment_method_type !== 'invoice' && - changeType === 'upgrade' - ) { + if (!result && subscription?.payment_method_type !== 'invoice' && changeType === 'upgrade') { return } @@ -185,7 +182,10 @@ export const SubscriptionPlanUpdateDialog = ({ updateOrgSubscription({ slug: selectedOrganization?.slug, tier, - paymentMethod: paymentMethod?.id, + paymentMethod: result?.paymentMethod?.id, + address: result?.address, + tax_id: result?.taxId ?? undefined, + billing_name: result?.customerName ?? undefined, }) } @@ -246,13 +246,40 @@ export const SubscriptionPlanUpdateDialog = ({ {/* Left Column */}
-

- {changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} - {selectedOrganization?.name} to{' '} - {changeType === 'downgrade' - ? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default'] - : PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']} -

+
+ {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && ( +
+ setSelectedPaymentMethod(pm)} + readOnly={paymentConfirmationLoading || isConfirming || isUpdating} + /> +
+ )} + + {billingViaPartner && ( +
+

+ This organization is billed through our partner{' '} + {billingPartnerLabel(billingPartner)}.{' '} + {billingPartner === 'aws' ? ( + <>The organization's credit balance will be decreased accordingly. + ) : ( + <>You will be charged by them directly. + )} +

+ {billingViaPartner && + billingPartner === 'fly' && + subscriptionPreview?.plan_change_type === 'downgrade' && ( +

+ Your organization will be downgraded at the end of your current billing + cycle. +

+ )} +
+ )} +
{subscriptionPreviewIsLoading && (
@@ -264,48 +291,6 @@ export const SubscriptionPlanUpdateDialog = ({ {subscriptionPreviewInitialized && ( <>
-
-
- {subscriptionPlanMeta?.name} Plan - - New - -
-
- {formatCurrency(newPlanCost)} -
-
- {subscription?.plan?.id !== 'free' && ( -
-
- Unused Time on {subscription?.plan?.name} Plan - - Your previous plan was charged upfront, so a plan change will prorate - any unused time in credits. If the prorated credits exceed the new plan - charge, the excessive credits are added to your organization for future - use. - -
-
- -{formatCurrency(proratedCredit)} -
-
- )} - - {/* Ignore rare case with negative balance (debt) */} - {customerBalance > 0 && ( -
-
- Credits - - Credits will be used first before charging your card. - -
-
- {formatCurrency(customerBalance)} -
-
- )}
Charge today
@@ -324,6 +309,39 @@ export const SubscriptionPlanUpdateDialog = ({ )}
+ + {subscription?.plan?.id !== 'free' && ( +
+
+ Unused Time on {subscription?.plan?.name} Plan + + Your previous plan was charged upfront, so a plan change will prorate + any unused time in credits. If the prorated credits exceed the new plan + charge, the excessive credits are added to your organization for future + use. + +
+
+ -{formatCurrency(proratedCredit)} +
+
+ )} + + {/* Ignore rare case with negative balance (debt) */} + {customerBalance > 0 && ( +
+
+ Credits + + Credits will be used first before charging your card. + +
+
+ {formatCurrency(customerBalance)} +
+
+ )} +
Monthly invoice estimate @@ -542,47 +560,11 @@ export const SubscriptionPlanUpdateDialog = ({
- {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && ( -
- - - setSelectedPaymentMethod(pm)} - createPaymentMethodInline={true} - readOnly={paymentConfirmationLoading || isConfirming || isUpdating} - /> -
- )} - - {billingViaPartner && ( -
-

- This organization is billed through our partner{' '} - {billingPartnerLabel(billingPartner)}.{' '} - {billingPartner === 'aws' ? ( - <>The organization's credit balance will be decreased accordingly. - ) : ( - <>You will be charged by them directly. - )} -

- {billingViaPartner && - billingPartner === 'fly' && - subscriptionPreview?.plan_change_type === 'downgrade' && ( -

- Your organization will be downgraded at the end of your current billing - cycle. -

- )} -
- )} - {projects.filter( (it) => it.status === PROJECT_STATUS.ACTIVE_HEALTHY || it.status === PROJECT_STATUS.COMING_UP - ).length === 0 && + ).length === 5 && subscriptionPreview?.plan_change_type !== 'downgrade' && (
@@ -618,16 +600,13 @@ export const SubscriptionPlanUpdateDialog = ({ )}
- @@ -637,6 +616,13 @@ export const SubscriptionPlanUpdateDialog = ({ {/* Right Column */}
+

+ {changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} + {selectedOrganization?.name} to{' '} + {changeType === 'downgrade' + ? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default'] + : PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']} +

{changeType === 'downgrade' ? featuresToLose.length > 0 && (
diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index 2001d661385..e70d9a7ab87 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -11,20 +11,20 @@ import PartnerManagedResource from 'components/ui/PartnerManagedResource' import { getInvoice } from 'data/invoices/invoice-query' import { useInvoicesCountQuery } from 'data/invoices/invoices-count-query' import { useInvoicesQuery } from 'data/invoices/invoices-query' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { formatCurrency } from 'lib/helpers' import { ChevronLeft, ChevronRight, Download, FileText } from 'lucide-react' import { Button } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import InvoicePayButton from './InvoicePayButton' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' const PAGE_LIMIT = 5 const InvoicesSettings = () => { const [page, setPage] = useState(1) - const selectedOrganization = useSelectedOrganization() - const { slug } = selectedOrganization ?? {} + const { data: selectedOrganization } = useSelectedOrganizationQuery() + const slug = selectedOrganization?.slug const offset = (page - 1) * PAGE_LIMIT const { data: count, isError: isErrorCount } = useInvoicesCountQuery( @@ -33,7 +33,7 @@ const InvoicesSettings = () => { }, { enabled: selectedOrganization?.managed_by === 'supabase' } ) - const { data, error, isLoading, isError, isSuccess } = useInvoicesQuery( + const { data, error, isLoading, isError } = useInvoicesQuery( { slug, offset, diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 9721137b0bb..8128ed29eb4 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -32,8 +32,6 @@ import { TooltipTrigger, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { BillingCustomerDataNewOrgDialog } from '../BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog' -import { FormCustomerData } from '../BillingSettings/BillingCustomerData/useBillingCustomerDataForm' import { useConfirmPendingSubscriptionCreateMutation } from 'data/subscriptions/org-subscription-confirm-pending-create' import { loadStripe } from '@stripe/stripe-js' import { useTheme } from 'next-themes' @@ -41,7 +39,12 @@ import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation' import { useProfile } from 'lib/profile' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' -import { NewPaymentMethodElement } from '../BillingSettings/PaymentMethods/NewPaymentMethodElement' +import { + NewPaymentMethodElement, + type PaymentMethodElementRef, +} from '../BillingSettings/PaymentMethods/NewPaymentMethodElement' +import { components } from 'api-types' +import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types' const ORG_KIND_TYPES = { PERSONAL: 'Personal', @@ -90,6 +93,8 @@ type FormState = z.infer const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) +const newMandatoryAddressInput = true + /** * No org selected yet, create a new one * [Joshen] Need to refactor to use Form_Shadcn here @@ -115,8 +120,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr const [isOrgCreationConfirmationModalVisible, setIsOrgCreationConfirmationModalVisible] = useState(false) - const [customerData, setCustomerData] = useState(null) - const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo( () => ({ @@ -244,7 +247,14 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr } as StripeElementsOptions }, [paymentIntentSecret, resolvedTheme]) - async function createOrg(paymentMethodId?: string) { + async function createOrg( + paymentMethodId?: string, + customerData?: { + address: CustomerAddress | null + billing_name: string | null + tax_id: CustomerTaxId | null + } + ) { const dbTier = formState.plan === 'PRO' && !formState.spend_cap ? 'PAYG' : formState.plan createOrganization({ @@ -258,12 +268,12 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr ...(formState.kind == 'COMPANY' ? { size: formState.size } : {}), payment_method: paymentMethodId, billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name, - address: dbTier === 'FREE' ? undefined : customerData?.address, + address: dbTier === 'FREE' ? null : customerData?.address, tax_id: dbTier === 'FREE' ? undefined : customerData?.tax_id ?? undefined, }) } - const paymentRef = useRef<{ createPaymentMethod: () => Promise }>(null) + const paymentRef = useRef(null) const handleSubmit = async () => { setNewOrgLoading(true) @@ -271,10 +281,16 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr if (formState.plan === 'FREE') { await createOrg() } else if (!paymentMethod) { - const paymentMethod = await paymentRef.current?.createPaymentMethod() - if (paymentMethod) { - setPaymentMethod(paymentMethod) - createOrg(paymentMethod.id) + const result = await paymentRef.current?.createPaymentMethod() + if (result) { + setPaymentMethod(result.paymentMethod) + const customerData = { + address: result.address, + billing_name: result.customerName, + tax_id: result.taxId, + } + + createOrg(result.paymentMethod.id, customerData) } else { setNewOrgLoading(false) } @@ -340,6 +356,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
} + className="overflow-visible" >

This is your organization within Supabase.

@@ -532,21 +549,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr )} - {formState.plan !== 'FREE' && ( - -
-
- - Billing Address - -
-
- -
-
-
- )} - {setupIntent && formState.plan !== 'FREE' && ( diff --git a/apps/studio/csp.js b/apps/studio/csp.js index 775afd742fa..ce9cf6e5802 100644 --- a/apps/studio/csp.js +++ b/apps/studio/csp.js @@ -66,6 +66,8 @@ const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu' const PUSHER_URL = 'https://*.pusher.com' const PUSHER_URL_WS = 'wss://*.pusher.com' +const GOOGLE_MAPS_API_URL = 'https://maps.googleapis.com' + module.exports.getCSP = function getCSP() { const DEFAULT_SRC_URLS = [ API_URL, @@ -86,6 +88,7 @@ module.exports.getCSP = function getCSP() { SUPABASE_ASSETS_URL, USERCENTRICS_URLS, STAPE_URL, + GOOGLE_MAPS_API_URL, ] const SCRIPT_SRC_URLS = [ CLOUDFLARE_CDN_URL, @@ -144,6 +147,7 @@ module.exports.getCSP = function getCSP() { ...SCRIPT_SRC_URLS, VERCEL_LIVE_URL, PUSHER_URL, + GOOGLE_MAPS_API_URL, ].join(' ') const frameSrcDirective = [`frame-src 'self'`, ...FRAME_SRC_URLS, VERCEL_LIVE_URL].join(' ') diff --git a/apps/studio/data/organizations/organization-create-mutation.ts b/apps/studio/data/organizations/organization-create-mutation.ts index 3c28c0a4941..5baffba7b98 100644 --- a/apps/studio/data/organizations/organization-create-mutation.ts +++ b/apps/studio/data/organizations/organization-create-mutation.ts @@ -1,12 +1,12 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import type { components } from 'data/api' import { handleError, post } from 'data/fetchers' import { permissionKeys } from 'data/permissions/keys' import type { ResponseError } from 'types' import { organizationKeys } from './keys' import { castOrganizationResponseToOrganization } from './organizations-query' +import type { CustomerAddress, CustomerTaxId } from './types' export type OrganizationCreateVariables = { name: string @@ -14,9 +14,9 @@ export type OrganizationCreateVariables = { size?: string tier: 'tier_payg' | 'tier_pro' | 'tier_free' | 'tier_team' | 'tier_enterprise' payment_method?: string - billing_name?: string - address?: components['schemas']['CreateOrganizationBody']['address'] - tax_id?: components['schemas']['CreateOrganizationBody']['tax_id'] + billing_name?: string | null + address?: CustomerAddress | null + tax_id?: CustomerTaxId | null } export async function createOrganization({ @@ -36,9 +36,9 @@ export async function createOrganization({ size, tier, payment_method, - address, - billing_name, - tax_id, + billing_name: billing_name ?? undefined, + tax_id: tax_id ?? undefined, + address: address ?? undefined, }, }) diff --git a/apps/studio/data/organizations/organization-credit-top-up-mutation.ts b/apps/studio/data/organizations/organization-credit-top-up-mutation.ts index 53d41c85955..f69aa24a0cf 100644 --- a/apps/studio/data/organizations/organization-credit-top-up-mutation.ts +++ b/apps/studio/data/organizations/organization-credit-top-up-mutation.ts @@ -3,12 +3,16 @@ import { toast } from 'sonner' import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' +import type { CustomerAddress, CustomerTaxId } from './types' export type OrganizationCreditTopUpVariables = { payment_method_id: string amount: number slug?: string hcaptchaToken?: string | null + address?: CustomerAddress | null + tax_id?: CustomerTaxId | null + billing_name?: string | null } export async function topUpCredits({ @@ -16,6 +20,9 @@ export async function topUpCredits({ amount, slug, hcaptchaToken, + address, + tax_id, + billing_name, }: OrganizationCreditTopUpVariables) { if (!payment_method_id) { throw new Error('Payment method id required') @@ -35,7 +42,14 @@ export async function topUpCredits({ slug, }, }, - body: { payment_method_id, amount, hcaptcha_token: hcaptchaToken }, + body: { + payment_method_id, + amount, + hcaptcha_token: hcaptchaToken, + address: address ?? undefined, + tax_id: tax_id ?? undefined, + billing_name: billing_name ?? undefined, + }, }) if (error) handleError(error) diff --git a/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts b/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts index ae62b58a1df..1aa48d9ab91 100644 --- a/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts +++ b/apps/studio/data/organizations/organization-customer-profile-update-mutation.ts @@ -1,14 +1,14 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { components } from 'api-types' import { handleError, put } from 'data/fetchers' import type { ResponseError } from 'types' import { organizationKeys } from './keys' +import type { CustomerAddress } from './types' export type OrganizationCustomerProfileUpdateVariables = { slug?: string - address?: components['schemas']['CustomerResponse']['address'] + address?: CustomerAddress billing_name: string } diff --git a/apps/studio/data/organizations/types.ts b/apps/studio/data/organizations/types.ts new file mode 100644 index 00000000000..f9e608fbf3f --- /dev/null +++ b/apps/studio/data/organizations/types.ts @@ -0,0 +1,4 @@ +import type { components } from 'api-types' + +export type CustomerAddress = NonNullable +export type CustomerTaxId = NonNullable diff --git a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts index d2806ab227b..e8194196781 100644 --- a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -7,17 +7,24 @@ import type { ResponseError } from 'types/base' import { subscriptionKeys } from './keys' import type { SubscriptionTier } from './types' import { organizationKeys } from 'data/organizations/keys' +import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types' export type OrgSubscriptionUpdateVariables = { slug: string paymentMethod?: string tier: SubscriptionTier + address?: CustomerAddress | null + tax_id?: CustomerTaxId | null + billing_name?: string } export async function updateOrgSubscription({ slug, tier, paymentMethod, + address, + tax_id, + billing_name, }: OrgSubscriptionUpdateVariables) { if (!slug) throw new Error('slug is required') if (!tier) throw new Error('tier is required') @@ -29,6 +36,9 @@ export async function updateOrgSubscription({ body: { payment_method: payload.payment_method, tier: payload.tier, + address: address ?? undefined, + tax_id: tax_id ?? undefined, + billing_name, }, params: { path: { slug } }, }) diff --git a/apps/studio/hooks/misc/useSelectedOrganization.ts b/apps/studio/hooks/misc/useSelectedOrganization.ts index e6c789d5700..d937a06f8ed 100644 --- a/apps/studio/hooks/misc/useSelectedOrganization.ts +++ b/apps/studio/hooks/misc/useSelectedOrganization.ts @@ -10,10 +10,10 @@ import { useProjectByRef, useProjectByRefQuery } from './useSelectedProject' * Example migration: * ``` * // Old: - * const organization = useSelectedOrganization(ref) + * const organization = useSelectedOrganization() * * // New: - * const { data: organization } = useSelectedOrganizationQuery(ref) + * const { data: organization } = useSelectedOrganizationQuery() * ``` */ export function useSelectedOrganization({ enabled = true } = {}) { diff --git a/apps/studio/package.json b/apps/studio/package.json index 038669dd947..c2545b712f2 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -51,8 +51,8 @@ "@radix-ui/react-visually-hidden": "^1.1.3", "@sentry/nextjs": "^8.52.1", "@std/path": "npm:@jsr/std__path@^1.0.8", - "@stripe/react-stripe-js": "^3.1.1", - "@stripe/stripe-js": "^5.5.0", + "@stripe/react-stripe-js": "^3.7.0", + "@stripe/stripe-js": "^7.5.0", "@supabase/auth-js": "catalog:", "@supabase/mcp-server-supabase": "^0.4.4", "@supabase/mcp-utils": "^0.2.0", diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index bbf227f10cb..671af91dc79 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -47,7 +47,6 @@ import { getFlags as getConfigCatFlags } from 'lib/configcat' import { API_URL, BASE_PATH, IS_PLATFORM } from 'lib/constants' import { ProfileProvider } from 'lib/profile' import { Telemetry } from 'lib/telemetry' -import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store' import { AppPropsWithLayout } from 'types' import { SonnerToaster, TooltipProvider } from 'ui' import { CommandProvider } from 'ui-patterns/CommandMenu' @@ -140,7 +139,6 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - {!isTestEnv && } {!isTestEnv && ( )} diff --git a/apps/studio/pages/new/index.tsx b/apps/studio/pages/new/index.tsx index 4c98990860f..b8c9cf76533 100644 --- a/apps/studio/pages/new/index.tsx +++ b/apps/studio/pages/new/index.tsx @@ -6,7 +6,6 @@ import AppLayout from 'components/layouts/AppLayout/AppLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import WizardLayout from 'components/layouts/WizardLayout' import { SetupIntentResponse, useSetupIntent } from 'data/stripe/setup-intent-mutation' -import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' import type { NextPageWithLayout } from 'types' /** @@ -14,7 +13,6 @@ import type { NextPageWithLayout } from 'types' */ const Wizard: NextPageWithLayout = () => { const [intent, setIntent] = useState() - const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) @@ -40,7 +38,7 @@ const Wizard: NextPageWithLayout = () => { if (selectedPlan == null || selectedPlan === 'FREE') return if (intent != null && !force) return - if (captchaRef && captchaLoaded) { + if (captchaRef) { let token = captchaToken try { @@ -59,7 +57,7 @@ const Wizard: NextPageWithLayout = () => { useEffect(() => { loadPaymentForm() - }, [captchaRef, captchaLoaded, selectedPlan]) + }, [captchaRef, selectedPlan]) const resetSetupIntent = () => { setIntent(undefined) diff --git a/apps/studio/stores/hcaptcha-loaded-store.tsx b/apps/studio/stores/hcaptcha-loaded-store.tsx deleted file mode 100644 index 008053d20b0..00000000000 --- a/apps/studio/stores/hcaptcha-loaded-store.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import HCaptcha from '@hcaptcha/react-hcaptcha' -import { proxy, useSnapshot } from 'valtio' - -const hCaptchaLoadedStoreState = proxy({ - loaded: false, - setLoaded() { - hCaptchaLoadedStoreState.loaded = true - }, -}) - -export const useIsHCaptchaLoaded = () => { - const snap = useSnapshot(hCaptchaLoadedStoreState) - - return snap.loaded -} - -const HCaptchaLoadedStore = () => { - const onLoad = () => { - hCaptchaLoadedStoreState.setLoaded() - } - - return ( - - ) -} - -export default HCaptchaLoadedStore diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 0f93564023b..1a901f0e60d 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -1603,7 +1603,7 @@ export interface components { role: string } | null /** @enum {string|null} */ - type?: 'publishable' | 'secret' | 'legacy' | null + type?: 'legacy' | 'publishable' | 'secret' | null /** Format: date-time */ updated_at?: string | null } @@ -1984,6 +1984,7 @@ export interface components { secrets?: { [key: string]: string } + with_data?: boolean } CreateOrganizationV1: { name: string diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 00a3b169b1a..b34c046fe09 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -4282,12 +4282,12 @@ export interface components { BillingCustomerUpdateBody: { additional_emails?: string[] address?: { - city?: string + city?: string | null country: string line1: string - line2?: string - postal_code?: string - state?: string + line2?: string | null + postal_code?: string | null + state?: string | null } billing_name?: string } @@ -4621,12 +4621,12 @@ export interface components { } CreateOrganizationBody: { address?: { - city?: string + city?: string | null country: string line1: string - line2?: string - postal_code?: string - state?: string + line2?: string | null + postal_code?: string | null + state?: string | null } billing_name?: string kind?: string @@ -5027,9 +5027,23 @@ export interface components { teamId?: string } CreditsTopUpRequest: { + address?: { + city?: string | null + country: string + line1: string + line2?: string | null + postal_code?: string | null + state?: string | null + } amount: number + billing_name?: string hcaptcha_token?: string payment_method_id: string + tax_id?: { + country: string + type: string + value: string + } } CreditsTopUpResponse: { payment_intent_secret?: string @@ -5046,12 +5060,12 @@ export interface components { CustomerResponse: { additional_emails: string[] | null address?: { - city?: string + city?: string | null country: string line1: string - line2?: string - postal_code?: string - state?: string + line2?: string | null + postal_code?: string | null + state?: string | null } balance: number billing_name?: string @@ -6536,6 +6550,7 @@ export interface components { last4: string } created: number + has_address: boolean id: string is_default: boolean type: string @@ -8584,7 +8599,21 @@ export interface components { fileSizeLimit?: number } UpdateSubscriptionBody: { + address?: { + city?: string | null + country: string + line1: string + line2?: string | null + postal_code?: string | null + state?: string | null + } + billing_name?: string payment_method?: string + tax_id?: { + country: string + type: string + value: string + } /** @enum {string} */ tier: 'tier_free' | 'tier_pro' | 'tier_payg' | 'tier_team' | 'tier_enterprise' } @@ -12635,7 +12664,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -12673,7 +12703,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -12715,7 +12746,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -12760,7 +12792,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -12798,7 +12831,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -12844,7 +12878,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -12884,7 +12919,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -12926,7 +12962,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -12964,7 +13001,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13008,7 +13046,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13051,7 +13090,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13092,7 +13132,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13130,7 +13171,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13174,7 +13216,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13214,7 +13257,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13264,7 +13308,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13305,7 +13350,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13343,7 +13389,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13387,7 +13434,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13427,7 +13475,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13469,7 +13518,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13507,7 +13557,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13551,7 +13602,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13591,7 +13643,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13633,7 +13686,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13737,7 +13791,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13775,7 +13830,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13819,7 +13875,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13859,7 +13916,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13909,7 +13967,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13947,7 +14006,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -13993,7 +14053,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -14033,7 +14094,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -14078,7 +14140,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -14116,7 +14179,8 @@ export interface operations { query?: never header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -14160,7 +14224,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -14200,7 +14265,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -14245,7 +14311,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ @@ -14291,7 +14358,8 @@ export interface operations { } header: { 'x-connection-encrypted': string - 'x-pg-application-name': string + /** @description PostgreSQL connection application name */ + 'x-pg-application-name'?: string } path: { /** @description Project ref */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a239e511204..de23cd1f79c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -733,11 +733,11 @@ importers: specifier: npm:@jsr/std__path@^1.0.8 version: '@jsr/std__path@1.0.8' '@stripe/react-stripe-js': - specifier: ^3.1.1 - version: 3.1.1(@stripe/stripe-js@5.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^3.7.0 + version: 3.7.0(@stripe/stripe-js@7.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@stripe/stripe-js': - specifier: ^5.5.0 - version: 5.5.0 + specifier: ^7.5.0 + version: 7.5.0 '@supabase/auth-js': specifier: 'catalog:' version: 2.71.1-rc.1 @@ -8056,15 +8056,15 @@ packages: '@stitches/core@1.2.8': resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==} - '@stripe/react-stripe-js@3.1.1': - resolution: {integrity: sha512-+JzYFgUivVD7koqYV7LmLlt9edDMAwKH7XhZAHFQMo7NeRC+6D2JmQGzp9tygWerzwttwFLlExGp4rAOvD6l9g==} + '@stripe/react-stripe-js@3.7.0': + resolution: {integrity: sha512-PYls/2S9l0FF+2n0wHaEJsEU8x7CmBagiH7zYOsxbBlLIHEsqUIQ4MlIAbV9Zg6xwT8jlYdlRIyBTHmO3yM7kQ==} peerDependencies: - '@stripe/stripe-js': ^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@stripe/stripe-js': '>=1.44.1 <8.0.0' react: '>=16.8.0 <20.0.0' react-dom: '>=16.8.0 <20.0.0' - '@stripe/stripe-js@5.5.0': - resolution: {integrity: sha512-lkfjyAd34aeMpTKKcEVfy8IUyEsjuAT3t9EXr5yZDtdIUncnZpedl/xLV16Dkd4z+fQwixScsCCDxSMNtBOgpQ==} + '@stripe/stripe-js@7.5.0': + resolution: {integrity: sha512-Cq3KKe+G1o7PSBMbmrgpT2JgBeyH2THHr3RdIX2MqF7AnBuspIMgtZ3ktcCgP7kZsTMvnmWymr7zZCT1zeWbMw==} engines: {node: '>=12.16'} '@supabase/auth-js@2.71.1-rc.1': @@ -26958,14 +26958,14 @@ snapshots: '@stitches/core@1.2.8': {} - '@stripe/react-stripe-js@3.1.1(@stripe/stripe-js@5.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@stripe/react-stripe-js@3.7.0(@stripe/stripe-js@7.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@stripe/stripe-js': 5.5.0 + '@stripe/stripe-js': 7.5.0 prop-types: 15.8.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@stripe/stripe-js@5.5.0': {} + '@stripe/stripe-js@7.5.0': {} '@supabase/auth-js@2.71.1-rc.1': dependencies: diff --git a/turbo.json b/turbo.json index fe75e665de7..0e9c09686f0 100644 --- a/turbo.json +++ b/turbo.json @@ -61,6 +61,7 @@ "NEXT_PUBLIC_NODE_ENV", "NEXT_PUBLIC_GOTRUE_URL", "NEXT_PUBLIC_VERCEL_BRANCH_URL", + "NEXT_PUBLIC_GOOGLE_MAPS_KEY", "NODE_ENV", "SUPABASE_URL", // These envs are used in the packages From ec6564b782f74d89daf39bc1995dbb736764a938 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 23 Jul 2025 18:43:13 +0800 Subject: [PATCH 29/38] Fix unified logs stuff on self-hosted (#37394) --- .../layouts/LogsLayout/LogsSidebarMenuV2.tsx | 28 ++++++++++--------- .../studio/pages/project/[ref]/logs/index.tsx | 5 ++-- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index 8f6d7571d47..86f999ecd7f 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -229,19 +229,21 @@ export function LogsSidebarMenuV2() { return (
- Coming soon} - title="New logs" - description="Get early access" - actions={ - - - - } - /> + {IS_PLATFORM && ( + Coming soon} + title="New logs" + description="Get early access" + actions={ + + + + } + /> + )} {isUnifiedLogsPreviewAvailable && ( { @@ -33,9 +34,9 @@ export const LogPage: NextPageWithLayout = () => { // Handle redirects when unified logs preview flag changes useEffect(() => { // Only handle redirects if we're currently on a logs page - if (!router.asPath.includes('/logs') || !hasLoaded) return + if (!router.asPath.includes('/logs') || (IS_PLATFORM && !hasLoaded)) return - if (isUnifiedLogsEnabled) { + if (IS_PLATFORM && isUnifiedLogsEnabled) { // If unified logs preview is enabled and we're not already on the main logs page if (router.asPath !== `/project/${ref}/logs` && router.asPath.includes('/logs/')) { router.push(`/project/${ref}/logs`) From dac5d896a90f7674e367280ba84d2bb2e4c0ec37 Mon Sep 17 00:00:00 2001 From: Jim Chanco Jr <69092730+jchancojr@users.noreply.github.com> Date: Wed, 23 Jul 2025 09:14:09 -0400 Subject: [PATCH 30/38] chore(doc): Update humans.txt (#37388) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 4aa7b3c1469..a700ad88b49 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -54,6 +54,7 @@ Inian P Ivan Vasilov Jenny Kibiri Jess Shears +Jim Chanco Jr John Pena Jon M Jonny Summers-Muir From 1efa09cf5449044b11360fc59627d779951f891d Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Wed, 23 Jul 2025 17:24:55 +0200 Subject: [PATCH 31/38] Add Chris Ward to humans.txt (#37149) Add Chris Ward to humans Signed-off-by: Chris Chinchilla Co-authored-by: Chris Chinchilla --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index a700ad88b49..4a367b66d20 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -27,6 +27,7 @@ Chris Chandler Chris Copplestone Chris Gwilliams Chris Stockton +Chris Ward Craig Cannon Danny White Darren Cunningham From eaa2f2e68b8c597a0eed66487e89636598435b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Thu, 24 Jul 2025 00:43:53 +0800 Subject: [PATCH 32/38] feat: allow 500 GB uploads (#37120) * feat: allow 500 GB uploads * upsell * docs + pricing page * Update StorageSettings.tsx * Small alignment fix --------- Co-authored-by: Joshen Lim --- .../guides/storage/uploads/file-limits.mdx | 6 +-- .../guides/storage/uploads/s3-uploads.mdx | 4 +- .../StorageSettings.constants.ts | 6 ++- .../StorageSettings/StorageSettings.tsx | 47 ++++++++++++++----- apps/studio/components/ui/UpgradeToPro.tsx | 6 ++- packages/shared-data/pricing.ts | 4 +- 6 files changed, 51 insertions(+), 22 deletions(-) diff --git a/apps/docs/content/guides/storage/uploads/file-limits.mdx b/apps/docs/content/guides/storage/uploads/file-limits.mdx index af1628c5bc9..d890c477687 100644 --- a/apps/docs/content/guides/storage/uploads/file-limits.mdx +++ b/apps/docs/content/guides/storage/uploads/file-limits.mdx @@ -8,13 +8,13 @@ sidebar_label: 'Limits' ## Global file size -You can set the max file size across all your buckets by setting this global value in the dashboard [here](https://supabase.com/dashboard/project/_/settings/storage). For Free projects, the limit can't exceed 50 MB. On the Pro Plan and up, you can set this value to up to 50 GB. If you need more than 50 GB, [contact us](https://supabase.com/dashboard/support/new). +You can set the max file size across all your buckets by setting this global value in the dashboard [here](https://supabase.com/dashboard/project/_/settings/storage). For Free projects, the limit can't exceed 50 MB. On the Pro Plan and up, you can set this value to up to 500 GB. If you need more than 500 GB, [contact us](https://supabase.com/dashboard/support/new). | Plan | Max File Size Limit | | ---------- | ------------------- | | Free | 50 MB | -| Pro | 50 GB | -| Team | 50 GB | +| Pro | 500 GB | +| Team | 500 GB | | Enterprise | Custom | diff --git a/apps/docs/content/guides/storage/uploads/s3-uploads.mdx b/apps/docs/content/guides/storage/uploads/s3-uploads.mdx index 13f888e2fb5..93c3f4f2be9 100644 --- a/apps/docs/content/guides/storage/uploads/s3-uploads.mdx +++ b/apps/docs/content/guides/storage/uploads/s3-uploads.mdx @@ -17,7 +17,7 @@ The S3 protocol supports file upload using: The `PutObject` action uploads the file in a single request. This matches the behavior of the Supabase SDK [Standard Upload](/docs/guides/storage/uploads/standard-uploads). -Use `PutObject` to upload smaller files, where retrying the entire upload won't be an issue. The maximum file size on paid plans is 50 GB. +Use `PutObject` to upload smaller files, where retrying the entire upload won't be an issue. The maximum file size on paid plans is 500 GB. For example, using JavaScript and the `aws-sdk` client: @@ -42,7 +42,7 @@ await s3Client.send(uploadCommand) Multipart Uploads split the file into smaller parts and upload them in parallel, maximizing the upload speed on a fast network. When uploading large files, this allows you to retry the upload of individual parts in case of network issues. -This method is preferable over [Resumable Upload](/docs/guides/storage/uploads/resumable-uploads) for server-side uploads, when you want to maximize upload speed at the cost of resumability. The maximum file size on paid plans is 50 GB. +This method is preferable over [Resumable Upload](/docs/guides/storage/uploads/resumable-uploads) for server-side uploads, when you want to maximize upload speed at the cost of resumability. The maximum file size on paid plans is 500 GB. ### Upload a file in parts diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.constants.ts b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.constants.ts index b46ecf2cca5..411a4fb9507 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.constants.ts +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.constants.ts @@ -1,4 +1,8 @@ -export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES = 50 * 1024 * 1024 * 1024 // 50 GB +export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_FREE_PLAN = 50 * 1024 * 1024 // 50 MB +/** Max bytes for capped storage (i.e. Pro with spend cap) */ +export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED = 50 * 1024 * 1024 * 1024 // 50 GB +/** Max bytes for uncapped storage (i.e. Pro without spend cap, Team, Enterprise) */ +export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED = 500 * 1024 * 1024 * 1024 // 500 GB export enum StorageSizeUnits { BYTES = 'bytes', diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx index 536b07fde31..43868c50c03 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx @@ -1,7 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Clock } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' @@ -16,6 +15,7 @@ import { useProjectStorageConfigQuery } from 'data/config/project-storage-config import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { formatBytes } from 'lib/helpers' import { Button, FormControl_Shadcn_, @@ -32,7 +32,12 @@ import { Select_Shadcn_, Switch, } from 'ui' -import { STORAGE_FILE_SIZE_LIMIT_MAX_BYTES, StorageSizeUnits } from './StorageSettings.constants' +import { + STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED, + STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_FREE_PLAN, + STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED, + StorageSizeUnits, +} from './StorageSettings.constants' import { convertFromBytes, convertToBytes } from './StorageSettings.utils' interface StorageSettingsState { @@ -56,6 +61,8 @@ const StorageSettings = () => { const organization = useSelectedOrganization() const isFreeTier = organization?.plan.id === 'free' + const isSpendCapOn = + organization?.plan.id === 'pro' && organization?.usage_billing_enabled === false const [initialValues, setInitialValues] = useState({ fileSizeLimit: 0, @@ -84,9 +91,17 @@ const StorageSettings = () => { } }, [isSuccess, config]) - const formattedMaxSizeBytes = `${new Intl.NumberFormat('en-US').format( - STORAGE_FILE_SIZE_LIMIT_MAX_BYTES - )} bytes` + const maxBytes = useMemo(() => { + if (organization?.plan.id === 'free') { + return STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_FREE_PLAN + } else if (organization?.usage_billing_enabled) { + return STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED + } else { + return STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED + } + }, [organization]) + + const formattedMaxSizeBytes = `${new Intl.NumberFormat('en-US').format(maxBytes)} bytes` const FormSchema = z .object({ @@ -96,7 +111,7 @@ const StorageSettings = () => { }) .superRefine((data, ctx) => { const { unit, fileSizeLimit } = data - const { value: formattedMaxLimit } = convertFromBytes(STORAGE_FILE_SIZE_LIMIT_MAX_BYTES, unit) + const { value: formattedMaxLimit } = convertFromBytes(maxBytes, unit) if (fileSizeLimit > formattedMaxLimit) { ctx.addIssue({ @@ -156,7 +171,7 @@ const StorageSettings = () => {
-
+
{ limit, storageUnit ).toLocaleString()} bytes. `} - Maximum size in bytes of a file that can be uploaded is 50 GB ( - {formattedMaxSizeBytes}). + Maximum upload file size is {formatBytes(maxBytes)}.

@@ -261,9 +275,18 @@ const StorageSettings = () => { {isFreeTier && (
} primaryText="Free Plan has a fixed upload file size limit of 50 MB." - secondaryText="Upgrade to the Pro Plan for a configurable upload file size limit of up to 50 GB." + secondaryText={`Upgrade to Pro Plan for a configurable upload file size limit of ${formatBytes(STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED)} and unlock image transformations.`} + source="storageSizeLimit" + /> +
+ )} + {isSpendCapOn && ( +
+
diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index b9c3ddf9ba2..a98778b7f39 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -69,7 +69,7 @@ const UpgradeToPro = ({ }, }} > - Reset database password + {buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')} ) : ( -
- Name, - Publication, - Lag, - Status, - , - ]} - className="mt-4" - body={mockRows.map((row, i) => ( - - {row.name} - - - All - {row.tables} tables - - - {row.lag} - - - - {row.status} - - - - - - - ))} - /> +
+
+ + + +
+
+
+ + +
+ +
+
Name, + Publication, + Lag, + Status, + , + ]} + className="mt-4" + body={mockRows.map((row, i) => ( + + {row.name} + + + All + {row.tables} tables + + + {row.lag} + + + + {row.status} + + + + + + + ))} + /> + + + ) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index edfab6d9239..04386674eeb 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -1,26 +1,28 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' +import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation' +import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query' import { ReplicationPipelinesData } from 'data/replication/pipelines-query' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' import { ResponseError } from 'types' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import RowMenu from './RowMenu' -import PipelineStatus, { PipelineStatusRequestStatus, PipelineStatusName } from './PipelineStatus' -import { useParams } from 'common' -import { - ReplicationPipelineStatusData, - useReplicationPipelineStatusQuery, -} from 'data/replication/pipeline-status-query' -import { useState } from 'react' -import { toast } from 'sonner' -import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' -import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' -import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation' import DeleteDestination from './DeleteDestination' import DestinationPanel from './DestinationPanel' +import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' +import { PipelineStatus, PipelineStatusName } from './PipelineStatus' +import { RowMenu } from './RowMenu' export type Pipeline = ReplicationPipelinesData['pipelines'][0] -const refreshFrequencyMs: number = 5000 +const refreshFrequencyMs: number = 2000 interface DestinationRowProps { sourceId: number | undefined @@ -32,9 +34,10 @@ interface DestinationRowProps { isLoading: boolean isError: boolean isSuccess: boolean + onSelectPipeline?: (pipelineId: number, destinationName: string) => void } -const DestinationRow = ({ +export const DestinationRow = ({ sourceId, destinationId, destinationName, @@ -44,6 +47,7 @@ const DestinationRow = ({ isLoading: isPipelineLoading, isError: isPipelineError, isSuccess: isPipelineSuccess, + onSelectPipeline, }: DestinationRowProps) => { const { ref: projectRef } = useParams() const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false) @@ -62,76 +66,23 @@ const DestinationRow = ({ }, { refetchInterval: refreshFrequencyMs } ) - const [requestStatus, setRequestStatus] = useState( - PipelineStatusRequestStatus.None - ) - const { mutateAsync: startPipeline } = useStartPipelineMutation() + const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus() + const requestStatus = pipeline?.id + ? getRequestStatus(pipeline.id) + : PipelineStatusRequestStatus.None + const { mutateAsync: stopPipeline } = useStopPipelineMutation() - const pipelineStatus = pipelineStatusData?.status - const getStatusName = ( - status: ReplicationPipelineStatusData['status'] | undefined - ): string | undefined => { - if (status && typeof status === 'object' && 'name' in status) { - return status.name - } - - return undefined - } - - const statusName = getStatusName(pipelineStatus) - if ( - (requestStatus === PipelineStatusRequestStatus.EnableRequested && - (statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)) || - (requestStatus === PipelineStatusRequestStatus.DisableRequested && - (statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED)) - ) { - setRequestStatus(PipelineStatusRequestStatus.None) - } - - const onEnableClick = async () => { - if (!projectRef) { - console.error('Project ref is required') - return - } - if (!pipeline) { - toast.error('No pipeline found') - return - } - - try { - await startPipeline({ projectRef, pipelineId: pipeline.id }) - } catch (error) { - toast.error('Failed to enable destination') - } - setRequestStatus(PipelineStatusRequestStatus.EnableRequested) - } - const onDisableClick = async () => { - if (!projectRef) { - console.error('Project ref is required') - return - } - if (!pipeline) { - toast.error('No pipeline found') - return - } - - try { - await stopPipeline({ projectRef, pipelineId: pipeline.id }) - } catch (error) { - toast.error('Failed to disable destination') - } - setRequestStatus(PipelineStatusRequestStatus.DisableRequested) - } const { mutateAsync: deleteDestination } = useDeleteDestinationMutation({}) + const pipelineStatus = pipelineStatusData?.status + const statusName = getStatusName(pipelineStatus) + const onDeleteClick = async () => { if (!projectRef) { - console.error('Project ref is required') - return + return console.error('Project ref is required') } if (!pipeline) { - toast.error('No pipeline found') - return + return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) } try { @@ -140,24 +91,33 @@ const DestinationRow = ({ // so we don't need to call deletePipeline explicitly await deleteDestination({ projectRef, destinationId: destinationId }) } catch (error) { - toast.error('Failed to delete destination') + toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION) } } + useEffect(() => { + if (pipeline?.id) { + updatePipelineStatus(pipeline.id, statusName) + } + }, [pipeline?.id, statusName, updatePipelineStatus]) + return ( <> {isPipelineError && ( - + )} {isPipelineSuccess && ( - - - {isPipelineLoading ? : destinationName} - - {isPipelineLoading ? : type} + { + if (pipeline) onSelectPipeline?.(pipeline.id, destinationName) + }} + > + {isPipelineLoading ? : destinationName} + {isPipelineLoading ? : type} {isPipelineLoading || !pipeline ? ( - + ) : ( + /> )} {isPipelineLoading || !pipeline ? ( - + ) : ( pipeline.config.publication_name )} - setShowDeleteDestinationForm(true)} - onEditClick={() => setShowEditDestinationPanel(true)} - > +
+ setShowDeleteDestinationForm(true)} + onEditClick={() => setShowEditDestinationPanel(true)} + /> +
)} @@ -211,5 +172,3 @@ const DestinationRow = ({ ) } - -export default DestinationRow diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index d1bbfc71b5d..8d384e7840d 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -1,19 +1,26 @@ +import { noop } from 'lodash' +import { Plus, Search } from 'lucide-react' +import { useState } from 'react' + import { useParams } from 'common' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import { useReplicationDestinationsQuery } from 'data/replication/destinations-query' -import { Plus } from 'lucide-react' -import { Button, cn } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' -import DestinationRow from './DestinationRow' import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query' -import { useState } from 'react' -import NewDestinationPanel from './DestinationPanel' import { useReplicationSourcesQuery } from 'data/replication/sources-query' -import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' +import { Button, cn, Input_Shadcn_ } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' +import NewDestinationPanel from './DestinationPanel' +import { DestinationRow } from './DestinationRow' +import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' -const Destinations = () => { +interface DestinationsProps { + onSelectPipeline?: (pipelineId: number, destinationName: string) => void +} + +export const Destinations = ({ onSelectPipeline = noop }: DestinationsProps) => { const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false) + const [filterString, setFilterString] = useState('') const { ref: projectRef } = useParams() const { @@ -21,12 +28,11 @@ const Destinations = () => { error: sourcesError, isLoading: isSourcesLoading, isError: isSourcesError, - isSuccess: isSourcesSuccess, } = useReplicationSourcesQuery({ projectRef, }) - let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id + const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id const { data: destinationsData, @@ -50,21 +56,44 @@ const Destinations = () => { const anyDestinations = isDestinationsSuccess && destinationsData.destinations.length > 0 + const filteredDestinations = + filterString.length === 0 + ? destinationsData?.destinations ?? [] + : (destinationsData?.destinations ?? []).filter((destination) => + destination.name.toLowerCase().includes(filterString.toLowerCase()) + ) + return ( <> - -
- Destinations +
+
+
+
+ + setFilterString(e.target.value)} + /> +
+
+
+ +
{(isSourcesLoading || isDestinationsLoading) && } {(isSourcesError || isDestinationsError) && ( )} @@ -77,7 +106,7 @@ const Destinations = () => { Publication, , ]} - body={destinationsData.destinations.map((destination) => { + body={filteredDestinations.map((destination) => { const pipeline = pipelinesData?.pipelines.find( (p) => p.destination_id === destination.id ) @@ -93,7 +122,8 @@ const Destinations = () => { isLoading={isPipelinesLoading} isError={isPipelinesError} isSuccess={isPipelinesSuccess} - > + onSelectPipeline={onSelectPipeline} + /> ) })} >
@@ -124,15 +154,22 @@ const Destinations = () => {
) )} - +
+ + {!isSourcesLoading && + !isDestinationsLoading && + filteredDestinations.length === 0 && + anyDestinations && ( +
+

No destinations match "{filterString}"

+
+ )} setShowNewDestinationPanel(false)} - > + /> ) } - -export default Destinations diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts new file mode 100644 index 00000000000..d15fe8040cc --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts @@ -0,0 +1,96 @@ +import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' +import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' + +export const PIPELINE_ERROR_MESSAGES = { + RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information', + RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status', + RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status', + RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations', + ENABLE_DESTINATION: 'Failed to enable destination', + DISABLE_DESTINATION: 'Failed to disable destination', + DELETE_DESTINATION: 'Failed to delete destination', + NO_PIPELINE_FOUND: 'No pipeline found', + COPY_TABLE_STATUS: 'Failed to copy table status', +} as const + +export const getStatusName = ( + status: ReplicationPipelineStatusData['status'] | undefined +): ReplicationPipelineStatusData['status']['name'] | undefined => { + if (status && typeof status === 'object' && 'name' in status) { + return status.name + } + return undefined +} + +export const PIPELINE_STATE_MESSAGES = { + enabling: { + title: 'Pipeline Enabling', + message: 'Starting the pipeline. Table replication will resume once enabled.', + badge: 'Enabling', + }, + disabling: { + title: 'Pipeline Disabling', + message: 'Stopping the pipeline. Table replication will be paused once disabled.', + badge: 'Disabling', + }, + failed: { + title: 'Pipeline Failed', + message: 'Replication has encountered an error. Check the logs for details.', + badge: 'Failed', + }, + stopped: { + title: 'Pipeline Stopped', + message: 'Replication is paused. Enable the pipeline to resume data synchronization.', + badge: 'Stopped', + }, + starting: { + title: 'Pipeline Starting', + message: 'Initializing replication. Table status will be available once running.', + badge: 'Starting', + }, + running: { + title: 'Pipeline Running', + message: 'Replication is active and processing data', + badge: 'Running', + }, + unknown: { + title: 'Pipeline Status Unknown', + message: 'Unable to determine replication status. Check the logs for more information.', + badge: 'Unknown', + }, + notRunning: { + title: 'Pipeline Not Running', + message: 'Replication is not active. Enable the pipeline to start data synchronization.', + badge: 'Disabled', + }, +} as const + +export const getPipelineStateMessages = ( + requestStatus: PipelineStatusRequestStatus | undefined, + statusName: string | undefined +) => { + // Always prioritize request status (enabling/disabling) over pipeline status + if (requestStatus === PipelineStatusRequestStatus.EnableRequested) { + return PIPELINE_STATE_MESSAGES.enabling + } + + if (requestStatus === PipelineStatusRequestStatus.DisableRequested) { + return PIPELINE_STATE_MESSAGES.disabling + } + + // Only check pipeline status if no request is in progress + switch (statusName) { + case 'failed': + return PIPELINE_STATE_MESSAGES.failed + case 'stopped': + return PIPELINE_STATE_MESSAGES.stopped + case 'starting': + return PIPELINE_STATE_MESSAGES.starting + case 'started': + return PIPELINE_STATE_MESSAGES.running + case 'unknown': + return PIPELINE_STATE_MESSAGES.unknown + default: + return PIPELINE_STATE_MESSAGES.notRunning + } +} diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx index 6fb360e6d97..f81a8df536a 100644 --- a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx @@ -1,28 +1,11 @@ import AlertError from 'components/ui/AlertError' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import { cn } from 'ui' -import { ResponseError } from 'types' -import { Loader2, ChevronDown, ChevronRight, Copy, AlertTriangle } from 'lucide-react' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, - Button, - Collapsible_Shadcn_ as Collapsible, - CollapsibleContent_Shadcn_ as CollapsibleContent, - CollapsibleTrigger_Shadcn_ as CollapsibleTrigger, -} from 'ui' -import { useState, useEffect, useRef } from 'react' -import { copyToClipboard } from 'ui' -import { toast } from 'sonner' import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' - -export enum PipelineStatusRequestStatus { - None = 'None', - EnableRequested = 'EnableRequested', - DisableRequested = 'DisableRequested', -} +import { AlertTriangle, Loader2 } from 'lucide-react' +import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' +import { ResponseError } from 'types' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { getPipelineStateMessages, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' export enum PipelineStatusName { FAILED = 'failed', @@ -41,10 +24,10 @@ interface PipelineStatusProps { isLoading: boolean isError: boolean isSuccess: boolean - requestStatus: PipelineStatusRequestStatus + requestStatus?: PipelineStatusRequestStatus } -const PipelineStatus = ({ +export const PipelineStatus = ({ pipelineStatus, error, isLoading, @@ -52,34 +35,6 @@ const PipelineStatus = ({ isSuccess, requestStatus, }: PipelineStatusProps) => { - const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false) - const errorDetailsRef = useRef(null) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (errorDetailsRef.current && !errorDetailsRef.current.contains(event.target as Node)) { - setIsErrorDetailsOpen(false) - } - } - - if (isErrorDetailsOpen) { - document.addEventListener('mousedown', handleClickOutside) - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [isErrorDetailsOpen]) - - const handleCopyToClipboard = async (text: string) => { - try { - await copyToClipboard(text) - toast.success('Error details copied to clipboard') - } catch (error) { - toast.error('Failed to copy error details') - } - } - const isFailedStatus = ( status: ReplicationPipelineStatusData['status'] | undefined ): status is FailedStatus => { @@ -91,144 +46,37 @@ const PipelineStatus = ({ ) } - const isLogLoadingState = (failedStatus: FailedStatus): boolean => { - // Right now we hardcode the error message which is returned when k8s is not able to find logs, which seems - // to be a transient error. In case we find a way to properly handle this in the backend, this hack will not - // be needed anymore. + const renderFailedStatus = () => { return ( - failedStatus.message?.startsWith('unable to retrieve container logs for containerd://') === - true - ) - } - - const renderFailedStatus = (failedStatus: FailedStatus) => { - const hasDetails = - failedStatus.message || failedStatus.reason || failedStatus.exit_code !== undefined - const isLoadingLogs = isLogLoadingState(failedStatus) - - return ( -
- - - -
- {isLoadingLogs ? ( - - ) : ( - - )} - Failed -
-
- -

- {isLoadingLogs - ? 'Pipeline failed - logs are being retrieved from container' - : 'Pipeline has failed - expand for error details'} -

-
-
-
- {hasDetails && ( - - - - - -
- {isLoadingLogs ? ( -
- -
-
- Pipeline Failed - Loading logs... -
-
- Error logs are being retrieved from the container. This may take a few - moments. -
-
-
- ) : ( - <> -
- - Pipeline Error Details - - {(failedStatus.message || failedStatus.reason) && ( - - )} -
-
- {failedStatus.exit_code !== undefined && ( -
- - Exit Code: - -
- {failedStatus.exit_code} -
-
- )} - {failedStatus.reason && ( -
- Reason: -
-                            {failedStatus.reason}
-                          
-
- )} - {failedStatus.message && ( -
- - Message: - -
-                            {failedStatus.message}
-                          
-
- )} -
- - )} -
-
-
- )} -
+ + +
+ + Failed +
+
+ + {getPipelineStateMessages(requestStatus, 'failed').message} + +
) } // Map backend statuses to UX-friendly display const getStatusConfig = () => { + const statusName = + pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus + ? pipelineStatus.name + : undefined + + // Get consistent tooltip message using the same logic as other components + const stateMessages = getPipelineStateMessages(requestStatus, statusName) + if (requestStatus === PipelineStatusRequestStatus.EnableRequested) { return { label: 'Enabling...', dot: , color: 'text-brand-600', - tooltip: 'Pipeline is being enabled and will start shortly', + tooltip: stateMessages.message, } } @@ -237,7 +85,7 @@ const PipelineStatus = ({ label: 'Disabling...', dot: , color: 'text-warning-600', - tooltip: 'Pipeline is being disabled and will stop shortly', + tooltip: stateMessages.message, } } @@ -255,35 +103,35 @@ const PipelineStatus = ({ label: 'Starting', dot: , color: 'text-warning-600', - tooltip: 'Pipeline is initializing and will be ready soon', + tooltip: stateMessages.message, } case PipelineStatusName.STARTED: return { label: 'Running', dot:
, color: 'text-brand-600', - tooltip: 'Pipeline is active and processing data', + tooltip: stateMessages.message, } case PipelineStatusName.STOPPED: return { label: 'Stopped', dot:
, color: 'text-foreground-light', - tooltip: 'Pipeline is not running - enable to start processing', + tooltip: stateMessages.message, } case PipelineStatusName.UNKNOWN: return { label: 'Unknown', dot:
, color: 'text-warning-600', - tooltip: 'Pipeline status could not be determined', + tooltip: stateMessages.message, } default: return { label: 'Unknown', dot:
, color: 'text-destructive-600', - tooltip: 'Pipeline status is unclear - check logs for details', + tooltip: stateMessages.message, } } } @@ -301,31 +149,27 @@ const PipelineStatus = ({ return ( <> - {isLoading && } - {isError && } + {isLoading && } + {isError && ( + + )} {isSuccess && ( <> {statusConfig.isFailedStatus ? ( -
{renderFailedStatus(pipelineStatus as FailedStatus)}
+
{renderFailedStatus()}
) : ( - - - -
- {statusConfig.dot} - {statusConfig.label} -
-
- -

{statusConfig.tooltip}

-
-
-
+ + +
+ {statusConfig.dot} + {statusConfig.label} +
+
+ {statusConfig.tooltip} +
)} )} ) } - -export default PipelineStatus diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx new file mode 100644 index 00000000000..24d41862f06 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx @@ -0,0 +1,396 @@ +import { Activity, AlertTriangle, ChevronLeft, Copy, ExternalLink, Search } from 'lucide-react' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +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 { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query' +import { useReplicationPipelineReplicationStatusQuery } from 'data/replication/pipeline-replication-status-query' +import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' +import { Badge, Button, cn, copyToClipboard, Input_Shadcn_ } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' +import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' +import { PipelineStatus } from './PipelineStatus' +import { TableState } from './ReplicationPipelineStatus.types' +import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils' + +interface ReplicationPipelineStatusProps { + pipelineId: number + destinationName?: string + onSelectBack: () => void +} + +export const ReplicationPipelineStatus = ({ + pipelineId, + destinationName, + onSelectBack, +}: ReplicationPipelineStatusProps) => { + const { ref: projectRef } = useParams() + const [filterString, setFilterString] = useState('') + + const { getRequestStatus, updatePipelineStatus, setRequestStatus } = usePipelineRequestStatus() + const requestStatus = getRequestStatus(pipelineId) + + const { + data: pipeline, + error: pipelineError, + isLoading: isPipelineLoading, + isError: isPipelineError, + } = useReplicationPipelineByIdQuery({ + projectRef, + pipelineId, + }) + + const { + data: pipelineStatusData, + error: pipelineStatusError, + isLoading: isPipelineStatusLoading, + isError: isPipelineStatusError, + isSuccess: isPipelineStatusSuccess, + } = useReplicationPipelineStatusQuery( + { projectRef, pipelineId }, + { + enabled: !!pipelineId, + refetchInterval: 2000, // Poll every 2 seconds + } + ) + + const { + data: replicationStatusData, + error: statusError, + isLoading: isStatusLoading, + isError: isStatusError, + } = useReplicationPipelineReplicationStatusQuery( + { projectRef, pipelineId }, + { + enabled: !!pipelineId, + refetchInterval: 2000, // Poll every 2 seconds + } + ) + + const { mutateAsync: startPipeline, isLoading: isStartingPipeline } = useStartPipelineMutation() + const { mutateAsync: stopPipeline, isLoading: isStoppingPipeline } = useStopPipelineMutation() + + const statusName = getStatusName(pipelineStatusData?.status) + const config = getDisabledStateConfig({ requestStatus, statusName }) + + const tableStatuses = replicationStatusData?.table_statuses || [] + const filteredTableStatuses = + filterString.length === 0 + ? tableStatuses + : tableStatuses.filter((table: TableState) => + table.table_name.toLowerCase().includes(filterString.toLowerCase()) + ) + + const errorTables = tableStatuses.filter((table: TableState) => table.state.name === 'error') + const hasErrors = errorTables.length > 0 + const isPipelineRunning = statusName === 'started' + const hasTableData = tableStatuses.length > 0 + const isEnablingDisabling = + requestStatus === PipelineStatusRequestStatus.EnableRequested || + requestStatus === PipelineStatusRequestStatus.DisableRequested + const showDisabledState = !isPipelineRunning || isEnablingDisabling + + const handleCopyTableStatus = async (tableName: string, state: TableState['state']) => { + const statusText = `Table: ${tableName}\nStatus: ${state.name}${ + 'message' in state ? `\nError: ${state.message}` : '' + }${'lag' in state ? `\nLag: ${state.lag}ms` : ''}` + + try { + await copyToClipboard(statusText) + toast.success('Table status copied to clipboard') + } catch { + toast.error(PIPELINE_ERROR_MESSAGES.COPY_TABLE_STATUS) + } + } + + const onTogglePipeline = async () => { + if (!projectRef) { + return console.error('Project ref is required') + } + if (!pipeline) { + return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + } + + try { + if (statusName === 'stopped') { + await startPipeline({ projectRef, pipelineId: pipeline.id }) + setRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested) + } else if (statusName === 'started') { + await stopPipeline({ projectRef, pipelineId: pipeline.id }) + setRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested) + } + } catch (error) { + toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) + } + } + + useEffect(() => { + updatePipelineStatus(pipelineId, statusName) + }, [pipelineId, statusName, updatePipelineStatus]) + + if (isPipelineError) { + return ( +
+
+
+
+
+ +
+ ) + } + + return ( +
+ {/* Header with back button and filters */} +
+
+
+
+
+ + setFilterString(e.target.value)} + /> +
+ +
+
+ + {(isPipelineLoading || isStatusLoading) && } + + {isStatusError && ( + + )} + + {hasErrors && ( +
+
+ +
+

+ {errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed +

+

+ Some tables encountered replication errors. Check the logs for detailed error + information. +

+ +
+
+
+ )} + + {hasTableData && ( +
+ {showDisabledState && ( +
+
+
+
{config.icon}
+
+
+

{config.title}

+

{config.message}

+
+
+
+ )} + +
+ Table, + Status, + Details, + + Actions + , + ]} + body={filteredTableStatuses.map((table: TableState, index: number) => { + const statusConfig = getStatusConfig(table.state) + return ( + + +
+

{table.table_name}

+ + } + tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }} + > + + +
+
+ + {showDisabledState ? ( + Not Available + ) : ( + statusConfig.badge + )} + + + {showDisabledState ? ( +

+ Status unavailable while pipeline is {config.badge.toLowerCase()} +

+ ) : ( +
+
{statusConfig.description}
+ {'lag' in table.state && ( +
+ Lag: {table.state.lag}ms +
+ )} +
+ )} +
+ + } + className="px-1.5" + disabled={showDisabledState} + onClick={() => handleCopyTableStatus(table.table_name, table.state)} + tooltip={{ + content: { + side: 'bottom', + text: showDisabledState + ? `Copy unavailable while pipeline is ${config.badge.toLowerCase()}` + : 'Copy status details', + }, + }} + /> + +
+ ) + })} + /> + + + )} + + {filteredTableStatuses.length === 0 && hasTableData && ( + + +
+

No results found

+

+ Your search for "{filterString}" did not return any results +

+
+
+
+ )} + + {!isStatusLoading && tableStatuses.length === 0 && ( +
+
+
+ +
+
+

+ {showDisabledState ? 'Pipeline Not Running' : 'No table status information'} +

+

+ {showDisabledState + ? `The replication pipeline is currently ${statusName || 'not active'}. Table status + information is not available while the pipeline is in this state.` + : `This pipeline doesn't have any table replication status data available yet. The status will appear here once replication begins.`} +

+
+

+ Data refreshes automatically every 2 seconds +

+
+
+ )} + + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts new file mode 100644 index 00000000000..c5ab523ec1e --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts @@ -0,0 +1,10 @@ +export type TableState = { + table_id: number + table_name: string + state: + | { name: 'queued' } + | { name: 'copying_table' } + | { name: 'copied_table' } + | { name: 'following_wal'; lag: number } + | { name: 'error'; message: string } +} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx new file mode 100644 index 00000000000..f9ff22a9e9a --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx @@ -0,0 +1,108 @@ +import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' +import { Activity, Clock, HelpCircle, Loader2, XCircle } from 'lucide-react' +import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' +import { Badge } from 'ui' +import { getPipelineStateMessages } from './Pipeline.utils' +import { TableState } from './ReplicationPipelineStatus.types' + +export const getStatusConfig = (state: TableState['state']) => { + switch (state.name) { + case 'queued': + return { + badge: Queued, + description: 'Waiting to start replication', + color: 'text-warning-600', + } + case 'copying_table': + return { + badge: Copying, + description: 'Initial data copy in progress', + color: 'text-brand-600', + } + case 'copied_table': + return { + badge: Copied, + description: 'Initial copy completed', + color: 'text-success-600', + } + case 'following_wal': + return { + badge: Live, + description: `Replicating live changes`, + color: 'text-success-600', + } + case 'error': + return { + badge: Error, + description: state.message, + color: 'text-destructive-600', + } + default: + return { + badge: Unknown, + description: 'Unknown status', + color: 'text-warning-600', + } + } +} + +export const getDisabledStateConfig = ({ + requestStatus, + statusName, +}: { + requestStatus: PipelineStatusRequestStatus + statusName?: ReplicationPipelineStatusData['status']['name'] +}) => { + const { title, message, badge } = getPipelineStateMessages(requestStatus, statusName) + + // Get icon and colors based on current state + const isEnabling = requestStatus === PipelineStatusRequestStatus.EnableRequested + const isDisabling = requestStatus === PipelineStatusRequestStatus.DisableRequested + const isTransitioning = isEnabling || isDisabling + + const icon = isTransitioning ? ( + + ) : statusName === 'failed' ? ( + + ) : statusName === 'starting' ? ( + + ) : statusName === 'unknown' ? ( + + ) : ( + + ) + + const colors = isEnabling + ? { + bg: 'bg-brand-50', + text: 'text-brand-900', + subtext: 'text-brand-700', + iconBg: 'bg-brand-600', + icon: 'text-white dark:text-black', + } + : isDisabling || statusName === 'starting' || statusName === 'unknown' + ? { + bg: 'bg-warning-50', + text: 'text-warning-900', + subtext: 'text-warning-700', + iconBg: 'bg-warning-600', + icon: 'text-white dark:text-black', + } + : statusName === 'failed' + ? { + bg: 'bg-destructive-50', + text: 'text-destructive-900', + subtext: 'text-destructive-700', + iconBg: 'bg-destructive-600', + icon: 'text-white dark:text-black', + } + : { + bg: 'bg-surface-100', + text: 'text-foreground', + subtext: 'text-foreground-light', + iconBg: 'bg-foreground-lighter', + icon: 'text-white dark:text-black', + } + + return { title, message, badge, icon, colors } +} diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx index f09cc703093..ccd49c4b520 100644 --- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -1,5 +1,14 @@ -import AlertError from 'components/ui/AlertError' import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react' +import { toast } from 'sonner' + +import { useParams } from 'common' +import AlertError from 'components/ui/AlertError' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' import { ResponseError } from 'types' import { Button, @@ -10,29 +19,31 @@ import { DropdownMenuTrigger, } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { Pipeline } from './DestinationRow' +import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' import { PipelineStatusName } from './PipelineStatus' interface RowMenuProps { + pipeline: Pipeline | undefined pipelineStatus: any error: ResponseError | null isLoading: boolean isError: boolean - onEnableClick: () => void - onDisableClick: () => void onEditClick: () => void onDeleteClick: () => void } -const RowMenu = ({ +export const RowMenu = ({ + pipeline, pipelineStatus, error, isLoading, isError, - onEnableClick, - onDisableClick, onEditClick, onDeleteClick, }: RowMenuProps) => { + const { ref: projectRef } = useParams() + const getStatusName = (status: any) => { if (status && typeof status === 'object' && 'name' in status) { return status.name @@ -43,33 +54,104 @@ const RowMenu = ({ const statusName = getStatusName(pipelineStatus) const pipelineEnabled = statusName !== PipelineStatusName.STOPPED + const { mutateAsync: startPipeline } = useStartPipelineMutation() + const { mutateAsync: stopPipeline } = useStopPipelineMutation() + const { setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus() + + const onEnablePipeline = async () => { + if (!projectRef) { + return console.error('Project ref is required') + } + if (!pipeline) { + return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + } + + try { + await startPipeline({ projectRef, pipelineId: pipeline.id }) + toast(`Enabling pipeline ${pipeline.destination_name}`) + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested) + } catch (error) { + toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) + } + } + + const onDisablePipeline = async () => { + if (!projectRef) { + console.error('Project ref is required') + return + } + if (!pipeline) { + toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + return + } + + try { + await stopPipeline({ projectRef, pipelineId: pipeline.id }) + toast(`Disabling pipeline ${pipeline.destination_name}`) + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested) + } catch (error) { + toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION) + } + } + return (
- {isLoading && } - {isError && } + {isLoading && } + {isError && ( + + )} -
) } - -export default RowMenu diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx index 835d77cc54e..0267de60693 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx @@ -15,7 +15,6 @@ import { DrawerTrigger, Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from 'ui' import { useMediaQuery } from '../hooks/useMediaQuery' @@ -32,26 +31,24 @@ export function DataTableFilterControlsDrawer() { return ( - - - - - - - - -

- Toggle controls with{' '} - - ⌘ - B - -

-
-
-
+ + + + + + + +

+ Toggle controls with{' '} + + ⌘ + B + +

+
+
diff --git a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx index 0e9c18c8911..3e373ff2ee5 100644 --- a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx +++ b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx @@ -1,7 +1,7 @@ import { X } from 'lucide-react' import { useHotKey } from 'hooks/ui/useHotKey' -import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Kbd } from './primitives/Kbd' import { useDataTable } from './providers/DataTableProvider' @@ -10,28 +10,21 @@ export function DataTableResetButton() { useHotKey(table.resetColumnFilters, 'Escape') return ( - - - - - - -

- Reset filters with{' '} - - ⌘ - Esc - -

-
-
-
+ + + + + +

+ Reset filters with{' '} + + ⌘ + Esc + +

+
+
) } diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx index 4a5ed2200fe..29f7a7f0495 100644 --- a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx +++ b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx @@ -1,16 +1,7 @@ import { ChevronDown, ChevronUp, X } from 'lucide-react' import { ReactNode, useCallback, useEffect, useMemo } from 'react' -import { - Button, - cn, - Separator, - Skeleton, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from 'ui' +import { Button, cn, Separator, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Kbd } from './primitives/Kbd' import { useDataTable } from './providers/DataTableProvider' @@ -79,44 +70,41 @@ export function DataTableSheetDetails({ {isLoading && !selectedRowKey ? : title}
- - - - - - -

- Toggle controls with{' '} - - ⌘ - B - -

-
-
-
+ + + + + +

+ Toggle controls with{' '} + + ⌘ + B + +

+
+
diff --git a/apps/studio/data/replication/keys.ts b/apps/studio/data/replication/keys.ts index ce20ee79d4a..30ae4cd85b7 100644 --- a/apps/studio/data/replication/keys.ts +++ b/apps/studio/data/replication/keys.ts @@ -13,4 +13,6 @@ export const replicationKeys = { ['projects', projectRef, 'pipelines', pipelineId] as const, pipelinesStatus: (projectRef: string | undefined, pipelineId: number | undefined) => ['projects', projectRef, 'pipelines', pipelineId, 'status'] as const, + pipelinesReplicationStatus: (projectRef: string | undefined, pipelineId: number | undefined) => + ['projects', projectRef, 'pipelines', pipelineId, 'replication-status'] as const, } diff --git a/apps/studio/data/replication/pipeline-replication-status-query.ts b/apps/studio/data/replication/pipeline-replication-status-query.ts new file mode 100644 index 00000000000..952fa97fa44 --- /dev/null +++ b/apps/studio/data/replication/pipeline-replication-status-query.ts @@ -0,0 +1,50 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicationKeys } from './keys' + +type ReplicationPipelineReplicationStatusParams = { projectRef?: string; pipelineId?: number } + +async function fetchReplicationPipelineReplicationStatus( + { projectRef, pipelineId }: ReplicationPipelineReplicationStatusParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + if (!pipelineId) throw new Error('pipelineId is required') + + const { data, error } = await get( + '/platform/replication/{ref}/pipelines/{pipeline_id}/replication-status', + { + params: { path: { ref: projectRef, pipeline_id: pipelineId } }, + signal, + } + ) + if (error) { + handleError(error) + } + + return data +} + +export type ReplicationPipelineReplicationStatusData = Awaited< + ReturnType +> + +export const useReplicationPipelineReplicationStatusQuery = < + TData = ReplicationPipelineReplicationStatusData, +>( + { projectRef, pipelineId }: ReplicationPipelineReplicationStatusParams, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId), + ({ signal }) => fetchReplicationPipelineReplicationStatus({ projectRef, pipelineId }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/pages/project/[ref]/database/replication.tsx b/apps/studio/pages/project/[ref]/database/replication.tsx deleted file mode 100644 index 20242cbeb77..00000000000 --- a/apps/studio/pages/project/[ref]/database/replication.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import ReplicationComingSoon from 'components/interfaces/Database/Replication/ComingSoon' -import Destinations from 'components/interfaces/Database/Replication/Destinations' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from 'components/layouts/DefaultLayout' -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from 'components/layouts/Scaffold' -import { useFlag } from 'hooks/ui/useFlag' -import type { NextPageWithLayout } from 'types' - -const DatabaseReplicationPage: NextPageWithLayout = () => { - const enablePgReplicate = useFlag('enablePgReplicate') - - return ( - <> - {enablePgReplicate ? ( - <> - - - Replication - - - - - ) : ( - <> - - - Replication - Send data to other destinations - - - - - )} - - ) -} - -DatabaseReplicationPage.getLayout = (page) => ( - - {page} - -) - -export default DatabaseReplicationPage diff --git a/apps/studio/pages/project/[ref]/database/replication/index.tsx b/apps/studio/pages/project/[ref]/database/replication/index.tsx new file mode 100644 index 00000000000..bc1a2a5445a --- /dev/null +++ b/apps/studio/pages/project/[ref]/database/replication/index.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' + +import { ReplicationComingSoon } from 'components/interfaces/Database/Replication/ComingSoon' +import { Destinations } from 'components/interfaces/Database/Replication/Destinations' +import { ReplicationPipelineStatus } from 'components/interfaces/Database/Replication/ReplicationPipelineStatus' +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 { useFlag } from 'hooks/ui/useFlag' +import { PipelineRequestStatusProvider } from 'state/replication-pipeline-request-status' +import type { NextPageWithLayout } from 'types' + +const DatabaseReplicationPage: NextPageWithLayout = () => { + const enablePgReplicate = useFlag('enablePgReplicate') + const [selectedPipelineId, setSelectedPipelineId] = useState() + const [selectedDestinationName, setSelectedDestinationName] = useState() + + // [Joshen] Ideally selecting a pipeline should be a route on its own with pipelineId as the param + // e.g /project/ref/database/replication/[pipelineId] + // Can destinationName be derived from pipeline ID or something? + + const handleSelectPipeline = (pipelineId: number, destinationName: string) => { + setSelectedPipelineId(pipelineId) + setSelectedDestinationName(destinationName) + } + + const handleSelectBack = () => { + setSelectedPipelineId(undefined) + setSelectedDestinationName(undefined) + } + + return ( + <> + {enablePgReplicate ? ( + + + +
+ + {selectedPipelineId === undefined ? ( + + ) : ( + + )} +
+
+
+
+ ) : ( + <> + + +
+ +
+
+
+ + + )} + + ) +} + +DatabaseReplicationPage.getLayout = (page) => ( + + {page} + +) + +export default DatabaseReplicationPage diff --git a/apps/studio/state/replication-pipeline-request-status.tsx b/apps/studio/state/replication-pipeline-request-status.tsx new file mode 100644 index 00000000000..722c4de9120 --- /dev/null +++ b/apps/studio/state/replication-pipeline-request-status.tsx @@ -0,0 +1,76 @@ +import { createContext, useContext, useState, ReactNode, useCallback } from 'react' + +export enum PipelineStatusRequestStatus { + None = 'None', + EnableRequested = 'EnableRequested', + DisableRequested = 'DisableRequested', +} + +interface PipelineRequestStatusContextType { + requestStatus: Record + setRequestStatus: (pipelineId: number, status: PipelineStatusRequestStatus) => void + getRequestStatus: (pipelineId: number) => PipelineStatusRequestStatus + updatePipelineStatus: (pipelineId: number, backendStatus: string | undefined) => void +} + +interface PipelineRequestStatusProviderProps { + children: ReactNode +} + +const PipelineRequestStatusContext = createContext( + undefined +) + +export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatusProviderProps) => { + const [requestStatus, setRequestStatusState] = useState< + Record + >({}) + + const setRequestStatus = (pipelineId: number, status: PipelineStatusRequestStatus) => { + setRequestStatusState((prev) => ({ + ...prev, + [pipelineId]: status, + })) + } + + const getRequestStatus = (pipelineId: number): PipelineStatusRequestStatus => { + return requestStatus[pipelineId] || PipelineStatusRequestStatus.None + } + + const updatePipelineStatus = useCallback( + (pipelineId: number, backendStatus: string | undefined) => { + const currentRequestStatus = requestStatus[pipelineId] || PipelineStatusRequestStatus.None + + if ( + (currentRequestStatus === PipelineStatusRequestStatus.EnableRequested && + (backendStatus === 'started' || backendStatus === 'failed')) || + (currentRequestStatus === PipelineStatusRequestStatus.DisableRequested && + (backendStatus === 'stopped' || backendStatus === 'failed')) + ) { + setRequestStatus(pipelineId, PipelineStatusRequestStatus.None) + } + }, + [requestStatus, setRequestStatus] + ) + + return ( + + {children} + + ) +} + +export const usePipelineRequestStatus = () => { + const context = useContext(PipelineRequestStatusContext) + if (context === undefined) { + throw new Error('usePipelineRequestStatus must be used within a PipelineRequestStatusProvider') + } + return context +} diff --git a/apps/studio/styles/ui.scss b/apps/studio/styles/ui.scss index 886f96393d4..f0f37298fec 100644 --- a/apps/studio/styles/ui.scss +++ b/apps/studio/styles/ui.scss @@ -119,7 +119,7 @@ } .table-container thead th:first-child { - @apply pl-6 rounded rounded-r-none rounded-b-none; + @apply rounded rounded-r-none rounded-b-none; @apply border-l; } diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index b34c046fe09..23e0737d942 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -3391,6 +3391,23 @@ export interface paths { patch?: never trace?: never } + '/platform/replication/{ref}/pipelines/{pipeline_id}/replication-status': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get the status of a replication pipeline. */ + get: operations['ReplicationPipelinesController_getPipelineReplicationStatus'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/replication/{ref}/pipelines/{pipeline_id}/start': { parameters: { query?: never @@ -7353,6 +7370,41 @@ export interface components { tenant_id: string }[] } + ReplicationPipelineReplicationStatusResponse: { + /** @description Pipeline id */ + pipeline_id: number + /** @description Table statuses */ + table_statuses: { + /** @description Table replication state */ + state: + | { + /** @enum {string} */ + name: 'queued' + } + | { + /** @enum {string} */ + name: 'copying_table' + } + | { + /** @enum {string} */ + name: 'copied_table' + } + | { + lag: number + /** @enum {string} */ + name: 'following_wal' + } + | { + message: string + /** @enum {string} */ + name: 'error' + } + /** @description Table id (internal Postgres OID) */ + table_id: number + /** @description Table name */ + table_name: string + }[] + } ReplicationPipelineResponse: { /** @description Pipeline config */ config: { @@ -16607,6 +16659,12 @@ export interface operations { 'application/json': components['schemas']['GetUserContentResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve project's content */ 500: { headers: { @@ -16638,6 +16696,12 @@ export interface operations { } content?: never } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to update project's content */ 500: { headers: { @@ -16671,6 +16735,12 @@ export interface operations { 'application/json': components['schemas']['UserContentObject'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to create project's content */ 500: { headers: { @@ -16702,6 +16772,12 @@ export interface operations { 'application/json': components['schemas']['BulkDeleteUserContentResponse'][] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to delete project's contents */ 500: { headers: { @@ -16734,6 +16810,12 @@ export interface operations { 'application/json': components['schemas']['GetContentCountV2Response'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve user's content counts */ 500: { headers: { @@ -16771,6 +16853,12 @@ export interface operations { 'application/json': components['schemas']['GetUserContentFolderResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve project's content root folder */ 500: { headers: { @@ -16804,6 +16892,12 @@ export interface operations { 'application/json': components['schemas']['CreateUserContentFolderResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to create project's content folder */ 500: { headers: { @@ -16830,6 +16924,12 @@ export interface operations { } content?: never } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to delete project's content folders */ 500: { headers: { @@ -16867,6 +16967,12 @@ export interface operations { 'application/json': components['schemas']['GetUserContentFolderResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve project's content folder */ 500: { headers: { @@ -16900,6 +17006,12 @@ export interface operations { } content?: never } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to update project's content folder */ 500: { headers: { @@ -16930,6 +17042,12 @@ export interface operations { 'application/json': components['schemas']['GetUserContentByIdResponse'] } } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Failed to retrieve project's content by the given id */ 500: { headers: { @@ -18639,6 +18757,44 @@ export interface operations { } } } + ReplicationPipelinesController_getPipelineReplicationStatus: { + parameters: { + query?: never + header?: never + path: { + /** @description Pipeline id */ + pipeline_id: number + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Returns the pipeline replication status. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ReplicationPipelineReplicationStatusResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Failed to get pipeline replication status. */ + 500: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } ReplicationPipelinesController_startPipeline: { parameters: { query?: never From 0620e2df8ffd8a8a36ffd620f867e4afcf99b7d9 Mon Sep 17 00:00:00 2001 From: Han Qiao Date: Thu, 24 Jul 2025 20:32:48 +0800 Subject: [PATCH 37/38] fix: never create a default branch from modal (#37430) --- .../components/interfaces/BranchManagement/CreateBranchModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index a23bf75eb81..67e66aae6a9 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -182,6 +182,7 @@ export const CreateBranchModal = () => { createBranch({ projectRef, branchName: data.branchName, + is_default: false, ...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}), }) } From 29f5fde08cc67b1d5f4d9e0a6d5651bb570a4f9e Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Thu, 24 Jul 2025 20:05:48 +0200 Subject: [PATCH 38/38] chore: Fix the loading state when updating roles twice in a row (#37438) Fix the loading state when updating roles twice in a row. --- .../UpdateRolesPanel/UpdateRolesConfirmationModal.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx index fc37dd4fe91..246dfd391d0 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx @@ -90,10 +90,11 @@ export const UpdateRolesConfirmationModal = ({ }) toast.success(`Successfully updated role for ${member.username}`) onClose(true) - return } catch (error: any) { + toast.error(`Failed to update role: ${error.message}`) + } finally { setSaving(false) - return toast.error(`Failed to update role: ${error.message}`) + return } } @@ -130,8 +131,10 @@ export const UpdateRolesConfirmationModal = ({ toast.success(`Successfully updated role for ${member.username}`) onClose(true) } catch (error: any) { + toast.error(`Failed to update role: ${error.message}`) + } finally { setSaving(false) - return toast.error(`Failed to update role: ${error.message}`) + return } }