From e491182054e3206a44899a616bfd5c45a13ae222 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 18 Jun 2026 17:34:56 +1000 Subject: [PATCH] Auth flow improvements (#46967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES/NO ## What kind of change does this PR introduce? Bug fix, feature, docs update, ... ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. ## Summary by CodeRabbit ## Release Notes * **New Features** * Added “Continue with {provider}” sign-in and sign-up flows using enabled external identity providers. * Enabled inbound branding to focus a specific provider for customized sign-in/sign-up experiences. * **Improvements** * Refined the sign-in options layout and “last used” tracking for clearer authentication choices. * Updated account identity/provider connection experiences (link/unlink and management UI). * **Bug Fixes** * Fixed hydration mismatches in sign-in and password-related layouts. --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Joshen Lim --- .../Preferences/AccountConnections.tsx | 62 +++---- .../Account/Preferences/AccountIdentities.tsx | 144 ++++++++++++----- .../interfaces/SignIn/LastSignInWrapper.tsx | 15 +- .../SignIn/SignInWithExternalProvider.tsx | 71 ++++++++ .../interfaces/SignIn/SignInWithGitHub.tsx | 58 ------- .../components/layouts/InterstitialLayout.tsx | 60 ++++--- .../SignInLayout/ForgotPasswordLayout.tsx | 12 +- .../layouts/SignInLayout/SignInLayout.tsx | 152 ++++++++++++++---- apps/studio/components/ui/ProviderIcon.tsx | 58 +++++++ .../useEnabledIdentityProviders.test.ts | 33 ++++ .../misc/__tests__/useInboundBranding.test.ts | 91 +++++++++++ .../useShowMultigresLogs.test.ts | 4 +- .../hooks/misc/useEnabledIdentityProviders.ts | 20 +++ apps/studio/hooks/misc/useInboundBranding.ts | 61 +++++++ apps/studio/hooks/misc/useLastSignIn.ts | 2 +- .../lib/external-identity-providers.test.ts | 73 +++++++++ .../studio/lib/external-identity-providers.ts | 116 +++++++++++++ apps/studio/lib/sign-in-destinations.test.ts | 17 ++ apps/studio/lib/sign-in-destinations.tsx | 36 +++++ apps/studio/pages/forgot-password-mfa.tsx | 2 +- apps/studio/pages/forgot-password.tsx | 2 +- apps/studio/pages/reset-password.tsx | 2 +- apps/studio/pages/sign-in-mfa.tsx | 4 +- apps/studio/pages/sign-in-partner.tsx | 2 +- apps/studio/pages/sign-in-sso.tsx | 2 +- apps/studio/pages/sign-in.tsx | 143 +++++++++------- apps/studio/pages/sign-up.tsx | 90 ++++++++--- 27 files changed, 1052 insertions(+), 280 deletions(-) create mode 100644 apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx delete mode 100644 apps/studio/components/interfaces/SignIn/SignInWithGitHub.tsx create mode 100644 apps/studio/components/ui/ProviderIcon.tsx create mode 100644 apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts create mode 100644 apps/studio/hooks/misc/__tests__/useInboundBranding.test.ts rename apps/studio/hooks/misc/{ => __tests__}/useShowMultigresLogs.test.ts (94%) create mode 100644 apps/studio/hooks/misc/useEnabledIdentityProviders.ts create mode 100644 apps/studio/hooks/misc/useInboundBranding.ts create mode 100644 apps/studio/lib/external-identity-providers.test.ts create mode 100644 apps/studio/lib/external-identity-providers.ts create mode 100644 apps/studio/lib/sign-in-destinations.test.ts create mode 100644 apps/studio/lib/sign-in-destinations.tsx diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx index 15f38bab788..8d4a578b9a4 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx @@ -98,7 +98,10 @@ export const AccountConnections = () => { alt={`GitHub icon`} />
-

GitHub

+
+

GitHub

+ {isConnected && Connected} +

Sync repos to Supabase projects for automatic branch creation and merging

@@ -106,36 +109,33 @@ export const AccountConnections = () => {
{isConnected ? ( - <> - Connected - - - - - - { - event.preventDefault() - handleReauthenticate() - }} - > - -

Re-authenticate

-
- - setIsRemoveModalOpen(true)} - > - -

Remove connection

-
-
-
- + + + + + + { + event.preventDefault() + handleReauthenticate() + }} + > + +

Re-authenticate

+
+ + setIsRemoveModalOpen(true)} + > + +

Remove connection

