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 |
| --- | --- |
| <img width="2576" height="1700" alt="CleanShot 2026-08-26 at 13 47
41@2x"
src="https://github.com/user-attachments/assets/a29547b9-7949-4ff3-a1d4-db8bfb3beee6"
/> | <img width="2576" height="1704" alt="CleanShot 2026-08-26 at 13 47
00@2x"
src="https://github.com/user-attachments/assets/4f778571-74fe-4c20-b76e-a87e0391e4a9"
/> |

## 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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-08-26 07:01:47 +00:00
1 parent bd02d7f297
commit ee3f78ff37
8 files changed
+258 -141

No files matched your search

@@ -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) => (
<SignInWithCustom key={providerName} providerName={providerName} />
))}
{providers.map((provider) => (
<SignInWithExternalProvider key={provider.id} provider={provider} />
))}
{signInWithSsoEnabled && <SignInWithSSOButton />}
{showOrDivider && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-strong" />
</div>
<div className="relative flex justify-center text-sm">
<span className={cn('px-2 text-sm text-foreground', dividerBgClass)}>or</span>
</div>
</div>
)}
</>
)
}
@@ -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 = () => {
</Form>
)
}
export const SignInWithSSOButton = () => {
const router = useRouter()
return (
<LastSignInWrapper type="sso">
<Button asChild block size="large" variant="outline" icon={<Lock />}>
<Link href={{ pathname: '/sign-in-sso', query: router.query }}>Continue with SSO</Link>
</Button>
</LastSignInWrapper>
)
}
@@ -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<HCaptcha>(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"
>
<Alert variant="default">
<CheckCircle />
<AlertTitle>Check your email to confirm</AlertTitle>
<AlertDescription className="text-xs">
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.
</AlertDescription>
</Alert>
<Admonition
type="success"
title="Check your email"
description="We sent you a link to finish signing up. It expires in 10 minutes."
/>
</motion.div>
)}
<div
inert={isSubmitted || undefined}
className={cn(
'w-full py-1 transition-all duration-500',
isSubmitted ? 'max-h-[100px] opacity-0 pointer-events-none' : 'max-h-[1000px] opacity-100'
'w-full transition-all duration-500',
isSubmitted
? 'max-h-0 overflow-hidden opacity-0 pointer-events-none py-0'
: 'max-h-[1000px] opacity-100'
)}
>
<Form {...form}>
@@ -213,13 +205,16 @@ export const SignUpForm = () => {
)}
/>
<div
className={`${
showConditions ? 'max-h-[500px]' : 'max-h-0'
} transition-all duration-400 overflow-y-hidden`}
>
<PasswordConditionsHelper password={password} />
</div>
{showConditions && (
<motion.div
initial={{ maxHeight: '0px' }}
animate={{ maxHeight: '500px' }}
transition={{ duration: 0.8, delay: 0 }}
className="overflow-y-hidden"
>
<PasswordConditionsHelper password={password} />
</motion.div>
)}
<div className="self-center">
<HCaptcha
@@ -10,6 +10,14 @@ import { customRenderHook } from '@/tests/lib/custom-render'
vi.mock('next/router', () => 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', () => ({
+2 -8
View File
@@ -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(
() =>
+11 -48
View File
@@ -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) => (
<SignInWithCustom key={providerName} providerName={providerName} />
))}
{providers.map((provider) => (
<SignInWithExternalProvider key={provider.id} provider={provider} />
))}
{signInWithSsoEnabled && (
<LastSignInWrapper type="sso">
<Button asChild block size="large" variant="outline" icon={<Lock />}>
<Link href={{ pathname: '/sign-in-sso', query: router.query }}>
Continue with SSO
</Link>
</Button>
</LastSignInWrapper>
)}
{showOrDivider && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-strong" />
</div>
<div className="relative flex justify-center text-sm">
<span className={cn('px-2 text-sm text-foreground', dividerBgClass)}>or</span>
</div>
</div>
)}
{signInWithEmailEnabled && <SignInForm />}
</>
)
}
useEffect(() => {
if (!IS_PLATFORM) {
// on selfhosted instance just redirect to projects page
@@ -106,9 +62,13 @@ const SignInPage: NextPageWithLayout = () => {
return (
<div className="flex flex-col gap-5">
<SignInWithExternalProvider provider={focusProvider} />
{hasOtherOptions &&
(showOtherOptions ? (
renderAuthOptions(otherProviders, 'bg-surface-100')
<>
<SignInOptions providers={otherProviders} dividerBgClass="bg-surface-100" />
{signInWithEmailEnabled && <SignInForm />}
</>
) : (
<Button
block
@@ -126,7 +86,10 @@ const SignInPage: NextPageWithLayout = () => {
return (
<>
<div className="flex flex-col gap-5">{renderAuthOptions(signInProviders)}</div>
<div className="flex flex-col gap-5">
<SignInOptions providers={signInProviders} />
{signInWithEmailEnabled && <SignInForm />}
</div>
{signUpEnabled && (
<div className="self-center my-8 text-sm">
+34 -54
View File
@@ -2,6 +2,7 @@ import Link from 'next/link'
import { useState } from 'react'
import { Button, cn } from 'ui'
import { SignInOptions } from '@/components/interfaces/SignIn/SignInOptions'
import { SignInWithExternalProvider } from '@/components/interfaces/SignIn/SignInWithExternalProvider'
import { SignUpForm } from '@/components/interfaces/SignIn/SignUpForm'
import { SignInLayout } from '@/components/layouts/SignInLayout/SignInLayout'
@@ -9,11 +10,11 @@ import { UnknownInterface } from '@/components/ui/UnknownInterface'
import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders'
import { useInboundBranding } from '@/hooks/misc/useInboundBranding'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers'
import type { NextPageWithLayout } from '@/types'
const SignUpPage: NextPageWithLayout = () => {
const [showOtherOptions, setShowOtherOptions] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const { dashboardAuthSignUp: signUpEnabled } = useIsFeatureEnabled(['dashboard_auth:sign_up'])
const { focusProvider } = useInboundBranding('sign-up')
@@ -23,62 +24,41 @@ const SignUpPage: NextPageWithLayout = () => {
return <UnknownInterface fullHeight={false} urlBack="/sign-in" />
}
// The sign-up options we offer besides a focused provider: other external providers and the email
// form. Rendered both on the full screen and when the user expands "other options" from the
// focused screen. The "or" pill's background matches the surface behind it: the page
// (`bg-studio`) on the full screen, or the interstitial card (`bg-surface-100`) when revealed.
const renderAuthOptions = (
providers: ExternalIdentityProviderConfig[],
dividerBgClass = 'bg-studio'
) => (
<>
{providers.map((provider) => (
<SignInWithExternalProvider key={provider.id} provider={provider} />
))}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-strong" />
</div>
<div className="relative flex justify-center text-sm">
<span className={cn('px-2 text-sm text-foreground', dividerBgClass)}>or</span>
</div>
</div>
<SignUpForm />
</>
)
// 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 (
<div className="flex flex-col gap-5">
<SignInWithExternalProvider provider={focusProvider} />
{showOtherOptions ? (
renderAuthOptions(otherProviders, 'bg-surface-100')
) : (
<Button
block
variant="text"
size="large"
className="-mt-2 text-foreground-light"
onClick={() => setShowOtherOptions(true)}
>
Show other options
</Button>
)}
</div>
)
}
return (
<>
<div className="flex flex-col gap-5">{renderAuthOptions(signUpProviders)}</div>
{focusProvider ? (
<div className="flex flex-col gap-5">
{!isSubmitted && <SignInWithExternalProvider provider={focusProvider} />}
{showOtherOptions ? (
<>
{!isSubmitted && (
<SignInOptions
providers={signUpProviders.filter((provider) => provider.id !== focusProvider.id)}
dividerBgClass="bg-surface-100"
/>
)}
<SignUpForm onSuccess={() => setIsSubmitted(true)} />
</>
) : (
<Button
block
variant="text"
size="large"
className="-mt-2 text-foreground-light"
onClick={() => setShowOtherOptions(true)}
>
Show other options
</Button>
)}
</div>
) : (
<div className="flex flex-col gap-5">
{!isSubmitted && <SignInOptions providers={signUpProviders} />}
<SignUpForm onSuccess={() => setIsSubmitted(true)} />
</div>
)}
<div className="my-8 self-center text-sm">
<div className={cn('self-center text-center text-sm mb-8', isSubmitted ? 'mt-2' : 'mt-8')}>
<span className="text-foreground-light">Have an account?</span>{' '}
<Link
href="/sign-in"
+101
View File
@@ -0,0 +1,101 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { Button } from 'ui'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
CHATGPT_IDENTITY_PROVIDER,
GITHUB_IDENTITY_PROVIDER,
type ExternalIdentityProviderConfig,
} from '@/lib/external-identity-providers'
import SignUpPage from '@/pages/sign-up'
import { customRender } from '@/tests/lib/custom-render'
const mocks = vi.hoisted(() => ({
focusProvider: undefined as ExternalIdentityProviderConfig | undefined,
}))
vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({
SignInWithExternalProvider: ({ provider }: { provider: ExternalIdentityProviderConfig }) => (
<Button>Continue with {provider.displayName}</Button>
),
}))
vi.mock('@/components/interfaces/SignIn/SignUpForm', () => ({
SignUpForm: ({ onSuccess }: { onSuccess?: () => void }) => {
const [isSubmitted, setIsSubmitted] = useState(false)
return (
<>
<Button
onClick={() => {
setIsSubmitted(true)
onSuccess?.()
}}
>
Complete email sign-up
</Button>
{isSubmitted && <div>Check your email</div>}
</>
)
},
}))
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(<SignUpPage dehydratedState={undefined} />)
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(<SignUpPage dehydratedState={undefined} />)
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')
})
})