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] 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 | 88 ++++++--------- apps/studio/tests/pages/sign-up.test.tsx | 101 ++++++++++++++++++ 8 files changed, 258 insertions(+), 141 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 00000000000..b2898124ca3 --- /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 e27443d1363..4127113a7d4 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 aa32fb110b0..14f36b063b6 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 dd9b45df4e1..dcfdf97247a 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 c000ad453bd..b91c8f62a98 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 && } + ) : ( - )} -
- ) - } - return ( <> -
{renderAuthOptions(signUpProviders)}
+ {focusProvider ? ( +
+ {!isSubmitted && } + {showOtherOptions ? ( + <> + {!isSubmitted && ( + provider.id !== focusProvider.id)} + dividerBgClass="bg-surface-100" + /> + )} + setIsSubmitted(true)} /> + + ) : ( + + )} +
+ ) : ( +
+ {!isSubmitted && } + setIsSubmitted(true)} /> +
+ )} -
+
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') + }) +})