From bd02d7f297481e7728dea459854ef536ba984733 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:28:15 +1000 Subject: [PATCH 01/11] feat(www + studio): broaden Select 2026 banner reach and soften www edges (#49569) ## What kind of change does this PR introduce? Feature polish for the Select 2026 promotion. ## What is the current behavior? - Studio only shows the Select Banner Stack card inside `ProjectLayout` (project routes). - www glyph fields read as hard rectangles on each side of the announcement banner. ## What is the new behavior? - Studio registers the Select banner from `AppBannerWrapper`, so it also appears outside project context (org / account surfaces). - www fields use per-row widths with edge alignment: top/middle shorter, bottom longer, growing inward from each side for a softer silhouette. https://github.com/user-attachments/assets/c5275967-63d0-40dd-a472-e3db08d1c39d ## To test - **Studio (non-project):** open an org home or account page on the deploy preview. Confirm the Select Banner Stack card appears in the usual stack and dismisses as before. - **Studio (project):** open any project route. Confirm the same card still appears and does not double-register. - **www:** open the marketing homepage. On `sm+`, confirm each side of the cream announcement bar has a 3-row field where the bottom row reaches further inward than the top, and the middle row is shortest. ## Summary by CodeRabbit * **New Features** * Added the Select 2026 promotional banner to eligible hosted Studio environments. * Banner visibility reflects promotion status, platform eligibility, loading state, and dismissal preferences. * **Style** * Refined decorative field layouts with improved row alignment, mirrored visuals, and flexible sizing. * **Bug Fixes** * Updated visibility behavior so the banner is no longer tied to being inside a specific project. --- .../interfaces/App/AppBannerWrapper.tsx | 41 ++++++++ .../layouts/ProjectLayout/index.tsx | 46 +-------- .../Banners/BannerSelect2026.utils.test.ts | 4 +- .../Banners/BannerSelect2026.utils.ts | 4 +- .../src/Banners/Select26Banner.tsx | 21 +++-- .../src/Banners/Select26Promotion.module.css | 19 ++++ .../src/Banners/Select26Promotion.tsx | 93 ++++++++++++++----- 7 files changed, 148 insertions(+), 80 deletions(-) diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index a0e19ea5d92b0..2c05bd85b65e4 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -2,6 +2,10 @@ import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag } from 'common' import dayjs from 'dayjs' import { usePathname } from 'next/navigation' import { PropsWithChildren, useEffect, useRef, useState } from 'react' +import { + SELECT_26_STUDIO_DISMISSAL_KEY, + useSelect26PromotionActive, +} from 'ui-patterns/Banners/Select26Promotion' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' import { isLogsOrObservabilityPath } from './AppBannerWrapper.utils' @@ -9,6 +13,11 @@ import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner' import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner' import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner' import { BannerLogsAllDeprecation } from '@/components/ui/BannerStack/Banners/BannerLogsAllDeprecation' +import { BannerSelect2026 } from '@/components/ui/BannerStack/Banners/BannerSelect2026' +import { + SELECT_26_BANNER_PRIORITY, + shouldShowSelect26Banner, +} from '@/components/ui/BannerStack/Banners/BannerSelect2026.utils' import { BannerTOSUpdate } from '@/components/ui/BannerStack/Banners/BannerTOSUpdate' import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' @@ -36,6 +45,38 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { false ) + const [isSelect26BannerDismissed, , { isSuccess: isSelect26DismissalLoaded }] = + useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false) + const isSelect26PromotionActive = useSelect26PromotionActive() + + useEffect(() => { + if (!isSelect26DismissalLoaded) return + + const shouldShow = shouldShowSelect26Banner({ + isPlatform: IS_PLATFORM, + dismissalLoaded: isSelect26DismissalLoaded, + isActive: isSelect26PromotionActive, + isDismissed: isSelect26BannerDismissed, + }) + + if (shouldShow) { + addBanner({ + id: BANNER_ID.SELECT_26, + isDismissed: false, + content: , + priority: SELECT_26_BANNER_PRIORITY, + }) + } else { + dismissBanner(BANNER_ID.SELECT_26) + } + }, [ + isSelect26DismissalLoaded, + isSelect26PromotionActive, + isSelect26BannerDismissed, + addBanner, + dismissBanner, + ]) + useEffect(() => { if (Date.now() >= TOSUpdateExpiry.getTime()) return diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 98a01a4a61887..3e63092a140bc 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -1,4 +1,4 @@ -import { IS_PLATFORM, LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { XIcon } from 'lucide-react' import Head from 'next/head' @@ -23,10 +23,6 @@ import { useIsMobile, usePanelRef, } from 'ui' -import { - SELECT_26_STUDIO_DISMISSAL_KEY, - useSelect26PromotionActive, -} from 'ui-patterns/Banners/Select26Promotion' import { useEditorType } from '../editors/EditorsLayout.hooks' import { useMainScrollContainer, useSetMainScrollContainer } from '../MainScrollContainerContext' @@ -47,11 +43,6 @@ import { UpgradingState } from './UpgradingState' import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal' import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade' -import { BannerSelect2026 } from '@/components/ui/BannerStack/Banners/BannerSelect2026' -import { - SELECT_26_BANNER_PRIORITY, - shouldShowSelect26Banner, -} from '@/components/ui/BannerStack/Banners/BannerSelect2026.utils' import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import PartnerIcon from '@/components/ui/PartnerIcon' @@ -165,9 +156,6 @@ export const ProjectLayout = forwardRef { - // Wait until project + dismissal state are known so we do not call - // dismissBanner on every early render (it always schedules a setState). - if (!selectedProject?.ref || !isSelect26DismissalLoaded) return - - const shouldShow = shouldShowSelect26Banner({ - isPlatform: IS_PLATFORM, - projectRef: selectedProject.ref, - dismissalLoaded: isSelect26DismissalLoaded, - isActive: isSelect26PromotionActive, - isDismissed: isSelect26BannerDismissed, - }) - - if (shouldShow) { - addBanner({ - id: BANNER_ID.SELECT_26, - isDismissed: false, - content: , - priority: SELECT_26_BANNER_PRIORITY, - }) - } else { - dismissBanner(BANNER_ID.SELECT_26) - } - }, [ - selectedProject?.ref, - isSelect26DismissalLoaded, - isSelect26PromotionActive, - isSelect26BannerDismissed, - addBanner, - dismissBanner, - ]) - useLayoutEffect(() => { const unregister = registerOpenMenu(() => { setMobileSheetContent( diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.utils.test.ts b/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.utils.test.ts index 5aa5507834c8c..8a10d313afc85 100644 --- a/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.utils.test.ts +++ b/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.utils.test.ts @@ -4,20 +4,18 @@ import { SELECT_26_BANNER_PRIORITY, shouldShowSelect26Banner } from './BannerSel const visibleState = { isPlatform: true, - projectRef: 'project-ref', dismissalLoaded: true, isActive: true, isDismissed: false, } describe('shouldShowSelect26Banner', () => { - it('shows the promotion on hosted project pages', () => { + it('shows the promotion on hosted Studio', () => { expect(shouldShowSelect26Banner(visibleState)).toBe(true) }) it.each([ ['self-hosted Studio', { isPlatform: false }], - ['outside a project', { projectRef: undefined }], ['before dismissal state loads', { dismissalLoaded: false }], ['after campaign expiry', { isActive: false }], ['after dismissal', { isDismissed: true }], diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.utils.ts b/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.utils.ts index 339d533ce045f..7c37aa0e7baa9 100644 --- a/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.utils.ts +++ b/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.utils.ts @@ -2,14 +2,12 @@ export const SELECT_26_BANNER_PRIORITY = -1 export const shouldShowSelect26Banner = ({ isPlatform, - projectRef, dismissalLoaded, isActive, isDismissed, }: { isPlatform: boolean - projectRef?: string dismissalLoaded: boolean isActive: boolean isDismissed: boolean -}) => isPlatform && !!projectRef && dismissalLoaded && isActive && !isDismissed +}) => isPlatform && dismissalLoaded && isActive && !isDismissed diff --git a/packages/ui-patterns/src/Banners/Select26Banner.tsx b/packages/ui-patterns/src/Banners/Select26Banner.tsx index 4095031ef6548..9d52a16040ea0 100644 --- a/packages/ui-patterns/src/Banners/Select26Banner.tsx +++ b/packages/ui-patterns/src/Banners/Select26Banner.tsx @@ -5,17 +5,26 @@ import Link from 'next/link' import { SELECT_26_CTA, SELECT_26_TITLE, SELECT_26_URL, Select26Field } from './Select26Promotion' +/** Three visible rows; top/bottom reach further inward than the middle row. */ +const WWW_FIELD_BASE_COLS = 24 +const WWW_FIELD_ROW_WIDTHS = [26, 24, 28] + export const Select26Banner = () => (

diff --git a/packages/ui-patterns/src/Banners/Select26Promotion.module.css b/packages/ui-patterns/src/Banners/Select26Promotion.module.css index 044c810bf983d..cf2a7a846f84d 100644 --- a/packages/ui-patterns/src/Banners/Select26Promotion.module.css +++ b/packages/ui-patterns/src/Banners/Select26Promotion.module.css @@ -7,6 +7,7 @@ } .field { + width: max-content; font-family: 'Departure Mono Superbase', ui-monospace, monospace; font-weight: 400; letter-spacing: 0; @@ -30,6 +31,24 @@ --select26-b4: #ffffff; } +.row { + display: grid; + width: fit-content; + flex-shrink: 0; +} + +.fieldAlignStart { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.fieldAlignEnd { + display: flex; + flex-direction: column; + align-items: flex-end; +} + .cell { display: grid; place-items: center; diff --git a/packages/ui-patterns/src/Banners/Select26Promotion.tsx b/packages/ui-patterns/src/Banners/Select26Promotion.tsx index d398b2c35c096..6e3ed2a5e74c1 100644 --- a/packages/ui-patterns/src/Banners/Select26Promotion.tsx +++ b/packages/ui-patterns/src/Banners/Select26Promotion.tsx @@ -67,7 +67,14 @@ type FieldCell = { weight: number } -const cellAt = (x: number, y: number, cols: number, rows: number, timeMs: number): FieldCell => { +const cellAt = ( + x: number, + y: number, + cols: number, + rows: number, + timeMs: number, + mirror = false +): FieldCell => { const cx = (cols - 1) / 2 const cy = (rows - 1) / 2 const nx = x - cx @@ -77,7 +84,8 @@ const cellAt = (x: number, y: number, cols: number, rows: number, timeMs: number const step = Math.floor(timeMs / BRACKET_STEP_MS + y * 1.7 + Math.abs(nx) * 0.8) const idx = positiveModulo(step, OPEN_BRACKETS.length) - const ch = x <= cx ? OPEN_BRACKETS[idx] : CLOSE_BRACKETS[idx] + const onLeft = mirror ? x > cx : x <= cx + const ch = onLeft ? OPEN_BRACKETS[idx] : CLOSE_BRACKETS[idx] const hue = positiveModulo(angle - sweep, Math.PI * 2) / (Math.PI * 2) const band = Math.min(4, Math.floor(hue * 5)) @@ -95,6 +103,12 @@ const cellAt = (x: number, y: number, cols: number, rows: number, timeMs: number type Select26FieldProps = HTMLAttributes & { cols?: number rows?: number + /** Per-row column counts; defaults to `cols` for every row. */ + rowWidths?: number[] + /** Which edge shorter rows hug when widths vary. */ + rowAlign?: 'start' | 'end' + /** Mirror bracket glyphs (for right-hand fields; prefer over CSS scale-x). */ + mirror?: boolean } /** @@ -102,11 +116,25 @@ type Select26FieldProps = HTMLAttributes & { * radar beam falloff. Stepped updates mirror the Select glyph-engine without * shipping its canvas runtime. */ -export const Select26Field = ({ cols = 10, rows = 6, className, ...props }: Select26FieldProps) => { +export const Select26Field = ({ + cols = 10, + rows = 6, + rowWidths, + rowAlign = 'start', + mirror = false, + className, + ...props +}: Select26FieldProps) => { const rootRef = useRef(null) const [timeMs, setTimeMs] = useState(0) const [isVisible, setIsVisible] = useState(true) + const widths = useMemo(() => { + if (rowWidths) return rowWidths + return Array.from({ length: rows }, () => cols) + }, [rowWidths, rows, cols]) + const fieldRows = widths.length + useEffect(() => { const node = rootRef.current if (!node || typeof IntersectionObserver === 'undefined') return @@ -142,34 +170,53 @@ export const Select26Field = ({ cols = 10, rows = 6, className, ...props }: Sele return () => cancelAnimationFrame(raf) }, [isVisible]) - const cells = useMemo(() => { - const next: FieldCell[] = [] - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - next.push(cellAt(x, y, cols, rows, timeMs)) + const cellsByRow = useMemo(() => { + return widths.map((rowCols, y) => { + const rowCells: FieldCell[] = [] + for (let x = 0; x < rowCols; x++) { + rowCells.push(cellAt(x, y, rowCols, fieldRows, timeMs, mirror)) } - } - return next - }, [cols, rows, timeMs]) + return rowCells + }) + }, [widths, fieldRows, timeMs, mirror]) return (

- {cells.map((cell, index) => ( - - {cell.ch} - - ))} + {cellsByRow.map((rowCells, y) => { + const rowCols = widths[y] + const cellWidth = 'calc(22 / 34 * 1em)' + + return ( +
+ {rowCells.map((cell, index) => ( + + {cell.ch} + + ))} +
+ ) + })}
) } From ee3f78ff37181b511fa10d55ab156ccee31f7ffc Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:01:47 +1000 Subject: [PATCH 02/11] fix(studio): hide social sign-in after email sign-up (#49571) ## What kind of change does this PR introduce? Bug fix. Resolves [FE-4264](https://linear.app/supabase/issue/FE-4264/hide-social-sign-in-options-after-email-sign-up). ## What is the current behavior? After a successful email and password sign-up, GitHub and ChatGPT sign-in options remain visible even though they do not confirm or link the new account. The success state is presented in a bespoke `Alert` with verbose copywriting. ## What is the new behavior? The social sign-in options and divider are hidden after email sign-up succeeds. The email confirmation message and link back to sign in remain available. The success state is presented in a standard `success` `Admonition` with clearer copywriting. | Before | After | | --- | --- | | CleanShot 2026-08-26 at 13 47
41@2x | CleanShot 2026-08-26 at 13 47
00@2x | ## To test 1. Open `/sign-up` and complete an email and password sign-up. 2. Confirm the success message is shown without the GitHub, ChatGPT, or `or` options. 3. Open `/sign-in` and confirm GitHub and ChatGPT remain available there. ## Summary by CodeRabbit - **New Features** - Added shared provider options across sign-in and sign-up flows, including custom providers, external identity providers, and optional SSO. - Added an SSO sign-in button that preserves the current page context. - After successful email signup, alternative signup options are hidden and confirmation messaging appears. - **Bug Fixes** - Improved signup form spacing, submission state, and animated password guidance. - **Tests** - Added coverage for signup behavior across standard and focused-provider configurations. --------- Co-authored-by: Joshen Lim --- .../interfaces/SignIn/SignInOptions.tsx | 60 +++++++++++ .../interfaces/SignIn/SignInSSOForm.tsx | 16 +++ .../interfaces/SignIn/SignUpForm.tsx | 57 +++++----- .../misc/__tests__/useInboundBranding.test.ts | 8 ++ apps/studio/hooks/misc/useInboundBranding.ts | 10 +- apps/studio/pages/sign-in.tsx | 59 ++-------- apps/studio/pages/sign-up.tsx | 86 ++++++--------- apps/studio/tests/pages/sign-up.test.tsx | 101 ++++++++++++++++++ 8 files changed, 257 insertions(+), 140 deletions(-) create mode 100644 apps/studio/components/interfaces/SignIn/SignInOptions.tsx create mode 100644 apps/studio/tests/pages/sign-up.test.tsx diff --git a/apps/studio/components/interfaces/SignIn/SignInOptions.tsx b/apps/studio/components/interfaces/SignIn/SignInOptions.tsx new file mode 100644 index 0000000000000..b2898124ca316 --- /dev/null +++ b/apps/studio/components/interfaces/SignIn/SignInOptions.tsx @@ -0,0 +1,60 @@ +import { cn } from 'ui' + +import { SignInWithSSOButton } from './SignInSSOForm' +import { SignInWithCustom } from './SignInWithCustom' +import { SignInWithExternalProvider } from './SignInWithExternalProvider' +import { useCustomContent } from '@/hooks/custom-content/useCustomContent' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { type ExternalIdentityProviderConfig } from '@/lib/external-identity-providers' + +export const SignInOptions = ({ + providers, + dividerBgClass = 'bg-studio', +}: { + providers: ExternalIdentityProviderConfig[] + dividerBgClass?: string +}) => { + const { + dashboardAuthSignInWithSso: signInWithSsoEnabled, + dashboardAuthSignInWithEmail: signInWithEmailEnabled, + } = useIsFeatureEnabled(['dashboard_auth:sign_in_with_sso', 'dashboard_auth:sign_in_with_email']) + + const { + dashboardAuthCustomProvider: customProvider, + dashboardAuthCustomProviders: customProvidersNew, + } = useCustomContent(['dashboard_auth:custom_provider', 'dashboard_auth:custom_providers']) + + // [Joshen] This is just for backward compatibility - singular customProvider needs to be deprecated subsequently + // Just need to remove customProvider and rename customProvidersNew to customProviders + const customProviders = customProvidersNew ?? (customProvider ? [customProvider] : []) + + const showOrDivider = + (providers.length > 0 || signInWithSsoEnabled || customProviders.length > 0) && + signInWithEmailEnabled + + return ( + <> + {Array.isArray(customProviders) && + customProviders.map((providerName: string) => ( + + ))} + + {providers.map((provider) => ( + + ))} + + {signInWithSsoEnabled && } + + {showOrDivider && ( +
+
+
+
+
+ or +
+
+ )} + + ) +} diff --git a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx index e27443d13634b..4127113a7d4fd 100644 --- a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx @@ -1,6 +1,9 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { zodResolver } from '@hookform/resolvers/zod' import { useQueryClient } from '@tanstack/react-query' +import { Lock } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' import { useRef, useState } from 'react' import { useForm, type SubmitHandler } from 'react-hook-form' import { toast } from 'sonner' @@ -8,6 +11,7 @@ import { Button, Form, FormControl, FormField, Input } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' +import { LastSignInWrapper } from './LastSignInWrapper' import { useLastSignIn } from '@/hooks/misc/useLastSignIn' import { BASE_PATH } from '@/lib/constants' import { captureCriticalError } from '@/lib/error-reporting' @@ -119,3 +123,15 @@ export const SignInSSOForm = () => { ) } + +export const SignInWithSSOButton = () => { + const router = useRouter() + + return ( + + + + ) +} diff --git a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx index aa32fb110b08a..14f36b063b6be 100644 --- a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx @@ -1,23 +1,14 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { zodResolver } from '@hookform/resolvers/zod' import { motion } from 'framer-motion' -import { CheckCircle, Eye, EyeOff } from 'lucide-react' +import { Eye, EyeOff } from 'lucide-react' import { useRouter } from 'next/router' import { parseAsString, useQueryStates } from 'nuqs' import { useRef, useState } from 'react' import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' -import { - Alert, - AlertDescription, - AlertTitle, - Button, - cn, - Form, - FormControl, - FormField, - Input, -} from 'ui' +import { Button, cn, Form, FormControl, FormField, Input } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' @@ -52,7 +43,7 @@ const schema = z.object({ const formId = 'sign-up-form' -export const SignUpForm = () => { +export const SignUpForm = ({ onSuccess }: { onSuccess?: () => void }) => { const captchaRef = useRef(null) const [showConditions, setShowConditions] = useState(false) const [isSubmitted, setIsSubmitted] = useState(false) @@ -76,6 +67,7 @@ export const SignUpForm = () => { onSuccess: () => { toast.success(`Signed up successfully!`) setIsSubmitted(true) + onSuccess?.() }, onError: (error) => { setCaptchaToken(null) @@ -135,22 +127,22 @@ export const SignUpForm = () => { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5, delay: 0.3 }} - className="absolute top-0 w-full" + className="w-full" > - - - Check your email to confirm - - You've successfully signed up. Please check your email to confirm your account before - signing in to the Supabase dashboard. The confirmation link expires in 10 minutes. - - + )}
@@ -213,13 +205,16 @@ export const SignUpForm = () => { )} /> -
- -
+ {showConditions && ( + + + + )}
import('next-router-mock')) +// The global mock in vitestSetup.ts stubs `useParams` to always return `{ ref: 'default' }`, which +// doesn't reflect the `method`/`destination` query params this hook relies on — restore the real +// implementation here so it reads them from the mocked router. +vi.mock('common', async (importOriginal) => { + const actual = (await importOriginal()) as object + return { ...actual } +}) + const mockEnabledProviders = vi.hoisted(() => vi.fn<() => ExternalIdentityProviderConfig[]>()) vi.mock('../useEnabledIdentityProviders', () => ({ diff --git a/apps/studio/hooks/misc/useInboundBranding.ts b/apps/studio/hooks/misc/useInboundBranding.ts index dd9b45df4e1d1..dcfdf97247a72 100644 --- a/apps/studio/hooks/misc/useInboundBranding.ts +++ b/apps/studio/hooks/misc/useInboundBranding.ts @@ -1,4 +1,4 @@ -import { useRouter } from 'next/router' +import { useParams } from 'common' import { useMemo } from 'react' import { useEnabledIdentityProviders } from './useEnabledIdentityProviders' @@ -33,15 +33,9 @@ export type InboundBranding = { * screen. */ export function useInboundBranding(flow: 'sign-in' | 'sign-up' = 'sign-in'): InboundBranding { - const router = useRouter() const enabledProviders = useEnabledIdentityProviders() - const destinationId = - router.isReady && typeof router.query.destination === 'string' - ? router.query.destination - : undefined - const focusId = - router.isReady && typeof router.query.method === 'string' ? router.query.method : undefined + const { destination: destinationId, method: focusId } = useParams() const focusProvider = useMemo( () => diff --git a/apps/studio/pages/sign-in.tsx b/apps/studio/pages/sign-in.tsx index c000ad453bd35..b91c8f62a983e 100644 --- a/apps/studio/pages/sign-in.tsx +++ b/apps/studio/pages/sign-in.tsx @@ -1,12 +1,10 @@ -import { Lock } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' -import { Button, cn } from 'ui' +import { Button } from 'ui' -import { LastSignInWrapper } from '@/components/interfaces/SignIn/LastSignInWrapper' import { SignInForm } from '@/components/interfaces/SignIn/SignInForm' -import { SignInWithCustom } from '@/components/interfaces/SignIn/SignInWithCustom' +import { SignInOptions } from '@/components/interfaces/SignIn/SignInOptions' import { SignInWithExternalProvider } from '@/components/interfaces/SignIn/SignInWithExternalProvider' import { AuthenticationLayout } from '@/components/layouts/AuthenticationLayout' import { SignInLayout } from '@/components/layouts/SignInLayout/SignInLayout' @@ -15,7 +13,6 @@ import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProv import { useInboundBranding } from '@/hooks/misc/useInboundBranding' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { IS_PLATFORM } from '@/lib/constants' -import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers' import { getSignUpReturnTo } from '@/lib/gotrue' import type { NextPageWithLayout } from '@/types' @@ -45,47 +42,6 @@ const SignInPage: NextPageWithLayout = () => { const { focusProvider } = useInboundBranding('sign-in') const signInProviders = useEnabledIdentityProviders().filter((provider) => provider.showOnSignIn) - const renderAuthOptions = ( - providers: ExternalIdentityProviderConfig[], - dividerBgClass = 'bg-studio' - ) => { - const showOrDivider = - (providers.length > 0 || signInWithSsoEnabled || customProviders.length > 0) && - signInWithEmailEnabled - - return ( - <> - {Array.isArray(customProviders) && - customProviders.map((providerName: string) => ( - - ))} - {providers.map((provider) => ( - - ))} - {signInWithSsoEnabled && ( - - - - )} - {showOrDivider && ( -
-
-
-
-
- or -
-
- )} - {signInWithEmailEnabled && } - - ) - } - useEffect(() => { if (!IS_PLATFORM) { // on selfhosted instance just redirect to projects page @@ -106,9 +62,13 @@ const SignInPage: NextPageWithLayout = () => { return (
+ {hasOtherOptions && (showOtherOptions ? ( - renderAuthOptions(otherProviders, 'bg-surface-100') + <> + + {signInWithEmailEnabled && } + ) : ( + )}
-
- or + ) : ( +
+ {!isSubmitted && } + setIsSubmitted(true)} />
-
- - - - ) - - // Inbound link focused us on a single provider — lead with that one (SignInLayout renders the - // matching interstitial frame around it), but let the user reveal the rest of our options. - if (focusProvider) { - const otherProviders = signUpProviders.filter((provider) => provider.id !== focusProvider.id) - - return ( -
- - {showOtherOptions ? ( - renderAuthOptions(otherProviders, 'bg-surface-100') - ) : ( - - )} -
- ) - } - - return ( - <> -
{renderAuthOptions(signUpProviders)}
+ )} -
+
Have an account?{' '} ({ + focusProvider: undefined as ExternalIdentityProviderConfig | undefined, +})) + +vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({ + SignInWithExternalProvider: ({ provider }: { provider: ExternalIdentityProviderConfig }) => ( + + ), +})) + +vi.mock('@/components/interfaces/SignIn/SignUpForm', () => ({ + SignUpForm: ({ onSuccess }: { onSuccess?: () => void }) => { + const [isSubmitted, setIsSubmitted] = useState(false) + + return ( + <> + + {isSubmitted &&
Check your email
} + + ) + }, +})) + +vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({ + useEnabledIdentityProviders: () => [GITHUB_IDENTITY_PROVIDER, CHATGPT_IDENTITY_PROVIDER], +})) + +vi.mock('@/hooks/misc/useInboundBranding', () => ({ + useInboundBranding: () => ({ focusProvider: mocks.focusProvider }), +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: () => ({ + dashboardAuthSignUp: true, + dashboardAuthSignInWithSso: false, + dashboardAuthSignInWithEmail: true, + }), +})) + +describe('SignUpPage', () => { + beforeEach(() => { + mocks.focusProvider = undefined + }) + + test('hides social sign-up options after email sign-up succeeds', async () => { + const user = userEvent.setup() + customRender() + + expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Continue with ChatGPT' })).toBeInTheDocument() + expect(screen.getByText('or')).toBeInTheDocument() + expect(screen.queryByText('Check your email')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Complete email sign-up' })) + + expect(screen.queryByRole('button', { name: 'Continue with GitHub' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Continue with ChatGPT' })).not.toBeInTheDocument() + expect(screen.queryByText('or')).not.toBeInTheDocument() + expect(screen.getByText('Check your email')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/sign-in') + }) + + test('hides the focused provider after email sign-up succeeds', async () => { + const user = userEvent.setup() + mocks.focusProvider = GITHUB_IDENTITY_PROVIDER + customRender() + + expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeInTheDocument() + expect(screen.queryByText('Check your email')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Show other options' })) + await user.click(screen.getByRole('button', { name: 'Complete email sign-up' })) + + expect(screen.queryByRole('button', { name: 'Continue with GitHub' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Continue with ChatGPT' })).not.toBeInTheDocument() + expect(screen.queryByText('or')).not.toBeInTheDocument() + expect(screen.getByText('Check your email')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/sign-in') + }) +}) From 81dfcc9c9359b7d799e86445df743638ad72359e Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:05:28 +1000 Subject: [PATCH 03/11] fix(studio): treat region flags as decorative by default (#49574) ## What kind of change does this PR introduce? Studio accessibility fix. ## What is the current behavior? `RegionFlag` defaults to `alt=""`, but only some callsites also pass `aria-hidden` / `role="presentation"`. Decorative flags beside visible region names can still be announced inconsistently. ## What is the new behavior? `RegionFlag` defaults to `aria-hidden` when `alt` is empty, matching how the component is used next to visible region text. Redundant callsite a11y props from #49517 are removed. ## To test - Open [Edge Function observability](https://studio-staging-git-dnywh-region-flag-decorative-a11y-supabase.vercel.app/dashboard/project/_/observability/edge-functions). Open the **Region** filter and confirm each option still shows a flag beside the region label. The DOM has `aria-hidden` on the flag `img`. Open the new project flow region selector. Confirm the selected-region flag still renders without role="presentation". - Open [New project](https://studio-staging-git-dnywh-region-flag-decorative-a11y-supabase.vercel.app/dashboard/new/_). Open the region selector and confirm the selected value and menu items still show flags beside the region names. The DOM has `aria-hidden` on the flag `img`. --- .../interfaces/ProjectCreation/RegionSelector.tsx | 6 +----- apps/studio/components/ui/RegionFlag.tsx | 9 ++++++++- .../pages/project/[ref]/observability/edge-functions.tsx | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 03f5663a64976..5e09ad47feec7 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -251,11 +251,7 @@ export const RegionSelector = ({
{isLoading && } {selectedRegion?.code && ( - + )} {triggerLabel}
diff --git a/apps/studio/components/ui/RegionFlag.tsx b/apps/studio/components/ui/RegionFlag.tsx index 9a4120a85d3f7..32750de114fb0 100644 --- a/apps/studio/components/ui/RegionFlag.tsx +++ b/apps/studio/components/ui/RegionFlag.tsx @@ -6,9 +6,16 @@ interface RegionFlagProps extends Omit, 'src region: string } -export const RegionFlag = ({ alt = '', className, region, ...props }: RegionFlagProps) => ( +export const RegionFlag = ({ + alt = '', + 'aria-hidden': ariaHidden, + className, + region, + ...props +}: RegionFlagProps) => ( {alt} { value: region.key, label: (
-