From 6932c85add02595f824c1a26faa83001cf8bee6c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 16:13:40 -0700 Subject: [PATCH 1/6] feat(auth): OAuth-only signup with Microsoft provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/oauth-provider-checker.tsx | 12 +- .../components/social-login-buttons.tsx | 43 +- apps/sim/app/(auth)/login/login-form.tsx | 5 +- apps/sim/app/(auth)/login/page.tsx | 4 +- apps/sim/app/(auth)/signup/page.tsx | 4 +- apps/sim/app/(auth)/signup/signup-form.tsx | 562 +----------------- .../components/auth-modal/auth-modal.tsx | 31 +- apps/sim/app/api/auth/providers/route.ts | 3 +- apps/sim/components/icons.tsx | 11 +- apps/sim/lib/api/contracts/auth.ts | 1 + apps/sim/lib/auth/auth.ts | 10 + .../lib/copilot/generated/tool-schemas-v1.ts | 196 +++--- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 6 + 14 files changed, 237 insertions(+), 652 deletions(-) diff --git a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx index 73a95f98b02..218377f07de 100644 --- a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx +++ b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx @@ -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 } } diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index 674ebe2eeb0..ed46b9413aa 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -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 = ( + ) + + const hasAnyOAuthProvider = githubAvailable || googleAvailable || microsoftAvailable if (!hasAnyOAuthProvider && !children) { return null @@ -102,6 +140,7 @@ export function SocialLoginButtons({ return (
{googleAvailable && googleButton} + {microsoftAvailable && microsoftButton} {githubAvailable && githubButton} {children}
diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 67ac09b9461..de314167c7e 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -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({
diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index 1f01e004643..390ebff69e5 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -14,12 +14,14 @@ export default async function SignupPage() { return
Registration is disabled, please contact your admin.
} - const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus() + const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } = + await getOAuthProviderStatus() return ( ) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 90490160dff..05f0b29c70f 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -1,109 +1,41 @@ 'use client' -import { Suspense, useEffect, useMemo, useRef, useState } from 'react' -import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' +import { Suspense, useEffect, useMemo } from 'react' import { createLogger } from '@sim/logger' -import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' -import { usePostHog } from 'posthog-js/react' -import { Input, Label, Loader } from '@/components/emcn' -import { client, useSession } from '@/lib/auth/auth-client' -import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' +import { useSearchParams } from 'next/navigation' +import { useSession } from '@/lib/auth/auth-client' +import { getEnv, isTruthy } from '@/lib/core/config/env' import { validateCallbackUrl } from '@/lib/core/security/input-validation' -import { cn } from '@/lib/core/utils/cn' -import { quickValidateEmail } from '@/lib/messaging/email/validation' -import { captureClientEvent, captureEvent } from '@/lib/posthog/client' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' +import { captureClientEvent } from '@/lib/posthog/client' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' const logger = createLogger('SignupForm') -const PASSWORD_VALIDATIONS = { - minLength: { regex: /.{8,}/, message: 'Password must be at least 8 characters long.' }, - uppercase: { - regex: /(?=.*?[A-Z])/, - message: 'Password must include at least one uppercase letter.', - }, - lowercase: { - regex: /(?=.*?[a-z])/, - message: 'Password must include at least one lowercase letter.', - }, - number: { regex: /(?=.*?[0-9])/, message: 'Password must include at least one number.' }, - special: { - regex: /(?=.*?[#?!@$%^&*-])/, - message: 'Password must include at least one special character.', - }, -} - -const NAME_VALIDATIONS = { - required: { - test: (value: string) => Boolean(value && typeof value === 'string'), - message: 'Name is required.', - }, - notEmpty: { - test: (value: string) => value.trim().length > 0, - message: 'Name cannot be empty.', - }, - validCharacters: { - regex: /^[\p{L}\s\-']+$/u, - message: 'Name can only contain letters, spaces, hyphens, and apostrophes.', - }, - noConsecutiveSpaces: { - regex: /^(?!.*\s\s).*$/, - message: 'Name cannot contain consecutive spaces.', - }, -} - -const validateEmailField = (emailValue: string): string[] => { - const errors: string[] = [] - - if (!emailValue || !emailValue.trim()) { - errors.push('Email is required.') - return errors - } - - const validation = quickValidateEmail(emailValue.trim().toLowerCase()) - if (!validation.isValid) { - errors.push(validation.reason || 'Please enter a valid email address.') - } - - return errors -} - interface SignupFormProps { githubAvailable: boolean googleAvailable: boolean + microsoftAvailable: boolean isProduction: boolean } -function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: SignupFormProps) { - const router = useRouter() +function SignupFormContent({ + githubAvailable, + googleAvailable, + microsoftAvailable, + isProduction, +}: SignupFormProps) { const searchParams = useSearchParams() - const { refetch: refetchSession } = useSession() - const posthog = usePostHog() - const [isLoading, setIsLoading] = useState(false) + useSession() useEffect(() => { captureClientEvent('signup_page_viewed', {}) }, []) - const [showPassword, setShowPassword] = useState(false) - const [password, setPassword] = useState('') - const [passwordErrors, setPasswordErrors] = useState([]) - const [showValidationError, setShowValidationError] = useState(false) - const [email, setEmail] = useState(() => searchParams.get('email') ?? '') - const [emailError, setEmailError] = useState('') - const [emailErrors, setEmailErrors] = useState([]) - const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [formError, setFormError] = useState(null) - const turnstileRef = useRef(null) - const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) + const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || '' const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false - const invalidCallbackRef = useRef(false) - if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) { - invalidCallbackRef.current = true + if (rawRedirectUrl && !isValidRedirectUrl) { logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl }) } const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : '' @@ -115,236 +47,9 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S [searchParams, redirectUrl] ) - const [name, setName] = useState('') - const [nameErrors, setNameErrors] = useState([]) - const [showNameValidationError, setShowNameValidationError] = useState(false) - - const validatePassword = (passwordValue: string): string[] => { - const errors: string[] = [] - - if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.minLength.message) - } - - if (!PASSWORD_VALIDATIONS.uppercase.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.uppercase.message) - } - - if (!PASSWORD_VALIDATIONS.lowercase.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.lowercase.message) - } - - if (!PASSWORD_VALIDATIONS.number.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.number.message) - } - - if (!PASSWORD_VALIDATIONS.special.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.special.message) - } - - return errors - } - - const validateName = (nameValue: string): string[] => { - const errors: string[] = [] - - if (!NAME_VALIDATIONS.required.test(nameValue)) { - errors.push(NAME_VALIDATIONS.required.message) - return errors - } - - if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) { - errors.push(NAME_VALIDATIONS.notEmpty.message) - return errors - } - - if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) { - errors.push(NAME_VALIDATIONS.validCharacters.message) - } - - if (!NAME_VALIDATIONS.noConsecutiveSpaces.regex.test(nameValue)) { - errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message) - } - - return errors - } - - const handlePasswordChange = (e: React.ChangeEvent) => { - const newPassword = e.target.value - setPassword(newPassword) - - const errors = validatePassword(newPassword) - setPasswordErrors(errors) - setShowValidationError(false) - } - - const handleNameChange = (e: React.ChangeEvent) => { - const rawValue = e.target.value - setName(rawValue) - - const errors = validateName(rawValue) - setNameErrors(errors) - setShowNameValidationError(false) - } - - const handleEmailChange = (e: React.ChangeEvent) => { - const newEmail = e.target.value - setEmail(newEmail) - - const errors = validateEmailField(newEmail) - setEmailErrors(errors) - setShowEmailValidationError(false) - - if (emailError) { - setEmailError('') - } - } - - async function onSubmit(e: React.FormEvent) { - e.preventDefault() - setIsLoading(true) - - const formData = new FormData(e.currentTarget) - const emailValueRaw = formData.get('email') as string - const emailValue = emailValueRaw.trim().toLowerCase() - const passwordValue = formData.get('password') as string - const nameValue = formData.get('name') as string - - const trimmedName = nameValue.trim() - - const nameValidationErrors = validateName(trimmedName) - setNameErrors(nameValidationErrors) - setShowNameValidationError(nameValidationErrors.length > 0) - - const emailValidationErrors = validateEmailField(emailValue) - setEmailErrors(emailValidationErrors) - setShowEmailValidationError(emailValidationErrors.length > 0) - - const errors = validatePassword(passwordValue) - setPasswordErrors(errors) - - setShowValidationError(errors.length > 0) - - try { - if ( - nameValidationErrors.length > 0 || - emailValidationErrors.length > 0 || - errors.length > 0 - ) { - setIsLoading(false) - return - } - - if (trimmedName.length > 100) { - setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.']) - setShowNameValidationError(true) - setIsLoading(false) - return - } - - let token: string | undefined - const widget = turnstileRef.current - if (turnstileSiteKey && widget) { - try { - widget.reset() - widget.execute() - token = await widget.getResponsePromise() - } catch { - captureEvent(posthog, 'signup_failed', { - error_code: 'captcha_client_failure', - }) - setFormError('Captcha verification failed. Please try again.') - setIsLoading(false) - return - } - } - - setFormError(null) - const response = await client.signUp.email( - { - email: emailValue, - password: passwordValue, - name: trimmedName, - }, - { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, - onError: (ctx) => { - logger.warn('Signup error:', ctx.error) - const errorMessage: string[] = ['Failed to create account'] - - let errorCode = 'unknown' - if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { - errorCode = 'user_already_exists' - setEmailError('An account with this email already exists. Please sign in instead.') - } else if ( - ctx.error.code?.includes('BAD_REQUEST') || - ctx.error.message?.includes('Email and password sign up is not enabled') - ) { - errorCode = 'signup_disabled' - errorMessage.push('Email signup is currently disabled.') - setEmailError(errorMessage[0]) - } else if (ctx.error.code?.includes('INVALID_EMAIL')) { - errorCode = 'invalid_email' - errorMessage.push('Please enter a valid email address.') - setEmailError(errorMessage[0]) - } else if (ctx.error.code?.includes('PASSWORD_TOO_SHORT')) { - errorCode = 'password_too_short' - errorMessage.push('Password must be at least 8 characters long.') - setPasswordErrors(errorMessage) - setShowValidationError(true) - } else if (ctx.error.code?.includes('PASSWORD_TOO_LONG')) { - errorCode = 'password_too_long' - errorMessage.push('Password must be less than 128 characters long.') - setPasswordErrors(errorMessage) - setShowValidationError(true) - } else if (ctx.error.code?.includes('network')) { - errorCode = 'network_error' - errorMessage.push('Network error. Please check your connection and try again.') - setPasswordErrors(errorMessage) - setShowValidationError(true) - } else if (ctx.error.code?.includes('rate limit')) { - errorCode = 'rate_limited' - errorMessage.push('Too many requests. Please wait a moment before trying again.') - setPasswordErrors(errorMessage) - setShowValidationError(true) - } else { - setPasswordErrors(errorMessage) - setShowValidationError(true) - } - - captureEvent(posthog, 'signup_failed', { error_code: errorCode }) - }, - } - ) - - if (!response || response.error) { - setIsLoading(false) - return - } - - try { - await refetchSession() - logger.info('Session refreshed after successful signup') - } catch (sessionError) { - logger.error('Failed to refresh session after signup:', sessionError) - } - - if (typeof window !== 'undefined') { - sessionStorage.setItem('verificationEmail', emailValue) - if (isInviteFlow && redirectUrl) { - sessionStorage.setItem('inviteRedirectUrl', redirectUrl) - sessionStorage.setItem('isInviteFlow', 'true') - } - } - - router.push('/verify?fromSignup=true') - } catch (error) { - logger.error('Signup error:', error) - setIsLoading(false) - } - } + const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) + const hasSocial = githubAvailable || googleAvailable || microsoftAvailable + const callbackURL = redirectUrl || '/workspace' return ( <> @@ -353,237 +58,24 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S Create an account

- Create an account or log in + Sign up with your preferred provider

- {/* 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 - })() && ( + {ssoEnabled && !hasSocial ? (
- +
- )} - - {/* Email/Password Form - show unless explicitly disabled */} - {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && ( -
-
-
-
- -
-
- 0 && - 'border-red-500 focus:border-red-500' - )} - /> -
0 - ? 'grid-rows-[1fr]' - : 'grid-rows-[0fr]' - )} - aria-live={showNameValidationError && nameErrors.length > 0 ? 'polite' : 'off'} - > -
-
- {nameErrors.map((error) => ( -

{error}

- ))} -
-
-
-
-
-
-
- -
-
- 0)) && - 'border-red-500 focus:border-red-500' - )} - /> -
0) || - (emailError && !showEmailValidationError) - ? 'grid-rows-[1fr]' - : 'grid-rows-[0fr]' - )} - aria-live={ - (showEmailValidationError && emailErrors.length > 0) || - (emailError && !showEmailValidationError) - ? 'polite' - : 'off' - } - > -
-
- {showEmailValidationError && emailErrors.length > 0 ? ( - emailErrors.map((error) =>

{error}

) - ) : emailError && !showEmailValidationError ? ( -

{emailError}

- ) : null} -
-
-
-
-
-
-
- -
-
-
- 0 && - 'border-red-500 focus:border-red-500' - )} - /> - -
-
0 - ? 'grid-rows-[1fr]' - : 'grid-rows-[0fr]' - )} - aria-live={showValidationError && passwordErrors.length > 0 ? 'polite' : 'off'} - > -
-
- {passwordErrors.map((error) => ( -

{error}

- ))} -
-
-
-
-
-
- - {turnstileSiteKey && ( - - )} - - {formError && ( -
-

{formError}

-
- )} - - - - )} - - {/* 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 - })() && ( -
-
-
-
-
- - Or continue with - -
-
- )} - - {(() => { - 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 - })() && ( -
+ ) : ( +
- {isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) && ( - - )} + {ssoEnabled && }
)} @@ -625,6 +117,7 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S export default function SignupPage({ githubAvailable, googleAvailable, + microsoftAvailable, isProduction, }: SignupFormProps) { return ( @@ -632,6 +125,7 @@ export default function SignupPage({ diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index f64ee69b34c..d0a1a985ac0 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -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 | null = null const FALLBACK_STATUS: ProviderStatus = { githubAvailable: false, googleAvailable: false, + microsoftAvailable: false, registrationDisabled: false, } @@ -49,9 +50,10 @@ const SOCIAL_BTN = function fetchProviderStatus(): Promise { 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(defaultView) const [providerStatus, setProviderStatus] = useState(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 )} + {providerStatus.microsoftAvailable && ( + + )} {providerStatus.githubAvailable && ( +
+
0 + ? 'grid-rows-[1fr]' + : 'grid-rows-[0fr]' + )} + aria-live={showValidationError && passwordErrors.length > 0 ? 'polite' : 'off'} + > +
+
+ {passwordErrors.map((error) => ( +

{error}

+ ))} +
+
+
+
+ + + + {turnstileSiteKey && ( + + )} + + {formError && ( +
+

{formError}

+
+ )} + + + + )} + + {showDivider && ( +
+
+
+
+
+ + Or continue with + +
+
+ )} + + {showBottomSection && ( +
- {ssoEnabled && } + {ssoEnabled && !hasOnlySSO && ( + + )}
)} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 6a8419db83e..7e746f3bfa6 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -505,6 +505,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms + NEXT_PUBLIC_DISABLE_EMAIL_SIGNUP: z.boolean().optional(), // Hide email/password form on /signup only (set alongside DISABLE_EMAIL_SIGNUP) NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget }, @@ -544,6 +545,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, + NEXT_PUBLIC_DISABLE_EMAIL_SIGNUP: process.env.NEXT_PUBLIC_DISABLE_EMAIL_SIGNUP, NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED, NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS: process.env.NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS, From 8af8418a69bc89559c992f811ff59a80d292140a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 16:31:35 -0700 Subject: [PATCH 5/6] refactor(auth): single DISABLE_EMAIL_SIGNUP env var controls both ui and backend --- apps/sim/app/(auth)/signup/page.tsx | 3 ++- apps/sim/app/(auth)/signup/signup-form.tsx | 7 +++++-- apps/sim/lib/core/config/env.ts | 2 -- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index 390ebff69e5..b43f6ebad56 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -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' @@ -23,6 +23,7 @@ export default async function SignupPage() { googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} isProduction={isProduction} + emailSignupEnabled={!isEmailSignupDisabled} /> ) } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index fa8114ac42f..ae73e36cb5a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -77,6 +77,7 @@ interface SignupFormProps { googleAvailable: boolean microsoftAvailable: boolean isProduction: boolean + emailSignupEnabled: boolean } function SignupFormContent({ @@ -84,6 +85,7 @@ function SignupFormContent({ googleAvailable, microsoftAvailable, isProduction, + emailSignupEnabled, }: SignupFormProps) { const router = useRouter() const searchParams = useSearchParams() @@ -354,8 +356,7 @@ function SignupFormContent({ const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) const emailEnabled = - !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && - !isTruthy(getEnv('NEXT_PUBLIC_DISABLE_EMAIL_SIGNUP')) + !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && emailSignupEnabled const hasSocial = githubAvailable || googleAvailable || microsoftAvailable const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) @@ -615,6 +616,7 @@ export default function SignupPage({ googleAvailable, microsoftAvailable, isProduction, + emailSignupEnabled, }: SignupFormProps) { return ( Loading…
}> @@ -623,6 +625,7 @@ export default function SignupPage({ googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} isProduction={isProduction} + emailSignupEnabled={emailSignupEnabled} /> ) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 7e746f3bfa6..6a8419db83e 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -505,7 +505,6 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms - NEXT_PUBLIC_DISABLE_EMAIL_SIGNUP: z.boolean().optional(), // Hide email/password form on /signup only (set alongside DISABLE_EMAIL_SIGNUP) NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget }, @@ -545,7 +544,6 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, - NEXT_PUBLIC_DISABLE_EMAIL_SIGNUP: process.env.NEXT_PUBLIC_DISABLE_EMAIL_SIGNUP, NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED, NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS: process.env.NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS, From e6d4bf224978e20ca39f3952f48730e545f28194 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 16:33:18 -0700 Subject: [PATCH 6/6] fix(config): restore isHosted hostname check --- apps/sim/lib/core/config/feature-flags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 4157a3a4e6e..cde33a750ed 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -29,7 +29,7 @@ try { } catch { // invalid URL — isHosted stays false } -export const isHosted = true //appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') +export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') /** * Is billing enforcement enabled