From f95d978cbc2ce4e794e0b475d864e7fd705e302d Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Mon, 11 May 2026 16:56:35 +0200 Subject: [PATCH 01/98] docs: Fix hono issues (#45798) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## Summary by CodeRabbit * **Refactor** * Simplified cookie handling in the Supabase SSR authentication middleware by streamlining the `setAll` handler to focus exclusively on cookie management, removing additional header manipulation from the response. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45798) --- examples/auth/hono/src/middleware/auth.middleware.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/auth/hono/src/middleware/auth.middleware.ts b/examples/auth/hono/src/middleware/auth.middleware.ts index 5ac6d20f890..b8896494b25 100644 --- a/examples/auth/hono/src/middleware/auth.middleware.ts +++ b/examples/auth/hono/src/middleware/auth.middleware.ts @@ -38,9 +38,8 @@ export const supabaseMiddleware = (): MiddlewareHandler => { getAll() { return parseCookieHeader(c.req.header('Cookie') ?? '') }, - setAll(cookiesToSet, cacheHeaders) { + setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value, options }) => setCookie(c, name, value, options)) - Object.entries(cacheHeaders).forEach(([key, value]) => c.header(key, value)) }, }, }) From 920571fcf7a798e738db2eebd19ddbab3b7d68c6 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Mon, 11 May 2026 09:11:45 -0600 Subject: [PATCH 02/98] feat(studio): rewrite secret key reveal flow without react-query [FE-3206] (#45792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Rewrites the secret API key reveal flow in `ApiKeyPill` to remove its dependency on React Query, replacing it with a lightweight custom hook. ## Changes - **`useRevealedSecret` (new hook)** A simple, reusable hook that: - Fetches the unmasked secret key via `getAPIKeysById` - Exposes `data`, `isLoading`, `reveal()`, and `clear()` - Keeps sensitive data in local component state (no global cache) - **`ApiKeyPill` (refactored)** - Removes all React Query imports (`useQueryClient`, `useAPIKeyIdQuery`, `apiKeysKeys`) - Uses `useRevealedSecret` for reveal / copy operations - Preserves existing UX: - 10-second auto-hide timer - Permission-based gating (`canManageSecretKeys`) - Loading states on toggle / copy - **`api-key-id-query.ts` (cleaned up)** - Removes the now-unused `useAPIKeyIdQuery` hook - Retains the `getAPIKeysById` fetcher for direct use ## Motivation The previous React Query–based flow had to aggressively disable caching (`staleTime: 0`, `gcTime: 0`) and manually purge queries from the cache on every interaction, which was cumbersome and leaked implementation details into the component. A plain fetch + local state is simpler and safer for transient, sensitive data. ## Testing - [x] Toggle reveal on a secret API key - [x] Verify 10-second auto-hide - [x] Copy a secret key (both revealed and unrevealed states) - [x] Verify restricted users cannot reveal/copy --- Resolves [FE-3206](https://linear.app/supabase/issue/FE-3206) ## Summary by CodeRabbit * **Refactor** * Improved API key reveal/copy flow: uses a dedicated reveal/clear mechanism, preserves permission checks and 10s auto-hide, and shows reveal/copy failures via user-facing toasts. Copy now falls back to masked key when needed and the reveal toggle behavior is more reliable. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45792) --- .../interfaces/APIKeys/ApiKeyPill.tsx | 102 +++++++----------- .../interfaces/APIKeys/useRevealedSecret.ts | 43 ++++++++ apps/studio/data/api-keys/api-key-id-query.ts | 15 --- 3 files changed, 81 insertions(+), 79 deletions(-) create mode 100644 apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx index 04d1f5a9285..1ea719f27b3 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useQueryClient } from '@tanstack/react-query' import { InputVariants } from '@ui/components/shadcn/ui/input' import { useParams } from 'common' import { Eye, EyeOff } from 'lucide-react' @@ -7,10 +6,9 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { useRevealedSecret } from './useRevealedSecret' import CopyButton from '@/components/ui/CopyButton' -import { useAPIKeyIdQuery } from '@/data/api-keys/api-key-id-query' import { APIKeysData } from '@/data/api-keys/api-keys-query' -import { apiKeysKeys } from '@/data/api-keys/keys' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' export function ApiKeyPill({ @@ -18,94 +16,70 @@ export function ApiKeyPill({ }: { apiKey: Extract }) { - const queryClient = useQueryClient() const { ref: projectRef } = useParams() - - // State that controls whether to show the full API key - const [show, setShowState] = useState(false) + const [show, setShow] = useState(false) const isSecret = apiKey.type === 'secret' - // Permission check for revealing/copying secret API keys const { can: canManageSecretKeys, isLoading: isLoadingPermission } = useAsyncCheckPermissions( PermissionAction.READ, 'service_api_keys' ) - // This query only runs when show=true (enabled: show) - // It fetches the fully revealed API key when needed const { - data, - error, - isPending: isLoading, - refetch: refetchApiKey, - } = useAPIKeyIdQuery( - { - projectRef, - id: apiKey.id as string, - reveal: true, // Request the unmasked key - }, - { - enabled: show, // Only run query when show is true - staleTime: 0, // Always consider data stale - gcTime: 0, // Don't cache the key data - } - ) + data: revealedKey, + isLoading, + reveal, + clear, + } = useRevealedSecret({ + projectRef, + id: apiKey.id as string, + }) // Auto-hide timer for the API key (security feature) useEffect(() => { - if (show && data?.api_key) { - // Auto-hide the key after 10 seconds + if (show && revealedKey) { const timer = setTimeout(() => { - setShowState(false) - // Clear the cached key from memory - queryClient.removeQueries({ - queryKey: apiKeysKeys.single(projectRef, apiKey.id as string), - exact: true, - }) - }, 10000) // Hide after 10 seconds + setShow(false) + clear() + }, 10000) return () => clearTimeout(timer) } - }, [show, data?.api_key, projectRef, queryClient, apiKey.id]) + }, [show, revealedKey, clear]) - async function onSubmitToggle() { - // Don't reveal key if not allowed or loading + async function onToggleShow() { if (isSecret && !canManageSecretKeys) return if (isLoadingPermission) return - // Toggle the show state - setShowState(!show) + if (show) { + setShow(false) + clear() + } else { + setShow(true) + try { + await reveal() + } catch { + toast.error('Failed to reveal secret API key') + setShow(false) + } + } } async function onCopy() { - // If key is already revealed, use that value - if (data?.api_key) return data?.api_key ?? '' + if (!isSecret) return apiKey.api_key + if (revealedKey) return revealedKey try { - // Fetch full key and immediately clear from cache after copying - const result = await refetchApiKey() - queryClient.removeQueries({ - queryKey: apiKeysKeys.single(projectRef, apiKey.id as string), - exact: true, - }) - - if (result.isSuccess) return result.data.api_key ?? '' - - if (error) { - toast.error('Failed to copy secret API key') - return '' - } - } catch (error) { - console.error('Failed to fetch API key:', error) - return '' + const key = await reveal() + clear() + return key ?? '' + } catch { + toast.error('Failed to copy secret API key') + return apiKey.api_key } - - // Fallback to the masked version if fetch fails - return apiKey.api_key } - // States for disabling buttons/showing tooltips const isRestricted = isSecret && !canManageSecretKeys return ( @@ -123,7 +97,7 @@ export function ApiKeyPill({ {isSecret ? ( <> {apiKey?.api_key.slice(0, 15)} - {show && data?.api_key ? data?.api_key.slice(15) : '••••••••••••••••'} + {show && revealedKey ? revealedKey.slice(15) : '••••••••••••••••'} ) : ( @@ -141,7 +115,7 @@ export function ApiKeyPill({ className="rounded-full px-2 pointer-events-auto" loading={show && isLoading} icon={show ? : } - onClick={onSubmitToggle} + onClick={onToggleShow} disabled={isRestricted} /> diff --git a/apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts b/apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts new file mode 100644 index 00000000000..5af3b323928 --- /dev/null +++ b/apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts @@ -0,0 +1,43 @@ +import { useCallback, useRef, useState } from 'react' + +import { getAPIKeysById } from '@/data/api-keys/api-key-id-query' + +interface UseRevealedSecretOptions { + projectRef?: string + id?: string +} + +export function useRevealedSecret({ projectRef, id }: UseRevealedSecretOptions) { + const [data, setData] = useState() + const [isLoading, setIsLoading] = useState(false) + const requestIdRef = useRef(0) + + const reveal = useCallback(async () => { + if (!projectRef || !id) return + + const requestId = ++requestIdRef.current + setIsLoading(true) + + try { + const result = await getAPIKeysById({ projectRef, id, reveal: true }) + if (requestId !== requestIdRef.current) return + setData(result.api_key) + return result.api_key + } catch (error) { + if (requestId !== requestIdRef.current) return + console.error('Failed to reveal secret key:', error) + throw error + } finally { + if (requestId === requestIdRef.current) { + setIsLoading(false) + } + } + }, [projectRef, id]) + + const clear = useCallback(() => { + requestIdRef.current++ + setData(undefined) + }, []) + + return { data, isLoading, reveal, clear } +} diff --git a/apps/studio/data/api-keys/api-key-id-query.ts b/apps/studio/data/api-keys/api-key-id-query.ts index fb7b8b1a525..9149361f401 100644 --- a/apps/studio/data/api-keys/api-key-id-query.ts +++ b/apps/studio/data/api-keys/api-key-id-query.ts @@ -1,8 +1,4 @@ -import { useQuery } from '@tanstack/react-query' - -import { apiKeysKeys } from './keys' import { get, handleError } from '@/data/fetchers' -import type { ResponseError, UseCustomQueryOptions } from '@/types' export interface APIKeyVariables { projectRef?: string @@ -33,14 +29,3 @@ export async function getAPIKeysById( } export type APIKeyIdData = Awaited> - -export const useAPIKeyIdQuery = ( - { projectRef, id, reveal }: APIKeyVariables, - { enabled = true, ...options }: UseCustomQueryOptions = {} -) => - useQuery({ - queryKey: apiKeysKeys.single(projectRef, id), - queryFn: ({ signal }) => getAPIKeysById({ projectRef, id, reveal }, signal), - enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined', - ...options, - }) From 11c4adc745ba09ebbb9c098dde43d1c72d504375 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Mon, 11 May 2026 08:26:21 -0700 Subject: [PATCH 03/98] chore(docs): add brentschroeter to humans.txt (#45689) ## 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? N/A ## What is the current behavior? N/A ## What is the new behavior? N/A ## Additional context N/A ## Summary by CodeRabbit * **Chores** * Updated team information in metadata. --- 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 49be8cdf0b5..ed643d86737 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -38,6 +38,7 @@ Bobbie Soedirgo Brad Gyger Brendan Stephens Brent Newson +Brent Schroeter Carel de Waal Cameron Blackwood Cameron Michie From 2c48c6a002fbb68e625d410657a5df67d8c8ad75 Mon Sep 17 00:00:00 2001 From: ChloeGarciaMillerand Date: Mon, 11 May 2026 17:30:16 +0200 Subject: [PATCH 04/98] fix: improve accessibility for icon buttons in LayoutHeader (#45790) ## 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? Bug fix (accessibility improvement in LayoutHeader) ## What is the current behavior? Icon-only buttons do not have explicit accessible names for screen readers. ## What is the new behavior? All icon-only buttons now have explicit accessible names using visually hidden text (sr-only), ensuring proper screen reader support. ## Additional context Tooltip text is preserved for visual users. No visual changes were introduced. ## Summary by CodeRabbit * **Accessibility** * Added hidden screen-reader labels to multiple toolbar and menu buttons (Settings, Advisor Center, AI Assistant, SQL Editor, Help) so icons are announced by assistive technologies, improving navigation and discoverability across the studio interface. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45790) --------- Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com> --- apps/studio/components/interfaces/LocalDropdown.tsx | 1 + apps/studio/components/layouts/AppLayout/AdvisorButton.tsx | 1 + apps/studio/components/layouts/AppLayout/AssistantButton.tsx | 1 + apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx | 1 + apps/studio/components/ui/HelpPanel/HelpButton.tsx | 1 + 5 files changed, 5 insertions(+) diff --git a/apps/studio/components/interfaces/LocalDropdown.tsx b/apps/studio/components/interfaces/LocalDropdown.tsx index de4f1ea3bf1..315ad590d7c 100644 --- a/apps/studio/components/interfaces/LocalDropdown.tsx +++ b/apps/studio/components/interfaces/LocalDropdown.tsx @@ -49,6 +49,7 @@ export const LocalDropdown = ({ tooltip={{ content: { text: 'Settings' } }} > + Settings diff --git a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx index 61c2c13599c..d9a1cb532f4 100644 --- a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx @@ -68,6 +68,7 @@ export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => { isOpen && 'text-background group-hover:text-background' )} /> + Advisor Center {hasCriticalIssues ? ( diff --git a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx index 2a61e166eca..5424a11fa15 100644 --- a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx @@ -44,6 +44,7 @@ export const AssistantButton = () => { size={16} className={cn(isOpen && 'text-background')} /> + AI Assistant ) } diff --git a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx index faf8293b247..f1cd45a5661 100644 --- a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx @@ -47,6 +47,7 @@ export const InlineEditorButton = () => { }} > + SQL Editor ) } diff --git a/apps/studio/components/ui/HelpPanel/HelpButton.tsx b/apps/studio/components/ui/HelpPanel/HelpButton.tsx index f2d4d691e55..ecfd92d18e3 100644 --- a/apps/studio/components/ui/HelpPanel/HelpButton.tsx +++ b/apps/studio/components/ui/HelpPanel/HelpButton.tsx @@ -42,6 +42,7 @@ export const HelpButton = () => { isOpen && 'text-background group-hover:text-background' )} /> + Help ) } From 27f31ef692b74b11120cbb2d0afc76524c2ec0f2 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Mon, 11 May 2026 12:30:25 -0300 Subject: [PATCH 05/98] feat(billing): prepaid credits in upcoming invoice (#45475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Display a "Prepaid Credits" line in the upcoming invoice breakdown, shown directly below Current Costs when the upcoming invoice contains a `Prepaid Credits` line item - Surfaces the credit amount being applied (e.g. `-$300`) so users can see how prepaid credits offset their projected bill - Consolidates the projected tax items into the tooltip for `Projected Costs` image ## Test plan - [x]  On an org with prepaid credits applied, verify the "Prepaid Credits" row appears below Current Costs with the negative amount and a tooltip showing the backend description - [x]  On an org without prepaid credits, verify the row is not rendered ## Summary by CodeRabbit ## Summary by CodeRabbit * **Improvements** * Clarified the "Upcoming Invoice" explanatory copy for non–AWS Marketplace-managed organizations: it now notes the invoice will continue updating until the end of your billing cycle. * Improved invoice breakdown: compute charges and compute credits are distinguished, and a dedicated "Prepaid Credits" row is shown before projected costs for clearer billing totals. --- .../BillingBreakdown/BillingBreakdown.tsx | 5 +- .../BillingBreakdown/UpcomingInvoice.tsx | 123 +++++++++--------- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx index 9eeebc2b440..cf3b15f16db 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx @@ -84,9 +84,8 @@ const BillingBreakdown = () => { ) : ( <> - Your upcoming invoice (excluding credits) will continue to update until the end of - your billing cycle on {billingCycleEnd.format('MMMM DD')}. For a more detailed - breakdown, visit the{' '} + Your upcoming invoice will continue to update until the end of your billing cycle on{' '} + {billingCycleEnd.format('MMMM DD')}. For a more detailed breakdown, visit the{' '} usage page. )} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx index 580cc1cbe61..0d75d521cd6 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx @@ -60,8 +60,11 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { // For non-platform customers, compute is broken down per project and contains a breakdown array const computeItems = - upcomingInvoice?.lines?.filter((item) => item.description?.toLowerCase().includes('compute')) || - [] + upcomingInvoice?.lines?.filter( + (item) => + item.description?.toLowerCase().includes('compute') && + !item.description.startsWith('Compute Credits') + ) || [] const computeCreditsItem = upcomingInvoice?.lines?.find((item) => item.description.startsWith('Compute Credits')) ?? null @@ -90,6 +93,9 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { ) .sort((a, b) => b.amount_before_discount - a.amount_before_discount) || [] + const prepaidCreditsItem = + upcomingInvoice?.lines?.find((item) => item.item_name === 'Prepaid Credits') ?? null + const hasTax = upcomingInvoice?.tax_status === 'calculated' && (upcomingInvoice?.tax?.tax_amount ?? 0) > 0 const taxFailed = upcomingInvoice?.tax_status === 'failed' @@ -283,6 +289,20 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { + {prepaidCreditsItem && ( + + + {prepaidCreditsItem.item_name} + + Prepaid credits purchased upfront, applied automatically against your + invoice. Any remaining balance rolls over to the next billing cycle. + + + + {formatCurrency(prepaidCreditsItem.amount) ?? '-'} + + + )} Current Costs @@ -299,75 +319,56 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { Projected Costs - - Projected costs at the end of the billing cycle. Includes predictable costs - for Compute Hours, IPv4, Custom Domain and Point-In-Time-Recovery, but no - costs for metrics like MAU, storage or function invocations. Final amounts - may vary depending on your usage. + +

+ Projected costs at the end of the billing cycle. Includes predictable + costs for Compute Hours, IPv4, Custom Domain and Point-In-Time-Recovery, + but no costs for metrics like MAU, storage or function invocations. Final + amounts may vary depending on your usage. +

+ + {hasTax && ( +
+
+ Subtotal + + {formatCurrency(upcomingInvoice.tax!.total_amount_excluding_tax)} + +
+
+ + Tax + {upcomingInvoice.tax?.tax_rate_percentage != null && + ` (${upcomingInvoice.tax.tax_rate_percentage}%)`} + + + {formatCurrency(upcomingInvoice.tax!.tax_amount)} + +
+

+ Estimated based on your organization's billing address. The final + amount may be adjusted at the end of the billing cycle. +

+
+ )} + + {taxFailed && ( +

+ We were unable to estimate tax for your organization. Please verify your + billing address in your organization settings. +

+ )}
{formatCurrency( hasTax - ? upcomingInvoice.tax!.total_amount_excluding_tax + ? upcomingInvoice.tax!.total_amount_including_tax : upcomingInvoice.amount_projected ) ?? '-'}
)} - - {hasTax && ( - <> - - -
- Projected Tax - - Estimated tax - {upcomingInvoice?.tax?.tax_rate_percentage != null && - ` at ${upcomingInvoice.tax.tax_rate_percentage}%`}{' '} - based on your organization's billing address. The final amount may be - adjusted at the end of the billing cycle. - -
-
- - {formatCurrency(upcomingInvoice?.tax?.tax_amount) ?? '-'} - -
- - - Projected Total - - Projected costs including applicable tax. The final amount may be adjusted - at the end of the billing cycle. - - - - {formatCurrency(upcomingInvoice?.tax!.total_amount_including_tax) ?? '-'} - - - - )} - - {taxFailed && ( - - -
- Projected Tax - - We were unable to estimate tax for your organization. Please verify your - billing address in your organization settings. - -
-
- -
- Could not be estimated -
-
-
- )}
From 12e08dc8923abbf917756e112c2483007e44c7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cemal=20K=C4=B1l=C4=B1=C3=A7?= Date: Mon, 11 May 2026 17:30:45 +0200 Subject: [PATCH 06/98] fix(docs): add `RedirectTo` and `Data` variables to email templates (#45799) ## 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 ## Summary by CodeRabbit * **Documentation** * Updated email template customization guide to document two additional template variables: `RedirectTo` (auth method redirect destination accessible via the `next` query parameter) and `Data` (user metadata) for local development templates. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45799) --- .../customizing-email-templates.mdx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/docs/content/guides/local-development/customizing-email-templates.mdx b/apps/docs/content/guides/local-development/customizing-email-templates.mdx index 60bcb1ea69e..88b8608e143 100644 --- a/apps/docs/content/guides/local-development/customizing-email-templates.mdx +++ b/apps/docs/content/guides/local-development/customizing-email-templates.mdx @@ -207,6 +207,28 @@ Contains your application's Site URL. This can be configured in your project's [

Visit here to log in.

``` +### `RedirectTo` + +Contains the redirect URL passed as the `redirectTo` option in the auth method call. + +**Usage** + +```html + + Confirm your email + +``` + +### `Data` + +Contains metadata from `auth.users.user_metadata`. Use this to personalize the email message. + +**Usage** + +```html +

Hello {{ .Data.first_name }}, please confirm your signup.

+``` + ### `Email` Contains the user's email address. From 10ce3b66647429c9294d6372d5daa07cc6dd730f Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Mon, 11 May 2026 17:45:13 +0200 Subject: [PATCH 07/98] docs: Fix missing export in Expo React Native docs (#45800) --- .../guides/getting-started/tutorials/with-expo-react-native.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx index 1aa5d7e3fca..64fbbdbc2b1 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx @@ -141,7 +141,7 @@ These variables are safe to expose in your Expo app since Supabase has const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL const supabasePublishableKey = YOUR_REACT_NATIVE_SUPABASE_PUBLISHABLE_KEY - const supabase = createClient(supabaseUrl, supabasePublishableKey, { + export const supabase = createClient(supabaseUrl, supabasePublishableKey, { auth: { storage: new LargeSecureStore(), autoRefreshToken: true, From a4f964e452716798343fa422d2fae775ad8bad5c Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Mon, 11 May 2026 13:09:10 -0300 Subject: [PATCH 08/98] fix: in-arrears transition in upcoming invoice (#45765) ## Show notice when plan fee is prepaid for the upcoming invoice When a subscription's plan fee has already been billed for the current period (e.g. transitioning to in-arrears billing), the upcoming invoice no longer contains a plan line item. Previously this rendered as an empty plan row with a `-`, which was confusing. image ## Summary by CodeRabbit * **New Features** * Billing breakdown now detects when the plan fee was already paid upfront, hides the redundant plan line, and notes only usage will be invoiced; shows the organization plan name when available. * Backup restoration: added an optional recovery time target for physical backups. * Expanded supported AWS instance types for deployments. * **UI** * Compute and Replica Compute docs links now use inline linking for a smoother in-app experience. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45765) --------- Co-authored-by: Joshen Lim --- .../BillingBreakdown/UpcomingInvoice.tsx | 60 ++++++++++++------- packages/api-types/types/platform.d.ts | 32 +++++++++- 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx index 0d75d521cd6..889f5036fdc 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx @@ -1,4 +1,3 @@ -import Link from 'next/link' import React from 'react' import { Table, TableBody, TableCell, TableFooter, TableRow } from 'ui' import { InfoTooltip } from 'ui-patterns/info-tooltip' @@ -12,6 +11,7 @@ import { UpcomingInvoiceResponse, useOrgUpcomingInvoiceQuery, } from '@/data/invoices/org-invoice-upcoming-query' +import { useOrganizationQuery } from '@/data/organizations/organization-query' import { DOCS_URL } from '@/lib/constants' import { formatCurrency } from '@/lib/helpers' @@ -58,6 +58,8 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { isSuccess, } = useOrgUpcomingInvoiceQuery({ orgSlug: slug }) + const { data: organization } = useOrganizationQuery({ slug }) + // For non-platform customers, compute is broken down per project and contains a breakdown array const computeItems = upcomingInvoice?.lines?.filter( @@ -100,6 +102,9 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { upcomingInvoice?.tax_status === 'calculated' && (upcomingInvoice?.tax?.tax_amount ?? 0) > 0 const taxFailed = upcomingInvoice?.tax_status === 'failed' + const planFeePaidInAdvance = + !planItem && upcomingInvoice?.fixed_fees_billing_mode === 'in_arrears' + return ( <> {isLoading && ( @@ -117,19 +122,21 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
- - {planItem?.description} - - {planItem == null ? ( - '-' - ) : ( - - )} - - + {!planFeePaidInAdvance && ( + + {planItem?.description} + + {!planItem ? ( + '-' + ) : ( + + )} + + + )} {/* Compute section */} { The first project is covered by Compute Credits. Additional projects incur compute costs starting at $10/month, independent of activity. See{' '} - + docs - + .

} @@ -161,12 +165,11 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { Each Read Replica is a dedicated database. You are charged for its resources: Compute, Disk Size, provisioned Disk IOPS, provisioned Disk Throughput, and IPv4. See{' '} - docs - + .

} @@ -369,6 +372,19 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { )} + + {planFeePaidInAdvance && ( + + + Your {organization?.plan?.name && `${organization.plan.name} `}Plan fee for + this period has already been paid. This invoice will only reflect usage + charges. + + + )}
diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index ebd99a5be18..4edc8c4d986 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -5444,6 +5444,33 @@ export interface components { cloud_provider: 'AWS' | 'FLY' | 'AWS_K8S' | 'AWS_NIMBUS' custom_supabase_internal_requests?: { ami: { + /** + * @description Exact AWS instance type to provision (e.g. `t3.nano`, `t4g.nano`). When omitted the default for `desired_instance_size` is used. Only for internal use; rejected for user-facing requests in production. + * @enum {string} + */ + instance_type?: + | 't4g.nano' + | 't3.nano' + | 't3a.nano' + | 't4g.micro' + | 't4g.small' + | 't4g.medium' + | 'm6g.medium' + | 'm6g.large' + | 'm6g.xlarge' + | 'm6g.2xlarge' + | 'm6g.4xlarge' + | 'm6g.8xlarge' + | 'm6g.12xlarge' + | 'm6g.16xlarge' + | 'm8g.24xlarge' + | 'c8g.24xlarge' + | 'r8g.24xlarge' + | 'x8g.24xlarge' + | 'm8g.48xlarge' + | 'c8g.48xlarge' + | 'r8g.48xlarge' + | 'x8g.48xlarge' search_tags?: { [key: string]: string } @@ -9914,6 +9941,7 @@ export interface components { } RestorePhysicalBackupBody: { id: number + recovery_time_target?: string } RevokeAuthorizedOAuthAppResponse: { authorized_at?: string @@ -10390,6 +10418,8 @@ export interface components { billing_cycle_start: string currency: string customer_balance: number + /** @enum {string} */ + fixed_fees_billing_mode: 'in_advance' | 'in_arrears' lines: { amount: number amount_before_discount: number @@ -15222,7 +15252,6 @@ export interface operations { OrgAuditLogsController_getAuditLogs: { parameters: { query: { - format?: 'legacy' | 'v2' /** @description End timestamp */ iso_timestamp_end: string /** @description Start timestamp */ @@ -19446,7 +19475,6 @@ export interface operations { UserAuditLogsController_getAuditLogs: { parameters: { query: { - format?: 'legacy' | 'v2' /** @description End timestamp */ iso_timestamp_end: string /** @description Start timestamp */ From e55411da5ed6aab85a466189b68619735f08782d Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Tue, 12 May 2026 02:24:47 +0800 Subject: [PATCH 09/98] feat(studio): Fly.io deprecation banner (#45778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adding an in-dashboard banner for the Fly.io May 31 suspension. Banner targets users on a Fly project (or with a Fly project in their currently-selected org) and surfaces a per-project breakdown of what's affected in a dialog. Detection is self-correcting: as soon as the user migrates off Fly, the banner disappears with no follow-up. Screenshot 2026-05-11 at 5 08 22 PM Screenshot 2026-05-11 at 3 55 06 PM ## Changes - **Detection hook** (`useFlyDeprecationProjects`): reads only from already-cached data — `useSelectedProjectQuery` for the current project, plus `useOrgProjectsInfiniteQuery` scoped to the selected org. Zero cross-org fan-out: worst case is one paginated query per session (the same one the project list page already makes). - **Banner component** (`FlyDeprecationBanner.tsx`): mounted in `AppBannerWrapper`. Dynamic title (primaries / branches / both), dialog lists affected projects with org name, numbered migration steps, links to backup/restore CLI + Dashboard backup + branching docs. List truncates to 5 entries with "…and N more." tail when more are affected. - **Telemetry**: `fly_deprecation_banner_exposed` and `fly_deprecation_banner_dismissed` events emitted via `useTrack` (auto-injects project + org groups). Properties: `primaryCount`, `branchCount`. CTA click tracking intentionally omitted — migration outcome is measured via warehouse `cloud_provider = 'FLY'` decay. - **LocalStorage**: dated dismissal key `FLY_DEPRECATION_2026_05_31`; orphan `FLY_POSTGRES_DEPRECATION_WARNING` from PR #33510 removed in the same change so users who dismissed the Feb 2025 banner still see this one. - **Support contact**: email `success@supabase.io` only (no support ticket link), per Brian's outreach copy in the Linear issues. ## Coverage trade-off Banner renders on project pages (selected-project check) and pages where the selected org's projects list is cached (org overview, project list). It does **not** render on `/dashboard` home or other pages without org context. Email outreach from GROWTH-817 / GROWTH-819 handles those users. This was a deliberate trade-off to avoid cross-org fan-out load. ## Lifecycle Banner expires `2026-06-01T00:00:00Z` (right after the May 31 deadline). Stale client bundles stop rendering it without a redeploy. Cleanup PR planned post-deadline to remove the component, hook, localStorage key, and telemetry events. ## Testing Tested on the Vercel preview with React Query cache overrides to mock a Fly project: - [x] Banner renders for a user with at least one project where `cloud_provider === 'FLY'` - [x] Banner does **not** render for a user with no Fly projects - [x] Banner does **not** render on `/sign-in` - [x] Title varies by primaries-only / branches-only / both - [x] Dialog lists affected projects with org name in parens - [x] Dialog list truncates to 5 with "…and N more." for larger sets - [x] Migration guide / Dashboard backup / branching links open in a new tab - [x] Dismiss (×) closes the banner and persists across hard reload (localStorage `fly-deprecation-2026-05-31-dismissed`) - [x] PostHog receives one `fly_deprecation_banner_exposed` per mount with `primaryCount` + `branchCount` and `$groups.organization` populated - [x] PostHog receives one `fly_deprecation_banner_dismissed` on close with the same property shape ## Linear - fixes GROWTH-817 - fixes GROWTH-819 --- .../interfaces/App/AppBannerWrapper.tsx | 2 + .../AppLayout/FlyDeprecationBanner.tsx | 222 ++++++++++++++++++ .../hooks/misc/useFlyDeprecationProjects.ts | 62 +++++ packages/common/constants/local-storage.ts | 2 +- packages/common/telemetry-constants.ts | 32 +++ 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 apps/studio/components/layouts/AppLayout/FlyDeprecationBanner.tsx create mode 100644 apps/studio/hooks/misc/useFlyDeprecationProjects.ts diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index b59ad529fab..22a4cb613c1 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -3,6 +3,7 @@ import { PropsWithChildren } from 'react' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner' +import { FlyDeprecationBanner } from '@/components/layouts/AppLayout/FlyDeprecationBanner' import { NoticeBanner, NoticeBanner2 } from '@/components/layouts/AppLayout/NoticeBanner' import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner' @@ -17,6 +18,7 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { {showNoticeBanner && } {showNoticeBanner2 && } + {/* Disabled until reintroduced or removed altogether. */} {/* */} diff --git a/apps/studio/components/layouts/AppLayout/FlyDeprecationBanner.tsx b/apps/studio/components/layouts/AppLayout/FlyDeprecationBanner.tsx new file mode 100644 index 00000000000..b4517d5447c --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/FlyDeprecationBanner.tsx @@ -0,0 +1,222 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { useRouter } from 'next/router' +import { useEffect, useRef, type ReactNode } from 'react' +import { + Button, + cn, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, +} from 'ui' + +import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner' +import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' +import { + useFlyDeprecationProjects, + type FlyDeprecationProject, +} from '@/hooks/misc/useFlyDeprecationProjects' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { useTrack } from '@/lib/telemetry/track' + +const BANNER_EXPIRES_AT = new Date('2026-06-01T00:00:00Z') +const BACKUP_RESTORE_CLI_URL = + 'https://supabase.com/docs/guides/platform/migrating-within-supabase/backup-restore' +const DASHBOARD_RESTORE_URL = + 'https://supabase.com/docs/guides/platform/migrating-within-supabase/dashboard-restore' +const BRANCHING_DASHBOARD_URL = 'https://supabase.com/docs/guides/deployment/branching/dashboard' +const SUPPORT_EMAIL = 'success@supabase.io' + +export const FlyDeprecationBanner = () => { + const router = useRouter() + + const [acknowledged, setAcknowledged, { isSuccess }] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.FLY_DEPRECATION_2026_05_31, + false + ) + + const isExpired = Date.now() >= BANNER_EXPIRES_AT.getTime() + const onSignIn = router.pathname.startsWith('/sign-in') + + const shouldEvaluate = !isExpired && !onSignIn && isSuccess && !acknowledged + + const { isReady, primaries, branches } = useFlyDeprecationProjects({ enabled: shouldEvaluate }) + + const hasFlyResources = primaries.length > 0 || branches.length > 0 + + const track = useTrack() + + const exposedRef = useRef(false) + useEffect(() => { + if (!shouldEvaluate || !isReady || !hasFlyResources || exposedRef.current) return + exposedRef.current = true + track('fly_deprecation_banner_exposed', { + primaryCount: primaries.length, + branchCount: branches.length, + }) + }, [shouldEvaluate, isReady, hasFlyResources, primaries.length, branches.length, track]) + + if (!shouldEvaluate || !isReady || !hasFlyResources) return null + + const onDismiss = () => { + track('fly_deprecation_banner_dismissed', { + primaryCount: primaries.length, + branchCount: branches.length, + }) + setAcknowledged(true) + } + + const title = + primaries.length > 0 && branches.length > 0 + ? 'Action required: Fly.io project and branch suspensions begin May 31' + : primaries.length > 0 + ? 'Action required: Fly.io project suspensions begin May 31' + : 'Action required: Fly.io branch suspensions begin May 31' + + return ( + } + onDismiss={onDismiss} + /> + ) +} + +const FlyDeprecationDialog = ({ + primaries, + branches, +}: { + primaries: FlyDeprecationProject[] + branches: FlyDeprecationProject[] +}) => { + return ( + + + View affected projects + + + + Fly.io deprecation: suspensions begin May 31, 2026 + + Supabase will begin suspending projects and branches still running on Fly.io + infrastructure on May 31, 2026. + + + + + + +

+ To preserve your data, migrate each project to Supabase's general infrastructure: +

+
    +
  1. + Back up your database using the{' '} + Supabase CLI (or take a{' '} + Dashboard backup). +
  2. +
  3. Create a new project on Supabase's general infrastructure.
  4. +
  5. Restore the backup into the new project.
  6. +
+ + } + /> + + +

Merge preview branches before May 31. For persistent branches:

+
    +
  1. + Take a{' '} + + snapshot of the branch database + {' '} + before shutting it down. +
  2. +
  3. + + Deploy a new persistent branch + {' '} + on Supabase's general infrastructure. +
  4. +
  5. Restore your data manually from the snapshot.
  6. +
+ + } + /> + + +

+ Questions or need an extension? Email{' '} + + {SUPPORT_EMAIL} + + . +

+
+ + + + + + +
+
+ ) +} + +const MAX_LISTED = 5 + +const ProjectList = ({ + label, + items, + instructions, +}: { + label: string + items: FlyDeprecationProject[] + instructions: ReactNode +}) => { + if (items.length === 0) return null + const visible = items.slice(0, MAX_LISTED) + const remaining = items.length - visible.length + return ( + <> + +

+ {label} ({items.length}) +

+ {items.length === 1 ? ( +

+ {items[0].name} ({items[0].orgName}) +

+ ) : ( +
    + {visible.map((p) => ( +
  • + {p.name} ({p.orgName}) +
  • + ))} + {remaining > 0 && ( +
  • …and {remaining} more.
  • + )} +
+ )} +
+ {instructions} + + ) +} diff --git a/apps/studio/hooks/misc/useFlyDeprecationProjects.ts b/apps/studio/hooks/misc/useFlyDeprecationProjects.ts new file mode 100644 index 00000000000..c2fc392c87d --- /dev/null +++ b/apps/studio/hooks/misc/useFlyDeprecationProjects.ts @@ -0,0 +1,62 @@ +import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { PROVIDERS } from '@/lib/constants/infrastructure' + +export interface FlyDeprecationProject { + ref: string + name: string + orgSlug: string + orgName: string +} + +export function useFlyDeprecationProjects({ enabled }: { enabled: boolean }) { + const { data: selectedProject } = useSelectedProjectQuery() + const { data: selectedOrg } = useSelectedOrganizationQuery() + + const orgSlug = selectedOrg?.slug + const orgName = selectedOrg?.name ?? '' + + const { data: orgProjectsData, isFetched } = useOrgProjectsInfiniteQuery( + { slug: orgSlug }, + { enabled: enabled && Boolean(orgSlug), staleTime: 30 * 60 * 1000 } + ) + + if (!enabled || !orgSlug) { + return { isReady: false, primaries: [], branches: [] } + } + + const byRef = new Map() + + if (selectedProject?.cloud_provider === PROVIDERS.FLY.id) { + byRef.set(selectedProject.ref, { + ref: selectedProject.ref, + name: selectedProject.name, + orgSlug, + orgName, + isBranch: Boolean(selectedProject.parent_project_ref), + }) + } + + const orgProjects = orgProjectsData?.pages.flatMap((page) => page.projects) ?? [] + for (const p of orgProjects) { + if (p.cloud_provider !== PROVIDERS.FLY.id) continue + if (byRef.has(p.ref)) continue + byRef.set(p.ref, { + ref: p.ref, + name: p.name, + orgSlug, + orgName, + isBranch: Boolean(p.is_branch), + }) + } + + const all = Array.from(byRef.values()) + const primaries: FlyDeprecationProject[] = [] + const branches: FlyDeprecationProject[] = [] + for (const { isBranch, ...rest } of all) { + ;(isBranch ? branches : primaries).push(rest) + } + + return { isReady: isFetched, primaries, branches } +} diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index ff35f0137ef..aa427c956ff 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -71,7 +71,7 @@ export const LOCAL_STORAGE_KEYS = { EXPAND_NAVIGATION_PANEL: 'supabase-expand-navigation-panel', GITHUB_AUTHORIZATION_STATE: 'supabase-github-authorization-state', // Notice banner keys - FLY_POSTGRES_DEPRECATION_WARNING: 'fly-postgres-deprecation-warning-dismissed', + FLY_DEPRECATION_2026_05_31: 'fly-deprecation-2026-05-31-dismissed', API_KEYS_FEEDBACK_DISMISSED: (ref: string) => `supabase-api-keys-feedback-dismissed-${ref}`, TERMS_OF_SERVICE_UPDATE: 'terms-of-service-update-2026-06-06', REPORT_DATERANGE: 'supabase-report-daterange', diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 3154ff64a05..58783240a55 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3072,6 +3072,36 @@ export interface ComputeBadgeUpgradeClickedEvent { groups: TelemetryGroups } +/** + * Fly.io deprecation banner rendered for a user with at least one Fly.io project or branch. + * + * @group Events + * @source studio + */ +export interface FlyDeprecationBannerExposedEvent { + action: 'fly_deprecation_banner_exposed' + groups: TelemetryGroups + properties: { + primaryCount: number + branchCount: number + } +} + +/** + * User dismissed the Fly.io deprecation banner. + * + * @group Events + * @source studio + */ +export interface FlyDeprecationBannerDismissedEvent { + action: 'fly_deprecation_banner_dismissed' + groups: TelemetryGroups + properties: { + primaryCount: number + branchCount: number + } +} + /** * User dismissed the free Micro upgrade banner. * @@ -3583,6 +3613,8 @@ export type TelemetryEvent = | OrgMenuBackClickedEvent | OrgMenuItemClickedEvent | ComputeBadgeUpgradeClickedEvent + | FlyDeprecationBannerExposedEvent + | FlyDeprecationBannerDismissedEvent | FreeMicroUpgradeBannerDismissedEvent | FreeMicroUpgradeBannerCtaClickedEvent | HeaderUpgradeCtaClickedEvent From 7092cac4a4eaf96ce7ba139894c44b4cc7473309 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 12 May 2026 01:23:56 +0530 Subject: [PATCH 10/98] e2e: fk save shortcut (#45804) adds coverage for: - #45761 which solved #45759 ## Summary by CodeRabbit * **Tests** * Added test coverage for keyboard shortcut save workflow in the column editor with foreign-key relationships. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45804) --- e2e/studio/features/table-editor.spec.ts | 79 ++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index 6e049ad08ec..47eb18424bb 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -1195,6 +1195,85 @@ testRunner('table editor', () => { await expect(page.getByTestId('table-editor-side-panel')).not.toBeVisible() }) + test('shortcut saves fk before column update', async ({ page, ref }) => { + const runId = `${test.info().parallelIndex}_${test.info().repeatEachIndex}` + const sourceTableName = `pw_column_fk_shortcut_source_${runId}` + const targetTableName = `pw_column_fk_shortcut_target_${runId}` + const columnEditor = page.getByRole('dialog', { + name: `Update column ref_id from ${sourceTableName}`, + exact: true, + }) + const foreignKeySchemaSelect = page.getByRole('combobox', { name: 'Select a schema' }) + const foreignKeyLink = columnEditor.getByRole('link', { + name: `public.${targetTableName}`, + exact: true, + }) + const openColumnEditor = async () => { + await page + .getByRole('columnheader', { name: 'ref_id' }) + .getByRole('button', { name: 'Column ref_id actions' }) + .click() + await page.getByRole('menuitem', { name: 'Edit column' }).click() + await expect(columnEditor).toBeVisible() + } + + await using _ = await withSetupCleanup( + () => + query(` + drop table if exists public.${sourceTableName} cascade; + drop table if exists public.${targetTableName} cascade; + + create table public.${targetTableName} ( + id text primary key + ); + + create table public.${sourceTableName} ( + id bigint generated by default as identity primary key, + ref_id text + ); + `), + async () => { + await dropTable(sourceTableName) + await dropTable(targetTableName) + } + ) + + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await page.getByRole('button', { name: `View ${sourceTableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + await openColumnEditor() + + await columnEditor.getByRole('button', { name: 'Add foreign key' }).click() + await expect(foreignKeySchemaSelect).toContainText('public') + + const tableQueryPromise = waitForApiResponseWithTimeout(page, (response) => + response.url().includes(`table-public-${targetTableName}`) + ) + + await page.getByRole('combobox', { name: 'Select a table to reference to' }).click() + await page.getByRole('option', { name: `public ${targetTableName}` }).click() + await tableQueryPromise + + await page.getByRole('combobox', { name: `Column from public.${targetTableName}` }).click() + await page.getByRole('option', { name: 'id text' }).click() + + await page.keyboard.press('ControlOrMeta+Enter') + + await expect(foreignKeySchemaSelect).not.toBeVisible() + await expect(foreignKeyLink).toBeVisible() + + const updateColumnPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-update', { + method: 'POST', + }) + await columnEditor.getByRole('button', { name: 'Save' }).click() + await updateColumnPromise + await expect(columnEditor).not.toBeVisible() + + await openColumnEditor() + await expect(foreignKeyLink).toBeVisible() + }) + test('CSV drag and drop imports data on empty table', async ({ page, ref }) => { const tableName = 'pw_table_csv_drag_drop' From 13e6b0400435ac5ebc8753c0aa361cef786ee046 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Mon, 11 May 2026 14:18:00 -0600 Subject: [PATCH 11/98] fix(studio): hide incorrect 'last paused' date in project paused state (#45805) Hides the 'Project last paused on' date shown in the paused state for free-plan projects, as the date returned by the API is currently incorrect (API team is investigating) Related ticket: FE-3149 --- .../ProjectLayout/PausedState/ProjectPausedState.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 9dac1bcbd53..ee0682cb8cf 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -99,16 +99,6 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { ? 'Upgrade to Pro to prevent pauses and unlock features like branching, compute upgrades, and daily backups.' : 'To prevent future pauses, consider upgrading to Pro.'}

- {!!pauseStatus.last_paused_on && ( -

- Project last paused on{' '} - -

- )} ) : (

From 791fc744125500e7302b80b6c253e91b31caae1a Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 12 May 2026 10:41:47 +1000 Subject: [PATCH 12/98] feat(studio): shared connect layout for organisation invites (#45774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature. Part of DEPR-279. ## What is the current behavior? The organization invite page has its own bespoke centered card and page-level Supabase logo. ## What is the new behavior? Introduces a minimal shared interstitial layout and migrates `/join` onto it as the first small connect-surface slice. The invite API and accept-invite mutation paths are unchanged. | Before | After | | --- | --- | | Supabase-F2325C57-D5DE-445D-8083-12EF8A1EE0CA | Join Organization
Supabase-66CDA329-0531-4B12-AC32-A7E21931F876 | | CleanShot 2026-03-13 at 11 04
43@2x-2616AECB-8203-4439-A1CD-45AB18FC4CA8
1-584A0600-CCE0-4F16-9111-9BEB94BE85EC | Join Organization
Supabase-26AD978E-4CF9-4600-9885-082084349E94 | | Supabase-4993D74C-D62B-43B7-9681-826BE1591AC4 | Join Organization
Supabase-C84D4E4C-24F5-463D-B1D6-D11D3256596F | | Supabase-D9BD2601-98A4-489D-A51D-CEB73F51FA6F | Join Organization
Supabase-50065F40-179A-4BD6-8F1D-6106FFD8A15C | ## Testing notes Staging invite emails are generated with the fixed staging dashboard origin, for example: ```text https://supabase.green/dashboard/join?token=...&slug=... ``` To test this PR preview with a real invite token, keep the path and query string from the email but replace the origin with the Vercel preview origin, for example: ```text https://studio-staging-git-dnywh-featconnect-interstitial-join-supabase.vercel.app/dashboard/join?token=...&slug=... ``` ### Manual state checks - **Signed out:** open the swapped invite URL in an incognito window or a browser signed out of Studio. Expected: `View invitation`, sign-in/create-account actions, and no loading skeleton hang. - **Wrong account:** sign in to the PR preview as an account that is not the invite recipient, then open the swapped invite URL. Expected: `Wrong account`, warning callout, and `Sign out`. - **Happy path:** sign in as the invited email address, then open the swapped invite URL. Expected: `Join {Organization}`, signed-in account row, `Accept invite`, and `Decline`. Accepting should join the organization. - **Invalid token:** alter one character in the token in the swapped invite URL. Expected: invalid invite state. - **No longer valid:** accept the invite once, then open the same swapped invite URL again. Expected: no-longer-valid/already-used state, depending on the backend response. ### Test-covered states Expired invites, generic backend error, loading, and create-account-disabled states are harder to force manually in staging. They are covered by `tests/components/OrganizationInvite.test.tsx`. ## Summary by CodeRabbit * **Refactor** * Redesigned the organization invitation experience with an interstitial layout, clearer early-return flows for signed-out, loading, expired/invalid, wrong-account, and accepted-invite states; primary CTA now reads “Accept invite”. * Streamlined error and sign-out flows with clearer, focused messaging. * **New Features** * Added a reusable interstitial layout and compact account row for invitation screens. * **Tests** * Added comprehensive tests covering invite states, accept/decline actions, and error handling. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45774) --- .../OrganizationInvite/OrganizationInvite.tsx | 240 +++++++++------ .../OrganizationInviteError.tsx | 117 +++++--- .../components/layouts/InterstitialLayout.tsx | 119 ++++++++ apps/studio/pages/join.tsx | 42 +-- .../components/OrganizationInvite.test.tsx | 283 ++++++++++++++++++ 5 files changed, 626 insertions(+), 175 deletions(-) create mode 100644 apps/studio/components/layouts/InterstitialLayout.tsx create mode 100644 apps/studio/tests/components/OrganizationInvite.test.tsx diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index dd4101c5e45..cb8fcc14995 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -1,21 +1,27 @@ import { useIsLoggedIn, useParams } from 'common' -import { CheckSquare } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' +import type { ReactNode } from 'react' import { toast } from 'sonner' -import { Button, cn } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Button, Card, CardContent } from 'ui' +import { Admonition, ShimmeringLoader } from 'ui-patterns' import { OrganizationInviteError } from './OrganizationInviteError' +import { + InterstitialAccountRow, + InterstitialLayout, + SupabaseLogo, +} from '@/components/layouts/InterstitialLayout' import { useOrganizationAcceptInvitationMutation } from '@/data/organization-members/organization-invitation-accept-mutation' import { useOrganizationInvitationTokenQuery } from '@/data/organization-members/organization-invitation-token-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useProfile } from '@/lib/profile' +import { useProfile, useProfileNameAndPicture } from '@/lib/profile' export const OrganizationInvite = () => { const router = useRouter() const isLoggedIn = useIsLoggedIn() const { profile, isLoading: isLoadingProfile } = useProfile() + const { username, avatarUrl, primaryEmail } = useProfileNameAndPicture() const { slug, token } = useParams() const isSignUpEnabled = useIsFeatureEnabled('dashboard_auth:sign_up') @@ -31,18 +37,53 @@ export const OrganizationInvite = () => { { retry: false, refetchOnWindowFocus: false, - enabled: !!profile, + enabled: !!profile && !!slug && !!token, } ) + const inviteIsNoLongerValid = + error?.code === 401 && error?.message.includes('Failed to retrieve organization') + const inviteIsInvalid = + (isSuccessInvitation && !!data?.token_does_not_exist) || + (isErrorInvitation && error?.code === 404) const hasError = isErrorInvitation || (isSuccessInvitation && (data.token_does_not_exist || data.expired_token || !data.email_match)) - const inviteIsNoLongerValid = - error?.code === 401 && error?.message.includes('Failed to retrieve organization') - const organizationName = isSuccessInvitation ? data?.organization_name : 'An organization' + const isWrongAccount = isSuccessInvitation && !!data && !data.email_match + const showOrganizationHeader = + isSuccessInvitation && + !!data && + !data.token_does_not_exist && + !data.expired_token && + !isWrongAccount + const organizationName = data?.organization_name ?? 'an organization' + const isSignedOut = !isLoggedIn || (!profile && !isLoadingProfile) + const isInvitationLoading = + !isSignedOut && (isLoadingProfile || isLoadingInvitation || !router.isReady) const loginRedirectLink = `/sign-in?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}` const signupRedirectLink = `/sign-up?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}` + const interstitialTitle = inviteIsNoLongerValid + ? 'Invite no longer available' + : isSignedOut + ? 'View invitation' + : isWrongAccount + ? 'Wrong account' + : inviteIsInvalid + ? 'Invite invalid' + : isErrorInvitation + ? 'Unable to load invitation' + : data?.expired_token + ? 'Invite expired' + : showOrganizationHeader + ? `Join ${organizationName}` + : undefined + const interstitialDescription = showOrganizationHeader + ? isSignedOut + ? `Sign in${isSignUpEnabled ? ' or create an account' : ''} to view this invitation` + : 'You have been invited to join this Supabase organization' + : isSignedOut + ? `Sign in${isSignUpEnabled ? ' or create an account' : ''} to view this invitation` + : undefined const { mutate: joinOrganization, isPending: isJoining } = useOrganizationAcceptInvitationMutation({ @@ -60,92 +101,107 @@ export const OrganizationInvite = () => { joinOrganization({ slug, token }) } - return ( -

( + } + title={ + isInvitationLoading ? ( + + ) : interstitialTitle ? ( + interstitialTitle + ) : undefined + } + description={ + isInvitationLoading ? ( + + ) : interstitialDescription ? ( + interstitialDescription + ) : undefined + } + titleClassName="text-xl" > - {!isLoggedIn || (!profile && !isLoadingProfile) ? ( - <> - -
- - {isSignUpEnabled && ( - - )} -
- - ) : isLoadingProfile || isLoadingInvitation ? ( -
- -
- ) : inviteIsNoLongerValid ? ( - <> - -
- -
- - ) : ( - <> -
-

You have been invited to join

-

{organizationName}

- {isSuccessInvitation && ( -

{`Organization slug: ${slug}`}

- )} -
-
-
- {hasError && ( - - )} - {isSuccessInvitation && !hasError && ( -
- - -
- )} +
{children}
+ + ) + + if (isSignedOut) { + return withLayout( +
+ + {isSignUpEnabled && ( + + )} +
+ ) + } + + if (isInvitationLoading) { + return withLayout( +
+ + + +
+ +
-
- - )} + + +
+ + +
+
+ ) + } + + if (inviteIsNoLongerValid) { + return withLayout( +
+ + +
+ ) + } + + if (hasError) { + return withLayout( + + ) + } + + return withLayout( +
+ + +
+ + +
) } diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx index c4c7b1c699c..e009e3789b7 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' -import { cn } from 'ui' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns' -import AlertError from '@/components/ui/AlertError' import { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' import { useSignOut } from '@/lib/auth' import { useProfile } from '@/lib/profile' @@ -11,58 +11,79 @@ interface OrganizationInviteError { data?: OrganizationInviteByToken error?: ResponseError | null isError: boolean + isInvalidInvite?: boolean } -export const OrganizationInviteError = ({ data, error, isError }: OrganizationInviteError) => { +export const OrganizationInviteError = ({ + data, + error, + isError, + isInvalidInvite, +}: OrganizationInviteError) => { const router = useRouter() const signOut = useSignOut() const { profile } = useProfile() - return ( -
- {isError ? ( - { + await signOut() + router.reload() + } + + if (isInvalidInvite) { + return ( + + ) + } + + if (isError) { + return ( + + ) + } + + if (!data?.email_match) { + return ( +
+ + You are signed in as{' '} + {profile.primary_email}. Sign + in with the email address that received this invite. + + ) : ( + 'Sign in with the email address that received this invite.' + ) + } /> - ) : !data?.email_match ? ( -
-

- Your email address {profile?.primary_email} does not match the email address this - invitation was sent to. -

-

- To accept this invitation, you will need to{' '} - { - await signOut() - router.reload() - }} - > - sign out - {' '} - and then sign in or create a new account using the same email address used in the - invitation. -

-
- ) : data.expired_token ? ( -
-

The invite token has expired.

-

- Please request a new one from the organization owner. -

-
- ) : ( -
-

The invite token is invalid.

-

- You could be logged in with the wrong account. Try copying and pasting the link from the - invite email, or ask the organization owner to invite you again. -

-
- )} -
+ +
+ ) + } + + if (data.expired_token) { + return ( + + ) + } + + return ( + ) } diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx new file mode 100644 index 00000000000..cd25002cfa0 --- /dev/null +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -0,0 +1,119 @@ +import { motion } from 'framer-motion' +import type { PropsWithChildren, ReactNode } from 'react' +import { Card, CardContent, CardHeader, cn } from 'ui' + +import { ProfileImage } from '@/components/ui/ProfileImage' +import { BASE_PATH } from '@/lib/constants' + +const MotionCard = motion.create(Card) + +interface InterstitialLayoutProps { + logo?: ReactNode + title?: ReactNode + description?: ReactNode + containerClassName?: string + cardClassName?: string + titleClassName?: string + descriptionClassName?: string +} + +export const InterstitialLayout = ({ + logo, + title, + description, + containerClassName, + cardClassName, + titleClassName, + descriptionClassName, + children, +}: PropsWithChildren) => { + const TitleElement = typeof title === 'string' ? 'h1' : 'div' + const DescriptionElement = typeof description === 'string' ? 'p' : 'div' + + return ( +
+ + {(logo || title || description) && ( + + {logo &&
{logo}
} + {(title || description) && ( +
+ {title && ( + + {title} + + )} + {description && ( + + {description} + + )} +
+ )} +
+ )} + {children} +
+
+ ) +} + +export const LogoBox = ({ children, className }: { children: ReactNode; className?: string }) => ( +
+ {children} +
+) + +export const SupabaseLogo = () => ( + + Supabase + +) + +export const InterstitialAccountRow = ({ + avatarUrl, + displayName, +}: { + avatarUrl?: string + displayName?: string +}) => ( + + + +
+

Signed in as

+

+ {displayName || Loading account} +

+
+
+
+) diff --git a/apps/studio/pages/join.tsx b/apps/studio/pages/join.tsx index cb63ecd6f5f..21c07dddd7c 100644 --- a/apps/studio/pages/join.tsx +++ b/apps/studio/pages/join.tsx @@ -1,48 +1,20 @@ -import { useTheme } from 'next-themes' -import Link from 'next/link' -import { useEffect, useMemo, useState } from 'react' -import { cn } from 'ui' +import Head from 'next/head' import { OrganizationInvite } from '@/components/interfaces/OrganizationInvite/OrganizationInvite' -import { BASE_PATH } from '@/lib/constants' +import { buildStudioPageTitle } from '@/lib/page-title' import type { NextPageWithLayout } from '@/types' +const PAGE_TITLE = buildStudioPageTitle({ section: 'Join Organization', brand: 'Supabase' }) + const JoinOrganizationPage: NextPageWithLayout = () => { - const { resolvedTheme } = useTheme() - const isDarkMode = resolvedTheme?.includes('dark') - - const [mounted, setMounted] = useState(false) - - const imgUrl = useMemo( - () => - isDarkMode ? `${BASE_PATH}/img/supabase-dark.svg` : `${BASE_PATH}/img/supabase-light.svg`, - [isDarkMode] - ) - - useEffect(() => setMounted(true), []) - return ( <> - - {mounted && ( - Supabase - )} - + + {PAGE_TITLE} + ) } -JoinOrganizationPage.getLayout = (page) => ( -
- {page} -
-) - export default JoinOrganizationPage diff --git a/apps/studio/tests/components/OrganizationInvite.test.tsx b/apps/studio/tests/components/OrganizationInvite.test.tsx new file mode 100644 index 00000000000..6f05b49c978 --- /dev/null +++ b/apps/studio/tests/components/OrganizationInvite.test.tsx @@ -0,0 +1,283 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { OrganizationInvite } from '@/components/interfaces/OrganizationInvite/OrganizationInvite' +import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' +import type { ProfileContextType } from '@/lib/profile' +import { render } from '@/tests/helpers' +import type { ResponseError } from '@/types' + +const mocks = vi.hoisted(() => ({ + isLoggedIn: vi.fn(), + useParams: vi.fn(), + useProfile: vi.fn(), + useProfileNameAndPicture: vi.fn(), + useIsFeatureEnabled: vi.fn(), + useInvitationQuery: vi.fn(), + useAcceptInvitationMutation: vi.fn(), + acceptInvitation: vi.fn(), + signOut: vi.fn(), + routerPush: vi.fn(), + routerReload: vi.fn(), +})) + +vi.mock('common', async (importOriginal) => { + const actual = (await importOriginal()) as typeof import('common') + return { + ...actual, + useIsLoggedIn: mocks.isLoggedIn, + useParams: mocks.useParams, + } +}) + +vi.mock('next/router', () => ({ + useRouter: () => ({ + isReady: true, + push: mocks.routerPush, + reload: mocks.routerReload, + }), +})) + +vi.mock('@/lib/profile', () => ({ + useProfile: mocks.useProfile, + useProfileNameAndPicture: mocks.useProfileNameAndPicture, +})) + +vi.mock('@/lib/auth', () => ({ + useSignOut: () => mocks.signOut, +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: mocks.useIsFeatureEnabled, +})) + +vi.mock('@/data/organization-members/organization-invitation-token-query', () => ({ + useOrganizationInvitationTokenQuery: mocks.useInvitationQuery, +})) + +vi.mock('@/data/organization-members/organization-invitation-accept-mutation', () => ({ + useOrganizationAcceptInvitationMutation: mocks.useAcceptInvitationMutation, +})) + +const READY_INVITE: OrganizationInviteByToken = { + authorized_user: true, + email_match: true, + expired_token: false, + invite_id: 42, + organization_name: 'Acme Corp', + sso_mismatch: false, + token_does_not_exist: false, +} + +const PROFILE: ProfileContextType['profile'] = { + id: 1, + auth0_id: 'auth0|test', + gotrue_id: 'gotrue-test', + username: 'jane', + primary_email: 'jane@acmecorp.io', + first_name: null, + last_name: null, + mobile: null, + is_alpha_user: false, + is_sso_user: false, + disabled_features: [], + free_project_limit: null, +} + +const responseError = (message: string, code = 500) => ({ message, code }) as ResponseError + +function setSignedInDefaults() { + mocks.isLoggedIn.mockReturnValue(true) + mocks.useParams.mockReturnValue({ slug: 'acme-corp', token: 'invite-token' }) + mocks.useProfile.mockReturnValue({ + profile: PROFILE, + isLoading: false, + }) + mocks.useProfileNameAndPicture.mockReturnValue({ + username: 'jane', + primaryEmail: 'jane@acmecorp.io', + avatarUrl: undefined, + isLoading: false, + }) + mocks.useIsFeatureEnabled.mockReturnValue(true) + mocks.useInvitationQuery.mockReturnValue({ + data: READY_INVITE, + error: null, + isSuccess: true, + isError: false, + isPending: false, + }) + mocks.useAcceptInvitationMutation.mockReturnValue({ + mutate: mocks.acceptInvitation, + isPending: false, + }) +} + +describe('OrganizationInvite', () => { + beforeEach(() => { + vi.clearAllMocks() + setSignedInDefaults() + }) + + test('renders sign-in actions for signed-out users', () => { + mocks.isLoggedIn.mockReturnValue(false) + mocks.useProfile.mockReturnValue({ profile: null, isLoading: true }) + mocks.useProfileNameAndPicture.mockReturnValue({ + username: undefined, + primaryEmail: undefined, + avatarUrl: undefined, + isLoading: false, + }) + + render() + + expect(screen.getByText('View invitation')).toBeInTheDocument() + expect( + screen.getByText('Sign in or create an account to view this invitation') + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute( + 'href', + '/sign-in?returnTo=%2Fjoin%3Ftoken%3Dinvite-token%26slug%3Dacme-corp' + ) + expect(screen.getByRole('link', { name: 'Create an account' })).toHaveAttribute( + 'href', + '/sign-up?returnTo=%2Fjoin%3Ftoken%3Dinvite-token%26slug%3Dacme-corp' + ) + }) + + test('renders a ready invite for the signed-in account', () => { + render() + + expect(screen.getByText('Join Acme Corp')).toBeInTheDocument() + expect( + screen.getByText('You have been invited to join this Supabase organization') + ).toBeInTheDocument() + expect(screen.getByText('Signed in as')).toBeInTheDocument() + expect(screen.getByText('jane@acmecorp.io')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Accept invite' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Decline' })).toHaveAttribute('href', '/projects') + }) + + test('accepts an invite with the current slug and token', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: 'Accept invite' })) + + expect(mocks.acceptInvitation).toHaveBeenCalledWith({ + slug: 'acme-corp', + token: 'invite-token', + }) + }) + + test('renders a wrong-account warning and signs out', async () => { + const user = userEvent.setup() + mocks.useInvitationQuery.mockReturnValue({ + data: { ...READY_INVITE, email_match: false }, + error: null, + isSuccess: true, + isError: false, + isPending: false, + }) + mocks.signOut.mockResolvedValue(undefined) + + render() + + expect(screen.getByText('Wrong account')).toBeInTheDocument() + expect(screen.queryByText('Join Acme Corp')).not.toBeInTheDocument() + expect( + screen.queryByText('You have been invited to join this Supabase organization') + ).not.toBeInTheDocument() + expect(screen.getByText(/jane@acmecorp\.io/)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Sign out' })) + + await waitFor(() => expect(mocks.signOut).toHaveBeenCalled()) + expect(mocks.routerReload).toHaveBeenCalled() + }) + + test('renders expired and invalid invite states', () => { + mocks.useInvitationQuery.mockReturnValueOnce({ + data: { ...READY_INVITE, expired_token: true }, + error: null, + isSuccess: true, + isError: false, + isPending: false, + }) + + const { rerender } = render() + + expect(screen.getByText('Invite expired')).toBeInTheDocument() + expect( + screen.getByText('Ask the organization owner to send you a new invite.') + ).toBeInTheDocument() + + mocks.useInvitationQuery.mockReturnValueOnce({ + data: { ...READY_INVITE, token_does_not_exist: true }, + error: null, + isSuccess: true, + isError: false, + isPending: false, + }) + + rerender() + + expect(screen.getByText('Invite invalid')).toBeInTheDocument() + expect( + screen.getByText( + 'Open the full invite link again, or ask the organization owner for a new invite.' + ) + ).toBeInTheDocument() + }) + + test('renders no-longer-valid, invalid lookup, and generic error states', () => { + mocks.useInvitationQuery.mockReturnValueOnce({ + data: undefined, + error: responseError('Failed to retrieve organization', 401), + isSuccess: false, + isError: true, + isPending: false, + }) + + const { rerender } = render() + + expect(screen.getByText('Invite no longer available')).toBeInTheDocument() + expect( + screen.getByText('This invite has already been accepted or declined.') + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Back to dashboard' })).toHaveAttribute('href', '/') + + mocks.useInvitationQuery.mockReturnValueOnce({ + data: undefined, + error: responseError('Not Found', 404), + isSuccess: false, + isError: true, + isPending: false, + }) + + rerender() + + expect(screen.getByText('Invite invalid')).toBeInTheDocument() + expect( + screen.getByText( + 'Open the full invite link again, or ask the organization owner for a new invite.' + ) + ).toBeInTheDocument() + expect(screen.queryByText('Not Found')).not.toBeInTheDocument() + + mocks.useInvitationQuery.mockReturnValueOnce({ + data: undefined, + error: responseError('Failed to retrieve token', 500), + isSuccess: false, + isError: true, + isPending: false, + }) + + rerender() + + expect(screen.getByText('Unable to load invitation')).toBeInTheDocument() + expect(screen.getByText('Failed to retrieve token')).toBeInTheDocument() + }) +}) From 7809574fc8ce716c9128447491be6af6c940a469 Mon Sep 17 00:00:00 2001 From: Christian Funkhouser Date: Mon, 11 May 2026 22:31:24 -0600 Subject: [PATCH 13/98] chore(docs): add Christian Funkhouser to humans.txt (#45810) ## 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? An update to humans.txt ## What is the current behavior? When viewing humans.txt, Christian is not included. ## What is the new behavior? When viewing humans.txt, Christian _is_ included. ## Additional context Christian works here now, and wants to be a human, too. ## Summary by CodeRabbit * **Chores** * Updated team contributor acknowledgments. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45810) --- 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 ed643d86737..22f6349c9d8 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -54,6 +54,7 @@ Chris Gwilliams Chris Martin Chris Stockton Chris Ward +Christian Funkhouser Clayton Kast Colin Goodheart-Smithe Colin Murray From 44315b79e9ba4d0090f721b5aaeee0240aed8444 Mon Sep 17 00:00:00 2001 From: Julian Domke <68325451+juleswritescode@users.noreply.github.com> Date: Tue, 12 May 2026 08:18:11 +0200 Subject: [PATCH 14/98] chore(docs): add Julian Domke to humans.txt (#45815) --- 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 22f6349c9d8..18ed262868c 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -144,6 +144,7 @@ Jordan McQueen Jordi Enric José Luis Ledesma Joshen Lim +Julian Domke Julien Goux Kai M Kalleby Santos From bdc8d076324091162814e59a50de6b3f81608556 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 12 May 2026 10:41:21 +0300 Subject: [PATCH 15/98] chore: Add blockExoticSubdeps to prevent GitHub URLs and tarballs (#45817) This pull request introduces a configuration update to the `pnpm-workspace.yaml` file. The most significant change is the addition of the `blockExoticSubdeps: true` setting, which helps prevent the installation of potentially problematic or non-standard subdependencies across the workspace. There is also a minor adjustment in the `overrides` section, but it does not result in any functional changes. ## Summary by CodeRabbit * **Chores** * Enhanced package dependency configuration to prevent exotic subdependencies and improve installation reliability. * Reorganized dependency override specifications for consistency. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45817) --- pnpm-workspace.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8f94154c69b..e5034ec9624 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,8 @@ packages: - blocks/* - e2e/* +blockExoticSubdeps: true + catalog: '@sentry/nextjs': ^10.26.0 '@supabase/auth-js': 2.105.4 @@ -69,8 +71,8 @@ overrides: '@rollup/plugin-terser>serialize-javascript': ^7.0.5 cacache>tar: ^7.5.11 dompurify: ^3.3.2 - express-rate-limit>ip-address: ^10.1.1 esbuild: ^0.25.2 + express-rate-limit>ip-address: ^10.1.1 lodash: 'catalog:' lodash-es: 'catalog:' node-gyp>tar: ^7.5.11 From 34170f6ed347c33444174b99ef7dbec235394746 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 12 May 2026 11:26:54 +0200 Subject: [PATCH 16/98] Add PierreD to humans.txt (#45598) ## 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? Add myself to the human list ## What is the current behavior? I am not in the list. ## What is the new behavior? I want to be in the list. ## Additional context I just joined the company... ## Summary by CodeRabbit * **Chores** * Updated contributor documentation --- 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 18ed262868c..9fe6c181089 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -209,6 +209,7 @@ Paweł Gulbinowicz Pedro Rodrigues Peter Lyn Peter Soderberg +Pierre Ducroquet Qiao Han Rafael Chacón Raminder Singh From 0b3e5686cd180fd46ce1e2572e2fcbfb9afe40a5 Mon Sep 17 00:00:00 2001 From: Marija Milicevic Date: Tue, 12 May 2026 11:27:27 +0200 Subject: [PATCH 17/98] Add Marija Milicevic to humans.txt (#45577) ## 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? 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. ## Summary by CodeRabbit * **Chores** * Added a new team member to the contributor list. --- 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 9fe6c181089..8089e3d14e5 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -177,6 +177,7 @@ Manuel Mendez Manan Gupta Marcus Weiner Margarita Sandomirskaia +Marija Milicevic Mark Burggraf Matthew Hambright Matt Hudson From 01734f473ad6017be602e9ba94e4cbe5609e22f5 Mon Sep 17 00:00:00 2001 From: Jess Date: Tue, 12 May 2026 17:27:40 +0800 Subject: [PATCH 18/98] Update humans.txt (#45506) Update new joiner ## 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? Update human.txt ## What is the current behavior? NA ## What is the new behavior? NA ## Additional context Add any other context or screenshots. ## Summary by CodeRabbit * **Chores** * Added new team member to public roster --- 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 8089e3d14e5..121b9fdebc5 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -125,6 +125,7 @@ Jean-Paul Argudo Jeff Smick Jenny Kibiri Jeremias Menichelli +Jess Fink Jess Shears Jim Brodeur Jim Chanco Jr From 0e6390df6c3cf4f7a366190cfb6d2e698d2bd334 Mon Sep 17 00:00:00 2001 From: rkasprzak-supabase Date: Tue, 12 May 2026 05:34:32 -0400 Subject: [PATCH 19/98] Add 'Richard Kasprzak' to the list of contributors (#45587) ## 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? Update to add 'Richard Kasprzak' to 'humans.txt' ## What is the current behavior? 'Richard Kasprzak' is not present in 'humans.txt' ## What is the new behavior? 'Richard Kasprzak' is present in 'humans.txt' ## Summary by CodeRabbit * **Chores** * Updated team roster information. --- 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 121b9fdebc5..291d9648f51 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -217,6 +217,7 @@ Rafael Chacón Raminder Singh Raúl Barroso Riccardo Busetti +Richard Kasprzak Rob Shields Rodrigo Esteves Rodrigo Mansueli From 6bd4812348d4c6f77ca0e4a144b08676238b90f1 Mon Sep 17 00:00:00 2001 From: Brian Cullen Date: Tue, 12 May 2026 10:35:59 +0100 Subject: [PATCH 20/98] Update humans.txt (#45638) Added myself to humans.txt ## Summary by CodeRabbit * **Chores** * Updated team roster in public documentation. Co-authored-by: Chris Chinchilla --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 291d9648f51..f035b189420 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -38,6 +38,7 @@ Bobbie Soedirgo Brad Gyger Brendan Stephens Brent Newson +Brian Cullen Brent Schroeter Carel de Waal Cameron Blackwood From 8255a63a868c91da8062fd3abb3ee80c66cad270 Mon Sep 17 00:00:00 2001 From: Riccardo Busetti Date: Tue, 12 May 2026 11:54:16 +0200 Subject: [PATCH 21/98] ref(etl): Improve error message handling (#45713) --- .../DestinationForm/NewPublicationPanel.tsx | 48 ++++++++++--------- .../DestinationForm/index.tsx | 10 ++-- .../Database/Replication/DestinationRow.tsx | 8 ++-- .../Database/Replication/Destinations.tsx | 3 +- .../Database/Replication/Pipeline.utils.ts | 12 ----- .../ReplicationPipelineStatus.tsx | 14 ++++-- .../Database/Replication/RowMenu.tsx | 21 ++++---- .../Replication/UpdateVersionModal.tsx | 9 ++-- 8 files changed, 61 insertions(+), 64 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx index d23e5cec4a6..6f8f5c05891 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NewPublicationPanel.tsx @@ -33,12 +33,18 @@ interface NewPublicationPanelProps { export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicationPanelProps) => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const { mutateAsync: createPublication, isPending: creatingPublication } = - useCreatePublicationMutation() - const { data: tables } = useReplicationTablesQuery({ - projectRef, - sourceId, - }) + + const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId }) + + const { mutate: createPublication, isPending: creatingPublication } = + useCreatePublicationMutation({ + onSuccess: () => { + toast.success('Successfully created publication') + form.reset(defaultValues) + onClose() + }, + }) + const formId = 'publication-editor' const FormSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -59,23 +65,19 @@ export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicati if (!projectRef) return console.error('Project ref is required') if (!project) return console.error('Project is required') if (!sourceId) return console.error('Source id is required') - try { - await createPublication({ - projectRef, - sourceId, - name: data.name, - tables: data.tables.map((table) => { - const [schema, name] = table.split('.') - return { schema, name } - }), - connectionString: project.connectionString, - }) - toast.success('Successfully created publication') - onClose() - } catch (error) { - toast.error('Failed to create publication') - } - form.reset(defaultValues) + + const tables = data.tables.map((table) => { + const [schema, name] = table.split('.') + return { schema, name } + }) + + createPublication({ + projectRef, + sourceId, + name: data.name, + tables, + connectionString: project.connectionString, + }) } return ( diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx index 63bae60250a..e9997898784 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -56,6 +56,7 @@ import { PipelineStatusRequestStatus, usePipelineRequestStatus, } from '@/state/replication-pipeline-request-status' +import { type ResponseError } from '@/types' const formId = 'destination-editor' @@ -368,8 +369,11 @@ export const DestinationForm = ({ const hasRequestError = results.some((r) => r.status === 'rejected') if (hasRequestError) { - // If any request failed, show a generic error and stop - toast.error('Failed to validate configuration. Please try again.') + // If any request failed, surface the upstream message so users see why + const rejected = results.find((r): r is PromiseRejectedResult => r.status === 'rejected') + const reason = + rejected?.reason instanceof Error ? rejected.reason.message : 'Please try again.' + toast.error(`Failed to validate configuration: ${reason}`) setHasRunValidation(false) return false } @@ -481,7 +485,7 @@ export const DestinationForm = ({ } } catch (error) { const action = editMode ? 'apply and run' : 'create and start' - toast.error(`Failed to ${action} destination`) + toast.error(`Failed to ${action} destination: ${(error as ResponseError).message}`) } } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index 7862020cac6..59ca3701744 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -16,7 +16,6 @@ import { import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { DeleteDestination } from './DeleteDestination' -import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' import { PipelineStatus } from './PipelineStatus' import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' import { RowMenu } from './RowMenu' @@ -32,6 +31,7 @@ import { PipelineStatusRequestStatus, usePipelineRequestStatus, } from '@/state/replication-pipeline-request-status' +import { type ResponseError } from '@/types' interface DestinationRowProps { destinationId: number @@ -99,7 +99,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { return console.error('Project ref is required') } if (!pipeline) { - return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + return toast.error('No pipeline found') } try { @@ -114,7 +114,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { setShowDeleteDestinationForm(false) toast.success(`Deleted destination "${destinationName}"`) } catch (error) { - toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION) + toast.error(`Failed to delete destination: ${(error as ResponseError).message}`) } finally { setIsDeleting(false) } @@ -129,7 +129,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { return ( <> {isPipelineError && ( - + )} {isPipelineSuccess && ( diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index 449a36c43ee..d588b953537 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -27,7 +27,6 @@ import { DestinationPanel } from './DestinationPanel/DestinationPanel' import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DestinationRow } from './DestinationRow' import { DisableExternalReplicationDialog } from './DisableExternalReplicationDialog' -import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow' import { useIsETLBigQueryPrivateAlpha, @@ -262,7 +261,7 @@ export const Destinations = () => { {hasErrorsFetchingData && ( )} diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts index 387e6a88509..9064e4fd2f1 100644 --- a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts @@ -2,18 +2,6 @@ import { PipelineStatusName } from './Replication.constants' import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query' import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status' -export const PIPELINE_ERROR_MESSAGES = { - RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information', - RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status', - RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status', - RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations', - ENABLE_DESTINATION: 'Failed to enable destination', - DISABLE_DESTINATION: 'Failed to disable destination', - DELETE_DESTINATION: 'Failed to delete destination', - NO_PIPELINE_FOUND: 'No pipeline found', - COPY_TABLE_STATUS: 'Failed to copy table status', -} as const - export const getStatusName = ( status: ReplicationPipelineStatusData['status'] | undefined ): PipelineStatusName | undefined => { diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx index caa5c9777bf..915389d6033 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx @@ -39,7 +39,6 @@ import { getPipelineDisplayState, getStatusName, PIPELINE_ACTIONABLE_STATES, - PIPELINE_ERROR_MESSAGES, } from '../Pipeline.utils' import { PipelineStatus } from '../PipelineStatus' import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' @@ -62,6 +61,7 @@ import { PipelineStatusRequestStatus, usePipelineRequestStatus, } from '@/state/replication-pipeline-request-status' +import { type ResponseError } from '@/types' /** * Component for displaying replication pipeline status and table replication details. @@ -232,8 +232,14 @@ export const ReplicationPipelineStatus = () => { const onPrimaryAction = async () => { if (!projectRef) return console.error('Project ref is required') - if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + if (!pipeline) return toast.error('No pipeline found') + const action = + statusName === PipelineStatusName.STOPPED + ? 'start' + : statusName === PipelineStatusName.STARTED + ? 'stop' + : 'restart' try { if (statusName === PipelineStatusName.STOPPED) { setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName) @@ -247,7 +253,7 @@ export const ReplicationPipelineStatus = () => { } } catch (error) { setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) - toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) + toast.error(`Failed to ${action} pipeline: ${(error as ResponseError).message}`) } } @@ -315,7 +321,7 @@ export const ReplicationPipelineStatus = () => {
{isPipelineError && ( - + )} {isStatusError && ( diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx index f2a9115e6f8..6360b277b79 100644 --- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -16,7 +16,6 @@ import { getStatusName, PIPELINE_DISABLE_ALLOWED_FROM, PIPELINE_ENABLE_ALLOWED_FROM, - PIPELINE_ERROR_MESSAGES, } from './Pipeline.utils' import { PipelineStatusName } from './Replication.constants' import AlertError from '@/components/ui/AlertError' @@ -88,46 +87,46 @@ export const RowMenu = ({ const onEnablePipeline = async () => { if (!projectRef) return console.error('Project ref is required') - if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + if (!pipeline) return toast.error('No pipeline found') try { // Only show 'enabling' when transitioning from allowed states - if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as any)) { + if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) { setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName) } await startPipeline({ projectRef, pipelineId: pipeline.id }) } catch (error) { setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) - toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) + toast.error(`Failed to start pipeline: ${(error as ResponseError).message}`) } } const onDisablePipeline = async () => { if (!projectRef) return console.error('Project ref is required') - if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + if (!pipeline) return toast.error('No pipeline found') try { // Only show 'disabling' when transitioning from allowed states - if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as any)) { + if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) { setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName) } await stopPipeline({ projectRef, pipelineId: pipeline.id }) } catch (error) { setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) - toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION) + toast.error(`Failed to stop pipeline: ${(error as ResponseError).message}`) } } const onRestartPipeline = async () => { if (!projectRef) return console.error('Project ref is required') - if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + if (!pipeline) return toast.error('No pipeline found') try { setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName) await restartPipeline({ projectRef, pipelineId: pipeline.id }) } catch (error) { setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) - toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) + toast.error(`Failed to restart pipeline: ${(error as ResponseError).message}`) } } @@ -135,9 +134,7 @@ export const RowMenu = ({
{isLoading && } - {isError && ( - - )} + {isError && } diff --git a/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx b/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx index 62fccf8478a..7f45fd1c002 100644 --- a/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx +++ b/apps/studio/components/interfaces/Database/Replication/UpdateVersionModal.tsx @@ -20,6 +20,7 @@ import { PipelineStatusRequestStatus, usePipelineRequestStatus, } from '@/state/replication-pipeline-request-status' +import { type ResponseError } from '@/types' interface UpdateVersionModalProps { visible: boolean @@ -65,9 +66,9 @@ export const UpdateVersionModal = ({ // Step 1: Update to the new version try { await updatePipelineVersion({ projectRef, pipelineId: pipeline.id, versionId }) - } catch (e: any) { + } catch (e) { // 404: default changed; version cache will refresh via mutation onError. Keep dialog open. - if (e?.code === 404) return + if ((e as ResponseError)?.code === 404) return // Other errors are already toasted by the mutation; do not double-toast here. return } @@ -80,10 +81,10 @@ export const UpdateVersionModal = ({ try { await restartPipeline({ projectRef, pipelineId: pipeline.id }) toast.success('Pipeline successfully updated and is currently restarting') - } catch (e: any) { + } catch (e) { // Clear optimistic state and surface a single concise error setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) - toast.error('Failed to restart pipeline') + toast.error(`Failed to restart pipeline: ${(e as ResponseError).message}`) } } else { toast.success('Pipeline successfully updated') From 602b879e4aa26290a09296fd475307fc722c3eb8 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 12 May 2026 18:21:03 +0800 Subject: [PATCH 22/98] Fix table editor editing a row, setting a timestamptz column to NULL doesn't work (#45824) ## Context There's a bug atm with the Table Editor whereby hitting "Set to NULL" on a date time format column, doesn't set it to `null` but rather an empty string - which then causes an error when trying to update the row, so this PR addresses that image ## Summary by CodeRabbit * **Bug Fixes** * Fixed date/time input field handling to properly support NULL value assignment. The "Set to NULL" action now correctly applies NULL values instead of empty strings in the table editor. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45824) --- .../SidePanelEditor/RowEditor/DateTimeInput/index.tsx | 4 ++-- .../TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/DateTimeInput/index.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/DateTimeInput/index.tsx index f7e280f896e..84032262ea4 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/DateTimeInput/index.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/DateTimeInput/index.tsx @@ -19,7 +19,7 @@ interface DateTimeInputProps { value: string isNullable: boolean description: string | ReactNode - onChange: (value: string) => void + onChange: (value: string | null) => void disabled?: boolean } @@ -65,7 +65,7 @@ export const DateTimeInput = ({ {isNullable && ( - onChange('')}>Set to NULL + onChange(null)}>Set to NULL )} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx index c1c0289d9be..300f170d6ed 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx @@ -276,7 +276,7 @@ export const InputField = ({ {field.comment &&

{field.comment}

} } - onChange={(value: any) => onUpdateField({ [field.name]: value })} + onChange={(value: string | null) => onUpdateField({ [field.name]: value })} disabled={!isEditable} /> ) From 52237e1ae481066fed60a899357a911658b3b95f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 12 May 2026 21:28:10 +0800 Subject: [PATCH 23/98] Open advanced settings + scroll to that collapsible if submitting has an error (#45818) ## Context Compute and Disk page: There's an odd scenario whereby down sizing the compute might involve some validation errors in the advanced settings, in which case because the advanced settings is in a collapsible and requires the user to scroll down, there's hence no visual indication of where the error is until the user opens the advanced settings to see the inline error. Am hence opting to open the advanced settings + scroll to it for this particular scenario, if submitting the form causes a validation issue on either IOPS, throughput or max disk size field inputs (the rest in this section do not have validation checks on that afaict and hence wouldn't be applicable to this scenario) ## To test Can be tested locally - Upsize compute to 8XL + change throughput to 750 - Once completed, try to down size to XL and hit "review changes" - It should be clear from a UX POV if there are any errors Also added an unrelated nit tidy up for ExitSurveyModal ## Summary by CodeRabbit * **Bug Fixes** * Advanced disk settings now auto-open and scroll into view (with a brief delay) when validation errors occur for throughput, IOPS, or size, improving error visibility. * **User Interface** * Improved advanced settings panel animation and separators for clearer visual boundaries. * Updated project deletion layout for the reason chooser and message input, providing a more consistent form appearance. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45818) --- .../DiskManagement/DiskManagementForm.tsx | 103 +++++++++++------- .../DeleteProjectPanel/DeleteProjectModal.tsx | 36 +++--- 2 files changed, 81 insertions(+), 58 deletions(-) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index 76c075fbf7e..f105c7d6e6d 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { ChevronRight } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { CloudProvider } from 'shared-data' import { toast } from 'sonner' @@ -13,6 +13,7 @@ import { Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, + DialogSectionSeparator, Form, Separator, } from 'ui' @@ -77,6 +78,8 @@ export function DiskManagementForm() { const { data: org } = useSelectedOrganizationQuery() const { setProjectStatus } = useSetProjectStatus() + const advancedSettingsRef = useRef(null) + const isSpendCapEnabled = org?.plan.id !== 'free' && !org?.usage_billing_enabled && project?.cloud_provider !== 'FLY' @@ -163,43 +166,8 @@ export function DiskManagementForm() { reValidateMode: 'onChange', }) - useEffect(() => { - if (!isDiskAttributesSuccess) return - // @ts-ignore - const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 } - const formValues = { - storageType: type, - provisionedIOPS: iops, - throughput: throughput_mbps, - totalSize: size_gb, - computeSize: form.getValues('computeSize'), - } - - if (!('requested_modification' in data)) { - if (refetchInterval !== false) { - form.reset(formValues) - setRefetchInterval(false) - toast.success('Disk configuration changes have been successfully applied!') - } - } else { - setRefetchInterval(2000) - } - }, [data, isDiskAttributesSuccess, form, refetchInterval]) - const { computeSize: modifiedComputeSize } = form.watch() - // We only support disk configurations for >=Large instances - // If a customer downgrades back to { - if (modifiedComputeSize && project?.infra_compute_size && isDialogOpen) { - if (RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)) { - form.setValue('storageType', DiskType.GP3) - form.setValue('throughput', DISK_LIMITS['gp3'].minThroughput) - form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops) - } - } - }, [modifiedComputeSize, isDialogOpen, project]) - const isSuccess = isAddonsSuccess && isDiskAttributesSuccess && @@ -213,6 +181,7 @@ export function DiskManagementForm() { const isPlanUpgradeRequired = !hasAccess const { formState } = form + const errors = formState.errors const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 const totalSize = formState.defaultValues?.totalSize || 0 const usedPercentage = (usedSize / totalSize) * 100 @@ -328,6 +297,41 @@ export function DiskManagementForm() { } } + useEffect(() => { + if (!isDiskAttributesSuccess) return + // @ts-ignore + const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 } + const formValues = { + storageType: type, + provisionedIOPS: iops, + throughput: throughput_mbps, + totalSize: size_gb, + computeSize: form.getValues('computeSize'), + } + + if (!('requested_modification' in data)) { + if (refetchInterval !== false) { + form.reset(formValues) + setRefetchInterval(false) + toast.success('Disk configuration changes have been successfully applied!') + } + } else { + setRefetchInterval(2000) + } + }, [data, isDiskAttributesSuccess, form, refetchInterval]) + + // We only support disk configurations for >=Large instances + // If a customer downgrades back to { + if (modifiedComputeSize && project?.infra_compute_size && isDialogOpen) { + if (RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)) { + form.setValue('storageType', DiskType.GP3) + form.setValue('throughput', DISK_LIMITS['gp3'].minThroughput) + form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops) + } + } + }, [modifiedComputeSize, isDialogOpen, project]) + useEffect(() => { // Initialize field values properly when data has been loaded, preserving any user changes if (isDiskAttributesSuccess || isSuccess) { @@ -336,6 +340,26 @@ export function DiskManagementForm() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, isDiskAttributesSuccess]) + useEffect(() => { + const fieldErrors = Object.keys(errors) + if (fieldErrors.length > 0) { + if ( + fieldErrors.includes('throughput') || + fieldErrors.includes('provisionedIOPS') || + fieldErrors.includes('maxSizeGb') + ) { + setAdvancedSettingsOpenState(true) + + // [Joshen] The timeout is to let the collapsible open prior to scrolling + const timeoutId = setTimeout(() => { + advancedSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 100) + + return () => clearTimeout(timeoutId) + } + } + }, [errors]) + return ( <> @@ -447,8 +471,6 @@ export function DiskManagementForm() { setAdvancedSettingsOpenState((prev) => !prev)} > @@ -467,16 +489,17 @@ export function DiskManagementForm() { />
- +
+ {/* [Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor but leaving that for the future. */} {!isFree && ( - <> -
-

What made you decide to delete your project?

-
-
+
+
{shuffledReasons.map((option) => { const active = selectedReason[0] === option.value @@ -181,19 +183,17 @@ export const DeleteProjectModal = ({ ) })}
-
- -