Skip to content

Commit d25c9d9

Browse files
committed
feat(turnstile): conditionally added CF turnstile to signup
1 parent cb3cc37 commit d25c9d9

File tree

12 files changed

+13705
-287
lines changed

12 files changed

+13705
-287
lines changed

apps/sim/app/(auth)/login/login-form.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

3-
import { useEffect, useRef, useState } from 'react'
3+
import { useEffect, useMemo, useRef, useState } from 'react'
4+
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
45
import { createLogger } from '@sim/logger'
56
import { Eye, EyeOff } from 'lucide-react'
67
import Link from 'next/link'
@@ -86,6 +87,9 @@ export default function LoginPage({
8687
const [password, setPassword] = useState('')
8788
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
8889
const [showValidationError, setShowValidationError] = useState(false)
90+
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
91+
const turnstileRef = useRef<TurnstileInstance>(null)
92+
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
8993
const buttonClass = useBrandedButtonClass()
9094

9195
const callbackUrlParam = searchParams?.get('callbackUrl')
@@ -185,7 +189,14 @@ export default function LoginPage({
185189
callbackURL: safeCallbackUrl,
186190
},
187191
{
192+
fetchOptions: {
193+
headers: {
194+
...(captchaToken ? { 'x-captcha-response': captchaToken } : {}),
195+
},
196+
},
188197
onError: (ctx) => {
198+
turnstileRef.current?.reset()
199+
setCaptchaToken(null)
189200
logger.error('Login error:', ctx.error)
190201

191202
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
@@ -460,6 +471,17 @@ export default function LoginPage({
460471
</div>
461472
</div>
462473

474+
{turnstileSiteKey && (
475+
<Turnstile
476+
ref={turnstileRef}
477+
siteKey={turnstileSiteKey}
478+
onSuccess={setCaptchaToken}
479+
onError={() => setCaptchaToken(null)}
480+
onExpire={() => setCaptchaToken(null)}
481+
options={{ size: 'invisible' }}
482+
/>
483+
)}
484+
463485
<BrandedButton
464486
type='submit'
465487
disabled={isLoading}

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

3-
import { Suspense, useMemo, useState } from 'react'
3+
import { Suspense, useMemo, useRef, useState } from 'react'
4+
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
45
import { createLogger } from '@sim/logger'
56
import { Eye, EyeOff } from 'lucide-react'
67
import Link from 'next/link'
@@ -90,6 +91,9 @@ function SignupFormContent({
9091
const [emailError, setEmailError] = useState('')
9192
const [emailErrors, setEmailErrors] = useState<string[]>([])
9293
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
94+
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
95+
const turnstileRef = useRef<TurnstileInstance>(null)
96+
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
9397
const buttonClass = useBrandedButtonClass()
9498

9599
const redirectUrl = useMemo(
@@ -252,7 +256,14 @@ function SignupFormContent({
252256
name: sanitizedName,
253257
},
254258
{
259+
fetchOptions: {
260+
headers: {
261+
...(captchaToken ? { 'x-captcha-response': captchaToken } : {}),
262+
},
263+
},
255264
onError: (ctx) => {
265+
turnstileRef.current?.reset()
266+
setCaptchaToken(null)
256267
logger.error('Signup error:', ctx.error)
257268
const errorMessage: string[] = ['Failed to create account']
258269

@@ -453,6 +464,17 @@ function SignupFormContent({
453464
</div>
454465
</div>
455466

467+
{turnstileSiteKey && (
468+
<Turnstile
469+
ref={turnstileRef}
470+
siteKey={turnstileSiteKey}
471+
onSuccess={setCaptchaToken}
472+
onError={() => setCaptchaToken(null)}
473+
onExpire={() => setCaptchaToken(null)}
474+
options={{ size: 'invisible' }}
475+
/>
476+
)}
477+
456478
<BrandedButton
457479
type='submit'
458480
disabled={isLoading}

apps/sim/lib/auth/auth.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
88
import { nextCookies } from 'better-auth/next-js'
99
import {
1010
admin,
11+
captcha,
1112
createAuthMiddleware,
1213
customSession,
1314
emailOTP,
@@ -17,6 +18,7 @@ import {
1718
oneTimeToken,
1819
organization,
1920
} from 'better-auth/plugins'
21+
import { emailHarmony } from 'better-auth-harmony'
2022
import { and, eq, inArray, sql } from 'drizzle-orm'
2123
import { headers } from 'next/headers'
2224
import Stripe from 'stripe'
@@ -69,11 +71,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
6971
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
7072
import { sendEmail } from '@/lib/messaging/email/mailer'
7173
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
72-
import {
73-
isDisposableEmailFull,
74-
isDisposableMxBackend,
75-
quickValidateEmail,
76-
} from '@/lib/messaging/email/validation'
74+
import { quickValidateEmail } from '@/lib/messaging/email/validation'
7775
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
7876
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
7977
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
@@ -629,23 +627,12 @@ export const auth = betterAuth({
629627
}
630628
}
631629

632-
if (ctx.path.startsWith('/sign-up')) {
630+
if (ctx.path.startsWith('/sign-up') && blockedSignupDomains) {
633631
const requestEmail = ctx.body?.email?.toLowerCase()
634632
if (requestEmail) {
635-
// Check manually blocked domains
636-
if (blockedSignupDomains) {
637-
const emailDomain = requestEmail.split('@')[1]
638-
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
639-
throw new Error('Sign-ups from this email domain are not allowed.')
640-
}
641-
}
642-
643-
// Check disposable email domains (full list + MX backend check)
644-
if (isDisposableEmailFull(requestEmail)) {
645-
throw new Error('Sign-ups from disposable email addresses are not allowed.')
646-
}
647-
if (await isDisposableMxBackend(requestEmail)) {
648-
throw new Error('Sign-ups from disposable email addresses are not allowed.')
633+
const emailDomain = requestEmail.split('@')[1]
634+
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
635+
throw new Error('Sign-ups from this email domain are not allowed.')
649636
}
650637
}
651638
}
@@ -677,6 +664,16 @@ export const auth = betterAuth({
677664
},
678665
plugins: [
679666
nextCookies(),
667+
emailHarmony(),
668+
...(env.TURNSTILE_SECRET_KEY
669+
? [
670+
captcha({
671+
provider: 'cloudflare-turnstile',
672+
secretKey: env.TURNSTILE_SECRET_KEY,
673+
endpoints: ['/sign-up/email', '/sign-in/email'],
674+
}),
675+
]
676+
: []),
680677
admin(),
681678
jwt({
682679
jwks: {

apps/sim/lib/core/config/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const env = createEnv({
2525
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
2626
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
2727
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
28+
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
2829
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
2930
API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS)
3031
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
@@ -411,6 +412,7 @@ export const env = createEnv({
411412
NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally
412413
NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted
413414
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
415+
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget
414416
},
415417

416418
// Variables available on both server and client
@@ -444,6 +446,7 @@ export const env = createEnv({
444446
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
445447
NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED,
446448
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
449+
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
447450
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
448451
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
449452
NEXT_PUBLIC_ENABLE_PLAYGROUND: process.env.NEXT_PUBLIC_ENABLE_PLAYGROUND,

0 commit comments

Comments
 (0)