Auth flow improvements (#46967)

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


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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-06-18 15:34:56 +08:00
1 parent 91861c4a1f
commit e491182054
27 files changed
+1052 -280

No files matched your search

@@ -98,7 +98,10 @@ export const AccountConnections = () => {
alt={`GitHub icon`}
/>
<div>
<p className="text-sm">GitHub</p>
<div className="flex items-center gap-x-2">
<p className="text-sm">GitHub</p>
{isConnected && <Badge variant="success">Connected</Badge>}
</div>
<p className="text-sm text-foreground-lighter">
Sync repos to Supabase projects for automatic branch creation and merging
</p>
@@ -106,36 +109,33 @@ export const AccountConnections = () => {
</div>
<div className="flex items-center gap-x-2 ml-2">
{isConnected ? (
<>
<Badge variant="success">Connected</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button iconRight={<ChevronDown size={14} />} variant="default">
<span>Manage</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuItem
className="space-x-2"
onSelect={(event) => {
event.preventDefault()
handleReauthenticate()
}}
>
<RefreshCw size={14} />
<p>Re-authenticate</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="space-x-2"
onSelect={() => setIsRemoveModalOpen(true)}
>
<Unlink size={14} />
<p>Remove connection</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button iconRight={<ChevronDown size={14} />} variant="default">
<span>Manage</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuItem
className="space-x-2"
onSelect={(event) => {
event.preventDefault()
handleReauthenticate()
}}
>
<RefreshCw size={14} />
<p>Re-authenticate</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="space-x-2"
onSelect={() => setIsRemoveModalOpen(true)}
>
<Unlink size={14} />
<p>Remove connection</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="primary" onClick={handleConnect}>
Connect
@@ -1,16 +1,15 @@
import type { Provider } from '@supabase/auth-js'
import dayjs from 'dayjs'
import { Edit, Unlink } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Badge,
Button,
Card,
CardContent,
cn,
Dialog,
DialogContent,
DialogHeader,
@@ -36,21 +35,30 @@ import {
SSOChangeEmailAddress,
} from './ChangeEmailAddress'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { IdentityProviderIcon } from '@/components/ui/ProviderIcon'
import { useProfileIdentitiesQuery } from '@/data/profile/profile-identities-query'
import { useUnlinkIdentityMutation } from '@/data/profile/profile-unlink-identity-mutation'
import { BASE_PATH } from '@/lib/constants'
const getProviderName = (provider: string) =>
provider === 'github'
? 'GitHub'
: provider.startsWith('sso')
? 'SSO'
: provider.replaceAll('_', ' ')
import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders'
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'
export const AccountIdentities = () => {
const router = useRouter()
const { data, isPending: isLoading, isSuccess } = useProfileIdentitiesQuery()
const enabledProviders = useEnabledIdentityProviders()
const connectableExternalProviders = useMemo(
() => enabledProviders.filter((provider) => provider.showInAccountPreferences),
[enabledProviders]
)
const identities = data?.identities ?? []
const isChangeExpired = data?.email_change_sent_at
? dayjs().utc().diff(dayjs(data?.email_change_sent_at).utc(), 'minute') > 10
@@ -58,23 +66,58 @@ export const AccountIdentities = () => {
const [selectedProviderUnlink, setSelectedProviderUnlink] = useState<string>()
const [selectedProviderUpdateEmail, setSelectedProviderUpdateEmail] = useState<string>()
const [linkingProviderId, setLinkingProviderId] = useState<string>()
const [, message] = router.asPath.split('#message=')
const unlinkedExternalProviders = connectableExternalProviders.filter((provider) => {
return !identities.some(
(identity) => identity.provider === provider.authProvider || identity.provider === provider.id
)
})
const { mutate: unlinkIdentity, isPending: isUnlinking } = useUnlinkIdentityMutation({
onSuccess: () => {
toast.success(
`Successfully unlinked ${getProviderName(selectedProviderUnlink ?? '')} identity!`
)
toast.success(`Successfully unlinked ${getProviderName(selectedProviderUnlink)} identity!`)
setSelectedProviderUnlink(undefined)
},
})
const [, message] = router.asPath.split('#message=')
const getProviderName = (provider?: string) =>
provider ? getProviderDisplay(provider).displayName : undefined
const getConfiguredExternalProvider = (provider: string) =>
connectableExternalProviders.find(
({ id, authProvider }) => provider === id || provider === authProvider
)
const onConfirmUnlinkIdentity = async () => {
const identity = identities.find((i) => i.provider === selectedProviderUnlink)
if (identity) unlinkIdentity(identity)
}
const onLinkExternalProvider = async (provider: ExternalIdentityProviderConfig) => {
setLinkingProviderId(provider.id)
try {
const redirectTo = buildPathWithParams(buildProviderAuthRedirect(provider.id, '/account/me'))
const { error } = await auth.linkIdentity({
provider: provider.authProvider as Provider,
options: { redirectTo, scopes: provider.scopes },
})
if (error) throw error
} catch (error: unknown) {
const message = getErrorMessage(error) ?? 'Unknown error'
toast.error(`Failed to link ${provider.displayName} identity: ${message}`)
captureCriticalError(
error instanceof Error ? error : new Error(message),
`link ${provider.displayName} identity`
)
setLinkingProviderId(undefined)
}
}
useEffect(() => {
if (message) toast.success(message.replaceAll('+', ' '))
}, [message])
@@ -101,24 +144,15 @@ export const AccountIdentities = () => {
{identities.map((identity) => {
const { identity_id, provider } = identity
const username = identity.identity_data?.user_name
const providerName = getProviderName(provider)
const iconKey =
provider === 'github'
? 'github-icon'
: provider === 'email'
? 'email-icon2'
: 'saml-icon'
const providerDisplay = getProviderDisplay(provider)
const providerName = providerDisplay.displayName
const configuredProvider = getConfiguredExternalProvider(provider)
const canUpdateEmail = !configuredProvider
return (
<CardContent key={identity_id} className="flex justify-between items-center py-4">
<div className="flex gap-x-4">
<Image
className={cn(iconKey === 'github-icon' ? 'dark:invert' : '')}
src={`${BASE_PATH}/img/icons/${iconKey}.svg`}
width={30}
height={30}
alt={`${identity.provider} icon`}
/>
<IdentityProviderIcon display={providerDisplay} size={30} />
<div>
<div className="flex items-center gap-x-2">
<p className="text-sm capitalize">{providerName}</p>
@@ -143,13 +177,15 @@ export const AccountIdentities = () => {
<Link href="/reset-password?type=change">Change password</Link>
</Button>
)}
<ButtonTooltip
variant="text"
icon={<Edit />}
className="w-7"
onClick={() => setSelectedProviderUpdateEmail(provider)}
tooltip={{ content: { side: 'bottom', text: 'Update email address' } }}
/>
{canUpdateEmail && (
<ButtonTooltip
variant="text"
icon={<Edit />}
className="w-7"
onClick={() => setSelectedProviderUpdateEmail(provider)}
tooltip={{ content: { side: 'bottom', text: 'Update email address' } }}
/>
)}
{identities.length > 1 && (
<ButtonTooltip
variant="text"
@@ -163,6 +199,33 @@ export const AccountIdentities = () => {
</CardContent>
)
})}
{unlinkedExternalProviders.map((provider) => {
const providerDisplay = getProviderDisplay(provider.authProvider)
return (
<CardContent key={provider.id} className="flex justify-between items-center py-4">
<div className="flex gap-x-4">
<IdentityProviderIcon display={providerDisplay} size={30} />
<div>
<p className="text-sm">{provider.displayName}</p>
<p className="text-sm text-foreground-lighter">
Link your {provider.displayName} account to sign in with{' '}
{provider.displayName}
</p>
</div>
</div>
<Button
variant="default"
loading={linkingProviderId === provider.id}
disabled={!!linkingProviderId}
onClick={() => onLinkExternalProvider(provider)}
>
Connect
</Button>
</CardContent>
)
})}
</div>
)}
</Card>
@@ -177,7 +240,7 @@ export const AccountIdentities = () => {
<DialogHeader className="border-b">
<DialogTitle>
{selectedProviderUpdateEmail !== 'email'
? `Updating email address for ${getProviderName(selectedProviderUpdateEmail ?? '')} identity`
? `Updating email address for ${getProviderName(selectedProviderUpdateEmail)} identity`
: 'Update email address'}
</DialogTitle>
</DialogHeader>
@@ -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.',
}}
/>
</PageSectionContent>
@@ -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 (
<div className="flex items-center relative">
{lastSignIn === type && (
{isLastUsed && (
<Badge
variant="success"
className="absolute -right-4 -top-3 shadow-sm z-10 bg-brand-400 text-foreground pointer-events-none"
@@ -24,8 +32,7 @@ export function LastSignInWrapper({
)}
<div
className={cn('w-full', {
'outline outline-1 outline-offset-4 outline-foreground-lighter/50 rounded-md ':
lastSignIn === type,
'outline outline-1 outline-offset-4 outline-foreground-lighter/50 rounded-md': isLastUsed,
})}
>
{children}
@@ -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 (
<LastSignInWrapper type={provider.id}>
<Button
block
onClick={handleSignIn}
icon={<IdentityProviderIcon display={getProviderDisplay(provider.id)} />}
size="large"
variant="outline"
loading={loading}
>
{label ?? `Continue with ${provider.displayName}`}
</Button>
</LastSignInWrapper>
)
}
@@ -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 (
<LastSignInWrapper type="github">
<Button
block
onClick={handleGithubSignIn}
// set the width to 20 so that it matches the loading spinner and don't push the text when loading
icon={<Github width={20} height={18} />}
size="large"
variant="default"
loading={loading}
>
Continue with GitHub
</Button>
</LastSignInWrapper>
)
}
@@ -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 = ({
</DescriptionElement>
) : null
const card = (
<MotionCard
layout="size"
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className={cn('overflow-hidden max-w-[400px] w-full mx-auto', cardClassName)}
>
{(logo || title || description) && (
<CardHeader className="font-normal items-center gap-0 space-y-0 px-6 py-6 text-center [--card-padding-x:1.5rem] border-0">
{logo && <div className="mb-4 flex justify-center">{logo}</div>}
{(titleElement || descriptionElement) && (
<div className="flex flex-col items-center gap-1">
{titleElement}
{descriptionElement}
</div>
)}
</CardHeader>
)}
{children}
</MotionCard>
)
return (
<div
className={cn(
@@ -67,24 +91,14 @@ export const InterstitialLayout = ({
containerClassName
)}
>
<MotionCard
layout="size"
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className={cn('overflow-hidden max-w-[400px] w-full mx-auto', cardClassName)}
>
{(logo || title || description) && (
<CardHeader className="font-normal items-center gap-0 space-y-0 px-6 py-6 text-center [--card-padding-x:1.5rem] border-0">
{logo && <div className="mb-4 flex justify-center">{logo}</div>}
{(titleElement || descriptionElement) && (
<div className="flex flex-col items-center gap-1">
{titleElement}
{descriptionElement}
</div>
)}
</CardHeader>
)}
{children}
</MotionCard>
{footer ? (
<div className="flex w-full max-w-[400px] flex-col items-center gap-4">
{card}
<div className="px-2 text-center text-balance">{footer}</div>
</div>
) : (
card
)}
</div>
)
}
@@ -121,6 +135,16 @@ export const PartnerLogo = ({ src, alt }: { src: string; alt: string }) => (
</LogoBox>
)
/**
* 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 }) => (
<LogoBox>
{icon ?? <span className="text-lg font-medium text-foreground-light">{name.slice(0, 1)}</span>}
</LogoBox>
)
/** Supabase symbol (not the wordmark) rendered inset inside a LogoBox. */
export const SupabaseLogo = () => (
<LogoBox className="bg-surface-75">
@@ -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<ForgotPasswordLayoutProps>) => {
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 (
<div
className={cn(
@@ -38,7 +42,7 @@ const ForgotPasswordLayout = ({
<Link href={logoLinkToMarketingSite ? 'https://supabase.com' : '/organizations'}>
<Image
src={
resolvedTheme?.includes('dark')
mounted && resolvedTheme?.includes('dark')
? `${BASE_PATH}/img/supabase-dark.svg`
: `${BASE_PATH}/img/supabase-light.svg`
}
@@ -67,5 +71,3 @@ const ForgotPasswordLayout = ({
</div>
)
}
export default ForgotPasswordLayout
@@ -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{' '}
<InlineLink href="https://supabase.com/terms">Terms of Service</InlineLink> and{' '}
<InlineLink href="https://supabase.com/privacy">Privacy Policy</InlineLink>, and to receive
periodic emails with updates.
</>
)
export const SignInLayout = ({
heading,
subheading,
showDisclaimer = true,
logoLinkToMarketingSite = false,
inboundFlow,
children,
}: PropsWithChildren<SignInLayoutProps>) => {
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<Quote | null>(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 (
<InterstitialLayout
logo={
<LogoPair
left={
<DestinationLogo
icon={
destination?.icon ?? (
<IdentityProviderIcon
display={getProviderDisplay(focusProvider.id)}
size={28}
/>
)
}
name={destination?.displayName ?? focusProvider.displayName}
/>
}
right={<SupabaseLogo />}
/>
}
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 ? (
<p className="text-xs text-foreground-lighter">
<TermsText />
</p>
) : undefined
}
>
<div className="px-6 pb-6">{children}</div>
</InterstitialLayout>
)
}
// 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 (
<>
<div className="relative flex flex-col bg-alternative min-h-screen">
<div
className={`absolute top-0 w-full px-8 mx-auto sm:px-6 lg:px-8 ${
className={cn(
'absolute top-0 w-full px-8 mx-auto sm:px-6 lg:px-8',
ongoingIncident ? 'mt-14' : 'mt-6'
}`}
)}
>
<nav className="relative flex items-center justify-between sm:h-10">
<div className="flex items-center grow shrink-0 lg:grow-0">
@@ -120,7 +207,7 @@ const SignInLayout = ({
<Link href={logoLinkToMarketingSite ? 'https://supabase.com' : '/organizations'}>
<img
src={
resolvedTheme?.includes('dark')
mounted && resolvedTheme?.includes('dark')
? `${BASE_PATH}/img/supabase-dark.svg`
: `${BASE_PATH}/img/supabase-light.svg`
}
@@ -140,10 +227,25 @@ const SignInLayout = ({
<div className="flex flex-1 h-full">
<main className="flex flex-col items-center flex-1 shrink-0 px-5 pt-16 pb-8 border-r shadow-lg bg-studio border-default">
<div className="flex-1 flex flex-col justify-center w-[330px] sm:w-[384px]">
<div className="mb-10">
<h1 className="mt-8 mb-2 lg:text-3xl">{heading}</h1>
<h2 className="text-sm text-foreground-light">{subheading}</h2>
</div>
{brandedDestination ? (
<div className="mb-10 flex flex-col items-center gap-5 text-center">
<LogoPair
left={
<DestinationLogo
icon={brandedDestination.icon}
name={brandedDestination.displayName}
/>
}
right={<SupabaseLogo />}
/>
<h1 className="text-balance lg:text-2xl">{brandedHeading}</h1>
</div>
) : (
<div className="mb-10">
<h1 className="mt-8 mb-2 lg:text-3xl">{heading}</h1>
<h2 className="text-sm text-foreground-light">{subheading}</h2>
</div>
)}
{children}
</div>
@@ -151,21 +253,7 @@ const SignInLayout = ({
{showDisclaimer && showTos && (
<div className="text-center text-balance">
<p className="text-xs text-foreground-lighter sm:mx-auto sm:max-w-sm">
By continuing, you agree to Supabase’s{' '}
<Link
href="https://supabase.com/terms"
className="underline hover:text-foreground-light"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
href="https://supabase.com/privacy"
className="underline hover:text-foreground-light"
>
Privacy Policy
</Link>
, and to receive periodic emails with updates.
<TermsText />
</p>
</div>
)}
@@ -206,5 +294,3 @@ const SignInLayout = ({
</>
)
}
export default SignInLayout
@@ -0,0 +1,58 @@
import Image from 'next/image'
import { cn } from 'ui'
import type { IdentityProviderDisplay } from '@/lib/external-identity-providers'
/**
* Renders a monochrome identity-provider mark as a CSS mask so it inherits the current theme's
* foreground color. The provider SVGs use `currentColor`, which a plain `<img>`/`next/image` would
* not respect (it would render the icon's literal fill and need a `dark:invert` hack). Masking tints
* any provider mark correctly in every theme, regardless of how its SVG is colored.
*
* Size via `size` (pixels) or `className` (e.g. `size-7`, `size-[30px]`).
*/
export const ProviderIcon = ({
src,
alt,
size,
className,
}: {
src: string
alt: string
size?: number
className?: string
}) => (
<span
role="img"
aria-label={alt}
className={cn('inline-block shrink-0 bg-foreground', className)}
style={{
...(size !== undefined ? { width: size, height: size } : undefined),
maskImage: `url(${src})`,
maskRepeat: 'no-repeat',
maskPosition: 'center',
maskSize: 'contain',
WebkitMaskImage: `url(${src})`,
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
WebkitMaskSize: 'contain',
}}
/>
)
/**
* Renders the icon for a provider's display metadata (see `getProviderDisplay`): monochrome marks
* are tinted to the theme via {@link ProviderIcon}, colored icons render as-is.
*/
export const IdentityProviderIcon = ({
display,
size = 20,
}: {
display: IdentityProviderDisplay
size?: number
}) =>
display.hasMonochromeIcon ? (
<ProviderIcon src={display.iconPath} alt={`${display.displayName} icon`} size={size} />
) : (
<Image src={display.iconPath} width={size} height={size} alt={`${display.displayName} icon`} />
)
@@ -0,0 +1,33 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useEnabledIdentityProviders } from '../useEnabledIdentityProviders'
import { GITHUB_IDENTITY_PROVIDER } from '@/lib/external-identity-providers'
const mockIsFeatureEnabled = vi.hoisted(() => vi.fn())
vi.mock('../useIsFeatureEnabled', () => ({
useIsFeatureEnabled: mockIsFeatureEnabled,
}))
describe('useEnabledIdentityProviders', () => {
it('returns every provider when all flags are enabled', () => {
mockIsFeatureEnabled.mockReturnValue({
dashboardAuthSignInWithGithub: true,
})
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([GITHUB_IDENTITY_PROVIDER])
})
it('returns no providers when all flags are disabled', () => {
mockIsFeatureEnabled.mockReturnValue({
dashboardAuthSignInWithGithub: false,
})
const { result } = renderHook(() => useEnabledIdentityProviders())
expect(result.current).toEqual([])
})
})
@@ -0,0 +1,91 @@
import mockRouter from 'next-router-mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useInboundBranding } from '../useInboundBranding'
import {
GITHUB_IDENTITY_PROVIDER,
type ExternalIdentityProviderConfig,
} from '@/lib/external-identity-providers'
import { customRenderHook } from '@/tests/lib/custom-render'
vi.mock('next/router', () => import('next-router-mock'))
const mockEnabledProviders = vi.hoisted(() => vi.fn<() => ExternalIdentityProviderConfig[]>())
vi.mock('../useEnabledIdentityProviders', () => ({
useEnabledIdentityProviders: mockEnabledProviders,
}))
const enabledProvider: ExternalIdentityProviderConfig = GITHUB_IDENTITY_PROVIDER
describe('useInboundBranding', () => {
beforeEach(() => {
mockRouter.setCurrentUrl('/sign-in')
mockEnabledProviders.mockReturnValue([enabledProvider])
})
it('focuses an enabled provider without deriving a destination', () => {
mockRouter.setCurrentUrl('/sign-in?method=github')
const { result } = customRenderHook(() => useInboundBranding('sign-in'))
expect(result.current.focusProvider?.authProvider).toBe('github')
expect(result.current.destination).toBeUndefined()
})
it('does not focus on a destination id passed as the method param', () => {
mockRouter.setCurrentUrl('/sign-in?method=cli')
const { result } = customRenderHook(() => useInboundBranding('sign-in'))
expect(result.current.focusProvider).toBeUndefined()
expect(result.current.destination).toBeUndefined()
})
it('derives the destination from the destination param without a focused provider', () => {
mockRouter.setCurrentUrl('/sign-in?destination=cli')
const { result } = customRenderHook(() => useInboundBranding('sign-in'))
expect(result.current.destination?.id).toBe('cli')
expect(result.current.focusProvider).toBeUndefined()
})
it('keeps the destination when a provider is focused', () => {
mockRouter.setCurrentUrl('/sign-in?destination=cli&method=github')
const { result } = customRenderHook(() => useInboundBranding('sign-in'))
expect(result.current.destination?.id).toBe('cli')
expect(result.current.focusProvider?.authProvider).toBe('github')
})
it('ignores an unknown destination param', () => {
mockRouter.setCurrentUrl('/sign-in?destination=not-a-real-destination')
const { result } = customRenderHook(() => useInboundBranding('sign-in'))
expect(result.current.destination).toBeUndefined()
expect(result.current.focusProvider).toBeUndefined()
})
it('does not focus a provider that is hidden from the current flow', () => {
mockEnabledProviders.mockReturnValue([{ ...enabledProvider, showOnSignUp: false }])
mockRouter.setCurrentUrl('/sign-up?method=github')
const { result } = customRenderHook(() => useInboundBranding('sign-up'))
expect(result.current.focusProvider).toBeUndefined()
expect(result.current.destination).toBeUndefined()
})
it('does not focus a provider that is not enabled', () => {
mockEnabledProviders.mockReturnValue([])
mockRouter.setCurrentUrl('/sign-in?method=github')
const { result } = customRenderHook(() => useInboundBranding('sign-in'))
expect(result.current.focusProvider).toBeUndefined()
expect(result.current.destination).toBeUndefined()
})
})
@@ -1,7 +1,7 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useShowMultigresLogs } from './useShowMultigresLogs'
import { useShowMultigresLogs } from '../useShowMultigresLogs'
const mockUseFlag = vi.fn()
const mockUseIsHighAvailability = vi.fn()
@@ -11,7 +11,7 @@ vi.mock('common', async (importOriginal) => ({
useFlag: (name: string) => mockUseFlag(name),
}))
vi.mock('./useSelectedProject', () => ({
vi.mock('../useSelectedProject', () => ({
useIsHighAvailability: () => mockUseIsHighAvailability(),
}))
@@ -0,0 +1,20 @@
import { useMemo } from 'react'
import { useIsFeatureEnabled } from './useIsFeatureEnabled'
import {
GITHUB_IDENTITY_PROVIDER,
type ExternalIdentityProviderConfig,
} from '@/lib/external-identity-providers'
/**
* Returns the statically-declared identity providers whose feature flag is currently enabled.
* To add a provider: declare its config in `lib/external-identity-providers.ts`, add a
* `dashboard_auth:sign_in_with_*` flag, and gate it here.
*/
export function useEnabledIdentityProviders(): ExternalIdentityProviderConfig[] {
const { dashboardAuthSignInWithGithub: githubEnabled } = useIsFeatureEnabled([
'dashboard_auth:sign_in_with_github',
])
return useMemo(() => [...(githubEnabled ? [GITHUB_IDENTITY_PROVIDER] : [])], [githubEnabled])
}
@@ -0,0 +1,61 @@
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { useEnabledIdentityProviders } from './useEnabledIdentityProviders'
import { type ExternalIdentityProviderConfig } from '@/lib/external-identity-providers'
import { getDestinationById, type SignInDestination } from '@/lib/sign-in-destinations'
export type InboundBranding = {
/**
* Destination the user is signing in on the way to (e.g. the Supabase CLI), selected by the
* `destination` query param and resolved from the static `SIGN_IN_DESTINATIONS` registry. Brands
* the screen's logo and heading.
*/
destination?: SignInDestination
/**
* Enabled identity provider the inbound link asked us to focus on (the `method` param).
* When set, sign-in/sign-up render a trimmed-down screen offering only that provider's button.
*/
focusProvider?: ExternalIdentityProviderConfig
}
/**
* Reads the branding context for users arriving at sign-in/sign-up from somewhere else, such as the
* Supabase CLI. Both signals come straight from the URL: these screens render while the user is
* signed out, so branding can't depend on an authenticated API lookup. (The OAuth consent screen
* runs post-auth and brands itself dynamically by `auth_id` instead.)
*
* Destination and focused provider are independent: a destination brands the screen, and a focused
* provider trims the screen to a single button whether or not we know the destination.
*
* The focused provider must be enabled and visible in the current flow (`showOnSignIn` /
* `showOnSignUp`), otherwise there'd be no button to offer and we fall back to the full-option
* 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 focusProvider = useMemo(
() =>
focusId
? enabledProviders.find(
(provider) =>
focusId === provider.authProvider &&
(flow === 'sign-up' ? provider.showOnSignUp : provider.showOnSignIn)
)
: undefined,
[focusId, flow, enabledProviders]
)
const destination = getDestinationById(destinationId)
return { destination, focusProvider }
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { LOCAL_STORAGE_KEYS } from 'common'
import { useLocalStorage } from './useLocalStorage'
export type LastSignInType = 'github' | 'email' | 'sso' | null
export type LastSignInType = string | null
export function useLastSignIn() {
return useLocalStorage<LastSignInType>(LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD, null)
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import {
buildProviderAuthRedirect,
getIdentityProviderConfig,
getProviderDisplay,
normalizeIconPath,
} from './external-identity-providers'
describe('external identity providers', () => {
test('normalizes relative icon paths against the dashboard base path', () => {
expect(normalizeIconPath('/img/icons/github-icon.svg')).toBe('/img/icons/github-icon.svg')
expect(normalizeIconPath('img/icons/github-icon.svg')).toBe('/img/icons/github-icon.svg')
expect(normalizeIconPath('https://example.com/icon.svg')).toBe('https://example.com/icon.svg')
})
test('resolves static provider config by provider id or auth provider', () => {
expect(getIdentityProviderConfig('github')?.displayName).toBe('GitHub')
expect(getIdentityProviderConfig('unknown')).toBeUndefined()
})
test('returns display metadata for static, built-in, and fallback providers', () => {
expect(getProviderDisplay('github').displayName).toBe('GitHub')
expect(getProviderDisplay('email').displayName).toBe('Email')
expect(getProviderDisplay('sso:test').displayName).toBe('SSO')
expect(getProviderDisplay('my_provider').displayName).toBe('my provider')
})
test('marks static provider icons as monochrome but not built-in or fallback icons', () => {
expect(getProviderDisplay('github').hasMonochromeIcon).toBe(true)
expect(getProviderDisplay('email').hasMonochromeIcon).toBeUndefined()
expect(getProviderDisplay('sso:test').hasMonochromeIcon).toBeUndefined()
})
describe('buildProviderAuthRedirect', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
test('builds the MFA-check URL against the configured site URL', () => {
vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://supabase.com/dashboard')
expect(buildProviderAuthRedirect('github')).toBe(
'https://supabase.com/dashboard/sign-in-mfa?method=github'
)
})
test('URL-encodes custom provider ids in the method param', () => {
vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://supabase.com/dashboard')
expect(buildProviderAuthRedirect('custom:example')).toBe(
'https://supabase.com/dashboard/sign-in-mfa?method=custom%3Aexample'
)
})
test('appends an encoded returnTo destination when provided', () => {
vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://supabase.com/dashboard')
expect(buildProviderAuthRedirect('custom:example', '/account/me')).toBe(
'https://supabase.com/dashboard/sign-in-mfa?method=custom%3Aexample&returnTo=%2Faccount%2Fme'
)
})
test('uses the current origin on Vercel preview deployments', () => {
vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', 'preview')
vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://supabase.com/dashboard')
expect(buildProviderAuthRedirect('github')).toBe(
`${location.origin}/sign-in-mfa?method=github`
)
})
})
})
@@ -0,0 +1,116 @@
import { BASE_PATH } from './constants'
export type ExternalIdentityProviderConfig = {
id: string
authProvider: string
displayName: string
iconPath: string
scopes?: string
showOnSignIn: boolean
showOnSignUp: boolean
showInAccountPreferences: boolean
}
export type IdentityProviderDisplay = {
id: string
displayName: string
iconPath: string
/** The icon is a single-color mark that should be tinted to the theme's foreground color. */
hasMonochromeIcon?: boolean
}
const BUILT_IN_IDENTITY_PROVIDERS: Record<string, IdentityProviderDisplay> = {
email: {
id: 'email',
displayName: 'Email',
iconPath: `${BASE_PATH}/img/icons/email-icon2.svg`,
},
}
// Statically supported identity providers. To add a new one, declare its config here, gate its
// visibility behind a `dashboard_auth:sign_in_with_*` feature flag in `useEnabledIdentityProviders`,
// and add the matching flag to `packages/common/enabled-features/enabled-features.json`.
export const GITHUB_IDENTITY_PROVIDER: ExternalIdentityProviderConfig = {
id: 'github',
authProvider: 'github',
displayName: 'GitHub',
iconPath: '/img/icons/github-icon.svg',
showOnSignIn: true,
showOnSignUp: true,
showInAccountPreferences: false,
}
// Registry of every known provider, independent of which are currently enabled. Used for config and
// display lookups (e.g. resolving the provider that a mid-flow interstitial was reached with).
const IDENTITY_PROVIDERS: ExternalIdentityProviderConfig[] = [GITHUB_IDENTITY_PROVIDER]
export function normalizeIconPath(iconPath: string): string {
if (
iconPath.startsWith('http://') ||
iconPath.startsWith('https://') ||
iconPath.startsWith('/')
) {
return iconPath.startsWith('/') ? `${BASE_PATH}${iconPath}` : iconPath
}
return `${BASE_PATH}/${iconPath}`
}
export function getProviderDisplay(provider: string): IdentityProviderDisplay {
const config = IDENTITY_PROVIDERS.find(
({ id, authProvider }) => provider === id || provider === authProvider
)
if (config) {
return {
id: config.id,
displayName: config.displayName,
iconPath: normalizeIconPath(config.iconPath),
hasMonochromeIcon: true,
}
}
if (provider.startsWith('sso')) {
return {
id: provider,
displayName: 'SSO',
iconPath: `${BASE_PATH}/img/icons/saml-icon.svg`,
}
}
return (
BUILT_IN_IDENTITY_PROVIDERS[provider] ?? {
id: provider,
displayName: provider.replaceAll('_', ' '),
iconPath: `${BASE_PATH}/img/icons/saml-icon.svg`,
}
)
}
/**
* Builds the absolute URL an external provider's OAuth flow redirects back to: the MFA-check page
* (`/sign-in-mfa`), tagged with the provider id as the sign-in method and an optional `returnTo`
* destination. Callers should pass the result through `buildPathWithParams` to preserve the current
* location's search params across the OAuth round-trip.
*/
export function buildProviderAuthRedirect(providerId: string, returnTo?: string): string {
const origin =
typeof window !== 'undefined' && process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
? window.location.origin
: process.env.NEXT_PUBLIC_SITE_URL
const params = new URLSearchParams({ method: providerId })
if (returnTo) params.set('returnTo', returnTo)
return `${origin}${BASE_PATH}/sign-in-mfa?${params.toString()}`
}
export function getIdentityProviderConfig(
provider: string | undefined
): ExternalIdentityProviderConfig | undefined {
if (!provider) return undefined
return IDENTITY_PROVIDERS.find(
({ id, authProvider }) => provider === id || provider === authProvider
)
}
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest'
import { getDestinationById } from './sign-in-destinations'
describe('sign-in destinations', () => {
test('resolves a registered destination from its id', () => {
expect(getDestinationById('cli')?.id).toBe('cli')
expect(getDestinationById('cli')?.displayName).toBe('Supabase CLI')
})
test('does not resolve a destination for unknown or missing ids', () => {
expect(getDestinationById('organizations')).toBeUndefined()
expect(getDestinationById('oauth-app')).toBeUndefined()
expect(getDestinationById('')).toBeUndefined()
expect(getDestinationById(undefined)).toBeUndefined()
})
})
+36
View File
@@ -0,0 +1,36 @@
import { Terminal } from 'lucide-react'
import type { ReactNode } from 'react'
/**
* A destination the user is signing in on the way to (e.g. the Supabase CLI). Destinations brand
* the sign-in/sign-up screens but are independent of identity providers: a destination may not have
* a matching sign-in button at all.
*
* The inbound link selects a destination by passing its `id` as the `destination` query param.
* Branding resolves entirely from this static registry with no API call — the sign-in/sign-up
* screens render while the user is signed out, so they can't fetch anything that requires auth.
*/
export type SignInDestination = {
/** Matches the `destination` query param that leads to this destination. */
id: string
displayName: string
/** Mark rendered inset in a LogoBox next to the Supabase logo. */
icon?: ReactNode
}
const CLI_DESTINATION: SignInDestination = {
id: 'cli',
displayName: 'Supabase CLI',
icon: <Terminal className="size-6 text-foreground" strokeWidth={2} />,
}
// Statically-branded sign-in destinations. To add a new one, declare its config above and add it
// here. OAuth app consent screens brand themselves on `/authorize`, which runs post-auth and can
// look the app up by `auth_id`.
const SIGN_IN_DESTINATIONS: SignInDestination[] = [CLI_DESTINATION]
/** Resolves the destination the `destination` query param refers to, if it's one we brand. */
export function getDestinationById(id: string | undefined): SignInDestination | undefined {
if (!id) return undefined
return SIGN_IN_DESTINATIONS.find((destination) => destination.id === id)
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { toast } from 'sonner'
import { LogoLoader } from 'ui'
import { SignInMfaForm } from '@/components/interfaces/SignIn/SignInMfaForm'
import ForgotPasswordLayout from '@/components/layouts/SignInLayout/ForgotPasswordLayout'
import { ForgotPasswordLayout } from '@/components/layouts/SignInLayout/ForgotPasswordLayout'
import { auth, buildPathWithParams, getReturnToPath } from '@/lib/gotrue'
import type { NextPageWithLayout } from '@/types'
+1 -1
View File
@@ -1,7 +1,7 @@
import Link from 'next/link'
import { ForgotPasswordWizard } from '@/components/interfaces/SignIn/ForgotPasswordWizard'
import ForgotPasswordLayout from '@/components/layouts/SignInLayout/ForgotPasswordLayout'
import { ForgotPasswordLayout } from '@/components/layouts/SignInLayout/ForgotPasswordLayout'
import type { NextPageWithLayout } from '@/types'
const ForgotPasswordPage: NextPageWithLayout = () => {
+1 -1
View File
@@ -1,5 +1,5 @@
import { ResetPasswordForm } from '@/components/interfaces/SignIn/ResetPasswordForm'
import ForgotPasswordLayout from '@/components/layouts/SignInLayout/ForgotPasswordLayout'
import { ForgotPasswordLayout } from '@/components/layouts/SignInLayout/ForgotPasswordLayout'
import { withAuth } from '@/hooks/misc/withAuth'
import type { NextPageWithLayout } from '@/types'
+2 -2
View File
@@ -7,7 +7,7 @@ import { toast } from 'sonner'
import { LogoLoader } from 'ui'
import { SignInMfaForm } from '@/components/interfaces/SignIn/SignInMfaForm'
import SignInLayout from '@/components/layouts/SignInLayout/SignInLayout'
import { SignInLayout } from '@/components/layouts/SignInLayout/SignInLayout'
import { useAddLoginEvent } from '@/data/misc/audit-login-mutation'
import useLatest from '@/hooks/misc/useLatest'
import { auth, buildPathWithParams, getReturnToPath } from '@/lib/gotrue'
@@ -19,7 +19,7 @@ const SignInMfaPage: NextPageWithLayout = () => {
const queryClient = useQueryClient()
const {
// current methods for mfa are github and sso
// the external identity provider id (e.g. github) or sso used to sign in
method: signInMethod = 'unknown',
} = useParams()
const signInMethodRef = useLatest(signInMethod)
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from 'ui'
import { SignInPartner } from '@/components/interfaces/SignIn/SignInPartner'
import ForgotPasswordLayout from '@/components/layouts/SignInLayout/ForgotPasswordLayout'
import { ForgotPasswordLayout } from '@/components/layouts/SignInLayout/ForgotPasswordLayout'
import type { NextPageWithLayout } from '@/types'
const SignInPartnerPage: NextPageWithLayout = () => {
+1 -1
View File
@@ -1,5 +1,5 @@
import { SignInSSOForm } from '@/components/interfaces/SignIn/SignInSSOForm'
import SignInLayout from '@/components/layouts/SignInLayout/SignInLayout'
import { SignInLayout } from '@/components/layouts/SignInLayout/SignInLayout'
import { UnknownInterface } from '@/components/ui/UnknownInterface'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import type { NextPageWithLayout } from '@/types'
+87 -56
View File
@@ -1,30 +1,32 @@
import { Lock } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { Button } from 'ui'
import { useEffect, useState } from 'react'
import { Button, cn } from 'ui'
import { LastSignInWrapper } from '@/components/interfaces/SignIn/LastSignInWrapper'
import { SignInForm } from '@/components/interfaces/SignIn/SignInForm'
import { SignInWithCustom } from '@/components/interfaces/SignIn/SignInWithCustom'
import { SignInWithGitHub } from '@/components/interfaces/SignIn/SignInWithGitHub'
import { SignInWithExternalProvider } from '@/components/interfaces/SignIn/SignInWithExternalProvider'
import { AuthenticationLayout } from '@/components/layouts/AuthenticationLayout'
import SignInLayout from '@/components/layouts/SignInLayout/SignInLayout'
import { SignInLayout } from '@/components/layouts/SignInLayout/SignInLayout'
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders'
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 type { NextPageWithLayout } from '@/types'
const SignInPage: NextPageWithLayout = () => {
const router = useRouter()
const [showOtherOptions, setShowOtherOptions] = useState(false)
const {
dashboardAuthSignInWithGithub: signInWithGithubEnabled,
dashboardAuthSignInWithSso: signInWithSsoEnabled,
dashboardAuthSignInWithEmail: signInWithEmailEnabled,
dashboardAuthSignUp: signUpEnabled,
} = useIsFeatureEnabled([
'dashboard_auth:sign_in_with_github',
'dashboard_auth:sign_in_with_sso',
'dashboard_auth:sign_in_with_email',
'dashboard_auth:sign_up',
@@ -34,8 +36,45 @@ const SignInPage: NextPageWithLayout = () => {
'dashboard_auth:custom_provider',
])
const showOrDivider =
(signInWithGithubEnabled || signInWithSsoEnabled || customProvider) && signInWithEmailEnabled
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 || !!customProvider) && signInWithEmailEnabled
return (
<>
{customProvider && <SignInWithCustom providerName={customProvider} />}
{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) {
@@ -44,59 +83,50 @@ const SignInPage: NextPageWithLayout = () => {
}
}, [router])
// 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 = signInProviders.filter((provider) => provider.id !== focusProvider.id)
const hasOtherOptions =
otherProviders.length > 0 ||
signInWithSsoEnabled ||
!!customProvider ||
signInWithEmailEnabled
return (
<div className="flex flex-col gap-5">
<SignInWithExternalProvider provider={focusProvider} label="Continue" />
{hasOtherOptions &&
(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">
{customProvider && <SignInWithCustom providerName={customProvider} />}
{signInWithGithubEnabled && <SignInWithGitHub />}
{signInWithSsoEnabled && (
<LastSignInWrapper type="sso">
<Button
asChild
block
size="large"
variant="outline"
icon={<Lock width={18} height={18} />}
>
<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="px-2 text-sm bg-studio text-foreground">or</span>
</div>
</div>
)}
{signInWithEmailEnabled && <SignInForm />}
</div>
<div className="flex flex-col gap-5">{renderAuthOptions(signInProviders)}</div>
{signUpEnabled && (
<div className="self-center my-8 text-sm">
<div>
<span className="text-foreground-light">Don’t have an account?</span>{' '}
<Link
href={{
pathname: '/sign-up',
query: router.query,
}}
className="underline transition text-foreground hover:text-foreground-light"
>
Sign up
</Link>
</div>
<span className="text-foreground-light">Don’t have an account?</span>{' '}
<Link
href={{ pathname: '/sign-up', query: router.query }}
className="underline transition text-foreground hover:text-foreground-light"
>
Sign up
</Link>
</div>
)}
</>
@@ -109,6 +139,7 @@ SignInPage.getLayout = (page) => (
heading="Welcome back"
subheading="Sign in to your account"
logoLinkToMarketingSite={true}
inboundFlow="sign-in"
>
{page}
</SignInLayout>
+65 -25
View File
@@ -1,42 +1,82 @@
import Link from 'next/link'
import { useState } from 'react'
import { Button, cn } from 'ui'
import { SignInWithGitHub } from '@/components/interfaces/SignIn/SignInWithGitHub'
import { SignInWithExternalProvider } from '@/components/interfaces/SignIn/SignInWithExternalProvider'
import { SignUpForm } from '@/components/interfaces/SignIn/SignUpForm'
import SignInLayout from '@/components/layouts/SignInLayout/SignInLayout'
import { SignInLayout } from '@/components/layouts/SignInLayout/SignInLayout'
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 {
dashboardAuthSignUp: signUpEnabled,
dashboardAuthSignInWithGithub: signInWithGithubEnabled,
} = useIsFeatureEnabled(['dashboard_auth:sign_up', 'dashboard_auth:sign_in_with_github'])
const [showOtherOptions, setShowOtherOptions] = useState(false)
const { dashboardAuthSignUp: signUpEnabled } = useIsFeatureEnabled(['dashboard_auth:sign_up'])
const { focusProvider } = useInboundBranding('sign-up')
const signUpProviders = useEnabledIdentityProviders().filter((provider) => provider.showOnSignUp)
if (!signUpEnabled) {
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} label="Continue" />
{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">
{signInWithGithubEnabled && (
<>
<SignInWithGitHub />
<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="bg-studio px-2 text-sm text-foreground">or</span>
</div>
</div>
</>
)}
<SignUpForm />
</div>
<div className="flex flex-col gap-5">{renderAuthOptions(signUpProviders)}</div>
<div className="my-8 self-center text-sm">
<span className="text-foreground-light">Have an account?</span>{' '}
@@ -52,7 +92,7 @@ const SignUpPage: NextPageWithLayout = () => {
}
SignUpPage.getLayout = (page) => (
<SignInLayout heading="Get started" subheading="Create a new account">
<SignInLayout heading="Get started" subheading="Create a new account" inboundFlow="sign-up">
{page}
</SignInLayout>
)