feat(auth): OAuth-only signup with Microsoft provider (#5073)

* feat(auth): OAuth-only signup with Microsoft provider

- Remove email/password form from /signup — Google, Microsoft, GitHub OAuth only
- Add Microsoft as a social provider (MICROSOFT_CLIENT_ID / MICROSOFT_CLIENT_SECRET / DISABLE_MICROSOFT_AUTH)
- Wire microsoftAvailable through provider checker, API contract, providers route, and all auth UI
- Hide "Continue with email" in auth modal signup view; login view unchanged
- Fix MicrosoftIcon SVG to use official brand colors and proportions

* fix(auth): remove unused useSession, guard invalid-callback warn with ref

* feat(auth): gate email signup via DISABLE_EMAIL_SIGNUP flag

* feat(auth): restore signup email form gated by NEXT_PUBLIC_DISABLE_EMAIL_SIGNUP

* refactor(auth): single DISABLE_EMAIL_SIGNUP env var controls both ui and backend

* fix(config): restore isHosted hostname check
This commit is contained in:
Theodore Li
2026-06-15 19:47:44 -04:00
committed by GitHub
parent 4f4ff53411
commit a49e755418
13 changed files with 155 additions and 56 deletions
@@ -1,5 +1,10 @@
import { env } from '@/lib/core/config/env'
import { isGithubAuthDisabled, isGoogleAuthDisabled, isProd } from '@/lib/core/config/feature-flags'
import {
isGithubAuthDisabled,
isGoogleAuthDisabled,
isMicrosoftAuthDisabled,
isProd,
} from '@/lib/core/config/feature-flags'
export async function getOAuthProviderStatus() {
const githubAvailable =
@@ -8,5 +13,8 @@ export async function getOAuthProviderStatus() {
const googleAvailable =
!!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) && !isGoogleAuthDisabled
return { githubAvailable, googleAvailable, isProduction: isProd }
const microsoftAvailable =
!!(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET) && !isMicrosoftAuthDisabled
return { githubAvailable, googleAvailable, microsoftAvailable, isProduction: isProd }
}
@@ -2,12 +2,13 @@
import { type ReactNode, useState } from 'react'
import { Button } from '@/components/emcn'
import { GithubIcon, GoogleIcon } from '@/components/icons'
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { client } from '@/lib/auth/auth-client'
interface SocialLoginButtonsProps {
githubAvailable: boolean
googleAvailable: boolean
microsoftAvailable: boolean
callbackURL?: string
isProduction: boolean
children?: ReactNode
@@ -16,12 +17,14 @@ interface SocialLoginButtonsProps {
export function SocialLoginButtons({
githubAvailable,
googleAvailable,
microsoftAvailable,
callbackURL = '/workspace',
isProduction,
children,
}: SocialLoginButtonsProps) {
const [isGithubLoading, setIsGithubLoading] = useState(false)
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
const [isMicrosoftLoading, setIsMicrosoftLoading] = useState(false)
async function signInWithGithub() {
if (!githubAvailable) return
@@ -69,6 +72,29 @@ export function SocialLoginButtons({
}
}
async function signInWithMicrosoft() {
if (!microsoftAvailable) return
setIsMicrosoftLoading(true)
try {
await client.signIn.social({ provider: 'microsoft', callbackURL })
} catch (err: any) {
let errorMessage = 'Failed to sign in with Microsoft'
if (err.message?.includes('account exists')) {
errorMessage = 'An account with this email already exists. Please sign in instead.'
} else if (err.message?.includes('cancelled')) {
errorMessage = 'Microsoft sign in was cancelled. Please try again.'
} else if (err.message?.includes('network')) {
errorMessage = 'Network error. Please check your connection and try again.'
} else if (err.message?.includes('rate limit')) {
errorMessage = 'Too many attempts. Please try again later.'
}
} finally {
setIsMicrosoftLoading(false)
}
}
const githubButton = (
<Button
variant='outline'
@@ -93,7 +119,19 @@ export function SocialLoginButtons({
</Button>
)
const hasAnyOAuthProvider = githubAvailable || googleAvailable
const microsoftButton = (
<Button
variant='outline'
className='w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
disabled={!microsoftAvailable || isMicrosoftLoading}
onClick={signInWithMicrosoft}
>
<MicrosoftIcon className='!h-[18px] !w-[18px] mr-1' />
{isMicrosoftLoading ? 'Connecting...' : 'Microsoft'}
</Button>
)
const hasAnyOAuthProvider = githubAvailable || googleAvailable || microsoftAvailable
if (!hasAnyOAuthProvider && !children) {
return null
@@ -102,6 +140,7 @@ export function SocialLoginButtons({
return (
<div className='grid gap-3 font-light'>
{googleAvailable && googleButton}
{microsoftAvailable && microsoftButton}
{githubAvailable && githubButton}
{children}
</div>
+4 -1
View File
@@ -78,10 +78,12 @@ const validatePassword = (passwordValue: string): string[] => {
export default function LoginPage({
githubAvailable,
googleAvailable,
microsoftAvailable,
isProduction,
}: {
githubAvailable: boolean
googleAvailable: boolean
microsoftAvailable: boolean
isProduction: boolean
}) {
const router = useRouter()
@@ -335,7 +337,7 @@ export default function LoginPage({
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable
const hasSocial = githubAvailable || googleAvailable || microsoftAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
const showTopSSO = hasOnlySSO
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
@@ -483,6 +485,7 @@ export default function LoginPage({
<div className={cn(!emailEnabled ? 'mt-8' : undefined)}>
<SocialLoginButtons
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
githubAvailable={githubAvailable}
isProduction={isProduction}
callbackURL={callbackUrl}
+3 -1
View File
@@ -10,13 +10,15 @@ export const metadata: Metadata = {
export const dynamic = 'force-dynamic'
export default async function LoginPage() {
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
await getOAuthProviderStatus()
return (
<Suspense fallback={null}>
<LoginForm
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
/>
</Suspense>
+5 -2
View File
@@ -1,5 +1,5 @@
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/feature-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import SignupForm from '@/app/(auth)/signup/signup-form'
@@ -14,13 +14,16 @@ export default async function SignupPage() {
return <div>Registration is disabled, please contact your admin.</div>
}
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
await getOAuthProviderStatus()
return (
<SignupForm
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
emailSignupEnabled={!isEmailSignupDisabled}
/>
)
}
+28 -35
View File
@@ -75,10 +75,18 @@ const validateEmailField = (emailValue: string): string[] => {
interface SignupFormProps {
githubAvailable: boolean
googleAvailable: boolean
microsoftAvailable: boolean
isProduction: boolean
emailSignupEnabled: boolean
}
function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: SignupFormProps) {
function SignupFormContent({
githubAvailable,
googleAvailable,
microsoftAvailable,
isProduction,
emailSignupEnabled,
}: SignupFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { refetch: refetchSession } = useSession()
@@ -346,6 +354,14 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
}
}
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled =
!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && emailSignupEnabled
const hasSocial = githubAvailable || googleAvailable || microsoftAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection
return (
<>
<div className='space-y-1 text-center'>
@@ -357,21 +373,13 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
</p>
</div>
{/* SSO Login Button (primary top-only when it is the only method) */}
{(() => {
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
return hasOnlySSO
})() && (
{hasOnlySSO && (
<div className='mt-8'>
<SSOLoginButton callbackURL={redirectUrl || '/workspace'} variant='primary' />
</div>
)}
{/* Email/Password Form - show unless explicitly disabled */}
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
{emailEnabled && (
<form onSubmit={onSubmit} className='mt-8 space-y-10'>
<div className='space-y-6'>
<div className='space-y-2'>
@@ -540,16 +548,7 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
</form>
)}
{/* Divider - show when we have multiple auth methods */}
{(() => {
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection
return showDivider
})() && (
{showDivider && (
<div className='relative my-6 font-light'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-[var(--landing-bg-elevated)] border-t' />
@@ -562,26 +561,16 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
</div>
)}
{(() => {
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
return showBottomSection
})() && (
<div
className={cn(
isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) ? 'mt-8' : undefined
)}
>
{showBottomSection && (
<div className={cn(!emailEnabled ? 'mt-8' : undefined)}>
<SocialLoginButtons
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
callbackURL={redirectUrl || '/workspace'}
isProduction={isProduction}
>
{isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) && (
{ssoEnabled && !hasOnlySSO && (
<SSOLoginButton callbackURL={redirectUrl || '/workspace'} variant='outline' />
)}
</SocialLoginButtons>
@@ -625,14 +614,18 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
export default function SignupPage({
githubAvailable,
googleAvailable,
microsoftAvailable,
isProduction,
emailSignupEnabled,
}: SignupFormProps) {
return (
<Suspense fallback={<div className='flex h-screen items-center justify-center'>Loading…</div>}>
<SignupFormContent
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
emailSignupEnabled={emailSignupEnabled}
/>
</Suspense>
)
@@ -14,7 +14,7 @@ import {
ModalTitle,
ModalTrigger,
} from '@/components/emcn'
import { GithubIcon, GoogleIcon } from '@/components/icons'
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { requestJson } from '@/lib/api/client/request'
import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth'
import { client } from '@/lib/auth/auth-client'
@@ -40,6 +40,7 @@ let fetchPromise: Promise<AuthProviderStatusResponse> | null = null
const FALLBACK_STATUS: ProviderStatus = {
githubAvailable: false,
googleAvailable: false,
microsoftAvailable: false,
registrationDisabled: false,
}
@@ -49,9 +50,10 @@ const SOCIAL_BTN =
function fetchProviderStatus(): Promise<ProviderStatus> {
if (fetchPromise) return fetchPromise
fetchPromise = requestJson(getAuthProvidersContract, {})
.then(({ githubAvailable, googleAvailable, registrationDisabled }) => ({
.then(({ githubAvailable, googleAvailable, microsoftAvailable, registrationDisabled }) => ({
githubAvailable,
googleAvailable,
microsoftAvailable,
registrationDisabled,
}))
.catch(() => {
@@ -66,14 +68,17 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
const [open, setOpen] = useState(false)
const [view, setView] = useState<AuthView>(defaultView)
const [providerStatus, setProviderStatus] = useState<ProviderStatus | null>(null)
const [socialLoading, setSocialLoading] = useState<'github' | 'google' | null>(null)
const [socialLoading, setSocialLoading] = useState<'github' | 'google' | 'microsoft' | null>(null)
const brand = useMemo(() => getBrandConfig(), [])
useEffect(() => {
fetchProviderStatus().then(setProviderStatus)
}, [])
const hasSocial = providerStatus?.githubAvailable || providerStatus?.googleAvailable
const hasSocial =
providerStatus?.githubAvailable ||
providerStatus?.googleAvailable ||
providerStatus?.microsoftAvailable
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasModalContent = hasSocial || ssoEnabled
@@ -104,7 +109,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
}
}
async function handleSocialLogin(provider: 'github' | 'google') {
async function handleSocialLogin(provider: 'github' | 'google' | 'microsoft') {
setSocialLoading(provider)
try {
await client.signIn.social({ provider, callbackURL: '/workspace' })
@@ -184,6 +189,19 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
</span>
</button>
)}
{providerStatus.microsoftAvailable && (
<button
type='button'
onClick={() => handleSocialLogin('microsoft')}
disabled={!!socialLoading}
className={SOCIAL_BTN}
>
<MicrosoftIcon className='absolute left-4 size-[18px] shrink-0' />
<span>
{socialLoading === 'microsoft' ? 'Connecting...' : 'Continue with Microsoft'}
</span>
</button>
)}
{providerStatus.githubAvailable && (
<button
type='button'
@@ -204,7 +222,8 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
)}
</div>
{emailEnabled && (
{/* Email option only available on login — signup is OAuth-only */}
{emailEnabled && view === 'login' && (
<>
<div className='relative my-4'>
<div className='absolute inset-0 flex items-center'>
+2 -1
View File
@@ -12,10 +12,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(getAuthProvidersContract, request, {})
if (!parsed.success) return parsed.response
const { githubAvailable, googleAvailable } = await getOAuthProviderStatus()
const { githubAvailable, googleAvailable, microsoftAvailable } = await getOAuthProviderStatus()
return NextResponse.json({
githubAvailable,
googleAvailable,
microsoftAvailable,
registrationDisabled: isRegistrationDisabled,
})
})
+5 -6
View File
@@ -2936,12 +2936,11 @@ export function ClickHouseIcon(props: SVGProps<SVGSVGElement>) {
export function MicrosoftIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 23 23' {...props}>
<path fill='#f3f3f3' d='M0 0h23v23H0z' />
<path fill='#f35325' d='M1 1h10v10H1z' />
<path fill='#81bc06' d='M12 1h10v10H12z' />
<path fill='#05a6f0' d='M1 12h10v10H1z' />
<path fill='#ffba08' d='M12 12h10v10H12z' />
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 109 109' {...props}>
<polygon fill='#F1511B' points='51.9,51.9 0,51.9 0,0 51.9,0' />
<polygon fill='#80CC28' points='109.3,51.9 57.3,51.9 57.3,0 109.3,0' />
<polygon fill='#00ADEF' points='51.9,109.3 0,109.3 0,57.4 51.9,57.4' />
<polygon fill='#FBBC09' points='109.3,109.3 57.3,109.3 57.3,57.4 109.3,57.4' />
</svg>
)
}
+1
View File
@@ -9,6 +9,7 @@ export const ssoProvidersQuerySchema = z.object({
export const authProviderStatusResponseSchema = z.object({
githubAvailable: z.boolean(),
googleAvailable: z.boolean(),
microsoftAvailable: z.boolean(),
registrationDisabled: z.boolean(),
})
+16
View File
@@ -65,10 +65,12 @@ import {
isAuthDisabled,
isBillingEnabled,
isEmailPasswordEnabled,
isEmailSignupDisabled,
isEmailVerificationEnabled,
isGithubAuthDisabled,
isGoogleAuthDisabled,
isHosted,
isMicrosoftAuthDisabled,
isOrganizationsEnabled,
isRegistrationDisabled,
isSignupEmailValidationEnabled,
@@ -724,6 +726,15 @@ export const auth = betterAuth({
],
},
}),
...(!isMicrosoftAuthDisabled &&
env.MICROSOFT_CLIENT_ID &&
env.MICROSOFT_CLIENT_SECRET && {
microsoft: {
clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET,
scope: ['openid', 'profile', 'email'],
},
}),
},
emailVerification: {
autoSignInAfterVerification: true,
@@ -874,6 +885,11 @@ export const auth = betterAuth({
})
}
if (isEmailSignupDisabled && ctx.path.startsWith('/sign-up/email'))
throw new APIError('FORBIDDEN', {
message: 'Email sign-up is disabled. Please use Google, Microsoft, or GitHub.',
})
const isSignIn = ctx.path.startsWith('/sign-in')
const isSignUp = ctx.path.startsWith('/sign-up')
+2
View File
@@ -317,6 +317,8 @@ export const env = createEnv({
GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret
DISABLE_GOOGLE_AUTH: z.boolean().optional(), // Disable Google OAuth login even when credentials are configured
DISABLE_GITHUB_AUTH: z.boolean().optional(), // Disable GitHub OAuth login even when credentials are configured
DISABLE_MICROSOFT_AUTH: z.boolean().optional(), // Disable Microsoft OAuth login even when credentials are configured
DISABLE_EMAIL_SIGNUP: z.boolean().optional(), // Block new email/password registrations while keeping email login working
X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID
X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret
+13
View File
@@ -254,6 +254,19 @@ export const isGoogleAuthDisabled = isTruthy(env.DISABLE_GOOGLE_AUTH)
*/
export const isGithubAuthDisabled = isTruthy(env.DISABLE_GITHUB_AUTH)
/**
* Is Microsoft OAuth login disabled
* When true, the Microsoft OAuth login button is hidden even when credentials are configured
*/
export const isMicrosoftAuthDisabled = isTruthy(env.DISABLE_MICROSOFT_AUTH)
/**
* Is email/password signup disabled
* When true, new registrations via email/password are blocked at the server level.
* Existing users can still sign in with email/password.
*/
export const isEmailSignupDisabled = isTruthy(env.DISABLE_EMAIL_SIGNUP)
/**
* Is React Grab enabled for UI element debugging
* When true and in development mode, enables React Grab for copying UI element context to clipboard