mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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()
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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,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'
|
||||
|
||||
|
||||
@@ -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,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,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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user