Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 79 additions & 48 deletions apps/sim/app/(auth)/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
Expand Down Expand Up @@ -86,6 +87,9 @@ export default function LoginPage({
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const buttonClass = useBrandedButtonClass()

const callbackUrlParam = searchParams?.get('callbackUrl')
Expand Down Expand Up @@ -115,19 +119,6 @@ export default function LoginPage({
: null
)

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && forgotPasswordOpen) {
handleForgotPassword()
}
}

window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [forgotPasswordEmail, forgotPasswordOpen])

const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
setEmail(newEmail)
Expand Down Expand Up @@ -178,13 +169,33 @@ export default function LoginPage({
const safeCallbackUrl = callbackUrl
let errorHandled = false

// Execute Turnstile challenge on submit and get a fresh token
let token: string | undefined
if (turnstileSiteKey && turnstileRef.current) {
try {
turnstileRef.current.reset()
turnstileRef.current.execute()
token = await turnstileRef.current.getResponsePromise(15_000)
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
}
}

setFormError(null)
const result = await client.signIn.email(
{
email,
password,
callbackURL: safeCallbackUrl,
},
{
fetchOptions: {
headers: {
...(token ? { 'x-captcha-response': token } : {}),
},
},
onError: (ctx) => {
logger.error('Login error:', ctx.error)

Expand Down Expand Up @@ -460,6 +471,20 @@ export default function LoginPage({
</div>
</div>

{turnstileSiteKey && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ size: 'invisible', execution: 'execute' }}
/>
)}

{formError && (
<div className='text-red-400 text-xs'>
<p>{formError}</p>
</div>
)}

<BrandedButton
type='submit'
disabled={isLoading}
Expand Down Expand Up @@ -540,45 +565,51 @@ export default function LoginPage({
<ModalContent className='dark' size='sm'>
<ModalHeader>Reset Password</ModalHeader>
<ModalBody>
<ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'>
Enter your email address and we'll send you a link to reset your password if your
account exists.
</ModalDescription>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='reset-email'>Email</Label>
<Input
id='reset-email'
value={forgotPasswordEmail}
onChange={(e) => setForgotPasswordEmail(e.target.value)}
placeholder='Enter your email'
required
type='email'
className={cn(
resetStatus.type === 'error' && 'border-red-500 focus:border-red-500'
<form
onSubmit={(e) => {
e.preventDefault()
handleForgotPassword()
}}
>
<ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'>
Enter your email address and we'll send you a link to reset your password if your
account exists.
</ModalDescription>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='reset-email'>Email</Label>
<Input
id='reset-email'
value={forgotPasswordEmail}
onChange={(e) => setForgotPasswordEmail(e.target.value)}
placeholder='Enter your email'
required
type='email'
className={cn(
resetStatus.type === 'error' && 'border-red-500 focus:border-red-500'
)}
/>
{resetStatus.type === 'error' && (
<div className='mt-1 text-red-400 text-xs'>
<p>{resetStatus.message}</p>
</div>
)}
/>
{resetStatus.type === 'error' && (
<div className='mt-1 text-red-400 text-xs'>
</div>
{resetStatus.type === 'success' && (
<div className='mt-1 text-[#4CAF50] text-xs'>
<p>{resetStatus.message}</p>
</div>
)}
<BrandedButton
type='submit'
disabled={isSubmittingReset}
loading={isSubmittingReset}
loadingText='Sending'
>
Send Reset Link
</BrandedButton>
</div>
{resetStatus.type === 'success' && (
<div className='mt-1 text-[#4CAF50] text-xs'>
<p>{resetStatus.message}</p>
</div>
)}
<BrandedButton
type='button'
onClick={handleForgotPassword}
disabled={isSubmittingReset}
loading={isSubmittingReset}
loadingText='Sending'
>
Send Reset Link
</BrandedButton>
</div>
</form>
</ModalBody>
</ModalContent>
</Modal>
Expand Down
40 changes: 39 additions & 1 deletion apps/sim/app/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import { Suspense, useMemo, useState } from 'react'
import { Suspense, useMemo, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
Expand Down Expand Up @@ -90,6 +91,9 @@ function SignupFormContent({
const [emailError, setEmailError] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const buttonClass = useBrandedButtonClass()

const redirectUrl = useMemo(
Expand Down Expand Up @@ -245,13 +249,33 @@ function SignupFormContent({

const sanitizedName = trimmedName

// Execute Turnstile challenge on submit and get a fresh token
let token: string | undefined
if (turnstileSiteKey && turnstileRef.current) {
try {
turnstileRef.current.reset()
turnstileRef.current.execute()
token = await turnstileRef.current.getResponsePromise(15_000)
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
}
}

setFormError(null)
const response = await client.signUp.email(
{
email: emailValue,
password: passwordValue,
name: sanitizedName,
},
{
fetchOptions: {
headers: {
...(token ? { 'x-captcha-response': token } : {}),
},
},
onError: (ctx) => {
logger.error('Signup error:', ctx.error)
const errorMessage: string[] = ['Failed to create account']
Expand Down Expand Up @@ -453,6 +477,20 @@ function SignupFormContent({
</div>
</div>

{turnstileSiteKey && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ size: 'invisible', execution: 'execute' }}
/>
)}

{formError && (
<div className='text-red-400 text-xs'>
<p>{formError}</p>
</div>
)}

<BrandedButton
type='submit'
disabled={isLoading}
Expand Down
38 changes: 18 additions & 20 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import {
admin,
captcha,
createAuthMiddleware,
customSession,
emailOTP,
Expand All @@ -17,6 +18,7 @@ import {
oneTimeToken,
organization,
} from 'better-auth/plugins'
import { emailHarmony } from 'better-auth-harmony'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { headers } from 'next/headers'
import Stripe from 'stripe'
Expand Down Expand Up @@ -63,17 +65,14 @@ import {
isHosted,
isOrganizationsEnabled,
isRegistrationDisabled,
isSignupEmailValidationEnabled,
} from '@/lib/core/config/feature-flags'
import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import {
isDisposableEmailFull,
isDisposableMxBackend,
quickValidateEmail,
} from '@/lib/messaging/email/validation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
Expand Down Expand Up @@ -629,23 +628,12 @@ export const auth = betterAuth({
}
}

if (ctx.path.startsWith('/sign-up')) {
if (ctx.path.startsWith('/sign-up') && blockedSignupDomains) {
const requestEmail = ctx.body?.email?.toLowerCase()
if (requestEmail) {
// Check manually blocked domains
if (blockedSignupDomains) {
const emailDomain = requestEmail.split('@')[1]
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
throw new Error('Sign-ups from this email domain are not allowed.')
}
}

// Check disposable email domains (full list + MX backend check)
if (isDisposableEmailFull(requestEmail)) {
throw new Error('Sign-ups from disposable email addresses are not allowed.')
}
if (await isDisposableMxBackend(requestEmail)) {
throw new Error('Sign-ups from disposable email addresses are not allowed.')
const emailDomain = requestEmail.split('@')[1]
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
throw new Error('Sign-ups from this email domain are not allowed.')
}
}
}
Expand Down Expand Up @@ -677,6 +665,16 @@ export const auth = betterAuth({
},
plugins: [
nextCookies(),
...(isSignupEmailValidationEnabled ? [emailHarmony()] : []),
...(env.TURNSTILE_SECRET_KEY
? [
captcha({
provider: 'cloudflare-turnstile',
secretKey: env.TURNSTILE_SECRET_KEY,
endpoints: ['/sign-up/email', '/sign-in/email'],
}),
]
: []),
admin(),
jwt({
jwks: {
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const env = createEnv({
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
SIGNUP_EMAIL_VALIDATION_ENABLED: z.boolean().optional(), // Enable disposable email blocking via better-auth-harmony (55K+ domains)
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS)
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
Expand Down Expand Up @@ -411,6 +413,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_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget
},

// Variables available on both server and client
Expand Down Expand Up @@ -444,6 +447,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_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
NEXT_PUBLIC_ENABLE_PLAYGROUND: process.env.NEXT_PUBLIC_ENABLE_PLAYGROUND,
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION)
*/
export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED)

/**
* Is signup email validation enabled (disposable email blocking via better-auth-harmony)
*/
export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATION_ENABLED)

/**
* Is Trigger.dev enabled for async job processing
*/
Expand Down
Loading
Loading