+
+
+
) : ( )} - } - className="w-7" - onClick={() => setSelectedProviderUpdateEmail(provider)} - tooltip={{ content: { side: 'bottom', text: 'Update email address' } }} - /> + {canUpdateEmail && ( + } + className="w-7" + onClick={() => setSelectedProviderUpdateEmail(provider)} + tooltip={{ content: { side: 'bottom', text: 'Update email address' } }} + /> + )} {identities.length > 1 && ( { ) })} + + {unlinkedExternalProviders.map((provider) => { + const providerDisplay = getProviderDisplay(provider.authProvider) + + return ( + +
+ +
+

{provider.displayName}

+

+ Link your {provider.displayName} account to sign in with{' '} + {provider.displayName} +

+
+
+ +
+ ) + })}
)} @@ -177,7 +240,7 @@ export const AccountIdentities = () => { {selectedProviderUpdateEmail !== 'email' - ? `Updating email address for ${getProviderName(selectedProviderUpdateEmail ?? '')} identity` + ? `Updating email address for ${getProviderName(selectedProviderUpdateEmail)} identity` : 'Update email address'} @@ -196,15 +259,16 @@ export const AccountIdentities = () => { size="small" loading={isUnlinking} visible={!!selectedProviderUnlink} - title={`Unlink ${getProviderName(selectedProviderUnlink ?? '')} identity`} + title={`Unlink ${getProviderName(selectedProviderUnlink)} identity`} onCancel={() => setSelectedProviderUnlink(undefined)} onConfirm={onConfirmUnlinkIdentity} confirmLabel="Unlink identity" confirmLabelLoading="Unlinking identity" alert={{ base: { variant: 'warning' }, - title: `Confirm to disconnect your ${getProviderName(selectedProviderUnlink ?? '')} identity`, - description: `After disconnecting, you will only be able to sign in via ${selectedProviderUnlink === 'github' ? 'email and password' : 'your GitHub identity'}`, + title: `Confirm to disconnect your ${getProviderName(selectedProviderUnlink)} identity`, + description: + 'After disconnecting, you will only be able to sign in with your remaining identities.', }} /> diff --git a/apps/studio/components/interfaces/SignIn/LastSignInWrapper.tsx b/apps/studio/components/interfaces/SignIn/LastSignInWrapper.tsx index 82ff36381ff..6aabf093f44 100644 --- a/apps/studio/components/interfaces/SignIn/LastSignInWrapper.tsx +++ b/apps/studio/components/interfaces/SignIn/LastSignInWrapper.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useEffect, useState } from 'react' import { Badge, cn } from 'ui' import { LastSignInType, useLastSignIn } from '@/hooks/misc/useLastSignIn' @@ -12,9 +12,17 @@ export function LastSignInWrapper({ }) { const [lastSignIn] = useLastSignIn() + // `useLastSignIn` reads localStorage, which is empty on the server but populated on the first + // client render — rendering the badge based on it directly would trip a hydration mismatch. Gate + // on mount so the server and first client render agree (no badge), then reveal it once mounted. + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + const isLastUsed = mounted && lastSignIn === type + return (
- {lastSignIn === type && ( + {isLastUsed && ( {children} diff --git a/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx b/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx new file mode 100644 index 00000000000..089dcea701a --- /dev/null +++ b/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx @@ -0,0 +1,71 @@ +import type { Provider } from '@supabase/auth-js' +import { useState } from 'react' +import { toast } from 'sonner' +import { Button } from 'ui' + +import { LastSignInWrapper } from './LastSignInWrapper' +import { IdentityProviderIcon } from '@/components/ui/ProviderIcon' +import { useLastSignIn } from '@/hooks/misc/useLastSignIn' +import { captureCriticalError } from '@/lib/error-reporting' +import { + buildProviderAuthRedirect, + getProviderDisplay, + type ExternalIdentityProviderConfig, +} from '@/lib/external-identity-providers' +import { getErrorMessage } from '@/lib/get-error-message' +import { auth, buildPathWithParams } from '@/lib/gotrue' + +interface SignInWithExternalProviderProps { + provider: ExternalIdentityProviderConfig + /** Overrides the default "Continue with {provider}" button label (e.g. a focused "Continue"). */ + label?: string +} + +export const SignInWithExternalProvider = ({ + provider, + label, +}: SignInWithExternalProviderProps) => { + const [loading, setLoading] = useState(false) + const [, setLastSignInUsed] = useLastSignIn() + + async function handleSignIn() { + setLoading(true) + + try { + // Redirects to /sign-in-mfa to check if the user has MFA set up before entering the dashboard + const redirectTo = buildPathWithParams(buildProviderAuthRedirect(provider.id)) + + const { error } = await auth.signInWithOAuth({ + // Configured external providers may include custom provider identifiers. + provider: provider.authProvider as Provider, + options: { redirectTo, scopes: provider.scopes }, + }) + + if (error) throw error + setLastSignInUsed(provider.id) + } catch (error: unknown) { + const message = getErrorMessage(error) ?? 'Unknown error' + toast.error(`Failed to sign in via ${provider.displayName}: ${message}`) + captureCriticalError( + error instanceof Error ? error : new Error(message), + `sign in via ${provider.displayName}` + ) + setLoading(false) + } + } + + return ( + + + + ) +} diff --git a/apps/studio/components/interfaces/SignIn/SignInWithGitHub.tsx b/apps/studio/components/interfaces/SignIn/SignInWithGitHub.tsx deleted file mode 100644 index 944d88ece38..00000000000 --- a/apps/studio/components/interfaces/SignIn/SignInWithGitHub.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Github } from 'lucide-react' -import { useState } from 'react' -import { toast } from 'sonner' -import { Button } from 'ui' - -import { LastSignInWrapper } from './LastSignInWrapper' -import { useLastSignIn } from '@/hooks/misc/useLastSignIn' -import { BASE_PATH } from '@/lib/constants' -import { captureCriticalError } from '@/lib/error-reporting' -import { auth, buildPathWithParams } from '@/lib/gotrue' - -export const SignInWithGitHub = () => { - const [loading, setLoading] = useState(false) - const [_, setLastSignInUsed] = useLastSignIn() - - async function handleGithubSignIn() { - setLoading(true) - - try { - // redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx) - const redirectTo = buildPathWithParams( - `${ - process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' - ? location.origin - : process.env.NEXT_PUBLIC_SITE_URL - }${BASE_PATH}/sign-in-mfa?method=github` - ) - - const { error } = await auth.signInWithOAuth({ - provider: 'github', - options: { redirectTo }, - }) - - if (error) throw error - else setLastSignInUsed('github') - } catch (error: any) { - toast.error(`Failed to sign in via GitHub: ${error.message}`) - captureCriticalError(error, 'sign in via GitHub') - setLoading(false) - } - } - - return ( - - - - ) -} diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index c24afec01dc..9d996d216b4 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -12,6 +12,8 @@ interface InterstitialLayoutProps { logo?: ReactNode title?: ReactNode description?: ReactNode + /** Optional content rendered beneath the card (e.g. a terms disclaimer), at the card's width. */ + footer?: ReactNode containerClassName?: string cardClassName?: string titleClassName?: string @@ -29,6 +31,7 @@ export const InterstitialLayout = ({ logo, title, description, + footer, containerClassName, cardClassName, titleClassName, @@ -60,6 +63,27 @@ export const InterstitialLayout = ({ ) : null + const card = ( + + {(logo || title || description) && ( + + {logo &&
{logo}
} + {(titleElement || descriptionElement) && ( +
+ {titleElement} + {descriptionElement} +
+ )} +
+ )} + {children} +
+ ) + return (
- - {(logo || title || description) && ( - - {logo &&
{logo}
} - {(titleElement || descriptionElement) && ( -
- {titleElement} - {descriptionElement} -
- )} -
- )} - {children} -
+ {footer ? ( +
+ {card} +
{footer}
+
+ ) : ( + card + )}
) } @@ -121,6 +135,16 @@ export const PartnerLogo = ({ src, alt }: { src: string; alt: string }) => ( ) +/** + * Sign-in destination mark, inset to match {@link SupabaseLogo}. Falls back to the destination's + * initial when no icon is available. + */ +export const DestinationLogo = ({ icon, name }: { icon?: ReactNode; name: string }) => ( + + {icon ?? {name.slice(0, 1)}} + +) + /** Supabase symbol (not the wordmark) rendered inset inside a LogoBox. */ export const SupabaseLogo = () => ( diff --git a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx index 17d88a9b47f..c7b24a8d3b2 100644 --- a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx +++ b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx @@ -1,7 +1,7 @@ import { useTheme } from 'next-themes' import Image from 'next/legacy/image' import Link from 'next/link' -import { PropsWithChildren } from 'react' +import { PropsWithChildren, useEffect, useState } from 'react' import { cn } from 'ui' import { BASE_PATH } from '@/lib/constants' @@ -14,7 +14,7 @@ type ForgotPasswordLayoutProps = { className?: string } -const ForgotPasswordLayout = ({ +export const ForgotPasswordLayout = ({ heading, subheading, logoLinkToMarketingSite = false, @@ -24,6 +24,10 @@ const ForgotPasswordLayout = ({ }: PropsWithChildren) => { const { resolvedTheme } = useTheme() + // Addresses hydration issue with `resolvedTheme` as its undefined during SSR and the first (hydrating) client render + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + return (
) } - -export default ForgotPasswordLayout diff --git a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx index 63c484374dc..fd12b00e6ac 100644 --- a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx +++ b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx @@ -5,31 +5,73 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { PropsWithChildren, useEffect, useState } from 'react' import { tweets } from 'shared-data' +import { cn } from 'ui' +import { + DestinationLogo, + InterstitialLayout, + LogoPair, + SupabaseLogo, +} from '@/components/layouts/InterstitialLayout' import { DocsButton } from '@/components/ui/DocsButton' +import { InlineLink } from '@/components/ui/InlineLink' +import { IdentityProviderIcon } from '@/components/ui/ProviderIcon' +import { useInboundBranding } from '@/hooks/misc/useInboundBranding' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { BASE_PATH, DOCS_URL } from '@/lib/constants' +import { getProviderDisplay } from '@/lib/external-identity-providers' import { auth, buildPathWithParams, getReturnToPath } from '@/lib/gotrue' +type Quote = { + text: string + url: string + handle: string + img_url: string +} + type SignInLayoutProps = { heading: string subheading: string showDisclaimer?: boolean logoLinkToMarketingSite?: boolean + /** + * When set, the layout can show a focused-provider interstitial or swap its default heading for + * a destination-branded header (see {@link useInboundBranding}). The flow controls the verb + * ("Sign in" vs "Sign up"). + */ + inboundFlow?: 'sign-in' | 'sign-up' } -const SignInLayout = ({ +const TermsText = () => ( + <> + By continuing, you agree to Supabase’s{' '} + Terms of Service and{' '} + Privacy Policy, and to receive + periodic emails with updates. + +) + +export const SignInLayout = ({ heading, subheading, showDisclaimer = true, logoLinkToMarketingSite = false, + inboundFlow, children, }: PropsWithChildren) => { const router = useRouter() - const queryClient = useQueryClient() const { resolvedTheme } = useTheme() + const queryClient = useQueryClient() const ongoingIncident = useFlag('ongoingIncident') + const { destination, focusProvider } = useInboundBranding(inboundFlow) + + // Addresses hydration issue with `resolvedTheme` as its undefined during SSR and the first (hydrating) client render + const [mounted, setMounted] = useState(false) + const [quote, setQuote] = useState(null) + + const verb = inboundFlow === 'sign-up' ? 'Sign up' : 'Sign in' + const { dashboardAuthShowTestimonial: showTestimonial, brandingLargeLogo: largeLogo, @@ -78,14 +120,9 @@ const SignInLayout = ({ .catch(() => {}) // catch all errors thrown by auth methods }, []) - const [quote, setQuote] = useState<{ - text: string - url: string - handle: string - img_url: string - } | null>(null) - useEffect(() => { + setMounted(true) + // Weighted random selection // Calculate total weight (default weight is fallbackWeight for tweets without weight specified) const fallbackWeight = 1 @@ -106,13 +143,63 @@ const SignInLayout = ({ } }, []) + // Focused provider: render a dedicated single-provider interstitial (same card layout as the + // external-identity flows). When we also know the destination the inbound link is returning the + // user to, frame the screen around it. + if (inboundFlow && focusProvider) { + return ( + + ) + } + name={destination?.displayName ?? focusProvider.displayName} + /> + } + right={} + /> + } + title={destination ? `Continue to ${destination.displayName}` : `${verb} to Supabase`} + description={ + destination + ? `${verb} to Supabase using your ${focusProvider.displayName} account` + : `Use your ${focusProvider.displayName} account to continue` + } + footer={ + showDisclaimer && showTos ? ( +

+ +

+ ) : undefined + } + > +
{children}
+
+ ) + } + + // Destination known but no focused provider: keep the regular screen but brand its heading. + const brandedDestination = inboundFlow ? destination : undefined + const brandedHeading = brandedDestination + ? `${verb} to continue to ${brandedDestination.displayName}` + : undefined + return ( <>