From 4d18c9615c752e1b542fd9122f653c2dd82f3425 Mon Sep 17 00:00:00 2001 From: Dane Harrigan <72265290+dane@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:12:22 -0400 Subject: [PATCH 01/51] chore(docs): add Dane Harrigan to humans.txt (#44956) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? This change adds me (Dane Harrigan) to the humans.txt file. ## What is the current behavior? I am not listed in the file. ## What is the new behavior? I am in the file. ## Summary by CodeRabbit * **Chores** * Updated public team listing: added team member Dane Harrigan. Co-authored-by: Alaister Young --- 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 599d5b0be7d..6d08d52e519 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -56,6 +56,7 @@ Colin Murray Colum Ferry Craig Cannon Cuong Do +Dane Harrigan Daniel Velázquez Lara Danny White Dave Wilson From bc863816fb99b5ff19737b7bb063fd61247208bc Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 17 Apr 2026 08:45:22 -0300 Subject: [PATCH 02/51] docs: Update JS and Kotlin SDK reference (stripNulls, OAuth 2.1 admin) (#44959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Updates reference docs based on recent SDK changes. ## Changes analyzed | SDK | Repo | Commits | Latest tag | |-----|------|---------|------------| | js | supabase/supabase-js | \`16dd265...7a9b2e1\` | v2.103.3 | | kt | supabase-community/supabase-kt | \`a671b7a...2a6f456\` | 3.5.0 | | dart | supabase/supabase-flutter | \`e37539e...01551e3\` | — (dep bumps only) | | py | supabase/supabase-py | \`761d972...36cfcd0\` | v3.0.0a1 (dep bumps only) | | swift | supabase/supabase-swift | \`17261e9...b2bf858\` | v2.43.1 (dep bumps only) | | csharp | supabase-community/supabase-csharp | — | no new commits | ## Documentation updates ### `apps/docs/spec/supabase_js_v2.yml` - Added `strip-nulls` entry pointing to `@supabase/postgrest-js.PostgrestTransformBuilder.stripNulls` — new method in v2.103.0 that strips null values from JSON responses via the `nulls=stripped` PostgREST media type parameter. TypeScript enrichments already contain the full documentation. ### `apps/docs/spec/supabase_kt_v3.yml` - Added OAuth 2.1 client admin CRUD API (`admin.oauth.*`) — new in supabase-kt 3.5.0: `listClients`, `createClient`, `getClient`, `updateClient`, `deleteClient`, `regenerateClientSecret` - Marked `IDToken.Config.data` parameter as deprecated (deprecated upstream in supabase-kt); updated sign-in example to remove deprecated usage ### `apps/docs/spec/common-client-libs-sections.json` - Added `strip-nulls` entry to the Database → Using modifiers nav section - Added 6 `admin-oauth-*` entries to the Auth Admin → OAuth Admin nav section for the new Kotlin OAuth 2.1 methods --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Documentation** * Added documentation for the `stripNulls` function to remove null values from database results. * Expanded OAuth Admin API documentation with 6 new endpoints for OAuth client management: list, get, create, update, delete, and secret regeneration. * Marked deprecated parameter in ID Token sign-in configuration. --------- Co-authored-by: Claude Sonnet 4.6 --- .../spec/common-client-libs-sections.json | 50 ++++++++ apps/docs/spec/supabase_js_v2.yml | 2 + apps/docs/spec/supabase_kt_v3.yml | 119 +++++++++++++++++- 3 files changed, 168 insertions(+), 3 deletions(-) diff --git a/apps/docs/spec/common-client-libs-sections.json b/apps/docs/spec/common-client-libs-sections.json index 813b0b21c49..0f1559522bb 100644 --- a/apps/docs/spec/common-client-libs-sections.json +++ b/apps/docs/spec/common-client-libs-sections.json @@ -368,6 +368,14 @@ "parent": "modifiers", "type": "function" }, + { + "id": "strip-nulls", + "title": "Strip null values", + "slug": "db-strip-nulls", + "product": "database", + "parent": "modifiers", + "type": "function" + }, { "id": "returns", "title": "Override type of successful response", @@ -893,6 +901,48 @@ "slug": "auth-admin-oauth-regenerateclientsecret", "product": "auth-admin", "type": "function" + }, + { + "id": "admin-oauth-list-clients", + "title": "List OAuth clients", + "slug": "auth-admin-oauth-listclients", + "product": "auth-admin", + "type": "function" + }, + { + "id": "admin-oauth-create-client", + "title": "Create OAuth client", + "slug": "auth-admin-oauth-createclient", + "product": "auth-admin", + "type": "function" + }, + { + "id": "admin-oauth-get-client", + "title": "Get OAuth client", + "slug": "auth-admin-oauth-getclient", + "product": "auth-admin", + "type": "function" + }, + { + "id": "admin-oauth-update-client", + "title": "Update OAuth client", + "slug": "auth-admin-oauth-updateclient", + "product": "auth-admin", + "type": "function" + }, + { + "id": "admin-oauth-delete-client", + "title": "Delete OAuth client", + "slug": "auth-admin-oauth-deleteclient", + "product": "auth-admin", + "type": "function" + }, + { + "id": "admin-oauth-regenerate-client-secret", + "title": "Regenerate client secret", + "slug": "auth-admin-oauth-regenerateclientsecret", + "product": "auth-admin", + "type": "function" } ] } diff --git a/apps/docs/spec/supabase_js_v2.yml b/apps/docs/spec/supabase_js_v2.yml index 6b2d34c52d8..efd08fbf093 100644 --- a/apps/docs/spec/supabase_js_v2.yml +++ b/apps/docs/spec/supabase_js_v2.yml @@ -452,6 +452,8 @@ functions: $ref: '@supabase/postgrest-js.PostgrestTransformBuilder.maybeSingle' - id: csv $ref: '@supabase/postgrest-js.PostgrestTransformBuilder.csv' + - id: strip-nulls + $ref: '@supabase/postgrest-js.PostgrestTransformBuilder.stripNulls' - id: returns $ref: '@supabase/postgrest-js.PostgrestTransformBuilder.returns' - id: overrideTypes diff --git a/apps/docs/spec/supabase_kt_v3.yml b/apps/docs/spec/supabase_kt_v3.yml index a55bc9f536e..27b4cd1ae19 100644 --- a/apps/docs/spec/supabase_kt_v3.yml +++ b/apps/docs/spec/supabase_kt_v3.yml @@ -3116,6 +3116,10 @@ functions: isOptional: true type: String? description: The captcha token when having captcha enabled. + - name: data + isOptional: true + type: JsonObject? + description: '**Deprecated** — use `extraData` or user metadata on the sign-up flow instead. Will be removed in a future release.' examples: - id: sign-in-with-id-token @@ -3127,9 +3131,6 @@ functions: provider = Google //Also supported: Apple, Azure and Facebook //optional: nonce = "nonce" - data = buildJsonObject { - //... - } } ``` - id: sign-in-with-otp @@ -4576,6 +4577,118 @@ functions: ```kotlin supabase.auth.admin.deleteFactor(uid = "id", factorId = "factor_id") ``` + - id: oauth-admin-api + title: OAuth Admin API + notes: | + The OAuth Admin API allows you to manage OAuth clients programmatically. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + These functions should only be called on a server. Never expose your `secret key` in the browser. + - id: admin-oauth-list-clients + title: 'admin.oauth.listClients()' + notes: | + Lists all OAuth 2.1 clients. Requires a `secret key`. + params: + - name: page + isOptional: true + type: Int? + description: The page number for pagination. + - name: perPage + isOptional: true + type: Int? + description: The number of results per page. + examples: + - id: list-oauth-clients + name: List all OAuth clients + isSpotlight: true + code: | + ```kotlin + val clients = supabase.auth.admin.oauth.listClients() + ``` + - id: admin-oauth-create-client + title: 'admin.oauth.createClient()' + notes: | + Creates a new OAuth 2.1 client. Requires a `secret key`. + examples: + - id: create-oauth-client + name: Create an OAuth client + isSpotlight: true + code: | + ```kotlin + val client = supabase.auth.admin.oauth.createClient { + name = "My App" + redirectUris = listOf("https://example.com/callback") + } + ``` + - id: admin-oauth-get-client + title: 'admin.oauth.getClient()' + notes: | + Retrieves an OAuth 2.1 client by ID. Requires a `secret key`. + params: + - name: clientId + isOptional: false + type: String + description: The ID of the OAuth client to retrieve. + examples: + - id: get-oauth-client + name: Get an OAuth client + isSpotlight: true + code: | + ```kotlin + val client = supabase.auth.admin.oauth.getClient(clientId = "client_id") + ``` + - id: admin-oauth-update-client + title: 'admin.oauth.updateClient()' + notes: | + Updates an existing OAuth 2.1 client. Requires a `secret key`. + params: + - name: clientId + isOptional: false + type: String + description: The ID of the OAuth client to update. + examples: + - id: update-oauth-client + name: Update an OAuth client + isSpotlight: true + code: | + ```kotlin + val client = supabase.auth.admin.oauth.updateClient(clientId = "client_id") { + name = "Updated App Name" + } + ``` + - id: admin-oauth-delete-client + title: 'admin.oauth.deleteClient()' + notes: | + Deletes an OAuth 2.1 client. Requires a `secret key`. + params: + - name: clientId + isOptional: false + type: String + description: The ID of the OAuth client to delete. + examples: + - id: delete-oauth-client + name: Delete an OAuth client + isSpotlight: true + code: | + ```kotlin + supabase.auth.admin.oauth.deleteClient(clientId = "client_id") + ``` + - id: admin-oauth-regenerate-client-secret + title: 'admin.oauth.regenerateClientSecret()' + notes: | + Regenerates the client secret for an OAuth 2.1 client. Requires a `secret key`. + params: + - name: clientId + isOptional: false + type: String + description: The ID of the OAuth client whose secret should be regenerated. + examples: + - id: regenerate-client-secret + name: Regenerate client secret + isSpotlight: true + code: | + ```kotlin + val client = supabase.auth.admin.oauth.regenerateClientSecret(clientId = "client_id") + ``` - id: invoke title: 'invoke()' description: | From 9c155a21921be84eaa6aa32cbf4759cbe0150189 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Fri, 17 Apr 2026 18:00:57 +0530 Subject: [PATCH 03/51] feat: show installed marketplace apps and remove official badge from them (#44973) This PR shows installed marketplace apps in the left pane on the integrations page and shows the `Installed` badge on the card. `Official` badge is not shown on marketplace listings. image ## Summary by CodeRabbit * **Bug Fixes** * Fixed excessive spacing on the integrations page * Enhanced integration installation detection for improved accuracy * **Style** * Updated official badge display on integration cards --- .../Integrations/Landing/IntegrationCard.tsx | 6 +- .../Landing/useInstalledIntegrations.tsx | 81 ++++++++++++++----- .../project/[ref]/integrations/index.tsx | 2 +- 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx b/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx index 180169f930d..83d067591c1 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx @@ -37,6 +37,7 @@ export const IntegrationLoadingCard = () => { export const IntegrationCard = ({ id, + listingId, status, name, icon, @@ -46,6 +47,7 @@ export const IntegrationCard = ({ image, }: IntegrationCardProps) => { const { data: project } = useSelectedProjectQuery() + const shouldShowOfficialBadge = !listingId if (featured) { return ( @@ -73,7 +75,7 @@ export const IntegrationCard = ({

{description}

{status && {status}} - Official + {shouldShowOfficialBadge && Official}
@@ -103,7 +105,7 @@ export const IntegrationCard = ({

{description}

{status && {status}} - Official + {shouldShowOfficialBadge && Official}
diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx index 48ea23bacb7..026c54e41fa 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx @@ -2,33 +2,47 @@ import { useMemo } from 'react' import { parseSchemaComment } from 'stripe-experiment-sync/supabase' import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils' -import { INTEGRATIONS } from './Integrations.constants' +import { useAvailableIntegrations } from './useAvailableIntegrations' import { isInstalled as checkIsInstalled, findStripeSchema, } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status' +import { useAPIKeysQuery } from '@/data/api-keys/api-keys-query' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSchemasQuery } from '@/data/database/schemas-query' import { useFDWsQuery } from '@/data/fdw/fdws-query' -import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { EMPTY_ARR } from '@/lib/void' export const useInstalledIntegrations = () => { const { data: project } = useSelectedProjectQuery() - const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) + const { + data: allIntegrations = EMPTY_ARR, + error: availableIntegrationsError, + isPending: isAvailableIntegrationsLoading, + isSuccess: isSuccessAvailableIntegrations, + isError: isErrorAvailableIntegrations, + } = useAvailableIntegrations() - const allIntegrations = useMemo(() => { - return INTEGRATIONS.filter((integration) => { - if ( - !integrationsWrappers && - (integration.type === 'wrapper' || integration.id.endsWith('_wrapper')) - ) { - return false - } - return true - }) - }, [integrationsWrappers]) + const hasSecretKeyPrefixIntegration = useMemo(() => { + return allIntegrations.some( + (integration) => + integration.type === 'oauth' && + integration.installIdentificationMethod === 'secret_key_prefix' && + !!integration.secretKeyPrefix + ) + }, [allIntegrations]) + + const { + data: apiKeys, + error: apiKeysError, + isError: isErrorApiKeys, + isLoading: isApiKeysLoading, + isSuccess: isSuccessApiKeys, + } = useAPIKeysQuery( + { projectRef: project?.ref, reveal: false }, + { enabled: !!project?.ref && hasSecretKeyPrefixIntegration } + ) const { data, @@ -89,15 +103,44 @@ export const useInstalledIntegrations = () => { return !!foundExtension?.installed_version }) } + if (integration.type === 'oauth') { + const prefix = integration.secretKeyPrefix + + if (integration.installIdentificationMethod !== 'secret_key_prefix' || !prefix) { + return false + } + + return (apiKeys ?? []).some((key) => key.type === 'secret' && key.name.startsWith(prefix)) + } return false }) .sort((a, b) => a.name.localeCompare(b.name)) - }, [allIntegrations, wrappers, extensions, schemas, isHooksEnabled]) + }, [allIntegrations, wrappers, extensions, schemas, isHooksEnabled, apiKeys]) - const error = fdwError || extensionsError || schemasError - const isLoading = isSchemasLoading || isFDWLoading || isExtensionsLoading - const isError = isErrorFDWs || isErrorExtensions || isErrorSchemas - const isSuccess = isSuccessFDWs && isSuccessExtensions && isSuccessSchemas + const error = + fdwError || + extensionsError || + schemasError || + availableIntegrationsError || + (hasSecretKeyPrefixIntegration ? apiKeysError : null) + const isLoading = + isSchemasLoading || + isFDWLoading || + isExtensionsLoading || + isAvailableIntegrationsLoading || + (hasSecretKeyPrefixIntegration && isApiKeysLoading) + const isError = + isErrorFDWs || + isErrorExtensions || + isErrorSchemas || + isErrorAvailableIntegrations || + (hasSecretKeyPrefixIntegration && isErrorApiKeys) + const isSuccess = + isSuccessFDWs && + isSuccessExtensions && + isSuccessSchemas && + isSuccessAvailableIntegrations && + (!hasSecretKeyPrefixIntegration || isSuccessApiKeys) return { // show all integrations at once instead of showing partial results diff --git a/apps/studio/pages/project/[ref]/integrations/index.tsx b/apps/studio/pages/project/[ref]/integrations/index.tsx index 904569fad86..9a441eb26ea 100644 --- a/apps/studio/pages/project/[ref]/integrations/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/index.tsx @@ -190,7 +190,7 @@ const IntegrationsPage: NextPageWithLayout = () => { {/* Featured Integrations */} {groupedIntegrations.featured.length > 0 && (
{groupedIntegrations.featured.map((integration) => ( From bd04a59decc06594b14b429e4e13b3bde84ed381 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:52:24 +0530 Subject: [PATCH 04/51] fix: phone provider (#44974) fixes phone provider saves that showed success but did not persist the enabled state (smol regression from the refactor) ## ref: - closes https://github.com/supabase/supabase/issues/44966 ig was introduced by 6b35cc80345d3eea63893bcd2f424d24d753bda5 ## Summary by CodeRabbit * **Bug Fixes** * Automatically clears certain conditional fields when their visibility condition becomes false to avoid stale values. * Preserves input values and registration state for fields removed from the UI (they no longer unregister on hide), improving form continuity during toggles. --- .../interfaces/Auth/AuthProvidersForm/FormField.tsx | 12 ++++++++++-- .../Auth/AuthProvidersForm/ProviderForm.tsx | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx index b923356c29c..2da5f06e915 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx @@ -1,6 +1,7 @@ import { format } from 'date-fns' import { CalendarIcon, ExternalLink } from 'lucide-react' -import { type Control } from 'react-hook-form' +import { useEffect } from 'react' +import { useFormContext, type Control } from 'react-hook-form' import ReactMarkdown from 'react-markdown' import { Button, @@ -53,6 +54,7 @@ const FormField = ({ disabled: disabledProp, readOnly, }: FormFieldProps) => { + const { setValue } = useFormContext() const { description: originalDescription } = properties let description = originalDescription @@ -79,6 +81,12 @@ const FormField = ({ disabled: properties.show == null, }) + useEffect(() => { + if (properties.show?.key != null && !showValue && fieldValue !== '') { + setValue(name, '', { shouldDirty: true }) + } + }, [fieldValue, name, properties.show?.key, setValue, showValue]) + if (properties.show) { if (properties.show.matches) { if (!properties.show.matches.includes(showValue)) { @@ -341,7 +349,7 @@ const FormField = ({ > diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index c4602505546..5fc691fac28 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -165,7 +165,7 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) const form = useForm({ defaultValues: INITIAL_VALUES, resolver: zodResolver(provider.validationSchema), - shouldUnregister: true, + shouldUnregister: false, }) useEffect(() => { From 8ef6181ef721564df88aadc5b6105ad28e790a9d Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 17 Apr 2026 07:28:49 -0600 Subject: [PATCH 05/51] fix(studio): show Vercel-specific copy in delete connection dialog (#44960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The delete confirmation dialog for integration connections hard-coded GitHub-specific copy about preview branches, even when deleting a Vercel connection. - Branch the dialog body on the connection `type` so Vercel connections explain that environment variable syncing to the Vercel project will stop. Fixes [FE-3006](https://linear.app/supabase/issue/FE-3006/deleting-vercel-connection-confirmation-dialog-incorrectly). ## Test plan - [x] Go to Organization > Integrations - [x] Set up a Vercel integration linking a Supabase project to a Vercel project - [x] Click Manage > Delete connection → dialog should reference Vercel env var syncing, not GitHub preview branches - [x] Repeat with a GitHub connection → dialog should still reference preview branches ## Summary by CodeRabbit * **Bug Fixes** * Updated integration disconnection confirmation messages to provide type-specific guidance, clarifying exactly what happens when stopping synchronization for each integration type. --- .../Integrations/VercelGithub/IntegrationConnection.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx index 97435d21e06..14055ffcdd8 100644 --- a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx @@ -155,8 +155,9 @@ export const IntegrationConnectionItem = forwardRef

- Deleting this GitHub connection will stop automatic creation and merging of preview - branches. Existing preview branches will remain unchanged. + {type === 'Vercel' + ? 'Deleting this Vercel connection will stop syncing environment variables to your Vercel project. Existing environment variables will remain unchanged.' + : 'Deleting this GitHub connection will stop automatic creation and merging of preview branches. Existing preview branches will remain unchanged.'}

From 3ed436de747acc512319ca67cc90ef4f09b886db Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 17 Apr 2026 08:20:36 -0600 Subject: [PATCH 06/51] feat: new shortcuts hook with registrations (#44954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? - Brand new hook APIs for registering shortcuts using tanstack hotkeys - Support for command menu injection when shortcut is added ## Summary by CodeRabbit * **New Features** * Centralized keyboard shortcuts system with per‑shortcut registration and per‑user enable/disable preferences stored locally * Added a "Copy results as Markdown" shortcut (Mod+Shift+M) * Shortcuts can be surfaced in the Command Menu with a visual shortcut badge for discoverability * **Documentation** * Legacy keyboard shortcut hooks marked as deprecated and documentation updated to point to the new shortcut API --- .../grid/components/common/Hooks.tsx | 4 + .../App/CommandMenu/CommandMenu.utils.ts | 1 + apps/studio/hooks/ui/useHotKey.ts | 14 ++- apps/studio/package.json | 1 + apps/studio/state/shortcuts/registry.ts | 46 +++++++++ apps/studio/state/shortcuts/state.ts | 51 ++++++++++ apps/studio/state/shortcuts/types.ts | 80 ++++++++++++++++ .../state/shortcuts/useIsShortcutEnabled.ts | 20 ++++ apps/studio/state/shortcuts/useShortcut.tsx | 94 +++++++++++++++++++ packages/common/constants/local-storage.ts | 3 + pnpm-lock.yaml | 33 ++++++- 11 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 apps/studio/state/shortcuts/registry.ts create mode 100644 apps/studio/state/shortcuts/state.ts create mode 100644 apps/studio/state/shortcuts/types.ts create mode 100644 apps/studio/state/shortcuts/useIsShortcutEnabled.ts create mode 100644 apps/studio/state/shortcuts/useShortcut.tsx diff --git a/apps/studio/components/grid/components/common/Hooks.tsx b/apps/studio/components/grid/components/common/Hooks.tsx index 2e081fa49cc..ef2fe369e31 100644 --- a/apps/studio/components/grid/components/common/Hooks.tsx +++ b/apps/studio/components/grid/components/common/Hooks.tsx @@ -7,6 +7,10 @@ function includes(array: string[], element: string) { /** * Hook for listening on key events. * + * @deprecated Use `useShortcut` from `state/shortcuts/useShortcut` instead. + * The new hook reads from a central registry, respects user preferences, and + * can surface shortcuts in the Cmd+P command menu. + * * @param {Object|Map} keyMap Key names mapped to event handlers. If a key name exists, its * default behavior will be suppressed. * @param {Array} whitelistNodes If target element is in the whitelist nodes array, will not diff --git a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.utils.ts b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.utils.ts index d203281c9d2..832332f31c7 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.utils.ts +++ b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.utils.ts @@ -7,4 +7,5 @@ export const COMMAND_MENU_SECTIONS = { SQL: 'SQL Editor', DATABASE: 'Database', INTEGRATIONS: 'Integrations', + SHORTCUTS: 'Shortcuts', } as const diff --git a/apps/studio/hooks/ui/useHotKey.ts b/apps/studio/hooks/ui/useHotKey.ts index 60dbe097c6a..cedb801e180 100644 --- a/apps/studio/hooks/ui/useHotKey.ts +++ b/apps/studio/hooks/ui/useHotKey.ts @@ -1,14 +1,24 @@ import { useEffect } from 'react' import { useLatest } from 'react-use' -// [Joshen] Refactor: Remove dependencies, and just make this into a single definition +/** + * @deprecated Use `useShortcut` from `state/shortcuts/useShortcut` instead. + * It reads from a central shortcut registry (`SHORTCUT_DEFINITIONS`) and + * integrates with the user's enable/disable preferences + the Cmd+P command menu. + * + * Migration: + * 1. Add an entry to `state/shortcuts/registry.ts` with a unique ID, label, and sequence. + * 2. Replace `useHotKey(cb, 'k', { shift: true })` with + * `useShortcut(SHORTCUT_IDS.YOUR_ID, cb)`. + */ function useHotKey( callback: (e: KeyboardEvent) => void, key: string, options?: { enabled?: boolean; shift?: boolean } ): void /** - * @deprecated The `dependencies` parameter is deprecated. Use the overload without dependencies instead. + * @deprecated Use `useShortcut` from `state/shortcuts/useShortcut` instead. + * The `dependencies` parameter is also deprecated in this legacy hook. */ function useHotKey( callback: (e: KeyboardEvent) => void, diff --git a/apps/studio/package.json b/apps/studio/package.json index 9da24a7d95a..ba7532a05d6 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -67,6 +67,7 @@ "@supabase/shared-types": "0.1.88", "@supabase/sql-to-rest": "^0.1.6", "@supabase/supabase-js": "catalog:", + "@tanstack/react-hotkeys": "^0.9.1", "@tanstack/react-query": "^5.0.0", "@tanstack/react-query-devtools": "^5.0.0", "@tanstack/react-table": "^8.21.3", diff --git a/apps/studio/state/shortcuts/registry.ts b/apps/studio/state/shortcuts/registry.ts new file mode 100644 index 00000000000..be08a32d51c --- /dev/null +++ b/apps/studio/state/shortcuts/registry.ts @@ -0,0 +1,46 @@ +import { ShortcutDefinition } from './types' + +/** + * The canonical list of shortcut IDs. Add new shortcuts here first, then + * register them in `SHORTCUT_DEFINITIONS` below. + * + * ID convention: `"."` in kebab-case, e.g. `"results.copy-markdown"`. + * The `` groups related shortcuts (sql-editor, table-editor, results, etc). + */ +export const SHORTCUT_IDS = { + RESULTS_COPY_MARKDOWN: 'results.copy-markdown', +} as const + +/** + * Union of all valid shortcut IDs. Use this as the `id` parameter type on any + * hook or util that takes a shortcut reference. + */ +export type ShortcutId = (typeof SHORTCUT_IDS)[keyof typeof SHORTCUT_IDS] + +/** + * The shortcut registry — every shortcut the app knows about, keyed by + * `ShortcutId`. The `Record` type ensures this map stays exhaustive: adding a + * new entry to `SHORTCUT_IDS` without a matching definition here is a type error. + * + * See `ShortcutDefinition` for the shape of each entry. + * + * @example + * // Add a new shortcut: + * // 1. Add to SHORTCUT_IDS: + * // SQL_EDITOR_RUN: 'sql-editor.run' + * // 2. Add to SHORTCUT_DEFINITIONS: + * // [SHORTCUT_IDS.SQL_EDITOR_RUN]: { + * // id: SHORTCUT_IDS.SQL_EDITOR_RUN, + * // label: 'Run query', + * // sequence: ['Mod+Enter'], + * // } + * // 3. Use in a component: + * // useShortcut(SHORTCUT_IDS.SQL_EDITOR_RUN, runQuery) + */ +export const SHORTCUT_DEFINITIONS: Record = { + [SHORTCUT_IDS.RESULTS_COPY_MARKDOWN]: { + id: SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, + label: 'Copy results as Markdown', + sequence: ['Mod+Shift+M'], + }, +} diff --git a/apps/studio/state/shortcuts/state.ts b/apps/studio/state/shortcuts/state.ts new file mode 100644 index 00000000000..e2e97dce815 --- /dev/null +++ b/apps/studio/state/shortcuts/state.ts @@ -0,0 +1,51 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { useCallback } from 'react' + +import type { ShortcutId } from './registry' +import { DisabledShortcuts } from './types' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' + +const STORAGE_KEY = LOCAL_STORAGE_KEYS.SHORTCUT_STORAGE_KEY + +const DEFAULT_DISABLED: DisabledShortcuts = {} + +export function useShortcutPreferences() { + const [disabled, setDisabled] = useLocalStorageQuery( + STORAGE_KEY, + DEFAULT_DISABLED + ) + + const setShortcutEnabled = useCallback( + (id: ShortcutId, enabled: boolean) => { + setDisabled((prev) => { + if (enabled) { + const { [id]: _removed, ...rest } = prev + return rest + } + return { ...prev, [id]: true } + }) + }, + [setDisabled] + ) + + const resetShortcut = useCallback( + (id: ShortcutId) => { + setDisabled((prev) => { + const { [id]: _removed, ...rest } = prev + return rest + }) + }, + [setDisabled] + ) + + const resetAllShortcuts = useCallback(() => { + setDisabled(DEFAULT_DISABLED) + }, [setDisabled]) + + return { + disabled, + setShortcutEnabled, + resetShortcut, + resetAllShortcuts, + } +} diff --git a/apps/studio/state/shortcuts/types.ts b/apps/studio/state/shortcuts/types.ts new file mode 100644 index 00000000000..8ee1cd3249b --- /dev/null +++ b/apps/studio/state/shortcuts/types.ts @@ -0,0 +1,80 @@ +import { HotkeySequence } from '@tanstack/react-hotkeys' + +export type DisabledShortcuts = Record + +/** + * Runtime options for a shortcut. Used in two places: + * + * 1. On a `ShortcutDefinition` in the registry — acts as the default options + * whenever the shortcut is mounted via `useShortcut`. + * 2. As the third argument to `useShortcut(id, callback, options)` — lets the + * call site override the registry defaults for a specific mount. + * + * Caller options take priority over registry defaults, which take priority over + * the hard-coded fallbacks listed on each field below. + */ +export interface ShortcutOptions { + /** + * Whether the shortcut is live. Defaults to `true`. + * + * Conjunctive with the user's global enable/disable preference: if the user + * has disabled a shortcut in Account → Preferences, passing `enabled: true` + * here will NOT re-enable it. Use this to gate the shortcut on local + * conditions (e.g. `enabled: hasUnsavedChanges`). + */ + enabled?: boolean + + /** + * Maximum time in milliseconds between consecutive keys in a multi-step + * sequence. Defaults to `undefined`, which falls through to TanStack's + * library default (1000ms). + * + * Only meaningful for multi-step sequences like `['G', 'G']`. Single-step + * shortcuts ignore this. + */ + timeout?: number + + /** + * When `true`, the shortcut also appears as an entry in the Cmd+P command + * menu (under the "Shortcuts" section) for as long as the hook is mounted. + * The entry's label comes from `ShortcutDefinition.label` and the keybind + * is rendered from `ShortcutDefinition.sequence`. + * + * Defaults to `false` — opt-in per call site so Cmd+P doesn't fill up with + * context-specific shortcuts that only make sense in certain views. + */ + registerInCommandMenu?: boolean +} + +/** + * A single entry in the shortcut registry. Every shortcut the app uses must + * have a matching definition in `SHORTCUT_DEFINITIONS`. + * + * The registry is the single source of truth for: + * - the keybind (`sequence`) — used by the hotkey listener AND the Cmd+P badge + * - the human-readable `label` — shown in Cmd+P and in the preferences UI + * - per-shortcut default `options` — overridable per call site + */ +export interface ShortcutDefinition { + /** Stable unique identifier. Must match the key used in `SHORTCUT_IDS`. */ + id: string + + /** Human-readable label shown in the command menu and preferences UI. */ + label: string + + /** + * Keybind as a TanStack Hotkeys sequence — an array of one or more hotkey + * strings. Single-step: `['Mod+Shift+M']`. Multi-step (chord): `['G', 'G']`. + * + * Use `Mod` for the platform modifier (⌘ on macOS, Ctrl elsewhere). Supports + * `Shift`, `Alt`, `Ctrl` (literal), named keys (`Enter`, `Escape`, arrows), + * and single character keys. + */ + sequence: HotkeySequence + + /** + * Default runtime options applied when the shortcut is mounted. Each field + * is overridable by the caller of `useShortcut`. + */ + options?: ShortcutOptions +} diff --git a/apps/studio/state/shortcuts/useIsShortcutEnabled.ts b/apps/studio/state/shortcuts/useIsShortcutEnabled.ts new file mode 100644 index 00000000000..7518418d7d4 --- /dev/null +++ b/apps/studio/state/shortcuts/useIsShortcutEnabled.ts @@ -0,0 +1,20 @@ +import type { ShortcutId } from './registry' +import { useShortcutPreferences } from './state' + +/** + * Reactive check for whether a shortcut is currently enabled for the user. + * + * Subscribes the calling component to the shortcut preferences query so it + * re-renders when the user toggles this shortcut in Account → Preferences. + * + * For one-off, non-reactive checks outside render (event handlers, module-level + * code), use the plain `isShortcutEnabled(id)` function from `./state` instead. + * + * @example + * const isEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE) + * return {isEnabled && } + */ +export function useIsShortcutEnabled(id: ShortcutId): boolean { + const { disabled } = useShortcutPreferences() + return !disabled[id] +} diff --git a/apps/studio/state/shortcuts/useShortcut.tsx b/apps/studio/state/shortcuts/useShortcut.tsx new file mode 100644 index 00000000000..c5a9faab23e --- /dev/null +++ b/apps/studio/state/shortcuts/useShortcut.tsx @@ -0,0 +1,94 @@ +import { useHotkeySequence } from '@tanstack/react-hotkeys' +import { Fragment, useCallback } from 'react' +import { KeyboardShortcut } from 'ui' +import { useRegisterCommands } from 'ui-patterns/CommandMenu' + +import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry' +import type { ShortcutOptions } from './types' +import { useIsShortcutEnabled } from './useIsShortcutEnabled' +import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils' +import useLatest from '@/hooks/misc/useLatest' + +const hotkeyToKeys = (hotkey: string): string[] => + hotkey.split('+').map((part) => (part === 'Mod' ? 'Meta' : part)) + +/** + * Subscribe to a registered keyboard shortcut. + * + * Looks up the shortcut's `sequence` and `label` from `SHORTCUT_DEFINITIONS`, + * wires up a global hotkey listener via `@tanstack/react-hotkeys`, and + * (optionally) registers the shortcut as an entry in the Cmd+P command menu + * under the "Shortcuts" section for as long as the hook is mounted. + * + * Option resolution priority (highest first): + * 1. `options` passed to this hook + * 2. `def.options` from the registry entry + * 3. Hard-coded fallbacks (`enabled: true`, `timeout: undefined`, `registerInCommandMenu: false`) + * + * `enabled` is ANDed with the user's global enable/disable preference — if the + * user has disabled the shortcut in Preferences, it won't fire even if the + * caller or registry say `enabled: true`. + * + * @param id The registered shortcut to bind to. See `SHORTCUT_IDS`. + * @param callback Runs when the sequence matches. Always calls the latest + * reference — no stale closure issues. + * @param options Per-mount overrides. See `ShortcutOptions`. + * + * @example + * useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, handleCopy) + * + * @example + * // Surface in Cmd+P while this component is mounted: + * useShortcut(SHORTCUT_IDS.SQL_EDITOR_RUN, runQuery, { + * registerInCommandMenu: true, + * }) + * + * @example + * // Gate on local state — disables hotkey AND hides Cmd+P entry when false: + * useShortcut(SHORTCUT_IDS.SAVE, handleSave, { + * enabled: hasUnsavedChanges, + * registerInCommandMenu: true, + * }) + */ +export function useShortcut(id: ShortcutId, callback: () => void, options?: ShortcutOptions) { + const def = SHORTCUT_DEFINITIONS[id] + + // Handle override for the shortcut + const globallyEnabled = useIsShortcutEnabled(id) + const callerEnabled = options?.enabled ?? def.options?.enabled ?? true + const enabled = globallyEnabled && callerEnabled + const timeout = options?.timeout ?? def.options?.timeout ?? undefined + + useHotkeySequence(def.sequence, callback, { enabled, timeout }) + + // Handle overrides for command menu + const enabledInCommandMenu = enabled && (options?.registerInCommandMenu ?? false) + const depsInCommandMenu = [enabled, def.label] + const callbackRef = useLatest(callback) + const stableAction = useCallback(() => callbackRef.current(), [callbackRef]) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.SHORTCUTS, + [ + { + id, + name: def.label, + action: stableAction, + badge: () => ( +
+ {def.sequence.map((step, i) => ( + + {i > 0 && then} + + + ))} +
+ ), + }, + ], + { + enabled: enabledInCommandMenu, + deps: depsInCommandMenu, + } + ) +} diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 34ae866c281..2f37b3dc6be 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -80,6 +80,9 @@ export const LOCAL_STORAGE_KEYS = { // api keys view switcher for new and legacy api keys API_KEYS_VIEW: (ref: string) => `supabase-api-keys-view-${ref}`, + // Shortcut preferences + SHORTCUT_STORAGE_KEY: 'supabase-shortcut-preferences', + LAST_VISITED_ORGANIZATION: 'last-visited-organization', // user impersonation selector previous searches diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33f27f05861..7de592f0234 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -933,7 +933,7 @@ importers: version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: 'catalog:' - version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) + version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) '@std/path': specifier: npm:@jsr/std__path@^1.0.8 version: '@jsr/std__path@1.0.8' @@ -967,6 +967,9 @@ importers: '@supabase/supabase-js': specifier: 'catalog:' version: 2.103.2 + '@tanstack/react-hotkeys': + specifier: ^0.9.1 + version: 0.9.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-query': specifier: ^5.0.0 version: 5.83.0(react@18.3.1) @@ -1086,7 +1089,7 @@ importers: version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: 2.7.1 - version: 2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) openai: specifier: ^4.104.0 version: 4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76) @@ -8945,12 +8948,23 @@ packages: resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} + '@tanstack/hotkeys@0.7.1': + resolution: {integrity: sha512-YHVO1z6wnvUCu7bg870Kv5k2D+FIuIOSIcbN0dAmTTsJ3mLMDLwcTVx0qVaq+SZp1B514JJTqGVstvUp85yIpQ==} + engines: {node: '>=18'} + '@tanstack/query-core@5.83.0': resolution: {integrity: sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==} '@tanstack/query-devtools@5.90.1': resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==} + '@tanstack/react-hotkeys@0.9.1': + resolution: {integrity: sha512-/qdQUUVkYAHAWRGdFXqFgWpW/S+a6OzkvxWNWKLLDHQODJlO6EPBPa073CglaafBfzig58RK07T09ET+NnZhpg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-query-devtools@5.90.2': resolution: {integrity: sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==} peerDependencies: @@ -25761,7 +25775,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))': + '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 @@ -26519,10 +26533,21 @@ snapshots: '@tanstack/history@1.161.6': {} + '@tanstack/hotkeys@0.7.1': + dependencies: + '@tanstack/store': 0.9.3 + '@tanstack/query-core@5.83.0': {} '@tanstack/query-devtools@5.90.1': {} + '@tanstack/react-hotkeys@0.9.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/hotkeys': 0.7.1 + '@tanstack/react-store': 0.9.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.83.0(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/query-devtools': 5.90.1 @@ -34199,7 +34224,7 @@ snapshots: mitt: 3.0.1 next: 15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) - nuqs@2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + nuqs@2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@standard-schema/spec': 1.0.0 react: 18.3.1 From e63cca3b4af8c3a85b730fcbe86ba28aaaca65d8 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 17 Apr 2026 09:15:25 -0600 Subject: [PATCH 07/51] fix(studio): preserve EXTERNAL_PHONE_ENABLED on phone provider save (#44982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Toggling the Phone auth provider on in Studio appeared to save (success toast) but snapped back to **Disabled** immediately. The backend value never changed. ## Root cause In `AuthProvidersFormValidation.tsx`, the phone schema's final `.transform` replaced the parsed values with `enabledSchema.parse(values)`: ``` .transform((values) => { if (values.EXTERNAL_PHONE_ENABLED === true) { return enabledSchema.parse(values) // ← strips EXTERNAL_PHONE_ENABLED } return values }) ``` `enabledSchema` is a `z.discriminatedUnion('SMS_PROVIDER', [...])` whose branch schemas (twilio / twilio_verify / messagebird / vonage / textlocal) don't declare `EXTERNAL_PHONE_ENABLED`. Zod objects strip unknown keys by default, so the flag was dropped from the submitted payload. The PATCH request to `/platform/auth/{ref}/config` went out without `EXTERNAL_PHONE_ENABLED`, the backend kept its previous value, and `form.reset` on the response snapped the toggle back to disabled. Regression was introduced in #44865 (zod migration). The recent #44974 fix addressed the `shouldUnregister` side of the form but not this transform. ## Fix Spread `enabledSchema.parse(values)` and re-add `EXTERNAL_PHONE_ENABLED: true` so the flag survives the transform. ## Test plan - [x] On a project with no phone provider configured, pick an SMS provider (e.g. Twilio), fill credentials, toggle Phone on, Save → toggle stays **Enabled**, network tab shows `EXTERNAL_PHONE_ENABLED: true` in PATCH payload and response - [x] Toggle Phone off → stays **Disabled** (unchanged behavior) - [x] Change SMS provider credentials while enabled → saves correctly - [x] With SMS hook enabled, phone provider fields remain optional as before ## Summary by CodeRabbit * **Bug Fixes** * Fixed an issue where phone authentication provider settings were not being properly retained during form submission. --- .../interfaces/Auth/AuthProvidersFormValidation.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx index 2f9b45b2482..dd444177e4a 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx @@ -378,10 +378,12 @@ export const getPhoneProviderValidationSchema = (config: ProjectAuthConfigData) return enabledSchema.parse(values) } }) - // Trick: use transform to ensure the correct shape when EXTERNAL_PHONE_ENABLED is true + // Trick: use transform to ensure the correct shape when EXTERNAL_PHONE_ENABLED is true. + // enabledSchema strips EXTERNAL_PHONE_ENABLED (not declared on its branches), so re-add it + // to keep the flag in the submitted payload. .transform((values) => { if (values.EXTERNAL_PHONE_ENABLED === true) { - return enabledSchema.parse(values) + return { ...enabledSchema.parse(values), EXTERNAL_PHONE_ENABLED: true as const } } return values }) From b4da9f213c3f6f36c631a19df30458cee94d8ea9 Mon Sep 17 00:00:00 2001 From: "supabase-supabase-autofixer[bot]" <248690971+supabase-supabase-autofixer[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:33:41 +0200 Subject: [PATCH 08/51] [bot] Sync from supabase/troubleshooting (#44967) This PR syncs the latest troubleshooting guides from the supabase/troubleshooting repository. --------- Co-authored-by: github-docs-bot Co-authored-by: Chris Chinchilla Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Chris Chinchilla --- ...t-shrinking-after-deleting-data-135390.mdx | 9 ++ ...-transaction-pooler-connections-ef582c.mdx | 74 ++++++++++ ...ity-to-specific-dashboard-users-8d3715.mdx | 134 ++++++++++++++++++ ...one-linked-to-incorrect-user-id-45368f.mdx | 17 +++ ...ing-for-persistent-git-branches-b9191e.mdx | 25 ++++ supa-mdx-lint/Rule003Spelling.toml | 1 + 6 files changed, 260 insertions(+) create mode 100644 apps/docs/content/troubleshooting/disk-size-not-shrinking-after-deleting-data-135390.mdx create mode 100644 apps/docs/content/troubleshooting/resolving-cannot-execute-update-in-a-read-only-transaction-on-transaction-pooler-connections-ef582c.mdx create mode 100644 apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx create mode 100644 apps/docs/content/troubleshooting/unexpected-behavior-with-authupdateuser-phone-phone-linked-to-incorrect-user-id-45368f.mdx create mode 100644 apps/docs/content/troubleshooting/vercel-integration-environment-variables-not-syncing-for-persistent-git-branches-b9191e.mdx diff --git a/apps/docs/content/troubleshooting/disk-size-not-shrinking-after-deleting-data-135390.mdx b/apps/docs/content/troubleshooting/disk-size-not-shrinking-after-deleting-data-135390.mdx new file mode 100644 index 00000000000..741ecadb9a6 --- /dev/null +++ b/apps/docs/content/troubleshooting/disk-size-not-shrinking-after-deleting-data-135390.mdx @@ -0,0 +1,9 @@ +--- +title = "'Disk size not shrinking after deleting data'" +topics = [ "database", "storage" ] +keywords = [] +--- + +If you've deleted a significant amount of data (e.g., by dropping large tables), you may observe that your project's disk size does not automatically shrink. This is expected behavior as Postgres reclaims freed space for internal reuse but does not return it to the operating system. Furthermore, cloud storage volumes (such as AWS EBS) do not support in-place shrinking, which means the disk size cannot be directly reduced via the dashboard UI. + +To reclaim disk space and reduce your project's disk allocation, you will need to initiate a Postgres version upgrade. This process provisions a new instance with a smaller disk attached, migrates your data, and involves brief downtime during the upgrade and switchover. You can start this process from the [Supabase Dashboard](/dashboard/project/_/settings/database). diff --git a/apps/docs/content/troubleshooting/resolving-cannot-execute-update-in-a-read-only-transaction-on-transaction-pooler-connections-ef582c.mdx b/apps/docs/content/troubleshooting/resolving-cannot-execute-update-in-a-read-only-transaction-on-transaction-pooler-connections-ef582c.mdx new file mode 100644 index 00000000000..30c9a12ca6d --- /dev/null +++ b/apps/docs/content/troubleshooting/resolving-cannot-execute-update-in-a-read-only-transaction-on-transaction-pooler-connections-ef582c.mdx @@ -0,0 +1,74 @@ +--- +title = "Resolving 'cannot execute UPDATE in a read-only transaction' on transaction pooler connections" +topics = [ "database", "supavisor" ] +keywords = [] +--- + +### Key technical terms + +**Transaction Pooling** +A connection management method used by tools like Supavisor or PgBouncer. Instead of giving every client a dedicated, permanent connection to the database, the pooler maintains a small set of "backend connections." It lends one of these connections to a client for the duration of a single transaction, then immediately takes it back to give to another client. + +**Backend Connection** +The actual physical process on the Postgres server that executes your queries. In pooling environments, one backend connection will serve many different database clients over its lifetime. + +**Session-Level State** +That backend connection has _state_. Postgres connections carry settings like timezone, memory limits, search path, read-only mode etc. In a normal setup where each client has its own connection, this doesn't matter. When the client disconnects, the connection and all its state go away. In a pooled environment, the connection doesn't go away. It goes back into the pool, settings and all, and the next client who gets it inherits whatever was left behind. + +--- + +### Understanding the problem: The "sticky" state + +When you encounter the error `cannot execute UPDATE in a read-only transaction` while using a transaction pooler (typically on port 6543), even when you have verified that the connection is made to the primary database and the database itself is not in read-only mode (check with `SHOW default_transaction_read_only;` or `SELECT pg_is_in_recovery();` using a direct connection on port 5432), it signifies that a backend connection has been unintentionally locked into a read-only state. + +**Note:** If your database is in read-only mode (for example, due to exceeding disk space limits), you can review this guide: [Database Size and Read-Only Mode](/docs/guides/platform/database-size#read-only-mode). The remainder of this guide addresses a different issue specific to transaction pooling. + +#### The cause: Connection contamination + +In transaction pooling mode, reset behavior is intentionally limited for performance, so session state can persist unless explicitly reset. If a client, script, or automated task changes a session-level setting, that setting "sticks" to the backend connection. + +When that backend connection is returned to the pool, the next client to use it inherits that exact state. If a previous script set the connection to "read-only" for safety and failed to reset it, any subsequent application attempt to perform an `UPDATE` or `INSERT` using that same backend will fail. + +#### Why is the error sporadic? + +The error appears intermittent because it only occurs when your application is randomly assigned a "contaminated" backend connection from the pool. Other connections in the same pool may still be in the default read-write state, leading to a confusing mix of successful and failed requests. + +--- + +### Step-by-step resolution + +To resolve this issue, you must identify and remove any commands that modify the session state globally rather than locally. + +#### 1. Audit application and scripts + +Search your application code, migration scripts, and maintenance tasks for the following session-level commands: + +- `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;` +- `SET default_transaction_read_only = on;` + +Even if these commands are used in secondary scripts (like data exports or safety-first maintenance tasks) and not the main application, they can still contaminate the pool used by the main application. + +#### 2. Implement "safe" settings + +If you need to execute a read-only transaction for safety, use transaction-level commands that only affect the current transaction and do not persist on the backend connection. + +- **Avoid:** `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;` or `SET default_transaction_read_only = on;` (these contaminate the connection pool) +- **Use:** `BEGIN TRANSACTION READ ONLY;` or `BEGIN; SET TRANSACTION READ ONLY;` (these only affect the current transaction) + +If you have existing scripts that use session-level settings and cannot be immediately refactored: + +- **Temporary workaround:** Ensure they explicitly reset the state before closing the connection with `SET default_transaction_read_only = off;` +- **Best approach:** Connect directly to port 5432 (bypassing the pooler) for scripts requiring special session states + +#### 3. Connection string verification + +Ensure your application is using the intended pooler. + +- **Shared Transaction Pooler (Supavisor):** `postgresql://postgres.PROJECT_REF:[YOUR-PASSWORD]@aws-X-REGION.pooler.supabase.com:6543/postgres` +- **Dedicated Transaction Pooler (PgBouncer):** `postgresql://postgres:[YOUR-PASSWORD]@db.PROJECT_REF.supabase.co:6543/postgres` + +--- + +### Best practices for transaction pooling + +- **Use Dedicated Connections for Maintenance:** If a script requires a specific session state (like a long-running read-only export), connect directly to the database (port 5432) rather than using the transaction pooler (port 6543). This prevents maintenance settings from leaking into the application's connection pool.% diff --git a/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx b/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx new file mode 100644 index 00000000000..325b647cac5 --- /dev/null +++ b/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx @@ -0,0 +1,134 @@ +--- +title = "Identifying Dashboard SQL Editor Activity by User" +topics = [ "database" ] +keywords = [] +--- + +When team members run SQL queries from the Dashboard SQL Editor, and if that query is logged in the Postgres Logs, it's not immediately clear who executed which query. This guide shows you how to track queries back to the specific team member who ran them. + +### **Understanding Dashboard query execution** + +First, it helps to understand how Dashboard queries are executed. When someone runs a query from the SQL editor, it's routed through the `postgres` role at the database level. The Supabase Dashboard automatically appends metadata comments to queries, specifically `-- user: [UUID]`, `-- source: dashboard`, and `-- date`. + +By default, that role has `log_statement` set to `ddl`, which means Postgres logs only schema-level changes such as `CREATE`, `ALTER`, and `DROP`. It does not log data-modifying statements such as `INSERT`, `UPDATE`, `DELETE` or `TRUNCATE`. + +So, if someone truncates a table, and you're relying on the default logging, you won't see it. + +### **Enabling data modification logging** + +To make those operations visible, you can increase the logging level for the `postgres` role: + +```sql +ALTER ROLE postgres SET log_statement='mod'; +``` + +**Note:** This step is only necessary if you need to track data-modifying statements like `INSERT`, `UPDATE`, `DELETE`, or `TRUNCATE`. If you're only looking to track DDL statements (such as `CREATE`, `ALTER`, `DROP`), the default `log_statement='ddl'` setting is already sufficient. + +Setting it to `mod` tells Postgres to log all data-modifying statements. Once that's in place, try running something like a `TRUNCATE` from the Dashboard. In the logs, you'll see an entry similar to: + +``` +statement: TRUNCATE TABLE public.data; +-- source: dashboard +-- user: f8c2e1a9-3b4d-4f7e-8c9a-1d2e3f4a5b6c +-- date: 2026-04-02T11:41:22.158Z +``` + +Notice that the log includes: + +- The full statement +- The timestamp +- A `user` field, which is actually the Supabase user UUID +- The source (dashboard) + +That UUID corresponds to the team member who logged in via the Supabase Dashboard and executed queries in the [SQL Editor](/dashboard/project/_/sql/new). But at this point, it's just an ID - not yet a name or email. + +### **Mapping UUIDs to team members** + +To map the UUID, you'll need to query the Management API. The process looks like this: + +**1. Create a Personal Access Token (PAT)** + +Generate a token from your [account settings](/dashboard/account/tokens). + +**2. Call the Organization Members Endpoint** + +```bash +curl -X GET "https://api.supabase.com/v1/organizations/your-org-slug/members" \ + -H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN" +``` + +**3. Match the UUID** + +The response will include entries like: + +```json +{ + "user_id": "f8c2e1a9-3b4d-4f7e-8c9a-1d2e3f4a5b6c", + "user_name": "john@supabase.io", + "email": "john@supabase.io", + "role_name": "Administrator" +} +``` + +Now you can directly match `user_id` values from the Postgres logs to the corresponding team members. + +### **Querying logs for specific operations** + +Navigate to the [Logs Explorer](/dashboard/project/_/logs/explorer) and query `postgres_logs`. Here's an example query that searches for data-modifying operations and maps user IDs to team members: + +```sql +SELECT + DATETIME(postgres_logs.timestamp) AS time, + parsed.session_id, + postgres_logs.identifier, + parsed.user_name AS db_role, + CASE + WHEN REGEXP_CONTAINS(postgres_logs.event_message, 'f8c2e1a9-3b4d-4f7e-8c9a-1d2e3f4a5b6c') + THEN 'john@example.com' + WHEN REGEXP_CONTAINS(postgres_logs.event_message, 'insert another-uuid-here') + THEN 'jane@example.io' + ELSE 'unknown' + END AS detected_user, + parsed.error_severity, + postgres_logs.event_message +FROM postgres_logs +CROSS JOIN UNNEST(metadata) AS metadata +CROSS JOIN UNNEST(parsed) AS parsed +WHERE postgres_logs.timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY) + AND ( + REGEXP_CONTAINS(postgres_logs.event_message, '(?i)DELETE|TRUNCATE|UPDATE|ALTER|DROP') + OR REGEXP_CONTAINS(parsed.query, '(?i)DELETE|TRUNCATE|UPDATE|ALTER|DROP') + ) +ORDER BY postgres_logs.timestamp DESC +LIMIT 500; +``` + +This query: + +- Searches the last 7 days of logs +- Filters for common data-modifying operations (`DELETE`, `TRUNCATE`, `UPDATE`, `ALTER`, `DROP`) +- Uses a `CASE` statement to map known UUIDs to team member emails +- Returns results ordered by timestamp (most recent first) + +**Example Output:** +| db_role | detected_user | error_severity | event_message | identifier | +| -------- | ------------- | -------------- | -------------------------------------------------------------------------------- | ---------- | +{/* supa-mdx-lint-disable-next-line Rule003Spelling */} +| postgres | support | LOG | statement: TRUNCATE TABLE public.data; -- source: dashboard -- user: f8c2e1a9... | ... | + +You can further refine your search by filtering for specific commands like `TRUNCATE` or `DELETE` where `parsed.user_name = 'postgres'`. + +### **Tracking external tools** + +For external tools like n8n or other applications connecting to your database, you can identify the source of database changes by appending `?application_name=example_app_name` to your connection string. This ensures the source is clearly identified in the logs, making it easier to distinguish between Dashboard operations and external tool operations. + +### **Additional logging levels** + +Postgres supports these `log_statement` values: + +- `none`: No statements are logged +- `ddl`: Log data definition statements (`CREATE`, `ALTER`, `DROP`) - this is the default for the `postgres` role +- `mod`: Log data modification statements plus all DDL (includes `INSERT`, `UPDATE`, `DELETE`, `TRUNCATE`) +- `all`: Log all statements (including `SELECT` queries) + +**Note:** Setting to `all` can generate very large log volumes. Use it only when necessary and for limited periods. Test the performance impact of `log_statement='mod'` in your specific environment, as the impact depends on your query volume and workload. diff --git a/apps/docs/content/troubleshooting/unexpected-behavior-with-authupdateuser-phone-phone-linked-to-incorrect-user-id-45368f.mdx b/apps/docs/content/troubleshooting/unexpected-behavior-with-authupdateuser-phone-phone-linked-to-incorrect-user-id-45368f.mdx new file mode 100644 index 00000000000..537246bb5d0 --- /dev/null +++ b/apps/docs/content/troubleshooting/unexpected-behavior-with-authupdateuser-phone-phone-linked-to-incorrect-user-id-45368f.mdx @@ -0,0 +1,17 @@ +--- +title = "`Unexpected behavior with 'auth.updateUser({ phone })': Phone linked to incorrect user ID`" +topics = [ "auth" ] +keywords = [] +--- + +When using `auth.updateUser({ phone: '...' })`, you might observe that a phone number is unexpectedly linked to a different `auth.users` record than the currently authenticated user during the phone verification process, even if `auth.getUser()` reports the correct user ID beforehand. + +**Why does this happen?** +Supabase phone verification identifies the user by searching for the provided phone number in the `phone_change` column, rather than relying solely on the active session. Unlike the `phone` column, the `phone_change` column does not enforce uniqueness. If multiple `auth.users` records contain the same phone number in `phone_change` due to uncompleted or abandoned verification attempts, the system may update an unintended user's `phone` field upon successful OTP verification. This occurs because the system finds and updates the first matching record in `phone_change`, which might not belong to the currently authenticated user. + +**How to prevent/resolve this:** +To prevent ambiguous lookups from abandoned verification attempts, implement application-level cleanup to remove stale `phone_change` values from your `auth.users` records. + +1. **Define a grace period:** Establish a reasonable time frame after which an unconfirmed phone verification attempt is considered stale. +2. **Identify stale records:** Periodically query `auth.users` to find accounts where `phone_verified` is `false` and the `phone_change` value has been present beyond your defined grace period. +3. **Clear `phone_change`:** For identified stale records, clear their `phone_change` value. This ensures that only active and unique `phone_change` entries are considered during verification. diff --git a/apps/docs/content/troubleshooting/vercel-integration-environment-variables-not-syncing-for-persistent-git-branches-b9191e.mdx b/apps/docs/content/troubleshooting/vercel-integration-environment-variables-not-syncing-for-persistent-git-branches-b9191e.mdx new file mode 100644 index 00000000000..59b4aa67c15 --- /dev/null +++ b/apps/docs/content/troubleshooting/vercel-integration-environment-variables-not-syncing-for-persistent-git-branches-b9191e.mdx @@ -0,0 +1,25 @@ +--- +title = "Vercel Integration: Environment variables explained" +topics = [ "platform" ] +keywords = [] +--- + +Vercel has three environments, which map to different stages of the deployment lifecycle: + +- **Production** is used for the branch configured as the production branch (usually main). Deployments from that branch go to the live site. +- **Preview** is used for all other Git branches, including pull requests, feature branches, and persistent branches like staging. If you deploy a staging branch, it still runs under the Preview environment unless you explicitly create a separate environment in Vercel and map that branch to it. +- **Development** is only used for local development via the Vercel CLI (`vercel dev`). It allows your local environment to pull env vars from Vercel, but it does not apply to Git branches or deployments on the platform. + +### Creating a staging environment + +On the Hobby plan, staging is implemented by scoping Preview environment variables to a branch. On the Pro plan, staging can be configured as a dedicated environment with its own settings. + +You can create a dedicated environment (Vercel Pro): + +1. Go to `Project` → `Settings` → `Environments` +2. Click `Create Environment` +3. Name it `staging` +4. Enable `Branch Tracking` and select the `staging` branch +5. Add environment variables scoped to this environment + +With this setup, the staging branch deploys to its own environment and is fully separate from Preview and Production. diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index e84afb9a48c..f1ede54ff82 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -254,6 +254,7 @@ allow_list = [ "LlamaIndex", "Llamafile", "Logflare", + "[Ll]ookups?", "Lovable", "Lovable Cloud", "Lua", From cb6bf30475312876f6fc6163ee37721d160581b0 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 17 Apr 2026 09:38:10 -0600 Subject: [PATCH 09/51] chore: sales team update (#44981) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Updated terms on the the site ## Summary by CodeRabbit * **Documentation** * Added a new Enterprise SaaS Subscription Agreement page presenting comprehensive legal terms. The agreement covers subscription definitions, access and usage policies, customer responsibilities, support obligations, confidentiality requirements, data security standards, intellectual property rights, warranties, indemnification clauses, liability limitations, termination conditions, and dispute resolution provisions. The page includes the last modified date for reference. --- apps/www/next.config.mjs | 9 + apps/www/pages/enterprise-terms.mdx | 291 ++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 apps/www/pages/enterprise-terms.mdx diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index d41e0f131e0..0709069e483 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -141,6 +141,15 @@ const nextConfig = { }, ], }, + { + source: '/enterprise-terms', + headers: [ + { + key: 'X-Robots-Tag', + value: 'noindex, nofollow', + }, + ], + }, ] }, async rewrites() { diff --git a/apps/www/pages/enterprise-terms.mdx b/apps/www/pages/enterprise-terms.mdx new file mode 100644 index 00000000000..70ae065539c --- /dev/null +++ b/apps/www/pages/enterprise-terms.mdx @@ -0,0 +1,291 @@ +import SectionContainer from '../components/Layouts/SectionContainer' +import Layout from '../layouts/Layout' + +export const meta = { + title: 'Enterprise SaaS Subscription Agreement | Supabase', + description: 'Supabase Enterprise SaaS Subscription Agreement', + noindex: true, + nofollow: true, +} + + + +# Enterprise SaaS Subscription Agreement + +_Last Modified: 17 April 2026_ + +This Enterprise SaaS Subscription Agreement (this "**Agreement**") is entered into by and between the customer identified in the signature to the Order which references these terms ("**Customer**") and the Supabase Affiliate entity identified in such Order ("**Supabase**") (Customer and Supabase each, a "**Party**" and collectively, the "**Parties**"), and is effective as of the Services Start Date identified in such Order ("**Effective Date**"), and sets forth the terms and conditions under which Customer may subscribe to certain products and services of Supabase as set forth in one or more Orders (defined below). + +## 1. Definitions. + +a. "**Aggregated Data**" means data and information related to or derived from Customer Data or Customer's use of the Services that is used by Supabase in an aggregate and anonymized manner, including to compile statistical and performance information related to the Services. + +b. "**Authorized User**" means Customer's employees, consultants, contractors, and agents (a) who are authorized by Customer to access and use the Services under the rights granted to Customer pursuant to this Agreement; and (b) for whom access to the Services has been purchased by Customer hereunder. + +c. "**Customer Data**" means information, data, and other content, in any form or medium, that is submitted, posted, or otherwise transmitted by or on behalf of Customer or an Authorized User through the Services; provided that, for purposes of clarity, Customer Data does not include Aggregated Data. + +d. "**Documentation**" means Supabase's end user documentation relating to the Services available at supabase.io. + +e. "**Harmful Code**" means any software, hardware, or other technology, device, or means, including any virus, worm, malware, or other malicious computer code, the purpose or effect of which is to permit unauthorized access to, or to destroy, disrupt, disable, distort, or otherwise harm or impede in any manner any (a) computer, software, firmware, hardware, system, or network or (b) any application or function of any of the foregoing or the security, integrity, confidentiality, or use of any data processed thereby. + +f. "**Order**" means: (a) the purchase order, order form, or other ordering document entered into by the Parties that incorporates this Agreement by reference; or (b) if Customer registered for the Services through Supabase's online ordering process, the results of such online ordering process. + +g. "**Personal Information**" means any information that, individually or in combination, does or can identify a specific individual or by or from which a specific individual may be identified, contacted, or located, including without limitation all data considered "personal data", "personally identifiable information", or something similar under applicable laws, rules, or regulations relating to data privacy. + +h. "**Services**" means Supabase's proprietary hosted software platform, as made available by Supabase to Authorized Users from time to time. + +i. "**Subscription Period**" means the period set forth in Section 13(a), below. + +j. "**Supabase IP**" means the Services, the Documentation, all related and underlying technology, documentation and other information, and any and all intellectual property provided to Customer or any Authorized User in connection with the foregoing, and any derivatives thereof. For the avoidance of doubt, Supabase IP includes Aggregated Data and any information, data, intellectual property or other content derived from Supabase's provision of the Services but does not include Customer Data. + +k. "**Third-Party Products**" means any third-party products, software components, or services provided with, integrated with, or incorporated into the Services. + +l. "**Usage Limitations**" means the usage limitations set forth in this Agreement and the Order, including without limitation any limitations on the number of Authorized Users (if any), and the applicable product, pricing, and support tiers agreed-upon by the Parties. + +## 2. Access and Use. + +### a. Provision of Access + +Subject to and conditioned on Customer's compliance with the terms and conditions of this Agreement, including without limitation the Usage Limitations, Supabase will make available to Customer during the Subscription Period, on a non-exclusive, non-transferable (except in compliance with Section 14(h)), and non-sublicensable basis, access to and use of the Services, solely for use by Authorized Users. Such use is limited to Customer's internal business purposes and the features and functionalities specified in the Order. Supabase shall provide to Customer the necessary access credentials to allow Customer to access the Services. Each Order will be incorporated into, and is fully governed by, this Agreement upon execution of the Order by both Parties. + +### b. Documentation License + +Subject to and conditioned on Customer's compliance with the terms and conditions of this Agreement, Supabase hereby grants to Customer a non-exclusive, non-transferable (except in compliance with Section 14(h)), and non-sublicensable license to use the Documentation during the Subscription Period solely for Customer's internal business purposes in connection with its use of the Services. + +### c. Use Restrictions + +Customer shall not use the Services for any purposes beyond the scope of the access granted in this Agreement. Customer shall not at any time, directly or indirectly, and shall not permit any Authorized Users to: (a) copy, modify, or create derivative works of any Supabase IP, whether in whole or in part; (b) rent, lease, lend, sell, license, sublicense, assign, distribute, publish, transfer, or otherwise make available the Services or Documentation to any third party; (c) reverse engineer, disassemble, decompile, decode, adapt, or otherwise attempt to derive or gain access to any software component of the Services, in whole or in part; (d) remove any proprietary notices from any Supabase IP; (e) use any Supabase IP in any manner or for any purpose that infringes, misappropriates, or otherwise violates any intellectual property right or other right of any person, or that violates any applicable law; (f) access or use any Supabase IP for purposes of competitive analysis of Supabase or the Services, the development, provision, or use of a competing software service or product, or any other purpose that is to Supabase's detriment or commercial disadvantage; (g) bypass or breach any security device or protection used by the Services or access or use the Services other than by an Authorized User through the use of valid access credentials; or (h) input, upload, transmit, or otherwise provide to or through the Services any information or materials that are unlawful or injurious, or that contain, transmit, or activate any Harmful Code. + +### d. Use by Affiliates + +Each of Customer's Affiliates (defined below) identified on an Order will be entitled to access and use the applicable Supabase IP in accordance with this Agreement and the applicable Order; provided that Customer shall remain responsible to Supabase for the actions and omissions of each such Affiliate (and each of such Affiliate's Authorized Users). The terms of this Agreement will govern, and will be incorporated by reference in, each such Order as if this Agreement were separately executed by the applicable Customer Affiliate, and the term "Customer" as used in this Agreement will be deemed as applying to such Affiliate of Customer for the purposes of such Order. "**Affiliate**" means an entity that, directly or indirectly, controls, is controlled by, or is under common control with a Party. As used herein, "control" means the power to direct the management or affairs of an entity or the beneficial ownership of fifty percent (50%) or more of the voting equity securities or other equivalent voting interests of an entity. + +### e. Reservation of Rights + +Supabase reserves all rights not expressly granted to Customer in this Agreement. Except for the limited rights and licenses expressly granted under this Agreement, nothing in this Agreement grants, by implication, waiver, estoppel, or otherwise, to Customer or any third party any intellectual property rights or any other right, title, or interest in or to the Supabase IP. + +### f. Suspension + +Notwithstanding anything to the contrary in this Agreement, Supabase may temporarily suspend Customer's and any Authorized User's access to any portion or all of the Services if: (a) Supabase reasonably determines that (i) there is a threat or attack on any of the Supabase IP, (ii) Customer's or any Authorized User's use of the Supabase IP disrupts or poses a security risk to the Supabase IP or to any other customer or vendor of Supabase, (iii) Customer, or any Authorized User, is using the Supabase IP for fraudulent or illegal activities, (iv) subject to applicable law, Customer has ceased to continue its business in the ordinary course, made an assignment for the benefit of creditors or similar disposition of its assets, or become the subject of any bankruptcy, reorganization, liquidation, dissolution, or similar proceeding, or (v) Supabase's provision of the Services to Customer or any Authorized User is prohibited by applicable law; (b) any vendor of Supabase has suspended or terminated Supabase's access to or use of any Third-Party Products required to enable Customer to access the Services; or (c) in accordance with Section 5.1 (any such suspension described in subclause (a), (b), or (c), a "**Service Suspension**"). Supabase shall use commercially reasonable efforts to provide prior written notice of any Service Suspension to Customer and to provide updates regarding resumption of access to the Services following any Service Suspension. Supabase shall use commercially reasonable efforts to resume providing access to the Services as soon as reasonably possible after the event giving rise to the Service Suspension is cured. Supabase will have no liability for any damage, liabilities, losses (including any loss of data or profits), or any other consequences that Customer or any Authorized User may incur as a result of a Service Suspension. + +### g. Aggregated Data + +Notwithstanding anything to the contrary in this Agreement, Supabase may monitor Customer's use of the Services and collect and compile Aggregated Data. As between Supabase and Customer, all right, title, and interest in Aggregated Data, and all intellectual property rights therein, belong to and are retained solely by Supabase. Customer acknowledges that Supabase may compile Aggregated Data based on Customer Data input into the Services. Customer agrees that Supabase may (a) make Aggregated Data available to third parties including its other customers in compliance with applicable law, and (b) use Aggregated Data to the extent and in the manner permitted under applicable law. + +### h. Service Level Agreement + +Supabase will make commercially reasonable efforts to provide the Services in accordance with the uptime commitment, service credit, and other terms available at: https://supabase.com/docs/company/sla ("**Service Level Agreement**"). + +## 3. Customer Responsibilities. + +### a. General + +Customer is responsible and liable for all uses of the Services and Documentation resulting from access provided by Customer, directly or indirectly, whether such access or use is permitted by or in violation of this Agreement. Without limiting the generality of the foregoing, Customer is responsible for all acts and omissions of Authorized Users, and any act or omission by an Authorized User that would constitute a breach of this Agreement if taken by Customer will be deemed a breach of this Agreement by Customer. Customer shall use reasonable efforts to make all Authorized Users aware of this Agreement's provisions as applicable to such Authorized User's use of the Services and shall cause Authorized Users to comply with such provisions. + +### b. Third-Party Terms + +Supabase may from time to time make Third-Party Products available to Customer or Supabase may allow for certain Third-Party Products to be integrated with the Services to allow for the transmission of Customer Data from such Third-Party Products into the Services. For purposes of this Agreement, such Third-Party Products are subject to their own terms and conditions. To the extent there is a conflict between the terms and conditions applicable to any such Third-Party Product and this Agreement, the Third-Party Product terms and conditions shall control. If Customer does not agree to abide by the applicable terms for any such Third-Party Products, then Customer should not install or use such Third-Party Products. By authorizing Supabase to transmit Customer Data from Third-Party Products into the Services, Customer represents and warrants to Supabase that it has all right, power, and authority to provide such authorization. ALL THIRD-PARTY PRODUCTS ARE MADE AVAILABLE ON AN "AS IS" BASIS WITHOUT WARRANTY OF ANY KIND. IF CUSTOMER USES ANY THIRD-PARTY PRODUCTS, SUPABASE WILL NOT BE RESPONSIBLE FOR ANY ACT OR OMISSION OF ANY PROVIDER OF SUCH THIRD-PARTY PRODUCTS. CUSTOMER ACKNOWLEDGES AND AGREES THAT SUPABASE WILL HAVE NO RESPONSIBILITY OR LIABILITY FOR THE ACTS OR OMISSIONS OF ANY AUTHORIZED USERS IN CONNECTION WITH ANY THIRD-PARTY PRODUCTS. + +### c. Customer Control and Responsibility + +Customer has and will retain sole responsibility for: (a) all Customer Data, including its content, accuracy, legality and use; (b) all information, instructions, and materials provided by or on behalf of Customer or any Authorized User in connection with the Services; (c) Customer's information technology infrastructure, including computers, software, databases, electronic systems (including database management systems), and networks, whether operated directly by Customer or through the use of third-party services ("**Customer Systems**"); (d) the security and use of Customer's and its Authorized Users' access credentials; and (e) all access to and use of the Services directly or indirectly by or through the Customer Systems or its or its Authorized Users' access credentials, with or without Customer's knowledge or consent, including all results obtained from, and all conclusions, decisions, and actions based on, such access or use. + +## 4. Support. + +During the Subscription Period, Supabase will use commercially reasonable efforts to provide Customer with basic customer support via Supabase's standard support channels during Supabase's normal business hours. + +## 5. Fees and Taxes. + +### a. Fees and Payment + +Where paid-for services are agreed between Supabase and Customer per the applicable Order, Customer shall pay Supabase the fees ("**Fees**") identified in the Order without offset or deduction at the cadence identified in the Order (e.g., monthly or annually). Customer will pay the Fees within thirty (30) days of the issuance of an invoice. Except as otherwise set forth in the applicable Order, Fees are payable by credit card or other payment method permitted by Supabase from time to time, payment will be in USD, and Customer authorizes Supabase to charge its credit card or bank account for all Fees. Customer further authorizes Supabase to use a third party to process payments, and consents to the disclosure of Customer's payment information to such third party. Fees paid by Customer are non-refundable (except as set forth in Sections 10(c) or 11(a)(i)). If Customer fails to make any payment when due, and Customer has not notified Supabase in writing within ten (10) days of the payment becoming due and payable that the payment is subject to a good faith dispute, without limiting Supabase's other rights and remedies: (a) Supabase may charge interest on the undisputed past due amount at the rate of 1.5% per month, calculated daily and compounded monthly or, if lower, the highest rate permitted under applicable law; (b) Customer shall reimburse Supabase for all reasonable costs incurred by Supabase in collecting any late payments or interest, including attorneys' fees, court costs, and collection agency fees; and (c) if such failure continues for ten (10) days or more, Supabase may suspend Customer's and its Authorized Users' access to any portion or all of the Services until such amounts are paid in full. + +### b. Taxes + +All Fees and other amounts payable by Customer under this Agreement are exclusive of taxes and similar assessments. Customer is responsible for all sales, use, and excise taxes, and any other similar taxes, duties, and charges of any kind imposed by any federal, state, or local governmental or regulatory authority on any amounts payable by Customer hereunder, other than any taxes imposed on Supabase's income. To the extent that Supabase is required by law to pay any such taxes, duties, or other charges to any governmental or regulatory authority, Supabase may invoice Customer for such taxes, duties, or other charges and Customer will pay such invoiced amounts in accordance with this Agreement. + +## 6. Confidential Information. + +### a. Definition + +From time to time during the Subscription Period, either Party may disclose or make available to the other Party information about its business affairs, products, confidential intellectual property, trade secrets, third-party confidential information, and other sensitive or proprietary information, whether orally or in written, electronic, or other form or media that: (a) is marked, designated or otherwise identified as "confidential" or something similar at the time of disclosure or within a reasonable period of time thereafter; or (b) would be considered confidential by a reasonable person given the nature of the information or the circumstances of its disclosure (collectively, "**Confidential Information**"). Except for Personal Information, Confidential Information does not include information that, at the time of disclosure is: (i) in the public domain; (ii) known to the receiving Party at the time of disclosure; (iii) rightfully obtained by the receiving Party on a non-confidential basis from a third party; or (iv) independently developed by the receiving Party without use of, reference to, or reliance upon the disclosing Party's Confidential Information, as proven by the receiving Party's then-contemporaneous written records. + +### b. Duty + +The receiving Party shall not disclose the disclosing Party's Confidential Information to any person or entity, except to the receiving Party's employees, contractors, and agents who have a need to know the Confidential Information for the receiving Party to exercise its rights or perform its obligations hereunder ("**Representatives**"). The receiving Party will be responsible for all the acts and omissions of its Representatives as they relate to Confidential Information hereunder. Notwithstanding the foregoing, each Party may disclose Confidential Information to the limited extent required (a) in order to comply with the order of a court or other governmental body, or as otherwise necessary to comply with applicable law, provided that the Party making the disclosure pursuant to the order shall first have given written notice to the other Party and made a reasonable effort to obtain a protective order, or (b) to establish a Party's rights under this Agreement, including to make required court filings. Further, notwithstanding the foregoing, each Party may disclose the terms and existence of this Agreement to its actual or potential investors, debtholders, acquirers, or merger partners under customary confidentiality terms. + +### c. Return of Materials; Effect of Termination/Expiration + +On the expiration or termination of this Agreement, the receiving Party shall promptly return to the disclosing Party all copies, whether in written, electronic, or other form or media, of the disclosing Party's Confidential Information, or destroy all such copies and certify in writing to the disclosing Party that such Confidential Information has been destroyed. Each Party's obligations of non-use and non-disclosure with regard to Confidential Information are effective as of the Effective Date and will expire three (3) years from the date of termination or expiration of this Agreement; provided, however, with respect to any Confidential Information that constitutes a trade secret (as determined under applicable law), such obligations of non-disclosure will survive the termination or expiration of this Agreement for as long as such Confidential Information remains subject to trade secret protection under applicable law. + +## 7. Personal Information. + +### a. General + +Customer will ensure that its Customer Data, and its use of such Customer Data, complies with this Agreement and any applicable local, state, federal and international laws, regulations and conventions, including, without limitation, those related to data privacy, international communications, and the exportation of technical or personal data (including Personal Information). Customer is responsible for properly configuring and using the Services and taking its own steps to maintain appropriate security, protection, and backup of Customer Data. Customer may not store or process protected health information (as defined in HIPAA) using the Services unless Customer signs a Business Associate Agreement with Supabase. Customer may not store any payment cardholder information using the Services without Supabase's prior written approval. + +### b. Data Processing + +If Customer is subject to the EU General Data Protection Regulation (EU GDPR), the UK General Data Protection Regulation (UK GDPR) and/or Swiss data protection laws, Supabase has prepared and pre-signed a Data Processing Addendum ("**DPA**"), which is available at http://security.supabase.com. To execute the DPA, Customer will complete Schedule 1 to the DPA and send the completed document to privacy@supabase.io for Supabase's records. + +## 8. Security. + +a. Supabase shall use reasonable physical, technical, and administrative procedures designed to protect, safeguard and help prevent loss, misuse, and unauthorized access, disclosure, alteration or destruction of Customer Data, and Supabase will choose these safeguards based on the sensitivity of the information that is collected, processed, and stored and the current state of applicable technology. + +b. Customer shall implement and maintain appropriate technical and organizational security measures consistent with industry standards to protect against unauthorized access, loss, or misuse of the Company platform and services. Under Supabase's shared responsibility model (found at https://supabase.com/docs/guides/deployment/shared-responsibility-model), Customer shall be solely responsible for the proper integration with, and configuration of, Company systems and services. Customer shall take all reasonable steps to ensure that any integrations, access controls, environment variables, and data retention or exposure settings are secure and aligned with Supabase's documentation, recommendations, and best practices. Company shall not be liable for any security incidents, data breaches, or system misuse resulting from Customer's misconfiguration, negligence, or failure to follow Supabase's guidance. Supabase reserves the right to suspend or restrict Customer's access to the platform and services if it reasonably determines that Customer's use or configuration poses a material security or operational risk to Supabase's infrastructure, systems, or other customers. + +## 9. Intellectual Property Ownership; Feedback. + +### a. Supabase IP + +Customer acknowledges that, as between Customer and Supabase, Supabase owns all right, title, and interest, including all intellectual property rights, in and to the Supabase IP and, with respect to Third-Party Products, the applicable third-party providers own all right, title, and interest, including all intellectual property rights, in and to the Third-Party Products. + +### b. Customer Data + +Supabase acknowledges that, as between Supabase and Customer, Customer owns all right, title, and interest, including all intellectual property rights, in and to the Customer Data. Customer hereby grants to Supabase a non-exclusive, royalty-free, worldwide license to reproduce, distribute, and otherwise use and display the Customer Data and perform all acts with respect to the Customer Data as may be necessary for Supabase to provide the Services to Customer. Customer may export the Customer Data at any time through the features and functionalities made available via the Services. + +### c. Feedback + +If Customer or any of its employees or contractors sends or transmits any communications or materials to Supabase by mail, email, telephone, or otherwise, suggesting or recommending changes to the Supabase IP, including without limitation, new features or functionality relating thereto, or any comments, questions, suggestions, or the like ("**Feedback**"), Customer hereby assigns to Supabase all right, title and interest in and to the Feedback, including all intellectual property rights therein, and acknowledges that Supabase shall own such Feedback. + +## 10. Warranties. + +### a. Mutual + +Each Party represents and warrants to the other Party that: + +i. it is duly organized, validly existing, and in good standing as a corporation or other entity under the Laws of the jurisdiction of its incorporation or other organization; + +ii. it has the full right, power, and authority to enter into and perform its obligations and grant the rights, licenses, consents, and authorizations it grants or is required to grant under this Agreement; + +iii. the execution of this Agreement by its representative whose signature is set forth at the end of this Agreement has been duly authorized by all necessary corporate or organizational action of such party; and + +iv. when executed and delivered by both parties, this Agreement will constitute the legal, valid, and binding obligation of such party, enforceable against such party in accordance with its terms. + +### b. Customer + +Customer represents, warrants, and covenants to Supabase that Customer owns or otherwise has and will have the necessary rights and consents in and relating to the Customer Data so that, as received by Supabase and processed in accordance with this Agreement, they do not and will not infringe, misappropriate, or otherwise violate any intellectual property, privacy or other rights of any third party or violate any applicable Law. + +### c. Limited Warranty + +Supabase warrants that it will provide the Services in substantial conformity with the applicable Documentation and the descriptions in the Order. Supabase's sole liability (and Customer's sole and exclusive remedy) for any breach of this warranty shall be, in Supabase's sole discretion and at no charge to Customer, to use commercially reasonable efforts to provide Customer with an error correction or work-around that corrects the reported non-conformity, or if Supabase determines such remedies to be impracticable, to allow Customer to terminate the Subscription Period and receive as its sole remedy and Supabase's entire liability, a refund of any Fees Customer has pre-paid for use of the Services or related services it has not received as of the date of the warranty claim. The limited warranty set forth in this Section 10(c) shall not apply: (a) unless Customer makes a claim within thirty (30) days of the date on which the condition giving rise to the claim first appeared, (b) if the error was caused by misuse, unauthorized modifications or third-party hardware, software or services, or (c) to Services provided on a no-charge or evaluation basis. + +### d. Warranty Disclaimer + +EXCEPT FOR THE LIMITED WARRANTY SET FORTH IN SECTION 10.3, THE SUPABASE IP IS PROVIDED "AS IS" AND ON AN "AS AVAILABLE" BASIS. SUPABASE HEREBY DISCLAIMS ALL WARRANTIES, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE. SUPABASE SPECIFICALLY DISCLAIMS ALL IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT, AND ALL WARRANTIES ARISING FROM COURSE OF DEALING, USAGE, OR TRADE PRACTICE. SUPABASE MAKES NO WARRANTY OF ANY KIND THAT THE SUPABASE IP, OR ANY PRODUCTS OR RESULTS OF THE USE THEREOF, WILL MEET CUSTOMER'S OR ANY OTHER PERSON'S REQUIREMENTS, OPERATE WITHOUT INTERRUPTION, ACHIEVE ANY INTENDED RESULT, BE COMPATIBLE OR WORK WITH ANY SOFTWARE, SYSTEM OR OTHER SERVICES, OR BE SECURE, ACCURATE, COMPLETE, FREE OF HARMFUL CODE, OR ERROR FREE. + +## 11. Indemnification. + +### a. Supabase Indemnification + +i. Supabase shall indemnify, defend, and hold harmless Customer from and against any and all losses, damages, liabilities, costs (including reasonable attorneys' fees) ("**Losses**") incurred by Customer resulting from any claim, suit, action, or proceeding asserted by a third-party ("**Third-Party Claim**") against Customer that the Services, or any use of the Services in accordance with this Agreement, infringes or misappropriates such third party's US copyrights or trade secrets; provided that Customer promptly notifies Supabase in writing of the claim (but in any event notice in sufficient time for Supabase to respond without prejudice), cooperates with Supabase, and allows Supabase sole authority to control the defense and settlement of such claim. + +ii. If such a claim is made or appears possible, Customer agrees to permit Supabase, at Supabase's sole discretion: to (i) modify or replace the Services, or component or part thereof, to make it non-infringing; or (ii) obtain the right for Customer to continue use. If Supabase determines that neither alternative is reasonably commercially available, Supabase may terminate this Agreement, in its entirety or with respect to the affected component or part, effective immediately on written notice to Customer. + +iii. This Section 11(a) will not apply to the extent that the alleged infringement arises from: (i) use of the Services in combination with data, software, hardware, equipment, or technology not provided by Supabase or authorized by Supabase in writing; (ii) modifications to the Services not made by Supabase; (iii) Customer Data; (iv) Third-Party Products; or (v) use of the Services in breach of this Agreement. + +### b. Customer Indemnification + +Customer shall indemnify, hold harmless, and, at Supabase's option, defend Supabase from and against any Losses resulting from (a) any Third-Party Claim that the Customer Data, or any use of the Customer Data in accordance with this Agreement, infringes or misappropriates such third party's US intellectual property or other rights, and (b) arising out of or in connection with any claim arising from or relating to Customer's or any Authorized User's: (i) negligence or willful misconduct; (ii) use of the Services in a manner not authorized by this Agreement; (iii) Customer's violation of any laws, regulations, or rights relating to Customer Data (including, without limitation, privacy laws, regulations, or rights), or (iv) use of the Services in combination with data, software, hardware, equipment or technology not provided by Supabase or authorized by Supabase in writing; in each case provided that Customer may not settle any Third-Party Claim against Supabase unless Supabase consents to such settlement, and further provided that Supabase will have the right, at its option, to defend itself against any such Third-Party Claim or to participate in the defense thereof by counsel of its own choice. This indemnification obligation is subject to Customer receiving prompt written notice of such claim (but in any event notice in sufficient time for Customer to respond without prejudice). + +### c. Sole Remedy + +Section 11(a) sets forth Customer's sole remedies and Supabase's sole liability and obligation for any actual, threatened, or alleged claims that the Services infringe, misappropriate, or otherwise violate any intellectual property rights of any third party. + +## 12. Limitations of Liability. + +EXCEPT FOR (A) A PARTY'S BREACH OF ITS CONFIDENTIALITY OBLIGATIONS, (B) A PARTY'S INDEMNITY OBLIGATIONS, OR (C) A PARTY'S GROSS NEGLIGENCE, FRAUD, OR WILLFUL MISCONDUCT ("**EXCLUDED LIABILITIES**"): (I) IN NO EVENT WILL EITHER PARTY BE LIABLE UNDER OR IN CONNECTION WITH THIS AGREEMENT UNDER ANY LEGAL OR EQUITABLE THEORY, INCLUDING BREACH OF CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, AND OTHERWISE, FOR ANY: (1) CONSEQUENTIAL, INCIDENTAL, INDIRECT, EXEMPLARY, SPECIAL, ENHANCED, OR PUNITIVE DAMAGES; (2) INCREASED COSTS, DIMINUTION IN VALUE OR LOST BUSINESS, PRODUCTION, CONTRACTS, ANTICIPATED SAVINGS, REVENUES, OR PROFITS; (3) LOSS OF GOODWILL OR REPUTATION; (4) USE, INABILITY TO USE, LOSS, INTERRUPTION, DELAY OR RECOVERY OF ANY DATA, OR BREACH OF DATA OR SYSTEM SECURITY; OR (5) COST OF REPLACEMENT GOODS OR SERVICES, IN EACH CASE REGARDLESS OF WHETHER SUCH PARTY WAS ADVISED OF THE POSSIBILITY OF SUCH LOSSES OR DAMAGES OR SUCH LOSSES OR DAMAGES WERE OTHERWISE FORESEEABLE; AND (II) IN NO EVENT WILL EITHER PARTY'S AGGREGATE LIABILITY ARISING OUT OF OR RELATED TO THIS AGREEMENT UNDER ANY LEGAL OR EQUITABLE THEORY, INCLUDING BREACH OF CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, AND OTHERWISE EXCEED THE TOTAL AMOUNTS PAID AND/OR PAYABLE TO SUPABASE UNDER THIS AGREEMENT IN THE TWELVE (12) MONTHS IMMEDIATELY PRECEDING THE EVENTS GIVING RISE TO THE APPLICABLE CLAIM; PROVIDED THAT, NOTWITHSTANDING THE FOREGOING, SUPABASE'S AGGREGATE, CUMULATIVE LIABILITY ARISING OUT OF OR RELATING TO ANY EXCLUDED LIABILITIES WILL NOT EXCEED THREE TIMES (3X) THE TOTAL AMOUNTS PAID AND/OR PAYABLE TO SUPABASE BY CUSTOMER UNDER THIS AGREEMENT IN THE TWELVE (12) MONTHS IMMEDIATELY PRECEDING THE EVENTS GIVING RISE TO THE APPLICABLE CLAIM. + +## 13. Subscription Period and Termination. + +### a. Subscription Period + +The initial term of this Agreement begins on the Effective Date and, unless terminated earlier pursuant to Section 13(b), will continue in effect for the period identified in the Order (the "**Initial Subscription Period**"). This Agreement will automatically renew for additional successive terms equal to the length of the Initial Subscription Period unless earlier terminated pursuant to this Agreement's express provisions or either Party gives the other Party written notice of non-renewal at least thirty (30) days prior to the expiration of the then-current term (each a "**Renewal Subscription Period**" and together with the Initial Subscription Period, the "**Subscription Period**"). + +### b. Termination + +In addition to any other express termination right set forth in this Agreement: + +i. Supabase may terminate this Agreement, effective on written notice to Customer, if Customer: (i) fails to pay any amount when due hereunder, and such failure continues more than ten (10) calendar days after Supabase's delivery of written notice thereof; or (ii) breaches any of its obligations under Section 2(c) or Section 6. + +ii. either Party may terminate this Agreement, effective on written notice to the other Party, if the other Party materially breaches this Agreement, and such breach: (i) is incapable of cure; or (ii) being capable of cure, remains uncured thirty (30) calendar days after the non-breaching Party provides the breaching Party with written notice of such breach; or + +iii. either Party may terminate this Agreement, effective immediately upon written notice to the other Party, if the other Party: (i) becomes insolvent or is generally unable to pay, or fails to pay, its debts as they become due; (ii) files or has filed against it, a petition for voluntary or involuntary bankruptcy or otherwise becomes subject, voluntarily or involuntarily, to any proceeding under any domestic or foreign bankruptcy or insolvency law; (iii) makes or seeks to make a general assignment for the benefit of its creditors; or (iv) applies for or has appointed a receiver, trustee, custodian, or similar agent appointed by order of any court of competent jurisdiction to take charge of or sell any material portion of its property or business. + +### c. Effect of Expiration or Termination + +Upon expiration or earlier termination of this Agreement, Customer shall immediately discontinue use of the Supabase IP and, without limiting Customer's obligations under Section 6, Customer shall delete, destroy, or return all copies of the Supabase IP and certify in writing to the Supabase that the Supabase IP has been deleted or destroyed. No expiration or termination will affect Customer's obligation to pay all Fees that may have become due before such expiration or termination or entitle Customer to any refund (except as provided under Section 10(a)). + +### d. Survival + +This Section 13(d) and Sections 1, 5, 6, 9, 10, 11(b), 12, and 14 survive any termination or expiration of this Agreement. No other provisions of this Agreement survive the expiration or earlier termination of this Agreement. + +## 14. Miscellaneous. + +### a. Entire Agreement + +This Agreement, together with any other documents incorporated herein by reference, constitutes the sole and entire agreement of the Parties with respect to the subject matter of this Agreement and supersedes all prior and contemporaneous understandings, agreements, and representations and warranties, both written and oral, with respect to such subject matter. In the event of any inconsistency between the statements made in the body of this Agreement, the related Exhibits, and any other documents incorporated herein by reference, the following order of precedence governs: (a) first, this Agreement; (b) second, any Order; and (c) any other documents incorporated herein by reference. This Agreement may be executed electronically and in counterparts (such as via DocuSign), which counterparts taken together shall form one legal instrument. Any pre-printed terms in a Customer purchase order or similar document are null and void. + +### b. Notices + +All notices, requests, consents, claims, demands, waivers, and other communications hereunder (each, a "**Notice**") must be in writing and addressed to the Parties at the addresses set forth on the first page of this Agreement or as identified on the Order (or to such other address that may be designated by the Party giving Notice from time to time in accordance with this Section 14(b)). All Notices must be delivered by personal delivery, nationally recognized signed for on delivery courier (with all fees pre-paid), or email (with confirmation of transmission). All email Notices to Supabase must be sent to legal@supabase.io. Except as otherwise provided in this Agreement, a Notice is effective only: (a) upon receipt by the receiving Party; and (b) if the Party giving the Notice has complied with the requirements of this Section 14(b). + +### c. Force Majeure + +In no event shall either Party be liable to the other Party, or be deemed to have breached this Agreement, for any failure or delay in performing its obligations under this Agreement (except for any obligations to make payments), if and to the extent such failure or delay is caused by any circumstances beyond such Party's reasonable control, including but not limited to acts of God, flood, fire, earthquake, explosion, war, terrorism, invasion, riot or other civil unrest, strikes, labor stoppages or slowdowns or other industrial disturbances, or passage of law or any action taken by a governmental or public authority, including imposing an embargo. + +### d. Amendment and Modification + +Supabase may modify this Agreement at any time by posting a revised version at https://supabase.com/enterprise-terms, which modifications will become effective as of the first day of the calendar month following the month in which they were first posted; provided, however, that if an Order specifies a fixed term of 12 months or longer, the modifications will instead be effective immediately upon the start of the next Renewal Subscription Period. In either case, if Customer objects to the updated Agreement, as its sole and exclusive remedy, Customer may choose not to renew or otherwise enter into an additional Order. For the avoidance of doubt, any Order is subject to the version of the Agreement in effect at the time of the Order. + +### e. Waiver + +No failure or delay by either Party in exercising any right or remedy available to it in connection with this Agreement will constitute a waiver of such right or remedy. No waiver under this Agreement will be effective unless made in writing and signed by an authorized representative of the Party granting the waiver. + +### f. Severability + +If any provision of this Agreement is adjudged by any court of competent jurisdiction to be invalid, illegal, or unenforceable in any jurisdiction, such invalidity, illegality, or unenforceability will not affect any other term or provision of this Agreement or invalidate or render unenforceable such term or provision in any other jurisdiction. Upon such determination that any term or other provision is invalid, illegal, or unenforceable, the Parties shall negotiate in good faith to modify this Agreement so as to effect their original intent as closely as possible in a mutually acceptable manner in order that the transactions contemplated hereby be consummated as originally contemplated to the greatest extent possible. + +### g. Governing Law; Submission to Jurisdiction; Dispute Resolution + +This Agreement is governed by and construed in accordance with the internal laws of the State of California without giving effect to any choice or conflict of law provision or rule that would require or permit the application of the laws of any jurisdiction other than those of the State of California. All disputes arising out of or in connection with this Agreement between the Parties shall be finally settled under the Rules of Arbitration of the International Chamber of Commerce by one or more arbitrators appointed in accordance with such Rules of Arbitration, subject to the terms of this Section 14(g). The place of arbitration shall be: (a) Singapore to the extent that Customer is located in Asia; (b) London, United Kingdom to the extent that Customer is located in Europe; or (c) San Francisco County, California to the extent that Customer is located in the United States or any other jurisdiction. The language of the arbitration shall be English. Nothing in this Section 14(g) will be deemed as preventing Supabase from seeking injunctive or other equitable relief from the courts as necessary to prevent the actual or threatened infringement, misappropriation, or violation of Supabase's data security, confidential information, or intellectual property rights. Customer agrees to submit to the exclusive personal jurisdiction of the federal and state courts located in California for any actions for which Supabase retains the right to seek injunctive or other equitable relief in a court of competent jurisdiction to prevent the actual or threatened infringement, misappropriation, or violation of data security, Confidential Information, or intellectual property rights, including any provisional relief required to prevent irreparable harm. + +### h. Assignment + +Customer may not assign any of its rights or delegate any of its obligations hereunder, in each case whether voluntarily, involuntarily, by operation of law or otherwise, without the prior written consent of Supabase. Any purported assignment or delegation in violation of this Section 14(h) will be null and void. No assignment or delegation will relieve the assigning or delegating Party of any of its obligations hereunder. This Agreement is binding upon and inures to the benefit of the Parties and their respective permitted successors and assigns. + +### i. Entire Agreement + +This Agreement, together with any other documents incorporated herein by reference, constitutes the sole and entire agreement of the Parties with respect to the subject matter of this Agreement and supersedes all prior and contemporaneous understandings, agreements, and representations and warranties, both written and oral, with respect to such subject matter. In the event of any inconsistency between the statements made in the body of this Agreement, the related Exhibits, and any other documents incorporated herein by reference, the following order of precedence governs: (i) first, this Agreement; and (ii) second, any other documents incorporated herein by reference. + +### j. Export Regulation + +The Services utilize software and technology that may be subject to US export control laws, including the US Export Administration Act and its associated regulations. Each Party shall comply with all applicable export and re-export control and trade and economic sanctions laws. Customer shall not, directly or indirectly, export, re-export, or release the Services or the underlying software or technology to, or make the Services or the underlying software or technology accessible from, any jurisdiction or country to which export, re-export, or release is prohibited by law, rule, or regulation. Customer shall comply with all applicable federal laws, regulations, and rules, and complete all required undertakings (including obtaining any necessary export license or other governmental approval), prior to exporting, re-exporting, releasing, or otherwise making the Services or the underlying software or technology available outside the US. Neither Party, nor any of its subsidiaries or any person acting on its behalf or owning 50% or more of its equity securities or other equivalent voting interests, is (a) a person on the List of Specially Designated Nationals and Blocked Persons or any other list of sanctioned persons administered by OFAC or any other governmental entity, or (b) a national or resident of, or a segment of the government of, any country or territory for which the United States has embargoed goods or imposed trade sanctions. + +### k. US Government Rights + +Each of the Documentation and the software components that constitute the Services is a "commercial item" as that term is defined at 48 C.F.R. § 2.101, consisting of "commercial computer software" and "commercial computer software documentation" as such terms are used in 48 C.F.R. § 12.212. Accordingly, if Customer is an agency of the US Government or any contractor therefor, Customer only receives those rights with respect to the Services and Documentation as are granted to all other end users, in accordance with (a) 48 C.F.R. § 227.7201 through 48 C.F.R. § 227.7204, with respect to the Department of Defense and their contractors, or (b) 48 C.F.R. § 12.212, with respect to all other US Government users and their contractors. + +### l. Equitable Relief + +Each Party acknowledges and agrees that a breach or threatened breach by such Party of any of its obligations under Section 6 or, in the case of Customer, Section 2(c), would cause the other Party irreparable harm for which monetary damages would not be an adequate remedy and agrees that, in the event of such breach or threatened breach, the other Party will be entitled to equitable relief, including a restraining order, an injunction, specific performance and any other relief that may be available from any court, without any requirement to post a bond or other security, or to prove actual damages or that monetary damages are not an adequate remedy. Such remedies are not exclusive and are in addition to all other remedies that may be available at law, in equity or otherwise. + +### m. Publicity + +Supabase may identify Customer as a user of the Services and may use Customer's name, logo, and other trademarks in Supabase's customer list, press releases, blog posts, advertisements, and website (and all use thereof and goodwill arising therefrom shall inure to the sole and exclusive benefit of Customer). Otherwise, neither Party may use the name, logo, or other trademarks of the other Party for any purpose without the other Party's prior written approval. Customer may opt out of the foregoing publicity rights at any time by providing written notice to Supabase at legal@supabase.com, and Supabase will cease any such use within a commercially reasonable period. + +### n. Subcontractors + +Supabase may use the services of subcontractors for performance of services under this Agreement, provided that Supabase remains responsible for such subcontractors' compliance with the terms of this Agreement. + +### o. Independent Contractors + +The Parties to this Agreement are independent contractors. There is no relationship of partnership, joint venture, employment, franchise or agency created hereby between the Parties. Neither Party will have the power to bind the other or incur obligations on the other Party's behalf without the other Party's prior written consent. + +### p. No Third Party Rights + +There are no third-party beneficiaries to this Agreement. + + + +export default (context) => From 945b9266bf3baf81a57984c559925d5455bbec07 Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:49:44 +0300 Subject: [PATCH 10/51] docs: initial version of doc to guide options for data deletion (#42978) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? docs update ## What is the current behavior? Some guidance on deleting objects (and data) with cascading deletes etc ## What is the new behavior? A guide that covers deleting objects (and data) and the impact that can have on the DB, as well as ways to mitigate it ## Additional context Drafted as a similar question about dropping large (>10TB) tables has been mentioned a few times --------- Co-authored-by: Jean-Paul Argudo --- .../NavigationMenu.constants.ts | 4 + .../database/postgres/data-deletion.mdx | 205 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 apps/docs/content/guides/database/postgres/data-deletion.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 8d3b08dbd47..8af6e8c9b6a 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1040,6 +1040,10 @@ export const database: NavMenuConstant = { name: 'Implementing cascade deletes', url: '/guides/database/postgres/cascade-deletes' as `/${string}`, }, + { + name: 'Deleting data and dropping objects safely', + url: '/guides/database/postgres/data-deletion' as `/${string}`, + }, { name: 'Managing enums', url: '/guides/database/postgres/enums' }, { name: 'Managing database functions', diff --git a/apps/docs/content/guides/database/postgres/data-deletion.mdx b/apps/docs/content/guides/database/postgres/data-deletion.mdx new file mode 100644 index 00000000000..685dcc44f80 --- /dev/null +++ b/apps/docs/content/guides/database/postgres/data-deletion.mdx @@ -0,0 +1,205 @@ +--- +id: 'data-deletion' +title: 'Deleting data and dropping objects safely' +description: 'Strategies for removing data and schema objects while minimising impact.' +footerHelpType: 'postgres' +--- + +Deleting rows and dropping database objects are routine operations, but on a live database they can lock tables, block queries, and cause downtime. This guide covers practical strategies for keeping these operations safe and fast. + +## Preparing to delete + +- Test in a staging environment +- Ensure you have a recent backup +- Confirm the table dependencies and foreign key constraints +- Drop dependent objects explicitly, use [CASCADE](/docs/guides/database/postgres/cascade-deletes) with caution +- Choose a low traffic time to run the operation +- Run operations inside a [migration](/docs/guides/deployment/database-migrations) +- Set timeouts, such as `lock_timeout` and `statement_timeout` + +### Identifying dependencies + +The system catalog tables `pg_class`, `pg_constraint`, and `pg_depend` can be used to identify dependencies: + +```sql +-- Find tables that depend on a specific table +select + d.classid::regclass as dependent_object, + d.objid::regclass as dependent_object_id, + d.refclassid::regclass as referenced_object, + d.refobjid::regclass as referenced_object_id +from pg_depend d +where d.refobjid = 'public.logs'::regclass; +``` + +If the object you want to delete has dependencies, you'll need to drop those first or use `CASCADE` which will automatically drop all related objects. + +## Data deletion strategies + +There are several ways to delete data from a table and the approach you choose depends on how much you want to delete. + +### Small deletes + +For tables with less than a few thousand rows, a `DELETE` operation is fine: + +```sql +delete from logs +where created_at < now() - interval '90 days'; +``` + +This acquires a `ROW EXCLUSIVE` lock on the table, which still allows other `SELECT`, `INSERT`, `UPDATE`, and `DELETE` statements to run concurrently. For small row counts, the operation completes quickly and has minimal impact. + +### Large deletes + +Deleting millions of rows in a single statement can hold locks for a long time, generate WAL (Write-Ahead Log) traffic, and impact replication. Instead, delete in batches: + +```sql +-- Delete 5,000 rows at a time +DELETE FROM logs +WHERE id IN ( + SELECT id + FROM logs + WHERE created_at < now() - interval '90 days' + LIMIT 5000 +); +``` + +This approach has the benefit of controlling when it runs, locking for a shorter period of time and minimising impact on other transactions. + +If you know in advance that such large deletes will have to happen in the business cycle of your database, then you should seriously think about using (table parititioning)[/docs/guides/database/partitions] as a management tool. + +### Soft deletes + +If you need to "delete" data but want the option to recover it, consider a soft-delete pattern: + +```sql +alter table orders +add column deleted_at timestamptz; + +-- "Delete" a row +update orders +set deleted_at = now() +where id = 42; +``` + +Then exclude soft-deleted rows in your queries or views: + +```sql +create view active_orders as + select * from orders where deleted_at is null; +``` + + + +Combine soft deletes with a scheduled hard-delete job (using [pg_cron](/docs/guides/database/extensions/pg_cron)) to permanently remove old soft-deleted rows in batches during low-traffic periods. + + + +### Deleting all data + +If you need to delete all data from a table, consider using `TRUNCATE` instead of `DELETE`: + +```sql +truncate table logs; +``` + +`TRUNCATE` is much faster than `DELETE` because it doesn't generate individual row-level WAL entries and doesn't scan the table. It also resets any auto-incrementing sequences. + +## Object deletion strategies + +### Dropping tables + +Dropping a table removes it and all its data permanently. Always use `IF EXISTS` to avoid errors in migrations: + +```sql +drop table if exists old_analytics; +``` + + + +`DROP TABLE` acquires an `ACCESS EXCLUSIVE` lock, which blocks **all** other operations on the table, including reads. On a busy table, this can queue up behind long-running queries. See [Monitoring locks](#monitoring-locks) below. + + + +### Dropping columns + +Dropping a column is a metadata-only operation in Postgres — it doesn't rewrite the table. However, it still requires an `ACCESS EXCLUSIVE` lock: + +```sql +alter table users +drop column if exists legacy_field; +``` + +Since the lock is brief (metadata-only), this is generally safe. But on a table with many concurrent transactions, even a brief `ACCESS EXCLUSIVE` lock can queue behind long-running queries. Use a lock timeout to avoid waiting indefinitely: + +```sql +set local lock_timeout = '5s'; +alter table users drop column if exists legacy_field; +``` + +If the statement times out, retry during a quieter period. + +### Dropping indexes + +Dropping a regular index takes an `ACCESS EXCLUSIVE` lock on the index but **not** on the table, so reads and writes to the table continue uninterrupted: + +```sql +drop index if exists idx_users_legacy_field; +``` + + + +The `inspect` command in the [Supabase CLI](/docs/reference/cli/supabase-inspect-db-index-stats) can help you identify unused indexes: + +```bash +supabase inspect db index-stats +``` + + + +## Monitoring + +### Check for blocked queries + +Query `pg_locks` and `pg_stat_activity` to see currently active queries and queries waiting for locks. + +The [Supabase CLI](/docs/reference/cli/supabase-inspect-db-locks) provides commands to view these metrics: + +```bash +supabase inspect db locks +supabase inspect db blocking +``` + +### Monitor table bloat after large deletes + +When deleting a large number of rows, the space is not always reclaimed and available for use. In normal cases, the rows are marked as deleted but the space is not immediately freed. You can monitor table bloat to see if the space is being reclaimed: + +```bash +supabase inspect db bloat +``` + +## Reclaiming disk space + +To reclaim the disk space freed by deleted rows, Postgres' autovacuum process runs automatically to mark deleted rows as reusable, but it may not always keep up with large deletes. + +If autovacuum is not keeping up, you can trigger a manual vacuum: + +```sql +vacuum (verbose) logs; +``` + +For reclaiming disk space (not just marking tuples as reusable), use `VACUUM FULL` — but be aware this rewrites the entire table and takes an `ACCESS EXCLUSIVE` lock: + +```sql +-- This locks the table for the duration — use during maintenance windows only +vacuum full logs; +``` + +The most efficient way to reclaim disk space, without locks, is to use [pg_repack](/docs/guides/database/extensions/pg_repack). + +## Related links + +- [Safe Cascading Deletes](/docs/guides/database/postgres/cascade-deletes) +- [Inspecting your Database](/docs/guides/database/inspect) +- [Understanding Database and Disk Size](/docs/guides/platform/database-size) +- [Bloat in Postgres](/docs/blog/postgres-bloat) From c04f2465e4c138d96ef3de62e2eda229ee6f487e Mon Sep 17 00:00:00 2001 From: Fatuma Abdullahi <67555014+FatumaA@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:51:39 +0300 Subject: [PATCH 11/51] fix for connect modal being stuck loading for non JS frameworks (#44992) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? It makes the connect modal responsive to other non JS frameworks selection. ## What is the current behavior? Modal is stuck when non JS is selected. Closes #44985 ## What is the new behavior? It's now responsive: Screenshot 2026-04-17 at 18 40 47 Screenshot 2026-04-17 at 18 40 59 ## Additional context Add any other context or screenshots. ## Summary by CodeRabbit * **Refactor** * Internal optimization to the framework library resolution logic with no visible user-facing changes. --- .../components/interfaces/ConnectSheet/useConnectState.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts index ffa3428151a..415eeb2542b 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts @@ -254,7 +254,6 @@ export function useConnectState(initialState?: Partial): UseConnec const libraryKey = resolveFrameworkLibraryKey({ framework: next.framework, frameworkVariant: next.frameworkVariant, - library: next.library, }) if (libraryKey) { next.library = libraryKey @@ -268,7 +267,6 @@ export function useConnectState(initialState?: Partial): UseConnec const libraryKey = resolveFrameworkLibraryKey({ framework: prev.framework, frameworkVariant: String(value), - library: next.library, }) if (libraryKey) next.library = libraryKey } @@ -297,7 +295,6 @@ export function useConnectState(initialState?: Partial): UseConnec const libraryKey = resolveFrameworkLibraryKey({ framework: next.framework, frameworkVariant: next.frameworkVariant, - library: next.library, }) if (libraryKey) next.library = libraryKey } From e8df67d5d5291e05ea56596aee0f2b7fa152929b Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 17 Apr 2026 10:02:56 -0600 Subject: [PATCH 12/51] chore: migrate shortcuts to new hooks API (#44955) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Cleanup shortcuts with new hooks ## Summary by CodeRabbit * **Refactor** * Centralized keyboard shortcut system for consistent shortcut behavior across the app and moved preference toggles to a unified registry. * **New Features** * Added explicit shortcuts for Command Menu, AI Assistant, Inline Editor, and result copy/download actions. * Hotkey preferences UI now renders dynamically from the centralized shortcut list. * **Tests** * Test helpers updated to include the command menu provider for accurate shortcut behavior in tests. --- .../Account/Preferences/HotkeySettings.tsx | 120 ++---------------- .../Account/Preferences/HotkeyToggle.tsx | 68 +++++----- .../App/CommandMenu/StudioCommandProvider.tsx | 9 +- .../interfaces/SQLEditor/MonacoEditor.tsx | 12 +- .../layouts/AppLayout/AssistantButton.tsx | 10 +- .../layouts/AppLayout/InlineEditorButton.tsx | 9 +- .../Navigation/LayoutHeader/LayoutHeader.tsx | 7 +- .../LayoutSidebar/LayoutSidebarProvider.tsx | 22 +++- .../components/ui/DownloadResultsButton.tsx | 62 +++------ .../components/ui/EditorPanel/EditorPanel.tsx | 15 +-- apps/studio/state/shortcuts/formatShortcut.ts | 8 ++ apps/studio/state/shortcuts/registry.ts | 36 ++++++ apps/studio/state/sidebar-manager-state.tsx | 25 ---- apps/studio/tests/helpers.tsx | 5 +- apps/studio/tests/lib/custom-render.tsx | 5 +- packages/common/constants/local-storage.ts | 10 -- 16 files changed, 154 insertions(+), 269 deletions(-) create mode 100644 apps/studio/state/shortcuts/formatShortcut.ts diff --git a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx index 3e2e69524cc..9eedb7b4809 100644 --- a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx @@ -1,7 +1,4 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { LOCAL_STORAGE_KEYS } from 'common' -import { useForm } from 'react-hook-form' -import { Card, Form_Shadcn_ } from 'ui' +import { Card } from 'ui' import { PageSection, PageSectionContent, @@ -10,65 +7,13 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' -import * as z from 'zod' import { HotkeyToggle } from './HotkeyToggle' -import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry' -const HotkeySchema = z.object({ - commandMenuEnabled: z.boolean(), - aiAssistantEnabled: z.boolean(), - inlineEditorEnabled: z.boolean(), - copyMarkdownEnabled: z.boolean(), - copyJsonEnabled: z.boolean(), - copyCsvEnabled: z.boolean(), - downloadCsvEnabled: z.boolean(), -}) +const SHORTCUT_ORDER = Object.values(SHORTCUT_DEFINITIONS) export const HotkeySettings = () => { - const [inlineEditorEnabled, setInlineEditorEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), - true - ) - const [commandMenuEnabled, setCommandMenuEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COMMAND_MENU, - true - ) - const [aiAssistantEnabled, setAiAssistantEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), - true - ) - const [copyMarkdownEnabled, setCopyMarkdownEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COPY_MARKDOWN, - true - ) - const [copyJsonEnabled, setCopyJsonEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COPY_JSON, - true - ) - const [copyCsvEnabled, setCopyCsvEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COPY_CSV, - true - ) - const [downloadCsvEnabled, setDownloadCsvEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_DOWNLOAD_CSV, - true - ) - - const form = useForm>({ - resolver: zodResolver(HotkeySchema), - values: { - commandMenuEnabled: commandMenuEnabled ?? true, - aiAssistantEnabled: aiAssistantEnabled ?? true, - inlineEditorEnabled: inlineEditorEnabled ?? true, - copyMarkdownEnabled: copyMarkdownEnabled ?? true, - copyJsonEnabled: copyJsonEnabled ?? true, - copyCsvEnabled: copyCsvEnabled ?? true, - downloadCsvEnabled: downloadCsvEnabled ?? true, - }, - }) - return ( @@ -80,60 +25,15 @@ export const HotkeySettings = () => { - - + + {SHORTCUT_ORDER.map((definition, index) => ( - - - - - - - - + ))} + ) diff --git a/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx b/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx index a5a7766c748..0a46e6315f5 100644 --- a/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/HotkeyToggle.tsx @@ -1,46 +1,40 @@ -import type { FieldValues, Path, UseFormReturn } from 'react-hook-form' -import { CardContent, FormControl_Shadcn_, FormField_Shadcn_, KeyboardShortcut, Switch } from 'ui' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { Fragment } from 'react' +import { CardContent, KeyboardShortcut, Switch } from 'ui' -interface HotkeyToggleProps { - form: UseFormReturn - name: Path - keys: string[] - label: string - onToggle: (value: boolean) => void +import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' +import type { ShortcutId } from '@/state/shortcuts/registry' +import { useShortcutPreferences } from '@/state/shortcuts/state' +import type { ShortcutDefinition } from '@/state/shortcuts/types' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' + +interface HotkeyToggleProps { + definition: ShortcutDefinition isLast?: boolean } -export function HotkeyToggle({ - form, - name, - keys, - label, - onToggle, - isLast, -}: HotkeyToggleProps) { +export function HotkeyToggle({ definition, isLast }: HotkeyToggleProps) { + const enabled = useIsShortcutEnabled(definition.id as ShortcutId) + const { setShortcutEnabled } = useShortcutPreferences() + return ( - ( - -
- - - { - field.onChange(value) - onToggle(value) - }} - /> - -
-
- )} - /> +
+ +
+
+ {definition.sequence.map((step, i) => ( + + {i > 0 && then} + + + ))} +
+ setShortcutEnabled(definition.id as ShortcutId, checked)} + /> +
+
) } diff --git a/apps/studio/components/interfaces/App/CommandMenu/StudioCommandProvider.tsx b/apps/studio/components/interfaces/App/CommandMenu/StudioCommandProvider.tsx index 89c978fd6f0..c794e23b3f3 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/StudioCommandProvider.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/StudioCommandProvider.tsx @@ -1,16 +1,13 @@ -import { LOCAL_STORAGE_KEYS } from 'common' import type { PropsWithChildren } from 'react' import { CommandProvider } from 'ui-patterns/CommandMenu' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useStudioCommandMenuTelemetry } from '@/hooks/misc/useStudioCommandMenuTelemetry' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' export function StudioCommandProvider({ children }: PropsWithChildren) { const { onTelemetry } = useStudioCommandMenuTelemetry() - const [commandMenuHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COMMAND_MENU, - true - ) + const commandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN) return ( ( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), - true - ) + const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE) // [Joshen] Lodash debounce doesn't seem to be working here, so opting to use useDebounce const [value, setValue] = useState('') @@ -80,6 +79,9 @@ const MonacoEditor = ({ const executeQueryRef = useRef(executeQuery) executeQueryRef.current = executeQuery + const aiHotkeyEnabledRef = useRef(isAIAssistantHotkeyEnabled) + aiHotkeyEnabledRef.current = isAIAssistantHotkeyEnabled + const handleEditorOnMount: OnMount = async (editor, monaco) => { editorRef.current = editor monacoRef.current = monaco @@ -134,7 +136,7 @@ const MonacoEditor = ({ label: 'Toggle AI Assistant', keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI], run: () => { - if (isAIAssistantHotkeyEnabled) { + if (aiHotkeyEnabledRef.current) { toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) } }, diff --git a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx index 56c7bb5f454..bb70352b8df 100644 --- a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx @@ -1,18 +1,14 @@ -import { LOCAL_STORAGE_KEYS } from 'common' import { AiIconAnimation, cn, KeyboardShortcut } from 'ui' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' export const AssistantButton = () => { const { activeSidebar, toggleSidebar } = useSidebarManagerSnapshot() - const [isAIAssistantHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), - true - ) + const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE) const isOpen = activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT diff --git a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx index 302667d33ab..ab02ed41a82 100644 --- a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx @@ -1,17 +1,14 @@ -import { LOCAL_STORAGE_KEYS } from 'common' import { SqlEditor } from 'icons' import { cn, KeyboardShortcut } from 'ui' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' const InlineEditorKeyboardTooltip = () => { - const [hotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), - true - ) + const hotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.INLINE_EDITOR_TOGGLE) return hotkeyEnabled ? : null } diff --git a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx index b79a51dc1f2..f5f4501297d 100644 --- a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useParams } from 'common' import dayjs from 'dayjs' import { AnimatePresence, motion } from 'framer-motion' import { ChevronLeft } from 'lucide-react' @@ -28,10 +28,11 @@ import { ProjectDropdown } from '@/components/layouts/AppLayout/ProjectDropdown' import { HelpButton } from '@/components/ui/HelpPanel/HelpButton' import { getResourcesExceededLimitsOrg } from '@/components/ui/OveragesBanner/OveragesBanner.utils' import { useOrgUsageQuery } from '@/data/usage/org-usage-query' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps) => ( @@ -71,7 +72,7 @@ export const LayoutHeader = ({ const { data: selectedOrganization } = useSelectedOrganizationQuery() const showFloatingMobileToolbar = useIsFloatingMobileToolbarEnabled() - const [commandMenuEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COMMAND_MENU, true) + const commandMenuEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN) const isAccountPage = router.pathname.startsWith('/account') diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx index c454079e01e..690c1bbf121 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx @@ -10,7 +10,13 @@ import useLatest from '@/hooks/misc/useLatest' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { useRegisterSidebar, useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' +import { + sidebarManagerState, + useRegisterSidebar, + useSidebarManagerSnapshot, +} from '@/state/sidebar-manager-state' const AdvisorPanel = dynamic(() => import('@/components/ui/AdvisorPanel/AdvisorPanel').then((m) => m.AdvisorPanel) @@ -48,9 +54,9 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { const sidebarURLParamRef = useLatest(sidebarURLParam) const sidebarLocalStorageRef = useLatest(sidebarLocalStorage) - useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i', !!project) - useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e', !!project) - useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => , {}, undefined, true) + useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, !!project) + useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, !!project) + useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => , {}, true) useRegisterSidebar( SIDEBAR_KEYS.HELP_PANEL, () => ( @@ -65,10 +71,16 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { /> ), {}, - undefined, true ) + useShortcut(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE, () => + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + ) + useShortcut(SHORTCUT_IDS.INLINE_EDITOR_TOGGLE, () => + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + ) + useEffect(() => { if (!!project) { if (activeSidebar) { diff --git a/apps/studio/components/ui/DownloadResultsButton.tsx b/apps/studio/components/ui/DownloadResultsButton.tsx index 72ce02cdd49..3277e12f04c 100644 --- a/apps/studio/components/ui/DownloadResultsButton.tsx +++ b/apps/studio/components/ui/DownloadResultsButton.tsx @@ -1,4 +1,4 @@ -import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import saveAs from 'file-saver' import { ChevronDown, Copy, Download, Settings } from 'lucide-react' import Link from 'next/link' @@ -20,8 +20,8 @@ import { convertResultsToJSON, convertResultsToMarkdown, } from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { useHotKey } from '@/hooks/ui/useHotKey' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' interface DownloadResultsButtonProps { iconOnly?: boolean @@ -51,11 +51,6 @@ export const DownloadResultsButton = ({ const { ref } = useParams() const pathname = usePathname() const isLogs = pathname?.includes?.('/logs') ?? false - const [copyMarkdownEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_MARKDOWN, true) - const [copyJsonEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_JSON, true) - const [copyCsvEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COPY_CSV, true) - const [downloadCsvEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_DOWNLOAD_CSV, true) - const isEmpty = useMemo(() => results.length === 0, [results]) const downloadAsCSV = () => { @@ -107,41 +102,22 @@ export const DownloadResultsButton = ({ }) } - useHotKey( - (e) => { - e.preventDefault() - copyAsMarkdown() - }, - 'm', - { enabled: copyMarkdownEnabled ?? isEmpty, shift: true } - ) - - useHotKey( - (e) => { - e.preventDefault() - copyAsJSON() - }, - 'j', - { enabled: copyJsonEnabled ?? isEmpty, shift: true } - ) - - useHotKey( - (e) => { - e.preventDefault() - copyAsCSV() - }, - 'c', - { enabled: copyCsvEnabled ?? isEmpty, shift: true } - ) - - useHotKey( - (e) => { - e.preventDefault() - downloadAsCSV() - }, - 'd', - { enabled: downloadCsvEnabled ?? isEmpty, shift: true } - ) + useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, copyAsMarkdown, { + enabled: !isEmpty, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.RESULTS_COPY_JSON, copyAsJSON, { + enabled: !isEmpty, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.RESULTS_COPY_CSV, copyAsCSV, { + enabled: !isEmpty, + registerInCommandMenu: true, + }) + useShortcut(SHORTCUT_IDS.RESULTS_DOWNLOAD_CSV, downloadAsCSV, { + enabled: !isEmpty, + registerInCommandMenu: true, + }) return ( diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index 21f1c6d1473..90d2a9c7bb2 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -1,7 +1,7 @@ import type { Monaco } from '@monaco-editor/react' import { useQueryClient } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useParams } from 'common' import { AlertCircle, Book, @@ -57,12 +57,13 @@ import { useContentQuery, type Content } from '@/data/content/content-query' import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' import { contentKeys } from '@/data/content/keys' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { BASE_PATH } from '@/lib/constants' import { useProfile } from '@/lib/profile' import { editorPanelState, useEditorPanelStateSnapshot } from '@/state/editor-panel-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' @@ -107,14 +108,8 @@ export const EditorPanel = () => { setActiveSnippet({ ...activeSnippet, name: newName }) setIsEditingTitle(false) } - const [isInlineEditorHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), - true - ) - const [isAIAssistantHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.AI_ASSISTANT), - true - ) + const isInlineEditorHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.INLINE_EDITOR_TOGGLE) + const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE) const currentValue = value || '' diff --git a/apps/studio/state/shortcuts/formatShortcut.ts b/apps/studio/state/shortcuts/formatShortcut.ts new file mode 100644 index 00000000000..1fa02073881 --- /dev/null +++ b/apps/studio/state/shortcuts/formatShortcut.ts @@ -0,0 +1,8 @@ +/** + * Convert a TanStack Hotkeys hotkey string (e.g. `"Mod+Shift+M"`) into the + * key-array format consumed by the `` component from `ui` + * (e.g. `["Meta", "Shift", "M"]`). `KeyboardShortcut` resolves `Meta` to ⌘ on + * macOS or `Ctrl` elsewhere, so this mapping is platform-safe. + */ +export const hotkeyToKeys = (hotkey: string): string[] => + hotkey.split('+').map((part) => (part === 'Mod' ? 'Meta' : part)) diff --git a/apps/studio/state/shortcuts/registry.ts b/apps/studio/state/shortcuts/registry.ts index be08a32d51c..542c01c5117 100644 --- a/apps/studio/state/shortcuts/registry.ts +++ b/apps/studio/state/shortcuts/registry.ts @@ -8,7 +8,13 @@ import { ShortcutDefinition } from './types' * The `` groups related shortcuts (sql-editor, table-editor, results, etc). */ export const SHORTCUT_IDS = { + COMMAND_MENU_OPEN: 'command-menu.open', + AI_ASSISTANT_TOGGLE: 'ai-assistant.toggle', + INLINE_EDITOR_TOGGLE: 'inline-editor.toggle', RESULTS_COPY_MARKDOWN: 'results.copy-markdown', + RESULTS_COPY_JSON: 'results.copy-json', + RESULTS_COPY_CSV: 'results.copy-csv', + RESULTS_DOWNLOAD_CSV: 'results.download-csv', } as const /** @@ -38,9 +44,39 @@ export type ShortcutId = (typeof SHORTCUT_IDS)[keyof typeof SHORTCUT_IDS] * // useShortcut(SHORTCUT_IDS.SQL_EDITOR_RUN, runQuery) */ export const SHORTCUT_DEFINITIONS: Record = { + [SHORTCUT_IDS.COMMAND_MENU_OPEN]: { + id: SHORTCUT_IDS.COMMAND_MENU_OPEN, + label: 'Open command menu', + sequence: ['Mod+K'], + }, + [SHORTCUT_IDS.AI_ASSISTANT_TOGGLE]: { + id: SHORTCUT_IDS.AI_ASSISTANT_TOGGLE, + label: 'Toggle AI Assistant panel', + sequence: ['Mod+I'], + }, + [SHORTCUT_IDS.INLINE_EDITOR_TOGGLE]: { + id: SHORTCUT_IDS.INLINE_EDITOR_TOGGLE, + label: 'Toggle inline SQL editor', + sequence: ['Mod+E'], + }, [SHORTCUT_IDS.RESULTS_COPY_MARKDOWN]: { id: SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, label: 'Copy results as Markdown', sequence: ['Mod+Shift+M'], }, + [SHORTCUT_IDS.RESULTS_COPY_JSON]: { + id: SHORTCUT_IDS.RESULTS_COPY_JSON, + label: 'Copy results as JSON', + sequence: ['Mod+Shift+J'], + }, + [SHORTCUT_IDS.RESULTS_COPY_CSV]: { + id: SHORTCUT_IDS.RESULTS_COPY_CSV, + label: 'Copy results as CSV', + sequence: ['Mod+Shift+C'], + }, + [SHORTCUT_IDS.RESULTS_DOWNLOAD_CSV]: { + id: SHORTCUT_IDS.RESULTS_DOWNLOAD_CSV, + label: 'Download results as CSV', + sequence: ['Mod+Shift+D'], + }, } diff --git a/apps/studio/state/sidebar-manager-state.tsx b/apps/studio/state/sidebar-manager-state.tsx index cbcc78e593c..c5ced2c442a 100644 --- a/apps/studio/state/sidebar-manager-state.tsx +++ b/apps/studio/state/sidebar-manager-state.tsx @@ -1,9 +1,7 @@ -import { LOCAL_STORAGE_KEYS } from 'common/constants' import { ReactNode, useEffect } from 'react' import { proxy, snapshot, useSnapshot } from 'valtio' import useLatest from '@/hooks/misc/useLatest' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' type SidebarHandlers = { onOpen?: () => void @@ -167,14 +165,8 @@ export const useRegisterSidebar = ( id: string, component: () => ReactNode, handlers: SidebarHandlers = {}, - hotKey?: string, enabled?: boolean ) => { - const [isSidebarHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(id), - true - ) - const componentRef = useLatest(component) const handlersRef = useLatest(handlers) @@ -187,21 +179,4 @@ export const useRegisterSidebar = ( sidebarManagerState.unregisterSidebar(id) } }, [id, enabled]) - - useEffect(() => { - if (!hotKey) return - - function hotKeyHandler(e: KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key === hotKey && !e.altKey && !e.shiftKey) { - sidebarManagerState.toggleSidebar(id) - } - } - - if (isSidebarHotkeyEnabled) { - window.addEventListener('keydown', hotKeyHandler) - return () => { - window.removeEventListener('keydown', hotKeyHandler) - } - } - }, [id, hotKey, isSidebarHotkeyEnabled]) } diff --git a/apps/studio/tests/helpers.tsx b/apps/studio/tests/helpers.tsx index 9a071749a94..6f32e9ab9f1 100644 --- a/apps/studio/tests/helpers.tsx +++ b/apps/studio/tests/helpers.tsx @@ -3,6 +3,7 @@ import { fireEvent, getByText, render as originalRender, screen } from '@testing import type React from 'react' import { useState } from 'react' import { TooltipProvider } from 'ui' +import { CommandProvider } from 'ui-patterns/CommandMenu' import { ProjectInfoInfinite } from '@/data/projects/projects-infinite-query' import type { Organization } from '@/types' @@ -106,7 +107,9 @@ const ReactQueryTestConfig: React.FC = ({ children }) = return ( - {children} + + {children} + ) } diff --git a/apps/studio/tests/lib/custom-render.tsx b/apps/studio/tests/lib/custom-render.tsx index e8dd5f50841..2a85d2e1541 100644 --- a/apps/studio/tests/lib/custom-render.tsx +++ b/apps/studio/tests/lib/custom-render.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, renderHook, type RenderOptions } from '@testing-library/react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { TooltipProvider } from 'ui' +import { CommandProvider } from 'ui-patterns/CommandMenu' // End of third-party imports @@ -33,7 +34,9 @@ const CustomWrapper = ({ const content = ( - {children} + + {children} + ) diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 2f37b3dc6be..97ce0162cec 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -89,18 +89,8 @@ export const LOCAL_STORAGE_KEYS = { USER_IMPERSONATION_SELECTOR_PREVIOUS_SEARCHES: (ref: string) => `user-impersonation-selector-previous-searches-${ref}`, - HOTKEY_COMMAND_MENU: 'supabase-dashboard-hotkey-command-menu', - LAST_OPENED_SIDE_BAR: (ref: string) => `last-opened-sidebar-${ref}`, - // Project sidebar hotkeys - HOTKEY_SIDEBAR: (sidebarId: string) => `supabase-dashboard-hotkey-sidebar-${sidebarId}`, - - HOTKEY_COPY_MARKDOWN: 'supabase-dashboard-hotkey-copy-markdown', - HOTKEY_COPY_JSON: 'supabase-dashboard-hotkey-copy-json', - HOTKEY_COPY_CSV: 'supabase-dashboard-hotkey-copy-csv', - HOTKEY_DOWNLOAD_CSV: 'supabase-dashboard-hotkey-download-csv', - // Index Advisor notice dismissed INDEX_ADVISOR_NOTICE_DISMISSED: (ref: string) => `index-advisor-notice-dismissed-${ref}`, From a7341c70eac60acfd67ffe4855ad33a1acadb3be Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 17 Apr 2026 10:31:39 -0600 Subject: [PATCH 13/51] feat(shortcuts): add showInSettings flag to ShortcutDefinition (#44997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes [FE-3021](https://linear.app/supabase/issue/FE-3021/hide-shortcut-in-settings-option-for-new-api). ## Summary - Adds an optional `showInSettings` field to `ShortcutDefinition` (defaults to `true`). - `HotkeySettings` filters out entries where `showInSettings === false` before rendering the Account → Preferences → Keyboard shortcuts list. - No registry entries are flipped in this PR — opt-in per shortcut as needed. ## Test plan - [x] Confirm all existing shortcuts still appear under Account → Preferences → Keyboard shortcuts. - [x] Temporarily set `showInSettings: false` on one entry and verify it disappears from the list. - [x] `pnpm --filter studio exec tsc --noEmit` passes. ## Summary by CodeRabbit * **New Features** * Keyboard shortcuts can now be selectively hidden from the Account preferences settings based on configuration. * **Refactor** * Updated keyboard shortcut filtering logic to respect visibility settings. --- .../interfaces/Account/Preferences/HotkeySettings.tsx | 4 +++- apps/studio/state/shortcuts/types.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx index 9eedb7b4809..c3b6f3eb759 100644 --- a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx @@ -11,7 +11,9 @@ import { import { HotkeyToggle } from './HotkeyToggle' import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry' -const SHORTCUT_ORDER = Object.values(SHORTCUT_DEFINITIONS) +const SHORTCUT_ORDER = Object.values(SHORTCUT_DEFINITIONS).filter( + (definition) => definition.showInSettings !== false +) export const HotkeySettings = () => { return ( diff --git a/apps/studio/state/shortcuts/types.ts b/apps/studio/state/shortcuts/types.ts index 8ee1cd3249b..76b5d46db90 100644 --- a/apps/studio/state/shortcuts/types.ts +++ b/apps/studio/state/shortcuts/types.ts @@ -77,4 +77,14 @@ export interface ShortcutDefinition { * is overridable by the caller of `useShortcut`. */ options?: ShortcutOptions + + /** + * Whether this shortcut appears as a toggleable entry in Account → + * Preferences → Keyboard shortcuts. Defaults to `true`. + * + * Set to `false` for shortcuts that users shouldn't be able to disable (e.g. + * the command menu opener) or for shortcuts that aren't meaningful as a + * standalone user preference. + */ + showInSettings?: boolean } From cacee2585ba44c3392e7ee9cf9249e941ff30f8d Mon Sep 17 00:00:00 2001 From: Aditya Kumar Mishra <154746713+adityakrmishra@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:38:25 +0530 Subject: [PATCH 14/51] fix(studio): await saveRow and surface foreign row errors to UI and Sentry (#44950) Fixes #44949 What kind of change does this PR introduce? [x] Bug fix (non-breaking change which fixes an issue) [ ] New feature (non-breaking change which adds functionality) Description This PR fixes a silent failure state in the Foreign Row Selector (SidePanelEditor.tsx). Previously, onSaveForeignRow called saveRow without awaiting it, and included an entirely empty catch block. This meant that any database write failures (RLS violations, network drops, foreign key constraint violations) were swallowed into the void. The user received no visual feedback that their save failed. Changes: Added await to saveRow to ensure the promise rejection is actually caught by the try/catch block. Added a toast.error notification to alert the user when the save fails. Added Sentry.captureException to ensure these silent failures are actually tracked in production observability. (Sentry was already imported in the file). ## Summary by CodeRabbit * **Bug Fixes** * Improved error handling and user notifications when saving data in the table editor, ensuring failures are properly communicated and logged. --- .../SidePanelEditor/SidePanelEditor.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index cffd091938c..1703e78a279 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -361,8 +361,15 @@ export const SidePanelEditor = ({ const isNewRecord = false const configuration = { identifiers, rowIdx: row.idx } - saveRow(value, isNewRecord, configuration, () => {}) - } catch (error) {} + await saveRow(value, isNewRecord, configuration, (error) => { + if (error) { + toast.error(`Failed to save row: ${error?.message ?? 'Unknown error'}`) + } + }) + } catch (error: any) { + toast.error(`Failed to save row: ${error?.message ?? 'Unknown error'}`) + Sentry.captureException(error, { tags: { workflow: 'save-foreign-row' } }) + } } const saveColumn = async ( From 7f8cc65fa99b802cd859197c19dfb7cc456298f0 Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:36:15 +0100 Subject: [PATCH 15/51] Added a new AWS Activate offer page (#44957) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Added a new go page for AWS Activate offers. Needed to embed a HubSpot form directly as it has conditional formatting, which is not supported on the go pages natively. ## Summary by CodeRabbit * **New Features** * New AWS Activate Offer landing page added to the platform * Integrated form system for capturing user information in the offer flow --- apps/www/_go/index.tsx | 2 + apps/www/_go/lead-gen/aws-activate-offer.tsx | 121 ++++++++++++++++++ .../lead-gen/components/HubSpotFormEmbed.tsx | 92 +++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 apps/www/_go/lead-gen/aws-activate-offer.tsx create mode 100644 apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx diff --git a/apps/www/_go/index.tsx b/apps/www/_go/index.tsx index f048385d219..5db678bb2c2 100644 --- a/apps/www/_go/index.tsx +++ b/apps/www/_go/index.tsx @@ -16,6 +16,7 @@ import stripeExecDinnerThankYou from './events/stripe-sessions-2026/exec-dinner- import stripeMeetingScheduler from './events/stripe-sessions-2026/meeting-scheduler' import stripeParty from './events/stripe-sessions-2026/party' import sxswContest from './events/sxsw-2026/contest' +import awsActivateOffer from './lead-gen/aws-activate-offer' import exampleLeadGen from './lead-gen/example-lead-gen' import amoe from './legal/amoe' import amoeThankYou from './legal/amoe-thankyou' @@ -29,6 +30,7 @@ import figmaWebinarMay2026ThankYou from './webinar/figma-webinar-may2026-thankyo import promptToProdSentry from './webinar/prompt-to-prod-sentry' const pages: GoPageInput[] = [ + awsActivateOffer, exampleLeadGen, byocEarlyAccess, amoe, diff --git a/apps/www/_go/lead-gen/aws-activate-offer.tsx b/apps/www/_go/lead-gen/aws-activate-offer.tsx new file mode 100644 index 00000000000..e00782d7494 --- /dev/null +++ b/apps/www/_go/lead-gen/aws-activate-offer.tsx @@ -0,0 +1,121 @@ +import type { GoPageInput } from 'marketing' + +import HubSpotFormEmbed from './components/HubSpotFormEmbed' + +const page: GoPageInput = { + template: 'lead-gen', + slug: 'aws-activate-offer', + metadata: { + title: 'Get $300 in Supabase credits through AWS Activate', + description: + 'Exclusively for VC-backed startups accessing Supabase through AWS Activate. Apply to receive $300 in Supabase credits.', + noIndex: true, + }, + hero: { + title: 'Get $300 in Supabase credits through AWS Activate', + description: + 'This offer is exclusively for VC-backed startups accessing Supabase through AWS Activate. Complete the form below to apply.', + image: { + src: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=partnerships&layout=icon-only©=%5B%24300%5D+in+credits+for%0AAWS+Activate+startups&icon=supabase.svg&icon2=aws.svg', + alt: 'Supabase and AWS Activate: $300 in credits for eligible startups', + width: 600, + height: 315, + }, + ctas: [ + { + label: 'Apply for your credits', + href: '#form', + variant: 'primary', + }, + ], + }, + sections: [ + { + type: 'single-column', + title: 'About the offer', + description: + 'Supabase is the easy-to-use, open-source managed Postgres with integrated backend services. With this exclusive AWS Activate offer, eligible startups receive $300 in Supabase credits to build, scale, and ship faster.', + }, + { + type: 'feature-grid', + title: 'What the credits unlock', + description: 'An all-in-one suite built on Postgres. Use one or all.', + columns: 3, + items: [ + { + title: 'Database', + description: 'A full Postgres instance hosted in the cloud.', + }, + { + title: 'Auth', + description: + 'A complete user management system with email, social, and passwordless login.', + }, + { + title: 'Storage', + description: 'Upload and serve files of any size.', + }, + { + title: 'Edge Functions', + description: 'Server-side TypeScript functions, distributed globally at the edge.', + }, + { + title: 'Realtime', + description: 'Live sync for collaborative applications, powered by Postgres replication.', + }, + { + title: 'Vector', + description: 'pgvector for fast semantic search and embedding storage.', + }, + ], + }, + { + type: 'steps', + title: 'Eligibility requirements', + description: 'To qualify for this offer you must:', + items: [ + { title: 'Be a VC-backed startup with less than $5M in total funding' }, + { title: 'Have an active AWS account' }, + { title: 'Not have previously redeemed this offer' }, + ], + }, + { + type: 'faq', + title: 'Frequently asked questions', + items: [ + { + question: 'Who is this offer for?', + answer: + 'VC-backed startups with less than $5M in total funding and an active AWS account.', + }, + { + question: 'What can I use the credits for?', + answer: + 'Credits apply to any Supabase product including database, auth, storage, and edge functions.', + }, + { + question: 'How long does it take to hear back?', + answer: + "We review applications within 5 business days. You'll receive a confirmation email once submitted.", + }, + { + question: 'What happens after I apply?', + answer: + 'If your application is approved, a member of our team will reach out to activate your credits.', + }, + ], + }, + { + type: 'single-column', + id: 'form', + title: 'Apply for your credits', + children: ( +
+ +
+ ), + }, + ], +} + +export default page diff --git a/apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx b/apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx new file mode 100644 index 00000000000..159cb31ebc2 --- /dev/null +++ b/apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx @@ -0,0 +1,92 @@ +'use client' + +import { useEffect, useId } from 'react' + +declare global { + interface Window { + hbspt?: { + forms: { + create: (config: { portalId: string; formId: string; target: string }) => void + } + } + } +} + +const HUBSPOT_SCRIPT_SRC = 'https://js.hsforms.net/forms/embed/v2.js' + +function loadHubSpotScript(): Promise { + return new Promise((resolve, reject) => { + if (window.hbspt) { + resolve() + return + } + + const existing = document.querySelector( + `script[src="${HUBSPOT_SCRIPT_SRC}"]` + ) + if (existing) { + existing.addEventListener('load', () => resolve(), { once: true }) + existing.addEventListener( + 'error', + () => reject(new Error('Failed to load HubSpot form script')), + { + once: true, + } + ) + return + } + + const script = document.createElement('script') + script.src = HUBSPOT_SCRIPT_SRC + script.async = true + script.defer = true + script.onload = () => resolve() + script.onerror = () => reject(new Error('Failed to load HubSpot form script')) + document.body.appendChild(script) + }) +} + +export default function HubSpotFormEmbed({ + portalId, + formId, +}: { + portalId: string + formId: string +}) { + const targetId = `hubspot-form-${useId().replace(/:/g, '-')}` + + useEffect(() => { + let cancelled = false + + const mountForm = async () => { + try { + await loadHubSpotScript() + if (cancelled || !window.hbspt) return + + const target = document.getElementById(targetId) + if (!target) return + + // Reset target to avoid duplicate forms on remounts. + while (target.firstChild) { + target.removeChild(target.firstChild) + } + + window.hbspt.forms.create({ + portalId, + formId, + target: `#${targetId}`, + }) + } catch (error) { + console.error('[go/hubspot] Failed to initialize HubSpot form embed', error) + } + } + + mountForm() + + return () => { + cancelled = true + } + }, [formId, portalId, targetId]) + + return
+} From 0849fad451605c859fd19394e01b24113b386f84 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 17 Apr 2026 18:45:46 -0400 Subject: [PATCH 16/51] sort desc date, hide past events (#44996) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES/NO ## What kind of change does this PR introduce? Bug fix, feature, docs update, ... ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. --- apps/www/app/events/context.tsx | 38 +++++++++------------------------ 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/apps/www/app/events/context.tsx b/apps/www/app/events/context.tsx index 247d780b228..0b95f6ecafb 100644 --- a/apps/www/app/events/context.tsx +++ b/apps/www/app/events/context.tsx @@ -1,7 +1,7 @@ 'use client' -import { createContext, useContext, useState, useEffect, useMemo, ReactNode } from 'react' -import { SupabaseEvent, SUPABASE_HOST } from '~/lib/eventsTypes' +import { SUPABASE_HOST, SupabaseEvent } from '~/lib/eventsTypes' +import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react' interface EventsContextValue { allEvents: SupabaseEvent[] @@ -91,9 +91,13 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps) fetchLumaEvents() }, []) - // Merge Notion (server) + Luma (client) events + // Merge Notion (server) + Luma (client) events, excluding past events const allEvents = useMemo(() => { - return [...notionEvents, ...lumaEvents] + const now = new Date() + return [...notionEvents, ...lumaEvents].filter((event) => { + const eventDate = new Date(event.end_date || event.date) + return eventDate >= now + }) }, [notionEvents, lumaEvents]) const categories = useMemo(() => { @@ -151,36 +155,14 @@ export function EventsProvider({ children, notionEvents }: EventsProviderProps) return [...filtered].sort((a, b) => { const dateA = new Date(a.date).getTime() const dateB = new Date(b.date).getTime() - return dateB - dateA + return dateA - dateB }) }, [allEvents, selectedCategories, searchQuery]) const featuredEvent = useMemo(() => { if (allEvents.length === 0) return undefined - const now = new Date() - - const upcomingEvents = allEvents.filter((event) => { - const eventDate = new Date(event.end_date || event.date) - return eventDate >= now - }) - - const pastEvents = allEvents.filter((event) => { - const eventDate = new Date(event.end_date || event.date) - return eventDate < now - }) - - if (upcomingEvents.length > 0) { - return upcomingEvents.sort( - (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() - )[0] - } - - if (pastEvents.length > 0) { - return pastEvents.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0] - } - - return undefined + return [...allEvents].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())[0] }, [allEvents]) const value: EventsContextValue = { From 3aed9a9a2a819f3649d79e684775dd501d59e6f4 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Sat, 18 Apr 2026 17:18:12 +0800 Subject: [PATCH 17/51] feat(studio): warn before CREATE TABLE without RLS in SQL editor (#45008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pre-execution warning in the SQL editor when a `CREATE TABLE` statement is run without enabling Row Level Security on the new table. Responds to the press call-out around SQL editor security. Screenshot 2026-04-18 at 4 31 07 PM **Added:** - Pre-execution check in `executeQuery` that detects `CREATE TABLE` statements without a matching `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` in the same submitted SQL. - New "Run and enable RLS" action in the warning modal that rewrites the SQL to append `ALTER TABLE [schema.] ENABLE ROW LEVEL SECURITY;` for each detected table before running. - Link in the modal to the RLS docs. **Changed:** - `RunQueryWarningModal` now renders `Dialog` directly (instead of `ConfirmationModal`) so it can show three buttons: Cancel / Run without RLS / Run and enable RLS. - `sqlEventParser` table-name regex now supports quoted identifiers containing spaces (e.g. `"My Table"`) and escaped quotes (e.g. `"user""table"`). The check runs against the SQL that's actually submitted, so partial-selection works correctly — selecting only the `CREATE TABLE` portion will trigger the warning even if there's a matching `ENABLE RLS` lower in the editor. ## To test - Open the SQL editor and run `create table foo (id int8 primary key);` → modal should appear with the RLS warning bullet and three buttons. - Click **Run and enable RLS** → query runs, table is created with RLS enabled. - Click **Run without RLS** → query runs as written, no RLS. - Run `create table foo (id int8); alter table foo enable row level security;` → no modal (RLS already enabled in same submission). - Run `create table public.bar (id int8); create table baz (id int8); alter table baz enable rls;` → modal flags only `public.bar`. - Select only the `create table` portion of a snippet that also enables RLS lower down and run the selection → modal should still fire. - Run an existing destructive query (`drop table x`) → modal still works as before with two buttons (Cancel / Run this query). ## Summary by CodeRabbit * **New Features** * SQL editor now detects CREATE TABLE statements missing Row Level Security (RLS) and shows counts and dynamic table/schema details in a redesigned warning dialog with updated pluralization and a “Learn more” link. * New actions: “Run without RLS” and, when available, “Run and enable RLS” which applies RLS and runs the query; editor can execute an overridden SQL payload when applying RLS changes. * **Tests** * Added comprehensive unit and e2e tests covering RLS detection, SQL augmentation, trigger handling, identifier parsing, and the “Run and enable RLS” flow. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../SQLEditor/RunQueryWarningModal.tsx | 192 +++++++++---- .../interfaces/SQLEditor/SQLEditor.tsx | 44 ++- .../interfaces/SQLEditor/SQLEditor.types.ts | 1 + .../SQLEditor/SQLEditor.utils.test.ts | 252 ++++++++++++++++++ .../interfaces/SQLEditor/SQLEditor.utils.ts | 92 +++++++ apps/studio/lib/sql-event-parser.ts | 18 +- e2e/studio/features/sql-editor.spec.ts | 54 ++++ 7 files changed, 587 insertions(+), 66 deletions(-) diff --git a/apps/studio/components/interfaces/SQLEditor/RunQueryWarningModal.tsx b/apps/studio/components/interfaces/SQLEditor/RunQueryWarningModal.tsx index fa27e55bc6a..81daa60ed73 100644 --- a/apps/studio/components/interfaces/SQLEditor/RunQueryWarningModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RunQueryWarningModal.tsx @@ -1,13 +1,25 @@ -import { DialogSectionSeparator, Separator } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { + Button, + cn, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, +} from 'ui' +import { Admonition } from 'ui-patterns' import { PotentialIssues } from './SQLEditor.types' +import { DOCS_URL } from '@/lib/constants' interface RunQueryWarningModalProps { visible: boolean potentialIssues: PotentialIssues | undefined onCancel: () => void onConfirm: () => void + onConfirmWithRLS?: () => void } export const RunQueryWarningModal = ({ @@ -15,63 +27,135 @@ export const RunQueryWarningModal = ({ potentialIssues, onCancel, onConfirm, + onConfirmWithRLS, }: RunQueryWarningModalProps) => { - const { hasDestructiveOperations, hasUpdateWithoutWhere, hasAlterDatabasePreventConnection } = - potentialIssues || {} + const { + hasDestructiveOperations, + hasUpdateWithoutWhere, + hasAlterDatabasePreventConnection, + createTablesMissingRLS, + } = potentialIssues || {} + + const missingRLSTables = createTablesMissingRLS ?? [] + const hasMissingRLS = missingRLSTables.length > 0 + const issueCount = + (hasDestructiveOperations ? 1 : 0) + + (hasUpdateWithoutWhere ? 1 : 0) + + (hasAlterDatabasePreventConnection ? 1 : 0) + + (hasMissingRLS ? 1 : 0) return ( - { + if (!open) onCancel() }} - onCancel={onCancel} - onConfirm={onConfirm} > -
-
    - {hasDestructiveOperations && ( -
  • - Query has destructive operations - - Make sure you are not accidentally removing something important. - -
  • + + + + {`Potential issue${issueCount > 1 ? 's' : ''} detected with your query`} + + + Review the warnings below before running this query. + + + + 1 + ? 'The following potential issues have been detected:' + : 'The following potential issue has been detected:' + } + description="Ensure that these are intentional before executing this query" + className="border-x-0 rounded-none -mt-px" + /> + + +
    +
      + {hasDestructiveOperations && ( +
    • + Query has destructive operations + + Make sure you are not accidentally removing something important. + +
    • + )} + {hasUpdateWithoutWhere && ( +
    • + Query uses update without a where clause + + Without a where clause, this could + update all rows in the table. + +
    • + )} + {hasAlterDatabasePreventConnection && ( +
    • + Query will prevent connections to your database + + The dashboard will no longer have access to your database, and you will need a + direct connection to your database to reconfigure this setting + +
    • + )} + {hasMissingRLS && ( +
    • + + {missingRLSTables.length === 1 + ? 'New table will not have Row Level Security enabled' + : 'New tables will not have Row Level Security enabled'} + + + Without RLS, any client using your project's anon or authenticated keys can read + and write to{' '} + {missingRLSTables.length === 1 ? ( + + {missingRLSTables[0].schema + ? `${missingRLSTables[0].schema}.${missingRLSTables[0].tableName}` + : missingRLSTables[0].tableName} + + ) : ( + 'these tables' + )} + . Enable RLS and add policies before exposing this table via the API.{' '} + + Learn more + + . + +
    • + )} +
    +
    +

    + Please confirm that you would like to execute this query. +

    +
    + + + +
    + + + {hasMissingRLS && onConfirmWithRLS && ( + )} - {hasUpdateWithoutWhere && ( -
  • - Query uses update without a where clause - - Without a where clause, this could update - all rows in the table. - -
  • - )} - {hasAlterDatabasePreventConnection && ( -
  • - Query will prevent connections to your database - - The dashboard will no longer have access to your database, and you will need a - direct connection to your database to reconfigure this setting - -
  • - )} -
-
-

- Please confirm that you would like to execute this query. -

-
+ + + ) } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 6e9b3477947..f3c2f1528b2 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -38,10 +38,14 @@ import { type PotentialIssues, } from './SQLEditor.types' import { + appendEnableRLSStatements, checkAlterDatabaseConnection, checkDestructiveQuery, checkIfAppendLimitRequired, createSqlSnippetSkeletonV2, + filterTablesCoveredByEnsureRLSTrigger, + getCreateTablesMissingRLS, + hasActiveEnsureRLSTrigger, isUpdateWithoutWhere, suffixWithLimit, } from './SQLEditor.utils' @@ -56,6 +60,7 @@ import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/L import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget' import { GridFooter } from '@/components/ui/GridFooter' import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation' +import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query' import { constructHeaders, isValidConnString } from '@/data/fetchers' import { lintKeys } from '@/data/lint/keys' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' @@ -177,6 +182,14 @@ export const SQLEditor = () => { { enabled: isValidConnString(project?.connectionString) } ) + const { data: eventTriggers } = useDatabaseEventTriggersQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { enabled: isValidConnString(project?.connectionString) } + ) + /* React query mutations */ const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation() const { mutate: sendEvent } = useSendEventMutation() @@ -298,7 +311,7 @@ export const SQLEditor = () => { }, [id, isDiffOpen, project, snapV2]) const executeQuery = useCallback( - async (force: boolean = false) => { + async (force: boolean = false, sqlOverride?: string) => { if (isDiffOpen) { clearPendingRunRefocus() return @@ -317,23 +330,32 @@ export const SQLEditor = () => { const selection = editor.getSelection() const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined - const sql = snippet + const editorSql = snippet ? ((selectedValue || editorRef.current?.getValue()) ?? snippet.snippet.content?.sql) : selectedValue || editorRef.current?.getValue() + const sql = sqlOverride ?? editorSql const hasDestructiveOperations = checkDestructiveQuery(sql) const hasUpdateWithoutWhere = isUpdateWithoutWhere(sql) const hasAlterDatabasePreventConnection = checkAlterDatabaseConnection(sql) + const createTablesMissingRLS = filterTablesCoveredByEnsureRLSTrigger( + getCreateTablesMissingRLS(sql), + hasActiveEnsureRLSTrigger(eventTriggers) + ) const queryHasIssues = !force && - (hasDestructiveOperations || hasUpdateWithoutWhere || hasAlterDatabasePreventConnection) + (hasDestructiveOperations || + hasUpdateWithoutWhere || + hasAlterDatabasePreventConnection || + createTablesMissingRLS.length > 0) if (queryHasIssues) { setPotentialIssues({ hasDestructiveOperations, hasUpdateWithoutWhere, hasAlterDatabasePreventConnection, + createTablesMissingRLS, }) return } @@ -395,6 +417,7 @@ export const SQLEditor = () => { setAiTitle, databaseSelectorState.selectedDatabaseId, databases, + eventTriggers, limit, ] ) @@ -816,6 +839,21 @@ export const SQLEditor = () => { refocusEditor() void executeQuery(true) }} + onConfirmWithRLS={() => { + const tables = potentialIssues?.createTablesMissingRLS ?? [] + if (tables.length === 0) return + const editor = editorRef.current + const selection = editor?.getSelection() + const selectedValue = selection + ? editor?.getModel()?.getValueInRange(selection) + : undefined + const baseSql = selectedValue || editor?.getValue() || '' + const rewrittenSql = appendEnableRLSStatements(baseSql, tables) + shouldRefocusAfterRunRef.current = true + setPotentialIssues(undefined) + refocusEditor() + void executeQuery(true, rewrittenSql) + }} />
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts index 092bd1a6422..185196f6fcc 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts @@ -35,4 +35,5 @@ export type PotentialIssues = { hasDestructiveOperations?: boolean hasUpdateWithoutWhere?: boolean hasAlterDatabasePreventConnection?: boolean + createTablesMissingRLS?: { schema?: string; tableName: string }[] } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index e6a9a109571..70be6d89c71 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -2,12 +2,30 @@ import { stripIndent } from 'common-tags' import { describe, expect, it, test } from 'vitest' import { + appendEnableRLSStatements, checkAlterDatabaseConnection, checkDestructiveQuery, checkIfAppendLimitRequired, + filterTablesCoveredByEnsureRLSTrigger, + getCreateTablesMissingRLS, + hasActiveEnsureRLSTrigger, isUpdateWithoutWhere, suffixWithLimit, } from './SQLEditor.utils' +import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' + +const buildTrigger = (overrides: Partial = {}): DatabaseEventTrigger => ({ + oid: 1, + name: 'ensure_rls', + event: 'ddl_command_end', + enabled_mode: 'ORIGIN', + tags: ['CREATE TABLE'], + function_name: 'rls_auto_enable', + function_schema: 'public', + owner: 'postgres', + function_definition: null, + ...overrides, +}) describe('SQLEditor.utils.ts:checkIfAppendLimitRequired', () => { test('Should return false if limit passed is <= 0', () => { @@ -263,6 +281,240 @@ describe('SQLEditor.utils:updateWithoutWhere', () => { }) }) +describe('SQLEditor.utils:getCreateTablesMissingRLS', () => { + it('flags a basic CREATE TABLE without RLS', () => { + const result = getCreateTablesMissingRLS('create table foo (id int8 primary key);') + expect(result).toEqual([{ schema: undefined, tableName: 'foo' }]) + }) + + it('flags CREATE TABLE IF NOT EXISTS', () => { + const result = getCreateTablesMissingRLS( + 'create table if not exists foo (id int8 primary key);' + ) + expect(result).toHaveLength(1) + expect(result[0].tableName).toBe('foo') + }) + + it('flags schema-qualified CREATE TABLE', () => { + const result = getCreateTablesMissingRLS('create table public.foo (id int8 primary key);') + expect(result).toEqual([{ schema: 'public', tableName: 'foo' }]) + }) + + it('flags quoted identifiers', () => { + const result = getCreateTablesMissingRLS( + 'create table "public"."user_table" (id int8 primary key);' + ) + expect(result).toEqual([{ schema: 'public', tableName: 'user_table' }]) + }) + + it('flags quoted identifiers containing spaces', () => { + const result = getCreateTablesMissingRLS( + 'create table "public"."My Table" (id int8 primary key);' + ) + expect(result).toEqual([{ schema: 'public', tableName: 'My Table' }]) + }) + + it('matches RLS to a table whose name contains spaces', () => { + const sql = stripIndent` + create table "My Table" (id int8 primary key); + alter table "My Table" enable row level security; + ` + expect(getCreateTablesMissingRLS(sql)).toEqual([]) + }) + + it('does not flag when ENABLE ROW LEVEL SECURITY is in the same SQL', () => { + const sql = stripIndent` + create table foo (id int8 primary key); + alter table foo enable row level security; + ` + expect(getCreateTablesMissingRLS(sql)).toEqual([]) + }) + + it('does not flag when ENABLE RLS shorthand is in the same SQL', () => { + const sql = stripIndent` + create table foo (id int8 primary key); + alter table foo enable rls; + ` + expect(getCreateTablesMissingRLS(sql)).toEqual([]) + }) + + it('matches RLS to the right table when multiple tables created', () => { + const sql = stripIndent` + create table foo (id int8 primary key); + create table bar (id int8 primary key); + alter table foo enable row level security; + ` + const result = getCreateTablesMissingRLS(sql) + expect(result).toHaveLength(1) + expect(result[0].tableName).toBe('bar') + }) + + it('does not flag when CREATE TABLE is inside a comment', () => { + const sql = stripIndent` + -- create table foo (id int8 primary key); + select 1; + ` + expect(getCreateTablesMissingRLS(sql)).toEqual([]) + }) + + it('does not flag when there is no CREATE TABLE at all', () => { + expect(getCreateTablesMissingRLS('select * from foo;')).toEqual([]) + }) + + it('schema-qualified RLS matches schema-qualified CREATE', () => { + const sql = stripIndent` + create table public.foo (id int8 primary key); + alter table public.foo enable row level security; + ` + expect(getCreateTablesMissingRLS(sql)).toEqual([]) + }) + + it('flags CREATE TEMP TABLE', () => { + const result = getCreateTablesMissingRLS('create temp table foo (id int8 primary key);') + expect(result).toHaveLength(1) + expect(result[0].tableName).toBe('foo') + }) + + it('does not collide quoted identifiers that differ only by case', () => { + // "MyTable" and "mytable" are distinct tables in Postgres, so the ALTER + // here targets a different table than the CREATE — the warning must fire. + const sql = stripIndent` + create table "MyTable" (id int8 primary key); + alter table "mytable" enable row level security; + ` + const result = getCreateTablesMissingRLS(sql) + expect(result).toHaveLength(1) + expect(result[0].tableName).toBe('MyTable') + }) +}) + +describe('SQLEditor.utils:appendEnableRLSStatements', () => { + it('appends a single ALTER TABLE ENABLE RLS statement', () => { + const result = appendEnableRLSStatements('create table foo (id int8 primary key);', [ + { tableName: 'foo' }, + ]) + expect(result).toContain('ALTER TABLE foo ENABLE ROW LEVEL SECURITY;') + }) + + it('appends one ALTER per table', () => { + const result = appendEnableRLSStatements( + 'create table foo (id int8); create table bar (id int8);', + [{ tableName: 'foo' }, { tableName: 'bar' }] + ) + expect(result).toContain('ALTER TABLE foo ENABLE ROW LEVEL SECURITY;') + expect(result).toContain('ALTER TABLE bar ENABLE ROW LEVEL SECURITY;') + }) + + it('schema-qualifies the table when schema is provided', () => { + const result = appendEnableRLSStatements('create table public.foo (id int8);', [ + { schema: 'public', tableName: 'foo' }, + ]) + expect(result).toContain('ALTER TABLE public.foo ENABLE ROW LEVEL SECURITY;') + }) + + it('quotes identifiers that are not simple', () => { + const result = appendEnableRLSStatements('create table "My Table" (id int8);', [ + { tableName: 'My Table' }, + ]) + expect(result).toContain('ALTER TABLE "My Table" ENABLE ROW LEVEL SECURITY;') + }) + + it('quotes mixed-case identifiers so Postgres does not fold them to lowercase', () => { + const result = appendEnableRLSStatements('create table "MyTable" (id int8);', [ + { tableName: 'MyTable' }, + ]) + expect(result).toContain('ALTER TABLE "MyTable" ENABLE ROW LEVEL SECURITY;') + }) + + it('quotes mixed-case schema and table identifiers', () => { + const result = appendEnableRLSStatements('create table "MySchema"."MyTable" (id int8);', [ + { schema: 'MySchema', tableName: 'MyTable' }, + ]) + expect(result).toContain('ALTER TABLE "MySchema"."MyTable" ENABLE ROW LEVEL SECURITY;') + }) + + it('returns the original SQL unchanged when there are no tables', () => { + const sql = 'select 1;' + expect(appendEnableRLSStatements(sql, [])).toBe(sql) + }) + + it('puts the terminator on its own line when SQL ends with a line comment', () => { + // Without this, the appended ';' would be swallowed by the line comment and + // the following ALTER TABLE would be parsed as part of the CREATE TABLE. + const sql = stripIndent` + create table foo (id int) + -- forgot the semicolon + ` + const result = appendEnableRLSStatements(sql, [{ tableName: 'foo' }]) + expect(result).toMatch(/-- forgot the semicolon\n;\n/) + expect(result).toContain('ALTER TABLE foo ENABLE ROW LEVEL SECURITY;') + }) +}) + +describe('SQLEditor.utils:hasActiveEnsureRLSTrigger', () => { + it('returns false for undefined triggers', () => { + expect(hasActiveEnsureRLSTrigger(undefined)).toBe(false) + }) + + it('returns false for an empty list', () => { + expect(hasActiveEnsureRLSTrigger([])).toBe(false) + }) + + it('returns true when a trigger named "ensure_rls" is active', () => { + expect(hasActiveEnsureRLSTrigger([buildTrigger()])).toBe(true) + }) + + it('returns true when a trigger uses the rls_auto_enable function (renamed trigger)', () => { + expect( + hasActiveEnsureRLSTrigger([ + buildTrigger({ name: 'something_else', function_name: 'rls_auto_enable' }), + ]) + ).toBe(true) + }) + + it('returns false when the matching trigger is DISABLED', () => { + expect(hasActiveEnsureRLSTrigger([buildTrigger({ enabled_mode: 'DISABLED' })])).toBe(false) + }) + + it('ignores unrelated triggers', () => { + expect( + hasActiveEnsureRLSTrigger([buildTrigger({ name: 'audit_log', function_name: 'log_changes' })]) + ).toBe(false) + }) +}) + +describe('SQLEditor.utils:filterTablesCoveredByEnsureRLSTrigger', () => { + it('returns the input unchanged when the trigger is not present', () => { + const tables = [{ tableName: 'foo' }, { schema: 'private', tableName: 'bar' }] + expect(filterTablesCoveredByEnsureRLSTrigger(tables, false)).toEqual(tables) + }) + + it('drops public-schema tables when the trigger is present', () => { + const tables = [ + { schema: 'public', tableName: 'foo' }, + { tableName: 'bar' }, // no schema → defaults to public + ] + expect(filterTablesCoveredByEnsureRLSTrigger(tables, true)).toEqual([]) + }) + + it('keeps tables in non-public schemas when the trigger is present', () => { + const tables = [ + { schema: 'public', tableName: 'foo' }, + { schema: 'private', tableName: 'bar' }, + { schema: 'app', tableName: 'baz' }, + ] + expect(filterTablesCoveredByEnsureRLSTrigger(tables, true)).toEqual([ + { schema: 'private', tableName: 'bar' }, + { schema: 'app', tableName: 'baz' }, + ]) + }) + + it('matches the public schema case-insensitively', () => { + const tables = [{ schema: 'PUBLIC', tableName: 'foo' }] + expect(filterTablesCoveredByEnsureRLSTrigger(tables, true)).toEqual([]) + }) +}) + describe('SQLEditor.utils:checkAlterDatabaseConnection', () => { it('detects connection limit 0', () => { const match = checkAlterDatabaseConnection('alter database postgres connection limit 0;') diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index b638d619a1e..2c022ada17b 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -1,3 +1,5 @@ +import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants' + import { alterDatabasePreventConnectionStatements, destructiveSqlRegex, @@ -6,10 +8,44 @@ import { updateWithoutWhereRegex, } from './SQLEditor.constants' import { ContentDiff } from './SQLEditor.types' +import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' import { generateUuid } from '@/lib/api/snippets.browser' import { removeCommentsFromSql } from '@/lib/helpers' +import { sqlEventParser } from '@/lib/sql-event-parser' import type { SnippetWithContent } from '@/state/sql-editor-v2' +export type CreateTableWithoutRLS = { + schema?: string + tableName: string +} + +// The ensure_rls event trigger only auto-enables RLS on tables in the public +// schema (see AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL). +const ENSURE_RLS_TRIGGER_SCHEMAS = new Set(['public']) + +export function hasActiveEnsureRLSTrigger(triggers: DatabaseEventTrigger[] | undefined) { + return ( + triggers?.some( + (t) => + (t.name === 'ensure_rls' || t.function_name === 'rls_auto_enable') && + t.enabled_mode !== 'DISABLED' + ) ?? false + ) +} + +/** + * Filters out CREATE TABLE entries that will be covered by the project's + * ensure_rls event trigger (which only handles tables in the public schema). + * Tables in any other schema are returned unchanged so the user is still warned. + */ +export function filterTablesCoveredByEnsureRLSTrigger( + tables: CreateTableWithoutRLS[], + hasTrigger: boolean +): CreateTableWithoutRLS[] { + if (!hasTrigger) return tables + return tables.filter((t) => !ENSURE_RLS_TRIGGER_SCHEMAS.has((t.schema ?? 'public').toLowerCase())) +} + export const createSqlSnippetSkeletonV2 = ({ name, sql, @@ -65,6 +101,62 @@ export function isUpdateWithoutWhere(sql: string): boolean { ) } +/** + * Returns CREATE TABLE statements in `sql` that do not have a matching + * ALTER TABLE ... ENABLE ROW LEVEL SECURITY in the same SQL submission. + * + * Operates on the SQL passed in (which is the user's selection if any, or the + * full editor contents otherwise) so partial-execution selects work naturally. + */ +export function getCreateTablesMissingRLS(sql: string): CreateTableWithoutRLS[] { + const events = sqlEventParser.getTableEvents(sql) + + // Match case-sensitively. Lowercasing would let quoted identifiers like + // "MyTable" and "mytable" — which are different tables in Postgres — collide + // and silently suppress the warning. The trade-off is rare false positives + // when users mix case for *unquoted* identifiers (Postgres would have folded + // them anyway), which is annoying but safe. + const key = (e: { schema?: string; tableName?: string }) => `${e.schema ?? ''}.${e.tableName}` + + const rlsEnabled = new Set( + events.filter((e) => e.type === TABLE_EVENT_ACTIONS.TableRLSEnabled && e.tableName).map(key) + ) + + return events + .filter((e) => e.type === TABLE_EVENT_ACTIONS.TableCreated && e.tableName) + .filter((e) => !rlsEnabled.has(key(e))) + .map((e) => ({ schema: e.schema, tableName: e.tableName as string })) +} + +/** + * Appends `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` statements to `sql` + * for each provided table. + */ +export function appendEnableRLSStatements(sql: string, tables: CreateTableWithoutRLS[]) { + if (tables.length === 0) return sql + + // Postgres folds unquoted identifiers to lowercase, so any identifier that + // isn't strictly lowercase-safe (e.g. "MyTable", "user table") must be quoted + // to refer back to the original table. + const quote = (identifier: string) => + /^[a-z_][a-z0-9_]*$/.test(identifier) ? identifier : `"${identifier.replace(/"/g, '""')}"` + + const additions = tables + .map(({ schema, tableName }) => { + const target = schema ? `${quote(schema)}.${quote(tableName)}` : quote(tableName) + return `ALTER TABLE ${target} ENABLE ROW LEVEL SECURITY;` + }) + .join('\n') + + const trimmed = sql.replace(/\s+$/, '') + // If the SQL ends with a line comment, the appended ';' would be swallowed, + // so put the terminator on its own line. + const endsWithLineComment = /--[^\r\n]*$/.test(trimmed) + const separator = trimmed.endsWith(';') ? '\n\n' : endsWithLineComment ? '\n;\n\n' : ';\n\n' + + return `${trimmed}${separator}-- Added by Supabase: enable Row Level Security on newly created tables\n${additions}\n` +} + export function checkAlterDatabaseConnection(sql: string): boolean { const cleanedSql = removeCommentsFromSql(sql) const statements = cleanedSql diff --git a/apps/studio/lib/sql-event-parser.ts b/apps/studio/lib/sql-event-parser.ts index dece7fd5c85..7b098d3ff8b 100644 --- a/apps/studio/lib/sql-event-parser.ts +++ b/apps/studio/lib/sql-event-parser.ts @@ -22,25 +22,25 @@ export class SQLEventParser { { type: TABLE_EVENT_ACTIONS.TableCreated, patterns: [ - /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, - /CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, - /CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, - /SELECT\s+.*?\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/is, - /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)\s+AS\s+SELECT/i, + /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i, + /CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i, + /CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i, + /SELECT\s+.*?\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/is, + /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))\s+AS\s+SELECT/i, ], }, { type: TABLE_EVENT_ACTIONS.TableDataAdded, patterns: [ - /INSERT\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, - /COPY\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)\s+FROM/i, + /INSERT\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i, + /COPY\s+(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))\s+FROM/i, ], }, { type: TABLE_EVENT_ACTIONS.TableRLSEnabled, patterns: [ - /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i, - /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+).*?ENABLE\s+RLS/i, + /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+)).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i, + /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+)).*?ENABLE\s+RLS/i, ], }, ] diff --git a/e2e/studio/features/sql-editor.spec.ts b/e2e/studio/features/sql-editor.spec.ts index 6ab9b744d37..e537f65c157 100644 --- a/e2e/studio/features/sql-editor.spec.ts +++ b/e2e/studio/features/sql-editor.spec.ts @@ -3,6 +3,7 @@ import { expect, Page } from '@playwright/test' import { env } from '../env.config.js' import { expectClipboardValue } from '../utils/clipboard.js' +import { dropTable, query } from '../utils/db/index.js' import { isCLI } from '../utils/is-cli.js' import { resetLocalStorage } from '../utils/reset-local-storage.js' import { test } from '../utils/test.js' @@ -289,6 +290,59 @@ test.describe('SQL Editor', () => { } }) + test('warns on CREATE TABLE without RLS and "Run and enable RLS" enables it', async ({ ref }) => { + const tableName = 'pw_rls_smoke_test' + + // Drop any leftover table from a previous failed run, and ensure cleanup + // after the test regardless of pass/fail. + await dropTable(tableName) + + try { + await expect(page.getByText('Loading...')).not.toBeVisible() + await page.locator('.view-lines').click() + await page.keyboard.press('ControlOrMeta+KeyA') + await page.keyboard.type(`create table ${tableName} (id int8 primary key);`) + + await page.getByTestId('sql-run-button').click() + + // Modal appears with the RLS warning + await expect( + page.getByRole('heading', { name: 'Potential issue detected with' }), + 'Warning modal should appear when CREATE TABLE has no RLS' + ).toBeVisible() + await expect( + page.getByText('Row Level Security'), + 'Modal should mention Row Level Security' + ).toBeVisible() + + // Click "Run and enable RLS" — query runs with appended ALTER + const sqlMutationPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { + method: 'POST', + }) + await page.getByRole('button', { name: 'Run and enable RLS' }).click() + await sqlMutationPromise + + // Verify the table was created with RLS enabled + const rows = await query<{ relrowsecurity: boolean }>( + `select c.relrowsecurity + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' and c.relname = $1`, + [tableName] + ) + expect(rows[0]?.relrowsecurity, 'Table should exist and have RLS enabled').toBe(true) + } finally { + await dropTable(tableName) + + // clear SQL snippet + if (!isCLI()) { + await deleteSqlSnippet(page, ref, newSqlSnippetName) + } else { + await page.reload() + } + } + }) + test('should not show warning modal for safe alter database statement', async ({ ref }) => { await expect(page.getByText('Loading...')).not.toBeVisible() await page.locator('.view-lines').click() From a5f4a59e0e262394b345600e8d8a2241d6ac3b64 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Sat, 18 Apr 2026 17:56:08 +0800 Subject: [PATCH 18/51] fix(studio): detect update-without-where on quoted table names (#45009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous \`updateWithoutWhereRegex\` only matched bareword table identifiers (\`messages\`, \`public.messages\`) or a fully qualified \`"schema"."table"\` pair, so statements like \`UPDATE "messages" SET id = 1\` skipped the pre-execution warning entirely. **Changed:** - Broaden each identifier slot in \`updateWithoutWhereRegex\` to accept either a bareword or a double-quoted identifier independently — covers \`"messages"\`, \`"public".messages\`, \`public."messages"\`, \`"my table"\`, and \`"weird""name"\` (escaped quote). **Added:** - 6 unit tests covering single quoted, mixed quoted/bareword, spaces in identifiers, and escaped quotes — both with and without \`WHERE\`. ## To test - Run \`pnpm --filter studio test -- SQLEditor.utils.test.ts\` — should pass 79 tests - In the SQL editor, run \`UPDATE "messages" SET id = 1\` — warning modal should now appear - Same statement with \`WHERE id = 2\` appended — no warning ## Summary by CodeRabbit * **Bug Fixes** * Improved SQL UPDATE detection in the SQL Editor to handle double-quoted identifiers, schema-qualified names, names with spaces, and escaped quotes. * Prevented false positives by ignoring quoted string and identifier contents when checking for a WHERE clause. * **Tests** * Added comprehensive tests covering varied quoting/qualification scenarios and quoted-content edge cases. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../SQLEditor/SQLEditor.constants.ts | 6 ++- .../SQLEditor/SQLEditor.utils.test.ts | 45 +++++++++++++++++++ .../interfaces/SQLEditor/SQLEditor.utils.ts | 11 ++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts index 3a89760fe14..3671b1123bd 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts @@ -40,8 +40,12 @@ export const destructiveSqlRegex = [ /^(.*;)?\s*(drop|delete|truncate|alter\s+table\s+.*\s+drop\s+column)\s/is, ] +// Matches `UPDATE
SET ...` where
is any combination of bareword +// or double-quoted identifiers, optionally schema-qualified. Quoted identifiers +// can contain any character (including spaces) and use `""` to escape an inner +// quote, mirroring Postgres syntax. export const updateWithoutWhereRegex = - /(?:^|;)\s*update\s+(?:"[\w.]+"\."[\w.]+"|[\w.]+)\s+set\s+[\w\W]+?(?!\s*where\s)/is + /(?:^|;)\s*update\s+(?:"(?:[^"]|"")+"|[\w]+)(?:\.(?:"(?:[^"]|"")+"|[\w]+))?\s+set\s+[\w\W]+?(?!\s*where\s)/is export const alterDatabasePreventConnectionStatements = [ 'alter database postgres connection limit 0', diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index 70be6d89c71..0541700b8d1 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -244,6 +244,51 @@ describe('SQLEditor.utils:updateWithoutWhere', () => { expect(match).toBe(true) }) + it('catches update on a single quoted table name without a where clause', () => { + const match = isUpdateWithoutWhere(`UPDATE "messages" SET id = 1;`) + expect(match).toBe(true) + }) + + it('does not flag update on a single quoted table name with a where clause', () => { + const match = isUpdateWithoutWhere(`UPDATE "messages" SET id = 1 WHERE id = 2;`) + expect(match).toBe(false) + }) + + it('catches update on a quoted schema with a bareword table without a where clause', () => { + const match = isUpdateWithoutWhere(`UPDATE "public".messages SET id = 1;`) + expect(match).toBe(true) + }) + + it('catches update on a bareword schema with a quoted table without a where clause', () => { + const match = isUpdateWithoutWhere(`UPDATE public."messages" SET id = 1;`) + expect(match).toBe(true) + }) + + it('catches update on a quoted table name containing a space without a where clause', () => { + const match = isUpdateWithoutWhere(`UPDATE "my table" SET id = 1;`) + expect(match).toBe(true) + }) + + it('catches update on a quoted table name containing escaped quotes without a where clause', () => { + const match = isUpdateWithoutWhere(`UPDATE "weird""name" SET id = 1;`) + expect(match).toBe(true) + }) + + it('catches update where a quoted identifier contains the word where', () => { + const match = isUpdateWithoutWhere(`UPDATE "where table" SET id = 1;`) + expect(match).toBe(true) + }) + + it('catches update where a string literal contains the word where', () => { + const match = isUpdateWithoutWhere(`UPDATE messages SET name = 'where x';`) + expect(match).toBe(true) + }) + + it('does not flag update where the only "where" sits inside a string literal but a real where clause exists', () => { + const match = isUpdateWithoutWhere(`UPDATE messages SET name = 'where x' WHERE id = 1;`) + expect(match).toBe(false) + }) + it('contains both an update query and a delete query, triggers destructive', () => { const match = checkDestructiveQuery(stripIndent` delete from countries; update countries set name = 'hello'; diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index 2c022ada17b..c853da6e533 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -91,13 +91,22 @@ export function checkDestructiveQuery(sql: string) { return destructiveSqlRegex.some((regex) => regex.test(cleanedSql)) } +// Replace the contents of single-quoted string literals and double-quoted +// identifiers with empty quotes, so a downstream `where` scan can't be fooled +// by tokens like `UPDATE "where table" SET ...` or `SET name = 'where x'`. +// Postgres uses doubled quotes to escape, so `''` and `""` are matched as +// part of the same span rather than terminating it. +const stripQuotedSpans = (sql: string) => + sql.replace(/'(?:''|[^'])*'/g, "''").replace(/"(?:""|[^"])*"/g, '""') + // Function to check for UPDATE queries without WHERE clause export function isUpdateWithoutWhere(sql: string): boolean { const updateStatements = sql .split(';') .filter((statement) => statement.trim().toLowerCase().startsWith('update')) return updateStatements.some( - (statement) => updateWithoutWhereRegex.test(statement) && !/where\s/i.test(statement) + (statement) => + updateWithoutWhereRegex.test(statement) && !/where\s/i.test(stripQuotedSpans(statement)) ) } From b721a2d78080f52d473558921bb80db7cc6b1e15 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:33:56 +1000 Subject: [PATCH 19/51] feat(studio): advisor signal items for banned IPs (#44372) ## What kind of change does this PR introduce? Feature. Resolves DEPR-430. ## What is the current behaviour? The homepage Advisor summary, shared Advisor panel, and top-nav Advisor indicator only surface lints and notifications. Banned IPs are not represented as dismissible Advisor items, so network bans are easy to miss unless a user visits Database Settings directly. The `public bucket allows listing` warning is no longer part of this PR. That warning will move to a follow-up Splinter `WARN` lint so it can flow through the standard lint surfaces instead of a bespoke Studio signal path. ## What is the new behaviour? - adds a new Advisor `signal` source for banned IPs on the platform homepage, in the shared Advisor panel, and in the top-nav Advisor indicator - keeps dismissals client-side only for now, scoped by project and exact IP fingerprint - keeps banned IP signals at `warning` severity because they still indicate suspicious traffic and remain actionable if a user wants to review or remove a ban - leaves `/project/[ref]/advisors/security` as follow-up work because that surface is still lint-native, and banned IPs are management-plane signals rather than Splinter lints | After | | --- | | Mallet Toolshed
Supabase-65A60B4A-107E-4D79-B9A8-23F754BEAB08 | ## Reviewer testing notes 1. Use a throwaway project. 2. Get the database connection string for that project. 3. Attempt to connect with the wrong password 3-4 times until you hit an `ECONNREFUSED`-style error, which should mean your IP has been banned. 4. Refresh Studio and confirm the project overview shows the new `Banned IP address` signal. 5. Open the Advisor Center and confirm: - the top-nav Advisor dot turns warning yellow - the signal detail shows `Entity`, `Issue`, and `Resolve` - `Edit network bans`, `Dismiss`, and `Learn more` are present 6. Open Database Settings > Network bans and confirm your banned IP appears there and can be unbanned. 7. Note that `/project/[ref]/advisors/security` will not show this item. That page is still lint-only, and this banned IP work is a short-term client-side signal rather than a true lint. Longer term, we likely want a more durable event model here so banned IPs can power notifications, webhooks, emails, and other project-level alerts. --------- Co-authored-by: kemal Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Joshen Lim --- .../interfaces/ProjectHome/AdvisorSection.tsx | 213 ++++++++----- .../layouts/AppLayout/AdvisorButton.test.tsx | 163 ++++++++++ .../layouts/AppLayout/AdvisorButton.tsx | 8 + .../ui/AdvisorPanel/AdvisorDetail.tsx | 13 +- .../ui/AdvisorPanel/AdvisorPanel.tsx | 140 +++------ .../ui/AdvisorPanel/AdvisorPanel.types.ts | 39 ++- .../AdvisorPanel/AdvisorPanel.utils.test.ts | 74 +++++ .../ui/AdvisorPanel/AdvisorPanel.utils.ts | 130 +++++++- .../ui/AdvisorPanel/AdvisorPanelBody.tsx | 49 ++- .../ui/AdvisorPanel/AdvisorPanelHeader.tsx | 26 +- .../ui/AdvisorPanel/AdvisorSignalDetail.tsx | 107 +++++++ .../AdvisorSignals.integration.test.tsx | 280 ++++++++++++++++++ .../ui/AdvisorPanel/EmptyAdvisor.tsx | 10 +- .../AdvisorPanel/useAdvisorSignals.test.tsx | 79 +++++ .../ui/AdvisorPanel/useAdvisorSignals.ts | 100 +++++++ .../data/banned-ips/banned-ips-query.ts | 15 +- .../__tests__/useLocalStorageQuery.test.ts | 113 +++++++ .../useLongRunningTransitionState.test.ts | 2 +- .../useTableEditorFiltersSort.test.ts | 2 +- apps/studio/hooks/misc/useLocalStorage.ts | 29 +- apps/studio/state/advisor-state.ts | 2 +- packages/common/telemetry-constants.ts | 5 +- 22 files changed, 1363 insertions(+), 236 deletions(-) create mode 100644 apps/studio/components/layouts/AppLayout/AdvisorButton.test.tsx create mode 100644 apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts create mode 100644 apps/studio/components/ui/AdvisorPanel/AdvisorSignalDetail.tsx create mode 100644 apps/studio/components/ui/AdvisorPanel/AdvisorSignals.integration.test.tsx create mode 100644 apps/studio/components/ui/AdvisorPanel/useAdvisorSignals.test.tsx create mode 100644 apps/studio/components/ui/AdvisorPanel/useAdvisorSignals.ts create mode 100644 apps/studio/hooks/misc/__tests__/useLocalStorageQuery.test.ts rename apps/studio/hooks/misc/{ => __tests__}/useLongRunningTransitionState.test.ts (94%) rename apps/studio/hooks/misc/{ => __tests__}/useTableEditorFiltersSort.test.ts (96%) diff --git a/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx b/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx index 9201ae2a27d..2875d292ac5 100644 --- a/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { BarChart, Shield } from 'lucide-react' import { useCallback, useMemo } from 'react' -import { AiIconAnimation, Button, Card, CardContent, CardHeader, CardTitle } from 'ui' +import { AiIconAnimation, Badge, Button, Card, CardContent, CardHeader, CardTitle, cn } from 'ui' import { Row } from 'ui-patterns' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -9,8 +9,18 @@ import { Markdown } from '../Markdown' import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants' import { createLintSummaryPrompt } from '@/components/interfaces/Linter/Linter.utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import type { AdvisorItem } from '@/components/ui/AdvisorPanel/AdvisorPanel.types' +import { + createAdvisorLintItems, + getAdvisorItemDisplayTitle, + MAX_HOMEPAGE_ADVISOR_ITEMS, + severityBadgeVariants, + severityColorClasses, + sortAdvisorItems, +} from '@/components/ui/AdvisorPanel/AdvisorPanel.utils' +import { useAdvisorSignals } from '@/components/ui/AdvisorPanel/useAdvisorSignals' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' -import { Lint, useProjectLintsQuery } from '@/data/lint/lint-query' +import { useProjectLintsQuery } from '@/data/lint/lint-query' import { useTrack } from '@/lib/telemetry/track' import { useAdvisorStateSnapshot } from '@/state/advisor-state' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' @@ -18,54 +28,82 @@ import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: boolean }) => { const { ref: projectRef } = useParams() - const { data: lints, isPending: isLoadingLints } = useProjectLintsQuery( - { - projectRef, - }, - { - enabled: !showEmptyState, - } - ) const track = useTrack() const snap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() const { setSelectedItem } = useAdvisorStateSnapshot() - const errorLints: Lint[] = useMemo(() => { - return lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ?? [] - }, [lints]) + const { data: lints, isLoading: isLoadingLints } = useProjectLintsQuery( + { projectRef }, + { enabled: !showEmptyState } + ) - const totalErrors = errorLints.length + const { data: signalItems } = useAdvisorSignals({ projectRef, enabled: !showEmptyState }) + + const advisorItems = useMemo(() => { + const criticalLintItems = createAdvisorLintItems(lints).filter( + (item) => item.source === 'lint' && item.original.level === LINTER_LEVELS.ERROR + ) + + return sortAdvisorItems([...criticalLintItems, ...signalItems]) + }, [lints, signalItems]) + + const visibleAdvisorItems = useMemo( + () => advisorItems.slice(0, MAX_HOMEPAGE_ADVISOR_ITEMS), + [advisorItems] + ) + + const totalIssues = advisorItems.length + const hiddenIssuesCount = totalIssues - visibleAdvisorItems.length const titleContent = useMemo(() => { - if (totalErrors === 0) return

Advisor found no issues

- const issuesText = totalErrors === 1 ? 'issue' : 'issues' - const numberDisplay = totalErrors.toString() + if (totalIssues === 0) return

Advisor found no issues

+ const issuesText = totalIssues === 1 ? 'issue' : 'issues' + const numberDisplay = totalIssues.toString() return (

Advisor found {numberDisplay} {issuesText}

) - }, [totalErrors]) + }, [totalIssues]) const handleAskAssistant = useCallback(() => { openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) track('advisor_assistant_button_clicked', { origin: 'homepage', - issuesCount: totalErrors, + issuesCount: totalIssues, }) - }, [track, openSidebar, totalErrors]) + }, [track, openSidebar, totalIssues]) const handleCardClick = useCallback( - (lint: Lint) => { - setSelectedItem(lint.cache_key, 'lint') + (item: AdvisorItem) => { + setSelectedItem(item.id, item.source) openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + + const advisorCategory = + item.source === 'lint' + ? item.original.categories.includes('SECURITY') + ? 'SECURITY' + : item.original.categories.includes('PERFORMANCE') + ? 'PERFORMANCE' + : undefined + : item.source === 'signal' + ? 'SECURITY' + : undefined + const advisorType = + item.source === 'signal' + ? item.type + : item.source === 'lint' + ? item.original.name + : item.title + const advisorLevel = item.source === 'lint' ? item.original.level : undefined + track('advisor_detail_opened', { origin: 'homepage', - advisorSource: 'lint', - advisorCategory: lint.categories[0], - advisorType: lint.name, - advisorLevel: lint.level, + advisorSource: item.source, + advisorCategory, + advisorType, + advisorLevel, }) }, [track, setSelectedItem, openSidebar] @@ -75,6 +113,9 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo return } + // [Joshen] Note that we're intentionally (for now) not waiting for advisor signals to load + // render main content as long as the main lints have been fetched + return (
{isLoadingLints ? ( @@ -87,73 +128,111 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
)} + {isLoadingLints ? ( -
+
- ) : errorLints.length > 0 ? ( + ) : visibleAdvisorItems.length > 0 ? ( <> - {errorLints.map((lint) => { + {visibleAdvisorItems.map((item) => { + const isLint = item.source === 'lint' + const categoryLabel = item.tab === 'performance' ? 'PERFORMANCE' : 'SECURITY' + const title = getAdvisorItemDisplayTitle(item) + const description = + item.source === 'signal' ? item.summary : isLint ? item.original.detail : '' + const cardClasses = + item.severity === 'critical' + ? 'border-destructive-400' + : item.severity === 'warning' + ? 'border-warning-400' + : '' + return ( { - handleCardClick(lint) + handleCardClick(item) }} >
- {lint.categories[0] === 'SECURITY' ? ( - + {item.tab === 'security' ? ( + ) : ( - + )} - {lint.categories[0]} + {categoryLabel}
-
{ - e.stopPropagation() - e.preventDefault() - }} - > - createLintSummaryPrompt(lint)} - onOpenAssistant={() => { - openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) - snap.newChat({ - name: 'Summarize lint', - initialInput: createLintSummaryPrompt(lint), - }) - track('advisor_assistant_button_clicked', { - origin: 'homepage', - advisorCategory: lint.categories[0], - advisorType: lint.name, - advisorLevel: lint.level, - }) - }} - telemetrySource="advisor_section" - type="text" - className="w-7 h-7" - /> +
+ + {item.severity.toUpperCase()} + + {isLint && ( +
{ + e.stopPropagation() + e.preventDefault() + }} + > + createLintSummaryPrompt(item.original)} + onOpenAssistant={() => { + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + snap.newChat({ + name: 'Summarise lint', + initialInput: createLintSummaryPrompt(item.original), + }) + track('advisor_assistant_button_clicked', { + origin: 'homepage', + advisorCategory: item.original.categories[0], + advisorType: item.original.name, + advisorLevel: item.original.level, + }) + }} + telemetrySource="advisor_section" + type="text" + className="w-7 h-7" + /> +
+ )}
-

{lint.title}

+

{title}

- {lint.detail && lint.detail.replace(/\\`/g, '`')} + {description && description.replace(/\\`/g, '`')}
) })} + {hiddenIssuesCount > 0 && ( +
+ +
+ )} ) : ( @@ -168,7 +247,7 @@ function EmptyState() {

- No security or performance errors found + No security or performance issues found

diff --git a/apps/studio/components/layouts/AppLayout/AdvisorButton.test.tsx b/apps/studio/components/layouts/AppLayout/AdvisorButton.test.tsx new file mode 100644 index 00000000000..9f722d69f86 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/AdvisorButton.test.tsx @@ -0,0 +1,163 @@ +import { screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { AdvisorButton } from '@/components/layouts/AppLayout/AdvisorButton' +import { render } from '@/tests/helpers' + +const { + mockUseProjectLintsQuery, + mockUseNotificationsV2Query, + mockUseAdvisorSignals, + mockToggleSidebar, +} = vi.hoisted(() => ({ + mockUseProjectLintsQuery: vi.fn(), + mockUseNotificationsV2Query: vi.fn(), + mockUseAdvisorSignals: vi.fn(), + mockToggleSidebar: vi.fn(), +})) + +vi.mock('@/data/lint/lint-query', () => ({ + useProjectLintsQuery: mockUseProjectLintsQuery, +})) + +vi.mock('@/data/notifications/notifications-v2-query', () => ({ + useNotificationsV2Query: mockUseNotificationsV2Query, +})) + +vi.mock('@/components/ui/AdvisorPanel/useAdvisorSignals', () => ({ + useAdvisorSignals: mockUseAdvisorSignals, +})) + +vi.mock('@/lib/constants', async (importOriginal) => ({ + ...(await importOriginal()), + IS_PLATFORM: true, +})) + +vi.mock('@/state/sidebar-manager-state', () => ({ + useSidebarManagerSnapshot: () => ({ + toggleSidebar: mockToggleSidebar, + activeSidebar: undefined, + }), +})) + +describe('AdvisorButton', () => { + beforeEach(() => { + mockUseProjectLintsQuery.mockReturnValue({ data: [], isPending: false, isError: false }) + mockUseNotificationsV2Query.mockReturnValue({ + data: { pages: [[]] }, + isPending: false, + isError: false, + }) + mockUseAdvisorSignals.mockReturnValue({ + data: [], + isPending: false, + isError: false, + dismissSignal: vi.fn(), + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('shows a warning dot when advisor signals are present', () => { + mockUseAdvisorSignals.mockReturnValue({ + data: [ + { + id: 'signal-1', + fingerprint: 'signal:banned-ip:203.0.113.10:v1', + source: 'signal', + signalType: 'banned-ip', + severity: 'warning', + tab: 'security', + title: 'Banned IP address', + description: 'Signal', + actions: [], + sourceData: { + type: 'banned-ip', + ip: '203.0.113.10', + }, + }, + ], + isPending: false, + isError: false, + dismissSignal: vi.fn(), + }) + + const { container } = render() + + expect(container.querySelector('.bg-warning')).toBeInTheDocument() + expect(container.querySelector('.bg-destructive')).not.toBeInTheDocument() + expect(container.querySelector('.bg-brand')).not.toBeInTheDocument() + }) + + it('keeps the destructive dot when a critical issue is present', () => { + mockUseProjectLintsQuery.mockReturnValue({ + data: [ + { + cache_key: 'lint-1', + name: 'unknown_lint', + detail: 'Critical lint detail', + description: 'Description', + level: 'ERROR', + categories: ['SECURITY'], + metadata: {}, + }, + ], + isPending: false, + isError: false, + }) + mockUseAdvisorSignals.mockReturnValue({ + data: [ + { + id: 'signal-1', + fingerprint: 'signal:banned-ip:203.0.113.10:v1', + source: 'signal', + signalType: 'banned-ip', + severity: 'warning', + tab: 'security', + title: 'Banned IP address', + description: 'Signal', + actions: [], + sourceData: { + type: 'banned-ip', + ip: '203.0.113.10', + }, + }, + ], + isPending: false, + isError: false, + dismissSignal: vi.fn(), + }) + + const { container } = render() + + expect(container.querySelector('.bg-destructive')).toBeInTheDocument() + expect(container.querySelector('.bg-warning')).not.toBeInTheDocument() + }) + + it('falls back to the brand dot for unread notifications when there are no issues', () => { + mockUseNotificationsV2Query.mockReturnValue({ + data: { + pages: [ + [ + { + id: 'notif-1', + status: 'new', + priority: 'Info', + }, + ], + ], + }, + isPending: false, + isError: false, + }) + + const { container } = render() + + expect(container.querySelector('.bg-brand')).toBeInTheDocument() + expect(container.querySelector('.bg-warning')).not.toBeInTheDocument() + expect(container.querySelector('.bg-destructive')).not.toBeInTheDocument() + expect(screen.getByRole('button')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx index 24783838552..1cd70691b72 100644 --- a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react' import { cn } from 'ui' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { useAdvisorSignals } from '@/components/ui/AdvisorPanel/useAdvisorSignals' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useProjectLintsQuery } from '@/data/lint/lint-query' import { useNotificationsV2Query } from '@/data/notifications/notifications-v2-query' @@ -12,6 +13,7 @@ export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => { const { toggleSidebar, activeSidebar } = useSidebarManagerSnapshot() const { data: lints } = useProjectLintsQuery({ projectRef }) + const { data: signalItems } = useAdvisorSignals({ projectRef }) const { data: notificationsData } = useNotificationsV2Query({ filters: {}, @@ -22,10 +24,14 @@ export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => { }, [notificationsData?.pages]) const hasUnreadNotifications = notifications.some((x) => x?.status === 'new') const hasCriticalNotifications = notifications.some((x) => x?.priority === 'Critical') + const hasSignals = signalItems.length > 0 + const hasCriticalSignals = signalItems.some((item) => item.severity === 'critical') const hasCriticalIssues = hasCriticalNotifications || + hasCriticalSignals || (Array.isArray(lints) && lints.some((lint) => lint.level === 'ERROR')) + const hasWarningIssues = hasSignals && !hasCriticalIssues const isOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL @@ -61,6 +67,8 @@ export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => { {hasCriticalIssues ? ( + ) : hasWarningIssues ? ( + ) : hasUnreadNotifications ? ( ) : null} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx index b0ee6244653..e18c77e43d0 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx @@ -1,10 +1,11 @@ import { noop } from 'lodash' import type { AdvisorItem } from './AdvisorPanel.types' +import { AdvisorSignalDetail } from './AdvisorSignalDetail' import { NotificationDetail } from './NotificationDetail' import LintDetail from '@/components/interfaces/Linter/LintDetail' -import { Lint } from '@/data/lint/lint-query' -import { Notification } from '@/data/notifications/notifications-v2-query' +import type { Lint } from '@/data/lint/lint-query' +import type { Notification } from '@/data/notifications/notifications-v2-query' interface AdvisorDetailProps { item: AdvisorItem @@ -26,6 +27,14 @@ export const AdvisorDetail = ({ ) } + if (item.source === 'signal') { + return ( +
+ +
+ ) + } + if (item.source === 'notification') { const notification = item.original as Notification return ( diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx index 66aa88d8e37..6694bbbc7b1 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx @@ -1,53 +1,26 @@ -import dayjs from 'dayjs' import { useMemo, useRef } from 'react' import { AdvisorDetail } from './AdvisorDetail' import { AdvisorFilters } from './AdvisorFilters' import type { AdvisorItem } from './AdvisorPanel.types' +import { + createAdvisorLintItems, + createAdvisorNotificationItems, + sortAdvisorItems, +} from './AdvisorPanel.utils' import { AdvisorPanelBody } from './AdvisorPanelBody' import { AdvisorPanelHeader } from './AdvisorPanelHeader' +import { useAdvisorSignals } from './useAdvisorSignals' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { Lint, useProjectLintsQuery } from '@/data/lint/lint-query' -import { - Notification, - NotificationData, - useNotificationsV2Query, -} from '@/data/notifications/notifications-v2-query' +import { useProjectLintsQuery } from '@/data/lint/lint-query' +import { Notification, useNotificationsV2Query } from '@/data/notifications/notifications-v2-query' import { useNotificationsV2UpdateMutation } from '@/data/notifications/notifications-v2-update-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' import { useTrack } from '@/lib/telemetry/track' -import { AdvisorSeverity, AdvisorTab, useAdvisorStateSnapshot } from '@/state/advisor-state' +import { AdvisorTab, useAdvisorStateSnapshot } from '@/state/advisor-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' -const severityOrder: Record = { - critical: 0, - warning: 1, - info: 2, -} - -const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => { - switch (level) { - case 'ERROR': - return 'critical' - case 'WARN': - return 'warning' - default: - return 'info' - } -} - -const notificationPriorityToSeverity = (priority: string | null | undefined): AdvisorSeverity => { - switch (priority) { - case 'Critical': - return 'critical' - case 'Warning': - return 'warning' - default: - return 'info' - } -} - export const AdvisorPanel = () => { const track = useTrack() const { @@ -70,15 +43,18 @@ export const AdvisorPanel = () => { const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL const markedRead = useRef([]) const hasProjectRef = !!project?.ref + const shouldLoadProjectAdvisorData = isSidebarOpen && hasProjectRef && activeTab !== 'messages' const { data: lintData, isPending: isLintsLoading, isError: isLintsError, - } = useProjectLintsQuery( - { projectRef: project?.ref }, - { enabled: isSidebarOpen && hasProjectRef && activeTab !== 'messages' } - ) + } = useProjectLintsQuery({ projectRef: project?.ref }, { enabled: shouldLoadProjectAdvisorData }) + + const { data: signalItems, isPending: isSignalsPending } = useAdvisorSignals({ + projectRef: project?.ref, + enabled: shouldLoadProjectAdvisorData, + }) // Notifications should always load when sidebar is open (shown in both 'all' and 'messages' tabs) const shouldLoadNotifications = isSidebarOpen && IS_PLATFORM @@ -126,61 +102,17 @@ export const AdvisorPanel = () => { } const lintItems = useMemo(() => { - if (!lintData) return [] - - return lintData - .map((lint): AdvisorItem | null => { - const categories = lint.categories || [] - const tab = categories.includes('SECURITY') - ? ('security' as const) - : categories.includes('PERFORMANCE') - ? ('performance' as const) - : undefined - - if (!tab) return null - - return { - id: lint.cache_key, - title: lint.detail, - severity: lintLevelToSeverity(lint.level), - createdAt: undefined, - tab, - source: 'lint' as const, - original: lint, - } - }) - .filter((item): item is AdvisorItem => item !== null) + return createAdvisorLintItems(lintData) }, [lintData]) const notificationItems = useMemo(() => { if (!IS_PLATFORM) return [] - return notifications?.map((notification): AdvisorItem => { - const data = notification.data as NotificationData - return { - id: notification.id, - title: data.title, - severity: notificationPriorityToSeverity(notification.priority), - createdAt: dayjs(notification.inserted_at).valueOf(), - tab: 'messages' as const, - source: 'notification' as const, - original: notification, - } - }) + return createAdvisorNotificationItems(notifications) }, [notifications]) const combinedItems = useMemo(() => { - const all = [...lintItems, ...notificationItems] - - return all.sort((a, b) => { - const severityDiff = severityOrder[a.severity] - severityOrder[b.severity] - if (severityDiff !== 0) return severityDiff - - const createdDiff = (b.createdAt ?? 0) - (a.createdAt ?? 0) - if (createdDiff !== 0) return createdDiff - - return a.title.localeCompare(b.title) - }) - }, [lintItems, notificationItems]) + return sortAdvisorItems([...lintItems, ...signalItems, ...notificationItems]) + }, [lintItems, signalItems, notificationItems]) const filteredItems = useMemo(() => { return combinedItems.filter((item) => { @@ -223,10 +155,13 @@ export const AdvisorPanel = () => { const isDetailView = !!selectedItem // Only show loading state if the query is actually enabled - const isLintsActuallyLoading = - isSidebarOpen && hasProjectRef && activeTab !== 'messages' && isLintsLoading + const isLintsActuallyLoading = shouldLoadProjectAdvisorData && isLintsLoading const isNotificationsActuallyLoading = shouldLoadNotifications && isNotificationsLoading - const isLoading = isLintsActuallyLoading || isNotificationsActuallyLoading + const isSignalsActuallyLoading = shouldLoadProjectAdvisorData && isSignalsPending + const isLoading = + isLintsActuallyLoading || isNotificationsActuallyLoading || isSignalsActuallyLoading + + // [Joshen] Opting to ignore error state of advisor signals for now - render lints irregardless of banned ips const isError = isLintsError || isNotificationsError const handleTabChange = (tab: string) => { @@ -255,17 +190,28 @@ export const AdvisorPanel = () => { } const advisorCategory = - item.source === 'lint' && 'categories' in item.original - ? item.original.categories[0] - : undefined - const advisorLevel = - item.source === 'lint' && 'level' in item.original ? item.original.level : undefined + item.source === 'lint' + ? item.original.categories.includes('SECURITY') + ? 'SECURITY' + : item.original.categories.includes('PERFORMANCE') + ? 'PERFORMANCE' + : undefined + : item.source === 'signal' + ? 'SECURITY' + : undefined + const advisorType = + item.source === 'signal' + ? item.type + : item.source === 'lint' + ? item.original.name + : item.title + const advisorLevel = item.source === 'lint' ? item.original.level : undefined track('advisor_detail_opened', { origin: 'advisor_panel', advisorCategory, advisorSource: item.source, - advisorType: item.original.name, + advisorType, advisorLevel, }) } diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts index 24103dcfcf6..c9281727c77 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts @@ -1,13 +1,42 @@ -import { Lint } from '@/data/lint/lint-query' -import { Notification } from '@/data/notifications/notifications-v2-query' -import { AdvisorItemSource, AdvisorSeverity } from '@/state/advisor-state' +import type { Lint } from '@/data/lint/lint-query' +import type { Notification } from '@/data/notifications/notifications-v2-query' +import type { AdvisorItemSource, AdvisorSeverity } from '@/state/advisor-state' -export type AdvisorItem = { +export type AdvisorSignalType = 'banned-ip' + +export type AdvisorSignalAction = { + label: string + href: string +} + +type AdvisorBaseItem = { id: string title: string severity: AdvisorSeverity createdAt?: number tab: 'security' | 'performance' | 'messages' source: AdvisorItemSource - original: Lint | Notification } + +export type AdvisorLintItem = AdvisorBaseItem & { + source: 'lint' + original: Lint +} + +export type AdvisorNotificationItem = AdvisorBaseItem & { + source: 'notification' + original: Notification +} + +export type AdvisorSignalItem = AdvisorBaseItem & { + source: 'signal' + type: AdvisorSignalType + dismissalKey: string + summary: string + description?: string + docsUrl?: string + actions: AdvisorSignalAction[] + sourceData: { type: 'banned-ip'; ip: string } +} + +export type AdvisorItem = AdvisorLintItem | AdvisorNotificationItem | AdvisorSignalItem diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts new file mode 100644 index 00000000000..d9e9eb5eff3 --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import type { AdvisorSignalItem } from './AdvisorPanel.types' +import { + createAdvisorLintItems, + createAdvisorNotificationItems, + getAdvisorItemSecondaryText, + sortAdvisorItems, +} from './AdvisorPanel.utils' +import type { Lint } from '@/data/lint/lint-query' +import type { Notification } from '@/data/notifications/notifications-v2-query' + +const createLint = (overrides: Partial = {}): Lint => + ({ + cache_key: 'lint-1', + name: 'unknown_lint', + detail: 'Critical lint detail', + level: 'ERROR', + categories: ['SECURITY'], + metadata: {}, + ...overrides, + }) as Lint + +const createNotification = (overrides: Partial = {}): Notification => + ({ + id: 'notification-1', + inserted_at: '2026-03-01T00:00:00.000Z', + priority: 'Info', + status: 'seen', + data: { + title: 'Notification title', + message: 'Notification body', + actions: [], + }, + ...overrides, + }) as Notification + +const createBannedIPSignalItem = (ip: string): AdvisorSignalItem => ({ + id: `signal:banned-ip:${ip}:v1`, + dismissalKey: `signal:banned-ip:${ip}:v1`, + source: 'signal', + type: 'banned-ip', + severity: 'warning', + tab: 'security', + title: 'Banned IP address', + summary: `The IP address \`${ip}\` is temporarily blocked.`, + docsUrl: 'https://supabase.com/docs/reference/cli/supabase-network-bans', + actions: [], + sourceData: { type: 'banned-ip', ip }, +}) + +describe('AdvisorPanel.utils', () => { + it('orders mixed lint, signal and notification items by severity and recency', () => { + const lintItems = createAdvisorLintItems([ + createLint({ cache_key: 'lint-critical', detail: 'Critical lint detail' }), + ]) + const signalItems = [createBannedIPSignalItem('203.0.113.10')] + const notificationItems = createAdvisorNotificationItems([ + createNotification({ + id: 'notification-info', + data: { title: 'Notification title', message: 'Body', actions: [] }, + }), + ]) + + const sorted = sortAdvisorItems([...notificationItems, ...signalItems, ...lintItems]) + + expect(sorted.map((item) => item.source)).toEqual(['lint', 'signal', 'notification']) + }) + + it('uses database surface-area metadata and the IP address for banned IP signals', () => { + const bannedIpSignal = createBannedIPSignalItem('203.0.113.10') + expect(getAdvisorItemSecondaryText(bannedIpSignal)).toBe('Database · 203.0.113.10') + }) +}) diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts index e740a405294..61e35f26298 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts @@ -1,10 +1,103 @@ import dayjs from 'dayjs' import { Gauge, Inbox, Shield } from 'lucide-react' +import type { ElementType } from 'react' -import type { AdvisorItem } from './AdvisorPanel.types' +import type { AdvisorItem, AdvisorLintItem, AdvisorNotificationItem } from './AdvisorPanel.types' import { lintInfoMap } from '@/components/interfaces/Linter/Linter.utils' -import { Lint } from '@/data/lint/lint-query' -import { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state' +import type { Lint } from '@/data/lint/lint-query' +import type { Notification, NotificationData } from '@/data/notifications/notifications-v2-query' +import type { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state' + +export const MAX_HOMEPAGE_ADVISOR_ITEMS = 4 + +export const severityOrder: Record = { + critical: 0, + warning: 1, + info: 2, +} + +export const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => { + switch (level) { + case 'ERROR': + return 'critical' + case 'WARN': + return 'warning' + default: + return 'info' + } +} + +export const notificationPriorityToSeverity = ( + priority: string | null | undefined +): AdvisorSeverity => { + switch (priority) { + case 'Critical': + return 'critical' + case 'Warning': + return 'warning' + default: + return 'info' + } +} + +export const createAdvisorLintItems = (lintData?: Lint[]): AdvisorLintItem[] => { + if (!lintData) return [] + + return lintData + .map((lint): AdvisorLintItem | null => { + const categories = lint.categories || [] + const tab = categories.includes('SECURITY') + ? ('security' as const) + : categories.includes('PERFORMANCE') + ? ('performance' as const) + : undefined + + if (!tab) return null + + return { + id: lint.cache_key, + title: lint.detail, + severity: lintLevelToSeverity(lint.level), + createdAt: undefined, + tab, + source: 'lint', + original: lint, + } + }) + .filter((item): item is AdvisorLintItem => item !== null) +} + +export const createAdvisorNotificationItems = ( + notifications?: Notification[] +): AdvisorNotificationItem[] => { + if (!notifications) return [] + + return notifications.map((notification) => { + const data = notification.data as NotificationData + + return { + id: notification.id, + title: data.title, + severity: notificationPriorityToSeverity(notification.priority), + createdAt: dayjs(notification.inserted_at).valueOf(), + tab: 'messages' as const, + source: 'notification' as const, + original: notification, + } + }) +} + +export const sortAdvisorItems = (items: T[]) => { + return [...items].sort((a, b) => { + const severityDiff = severityOrder[a.severity] - severityOrder[b.severity] + if (severityDiff !== 0) return severityDiff + + const createdDiff = (b.createdAt ?? 0) - (a.createdAt ?? 0) + if (createdDiff !== 0) return createdDiff + + return getAdvisorItemDisplayTitle(a).localeCompare(getAdvisorItemDisplayTitle(b)) + }) +} export const formatItemDate = (timestamp: number): string => { const daysFromNow = dayjs().diff(dayjs(timestamp), 'day') @@ -15,15 +108,40 @@ export const formatItemDate = (timestamp: number): string => { export const getAdvisorItemDisplayTitle = (item: AdvisorItem): string => { if (item.source === 'lint') { - const lint = item.original as Lint return ( - lintInfoMap.find((info) => info.name === lint.name)?.title || item.title.replace(/[`\\]/g, '') + lintInfoMap.find((info) => info.name === item.original.name)?.title || + item.title.replace(/[`\\]/g, '') ) } + + if (item.source === 'signal') { + return `${item.title}` + } + return item.title.replace(/[`\\]/g, '') } -export const tabIconMap: Record, React.ElementType> = { +export const getAdvisorPanelItemDisplayTitle = (item: AdvisorItem): string => { + if (item.source === 'signal') { + return item.title + } + + return getAdvisorItemDisplayTitle(item) +} + +export const getAdvisorItemSecondaryText = (item: AdvisorItem): string | undefined => { + if (item.source === 'lint') { + return getLintEntityString(item.original) + } + + if (item.source === 'signal') { + return `Database · ${item.sourceData.ip}` + } + + return undefined +} + +export const tabIconMap: Record, ElementType> = { security: Shield, performance: Gauge, messages: Inbox, diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx index b3a190826dd..e08eeeb6d45 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx @@ -5,17 +5,16 @@ import { GenericSkeletonLoader } from 'ui-patterns' import type { AdvisorItem } from './AdvisorPanel.types' import { formatItemDate, - getAdvisorItemDisplayTitle, - getLintEntityString, + getAdvisorItemSecondaryText, + getAdvisorPanelItemDisplayTitle, severityBadgeVariants, severityColorClasses, severityLabels, tabIconMap, } from './AdvisorPanel.utils' import { EmptyAdvisor } from './EmptyAdvisor' -import { Lint } from '@/data/lint/lint-query' -import { Notification } from '@/data/notifications/notifications-v2-query' -import { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state' +import type { Notification } from '@/data/notifications/notifications-v2-query' +import type { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state' const NoProjectNotice = () => { return ( @@ -71,10 +70,12 @@ export const AdvisorPanelBody = ({ if (isError) { return ( -
+
-

Error loading advisories

-

Please try again later.

+
+

Error loading advisories

+

Please try again later.

+
) } @@ -98,14 +99,14 @@ export const AdvisorPanelBody = ({ const isNotification = item.source === 'notification' const notification = isNotification ? (item.original as Notification) : null const isUnread = notification?.status === 'new' - const lint = !isNotification ? (item.original as Lint) : null - // Primary text: issue type for lint items, title for notifications - const primaryText = getAdvisorItemDisplayTitle(item) - - // Secondary text: entity for lint items when no date, date for notifications - const hasDate = !!item.createdAt - const entityString = getLintEntityString(lint) + const primaryText = getAdvisorPanelItemDisplayTitle(item) + const secondaryText = getAdvisorItemSecondaryText(item) + const metadataText = + secondaryText ?? (item.createdAt ? formatItemDate(item.createdAt) : undefined) + // Date strings (e.g. "a few seconds ago") come from formatItemDate and + // need sentence-case capitalisation; entity strings (lint / signal) don't. + const metadataCapitalize = secondaryText === undefined && item.createdAt !== undefined return (
@@ -126,16 +127,14 @@ export const AdvisorPanelBody = ({ />
{primaryText}
- {hasDate ? ( - - {formatItemDate(item.createdAt!)} - - ) : ( - entityString && ( -
- {entityString} -
- ) + {metadataText && ( +
+ + {metadataText} + +
)}
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx index 42f73d1a5c2..f4c43c01843 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx @@ -4,7 +4,8 @@ import { Badge } from 'ui' import type { AdvisorItem } from './AdvisorPanel.types' import { formatItemDate, - getAdvisorItemDisplayTitle, + getAdvisorItemSecondaryText, + getAdvisorPanelItemDisplayTitle, severityBadgeVariants, severityLabels, } from './AdvisorPanel.utils' @@ -17,7 +18,18 @@ interface AdvisorPanelHeaderProps { } export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPanelHeaderProps) => { - const displayTitle = selectedItem ? getAdvisorItemDisplayTitle(selectedItem) : undefined + const displayTitle = selectedItem ? getAdvisorPanelItemDisplayTitle(selectedItem) : undefined + const secondaryText = selectedItem ? getAdvisorItemSecondaryText(selectedItem) : undefined + const metadataText = selectedItem + ? (secondaryText ?? + (selectedItem.createdAt ? formatItemDate(selectedItem.createdAt) : undefined)) + : undefined + // Only capitalize date strings (e.g. "a few seconds ago"); entity strings + // like "public.users" must not be case-altered. + const metadataCapitalize = + selectedItem !== undefined && + secondaryText === undefined && + selectedItem.createdAt !== undefined return (
@@ -29,11 +41,13 @@ export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPan tooltip={{ content: { side: 'bottom', text: 'Back to list' } }} />
-
+
{displayTitle} - {selectedItem?.createdAt && ( - - {formatItemDate(selectedItem.createdAt)} + {metadataText && ( + + {metadataText} )}
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorSignalDetail.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorSignalDetail.tsx new file mode 100644 index 00000000000..2e6127b13df --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorSignalDetail.tsx @@ -0,0 +1,107 @@ +import { useParams } from 'common' +import { EyeOff, Globe } from 'lucide-react' +import Link from 'next/link' +import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import type { AdvisorSignalItem } from './AdvisorPanel.types' +import { useAdvisorSignals } from './useAdvisorSignals' +import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' +import { InlineLink } from '@/components/ui/InlineLink' +import { useAdvisorStateSnapshot } from '@/state/advisor-state' +import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' + +interface AdvisorSignalDetailProps { + item: AdvisorSignalItem +} + +const buildSignalAssistantPrompt = (item: AdvisorSignalItem) => { + return [ + `I'm reviewing an Advisor signal for a banned IP address: ${item.sourceData.ip}.`, + item.description ?? item.summary, + 'Help me assess whether this ban should remain in place, what I should investigate before removing it, and what the safest next step is.', + 'Please include when it is reasonable to dismiss this signal versus remove the ban.', + ].join('\n\n') +} + +export const AdvisorSignalDetail = ({ item }: AdvisorSignalDetailProps) => { + const { ref: projectRef } = useParams() + + const snap = useAiAssistantStateSnapshot() + const { openSidebar } = useSidebarManagerSnapshot() + const { setSelectedItem } = useAdvisorStateSnapshot() + const { dismissSignal } = useAdvisorSignals({ projectRef }) + + const issueDescription = ( + <> + The IP address {item.sourceData.ip} is temporarily + blocked because of suspicious traffic or repeated failed password attempts. If this block is + expected, you can dismiss this signal or remove the ban. + + ) + + const handleAskAssistant = () => { + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + snap.newChat({ + name: `Review ${item.title.toLowerCase()}`, + initialInput: buildSignalAssistantPrompt(item), + }) + } + + const onDismissSignal = () => { + dismissSignal(item.dismissalKey) + setSelectedItem(undefined) + } + + return ( +
+

Entity

+ + +
+ + {item.sourceData.ip} +
+
+ IP address currently blocked by network bans +
+ +

Issue

+

+ {issueDescription}{' '} + {item.docsUrl !== undefined && ( + <> + Learn more. + + )} +

+ +

Resolve

+
+ buildSignalAssistantPrompt(item)} + onOpenAssistant={handleAskAssistant} + telemetrySource="advisor_signal_detail" + /> + {item.actions.map((action) => ( + + ))} + +
+
+ ) +} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorSignals.integration.test.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorSignals.integration.test.tsx new file mode 100644 index 00000000000..f8f84356d00 --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorSignals.integration.test.tsx @@ -0,0 +1,280 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { AdvisorSection } from '@/components/interfaces/ProjectHome/AdvisorSection' +import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { AdvisorPanel } from '@/components/ui/AdvisorPanel/AdvisorPanel' +import { advisorState } from '@/state/advisor-state' +import { sidebarManagerState } from '@/state/sidebar-manager-state' +import { render } from '@/tests/helpers' + +const { + mockUseProjectLintsQuery, + mockUseBannedIPsQuery, + mockUseSelectedProjectQuery, + mockUseNotificationsV2Query, + mockUseNotificationsV2UpdateMutation, + mockUseTrack, +} = vi.hoisted(() => ({ + mockUseProjectLintsQuery: vi.fn(), + mockUseBannedIPsQuery: vi.fn(), + mockUseSelectedProjectQuery: vi.fn(), + mockUseNotificationsV2Query: vi.fn(), + mockUseNotificationsV2UpdateMutation: vi.fn(), + mockUseTrack: vi.fn(), +})) + +vi.mock('common', async () => { + const actual = await vi.importActual('common') + + return { + ...actual, + useParams: () => ({ ref: 'project-ref' }), + } +}) + +vi.mock('@/data/lint/lint-query', () => ({ + useProjectLintsQuery: mockUseProjectLintsQuery, +})) + +vi.mock('@/data/banned-ips/banned-ips-query', () => ({ + useBannedIPsQuery: mockUseBannedIPsQuery, +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: mockUseSelectedProjectQuery, +})) + +vi.mock('@/data/notifications/notifications-v2-query', () => ({ + useNotificationsV2Query: mockUseNotificationsV2Query, +})) + +vi.mock('@/data/notifications/notifications-v2-update-mutation', () => ({ + useNotificationsV2UpdateMutation: mockUseNotificationsV2UpdateMutation, +})) + +vi.mock('@/lib/constants', async (importOriginal) => ({ + ...(await importOriginal()), + IS_PLATFORM: true, +})) + +vi.mock('@/lib/telemetry/track', () => ({ + useTrack: mockUseTrack, +})) + +vi.mock('@/state/ai-assistant-state', () => ({ + useAiAssistantStateSnapshot: () => ({ + newChat: vi.fn(), + }), +})) + +vi.mock('@/components/ui/AiAssistantDropdown', () => ({ + AiAssistantDropdown: () =>
, +})) + +vi.mock('./AdvisorFilters', () => ({ + AdvisorFilters: () =>
, +})) + +vi.mock('./AdvisorPanelHeader', () => ({ + AdvisorPanelHeader: () =>
, +})) + +describe('Advisor signals integration', () => { + beforeEach(() => { + window.localStorage.clear() + advisorState.reset() + sidebarManagerState.unregisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + sidebarManagerState.registerSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => null) + sidebarManagerState.clearActiveSidebar() + + mockUseTrack.mockReturnValue(vi.fn()) + mockUseSelectedProjectQuery.mockReturnValue({ + data: { ref: 'project-ref' }, + }) + mockUseProjectLintsQuery.mockImplementation((_variables, options) => { + if (options?.enabled === false) { + return { + data: undefined, + isPending: false, + isError: false, + } + } + + return { + data: [ + { + cache_key: 'lint-1', + name: 'unknown_lint', + detail: 'Critical lint detail', + level: 'ERROR', + categories: ['SECURITY'], + metadata: {}, + }, + ], + isPending: false, + isError: false, + } + }) + mockUseBannedIPsQuery.mockImplementation((_variables, options) => { + if (options?.enabled === false) { + return { + data: undefined, + isPending: false, + isError: false, + } + } + + return { + data: { + banned_ipv4_addresses: ['203.0.113.10'], + }, + isPending: false, + isError: false, + } + }) + mockUseNotificationsV2Query.mockReturnValue({ + data: { pages: [[]] }, + isPending: false, + isError: false, + }) + mockUseNotificationsV2UpdateMutation.mockReturnValue({ + mutate: vi.fn(), + }) + }) + + afterEach(() => { + advisorState.reset() + sidebarManagerState.unregisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + sidebarManagerState.clearActiveSidebar() + vi.clearAllMocks() + }) + + it('renders signal items and dismisses them across the homepage and panel', async () => { + render( + <> + + + + ) + + expect(screen.getByText('Advisor found 2 issues')).toBeInTheDocument() + expect(screen.getByText('Banned IP address')).toBeInTheDocument() + expect(screen.getAllByText('Critical lint detail').length).toBeGreaterThan(0) + expect( + screen.getAllByText((_, node) => + Boolean( + node?.textContent?.includes( + 'The IP address 203.0.113.10 is temporarily blocked because of suspicious traffic or repeated failed password attempts.' + ) + ) + ).length + ).toBeGreaterThan(0) + + await userEvent.click(screen.getByText('Banned IP address')) + + expect(screen.getByText('Entity')).toBeInTheDocument() + expect(screen.getByText('Issue')).toBeInTheDocument() + expect(screen.getByText('Resolve')).toBeInTheDocument() + expect(screen.getAllByTestId('advisor-assistant-dropdown').length).toBeGreaterThan(0) + expect( + screen.getAllByText((_, node) => + Boolean( + node?.textContent?.includes( + 'The IP address 203.0.113.10 is temporarily blocked because of suspicious traffic or repeated failed password attempts.' + ) + ) + ).length + ).toBeGreaterThan(0) + expect(screen.getByRole('link', { name: 'Learn more' })).toHaveAttribute( + 'href', + 'https://supabase.com/docs/reference/cli/supabase-network-bans' + ) + + expect(screen.getAllByText('Banned IP address').length).toBeGreaterThan(0) + + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + + await waitFor(() => { + expect(screen.queryByText('Banned IP address')).not.toBeInTheDocument() + }) + + expect(screen.getAllByText('Critical lint detail').length).toBeGreaterThan(0) + }) + + it('shows an overflow affordance when the homepage cap hides additional issues', () => { + mockUseProjectLintsQuery.mockImplementation((_variables, options) => { + if (options?.enabled === false) { + return { + data: undefined, + isPending: false, + isError: false, + } + } + + return { + data: [ + { + cache_key: 'lint-1', + name: 'unknown_lint', + detail: 'Critical lint detail 1', + level: 'ERROR', + categories: ['SECURITY'], + metadata: {}, + }, + { + cache_key: 'lint-2', + name: 'unknown_lint', + detail: 'Critical lint detail 2', + level: 'ERROR', + categories: ['SECURITY'], + metadata: {}, + }, + { + cache_key: 'lint-3', + name: 'unknown_lint', + detail: 'Critical lint detail 3', + level: 'ERROR', + categories: ['SECURITY'], + metadata: {}, + }, + { + cache_key: 'lint-4', + name: 'unknown_lint', + detail: 'Critical lint detail 4', + level: 'ERROR', + categories: ['SECURITY'], + metadata: {}, + }, + ], + isPending: false, + isError: false, + } + }) + + render() + + expect(screen.getByText('Advisor found 5 issues')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'View 1 more issue in Advisor' })).toBeInTheDocument() + expect(screen.queryByText('Banned IP address')).not.toBeInTheDocument() + }) + + it('does not block the homepage while signal items are pending', () => { + mockUseProjectLintsQuery.mockReturnValue({ + data: [], + isPending: false, + isError: false, + }) + mockUseBannedIPsQuery.mockReturnValue({ + data: undefined, + isPending: true, + isError: false, + }) + + render() + + // Lints have loaded — homepage renders without being blocked by signal loading + expect(screen.getByText('Advisor found no issues')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx index 5032cea79c4..509dec8e1b7 100644 --- a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx +++ b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx @@ -1,7 +1,7 @@ import { TextSearch } from 'lucide-react' import { Button } from 'ui' -import { AdvisorTab } from '@/state/advisor-state' +import type { AdvisorTab } from '@/state/advisor-state' interface EmptyAdvisorProps { activeTab: AdvisorTab @@ -41,11 +41,11 @@ export const EmptyAdvisor = ({ activeTab, hasFilters, onClearFilters }: EmptyAdv } return ( -
+
-
-

{getHeading()}

-

{getMessage()}

+
+

{getHeading()}

+

{getMessage()}

{hasFilters && (