diff --git a/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx b/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx
new file mode 100644
index 00000000000..232cb399f12
--- /dev/null
+++ b/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx
@@ -0,0 +1,226 @@
+import { act, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithConsoleQuery } from '@/test/console/query-data'
+import MailAndCodeAuth from '../mail-and-code-auth'
+
+type TurnstileOptions = {
+ sitekey: string
+ action: string
+ callback: (token: string) => void
+ 'error-callback': (errorCode: string) => boolean
+ 'expired-callback': () => void
+ 'timeout-callback': () => void
+}
+
+const mocks = vi.hoisted(() => ({
+ push: vi.fn(),
+ remove: vi.fn(),
+ render: vi.fn(),
+ sendEMailLoginCode: vi.fn(),
+ setCountdownLeftTime: vi.fn(),
+ turnstileSiteKey: 'site-key-for-tests',
+}))
+
+let turnstileOptions: TurnstileOptions | undefined
+
+const renderMailAndCodeAuth = (deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD') =>
+ renderWithConsoleQuery(
, {
+ systemFeatures: { deployment_edition: deploymentEdition },
+ })
+
+vi.mock('@/next/script', async () => {
+ const { useEffect } = await vi.importActual
('react')
+
+ function ScriptMock({ onReady }: { onReady?: () => void }) {
+ useEffect(() => {
+ onReady?.()
+ }, [onReady])
+ return null
+ }
+
+ return {
+ default: ScriptMock,
+ }
+})
+
+vi.mock('@/next/navigation', () => ({
+ useRouter: () => ({ push: mocks.push }),
+ useSearchParams: () => new URLSearchParams(),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useLocale: () => 'en-US',
+}))
+
+vi.mock('@/app/components/signin/storage', () => ({
+ COUNT_DOWN_TIME_MS: 60_000,
+ useSetCountdownLeftTime: () => mocks.setCountdownLeftTime,
+}))
+
+vi.mock('@/config', async () => {
+ const actual = await vi.importActual('@/config')
+ return {
+ ...actual,
+ get TURNSTILE_SITE_KEY() {
+ return mocks.turnstileSiteKey
+ },
+ }
+})
+
+vi.mock('@/service/common', () => ({
+ sendEMailLoginCode: (...args: unknown[]) => mocks.sendEMailLoginCode(...args),
+}))
+
+describe('MailAndCodeAuth', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ turnstileOptions = undefined
+ mocks.turnstileSiteKey = 'site-key-for-tests'
+ mocks.render.mockImplementation((_container: HTMLElement, options: TurnstileOptions) => {
+ turnstileOptions = options
+ return 'widget-id'
+ })
+ mocks.sendEMailLoginCode.mockResolvedValue({ result: 'success', data: 'login-token' })
+ Object.defineProperty(window, 'turnstile', {
+ configurable: true,
+ value: {
+ remove: mocks.remove,
+ render: mocks.render,
+ },
+ })
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('enables SaaS email-code login only while Turnstile verification is valid', async () => {
+ const user = userEvent.setup()
+ renderMailAndCodeAuth()
+
+ await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com')
+ const continueButton = screen.getByRole('button', { name: 'login.signup.verifyMail' })
+
+ await waitFor(() => {
+ expect(mocks.render).toHaveBeenCalledTimes(1)
+ })
+ expect(turnstileOptions).toMatchObject({
+ sitekey: 'site-key-for-tests',
+ action: 'signin_code',
+ })
+ expect(continueButton).toBeDisabled()
+
+ act(() => {
+ turnstileOptions?.callback('turnstile-token')
+ })
+ expect(continueButton).toBeEnabled()
+
+ act(() => {
+ turnstileOptions?.['expired-callback']()
+ })
+ expect(continueButton).toBeDisabled()
+
+ act(() => {
+ turnstileOptions?.callback('fresh-turnstile-token')
+ turnstileOptions?.['error-callback']('network-error')
+ })
+ expect(continueButton).toBeDisabled()
+
+ act(() => {
+ turnstileOptions?.callback('another-turnstile-token')
+ turnstileOptions?.['timeout-callback']()
+ })
+ expect(continueButton).toBeDisabled()
+ })
+
+ it('submits the SaaS email-code login after Turnstile verification succeeds', async () => {
+ const user = userEvent.setup()
+ renderMailAndCodeAuth()
+
+ await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com')
+ await waitFor(() => {
+ expect(turnstileOptions).toBeDefined()
+ })
+ act(() => {
+ turnstileOptions?.callback('turnstile-token')
+ })
+ await user.click(screen.getByRole('button', { name: 'login.signup.verifyMail' }))
+
+ await waitFor(() => {
+ expect(mocks.sendEMailLoginCode).toHaveBeenCalledWith(
+ 'user@example.com',
+ 'en-US',
+ 'turnstile-token',
+ )
+ })
+ expect(mocks.push).toHaveBeenCalledWith(expect.stringContaining('/signin/check-code?'))
+ })
+
+ it('requires a fresh Turnstile token after an email-code request fails', async () => {
+ const user = userEvent.setup()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ mocks.sendEMailLoginCode
+ .mockRejectedValueOnce(new Error('email send failed'))
+ .mockResolvedValueOnce({ result: 'success', data: 'login-token' })
+ renderMailAndCodeAuth()
+
+ await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com')
+ await waitFor(() => {
+ expect(turnstileOptions).toBeDefined()
+ })
+ act(() => {
+ turnstileOptions?.callback('consumed-turnstile-token')
+ })
+ const continueButton = screen.getByRole('button', { name: 'login.signup.verifyMail' })
+ await user.click(continueButton)
+
+ await waitFor(() => {
+ expect(mocks.sendEMailLoginCode).toHaveBeenCalledWith(
+ 'user@example.com',
+ 'en-US',
+ 'consumed-turnstile-token',
+ )
+ })
+ await waitFor(() => {
+ expect(continueButton).toBeDisabled()
+ expect(mocks.render).toHaveBeenCalledTimes(2)
+ })
+
+ act(() => {
+ turnstileOptions?.callback('fresh-turnstile-token')
+ })
+ expect(continueButton).toBeEnabled()
+ await user.click(continueButton)
+
+ await waitFor(() => {
+ expect(mocks.sendEMailLoginCode).toHaveBeenLastCalledWith(
+ 'user@example.com',
+ 'en-US',
+ 'fresh-turnstile-token',
+ )
+ })
+ expect(mocks.push).toHaveBeenCalledWith(expect.stringContaining('/signin/check-code?'))
+ })
+
+ it('keeps non-SaaS email-code login independent of Turnstile', async () => {
+ const user = userEvent.setup()
+ renderMailAndCodeAuth('COMMUNITY')
+
+ await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com')
+
+ expect(screen.getByRole('button', { name: 'login.signup.verifyMail' })).toBeEnabled()
+ expect(mocks.render).not.toHaveBeenCalled()
+ })
+
+ it('keeps SaaS email-code login disabled when the Turnstile site key is missing', async () => {
+ const user = userEvent.setup()
+ mocks.turnstileSiteKey = ''
+ renderMailAndCodeAuth()
+
+ await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com')
+
+ expect(screen.getByRole('button', { name: 'login.signup.verifyMail' })).toBeDisabled()
+ expect(mocks.render).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/signin/components/__tests__/turnstile.spec.tsx b/web/app/signin/components/__tests__/turnstile.spec.tsx
new file mode 100644
index 00000000000..f573dce15b3
--- /dev/null
+++ b/web/app/signin/components/__tests__/turnstile.spec.tsx
@@ -0,0 +1,190 @@
+import { act, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { StrictMode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Turnstile from '../turnstile'
+
+type ScriptProps = {
+ id: string
+ src: string
+ onReady?: () => void
+ onError?: () => void
+}
+
+type TurnstileOptions = {
+ callback: (token: string) => void
+ 'error-callback': (errorCode: string) => boolean
+ 'expired-callback': () => void
+ 'timeout-callback': () => void
+ 'unsupported-callback': () => void
+}
+
+const mocks = vi.hoisted(() => ({
+ remove: vi.fn(),
+ render: vi.fn(),
+ scriptIsCached: false,
+ scriptProps: undefined as ScriptProps | undefined,
+}))
+
+let turnstileOptions: TurnstileOptions | undefined
+
+vi.mock('@/next/script', async () => {
+ const { useEffect, useRef } = await vi.importActual('react')
+
+ function ScriptMock(props: ScriptProps) {
+ const { onReady } = props
+ const hasCalledOnReadyRef = useRef(false)
+ mocks.scriptProps = props
+
+ useEffect(() => {
+ if (!mocks.scriptIsCached || hasCalledOnReadyRef.current) return
+ hasCalledOnReadyRef.current = true
+ onReady?.()
+ }, [onReady])
+
+ return null
+ }
+
+ return {
+ default: ScriptMock,
+ }
+})
+
+describe('Turnstile', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.scriptIsCached = false
+ mocks.scriptProps = undefined
+ turnstileOptions = undefined
+ Object.defineProperty(window, 'turnstile', {
+ configurable: true,
+ value: undefined,
+ })
+ })
+
+ it('keeps a cached-script widget mounted after Strict Mode replays effects', async () => {
+ const mountedWidgets = new Map()
+ mocks.scriptIsCached = true
+ mocks.render.mockImplementation((container: HTMLElement) => {
+ const widgetId = `widget-${mocks.render.mock.calls.length}`
+ const widget = document.createElement('div')
+ widget.setAttribute('role', 'region')
+ widget.setAttribute('aria-label', 'Turnstile challenge')
+ container.appendChild(widget)
+ mountedWidgets.set(widgetId, widget)
+ return widgetId
+ })
+ mocks.remove.mockImplementation((widgetId: string) => {
+ mountedWidgets.get(widgetId)?.remove()
+ mountedWidgets.delete(widgetId)
+ })
+ Object.defineProperty(window, 'turnstile', {
+ configurable: true,
+ value: {
+ remove: mocks.remove,
+ render: mocks.render,
+ },
+ })
+
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByRole('region', { name: 'Turnstile challenge' })).toBeInTheDocument()
+ })
+
+ it('shows a recoverable error when the script fails to load', async () => {
+ const user = userEvent.setup()
+ const onInvalidate = vi.fn()
+ const onError = vi.fn()
+ render(
+ ,
+ )
+ const initialScriptSrc = mocks.scriptProps?.src
+
+ act(() => {
+ mocks.scriptProps?.onError?.()
+ })
+
+ expect(screen.getByRole('alert')).toHaveTextContent('login.turnstile.loadError')
+ expect(onInvalidate).not.toHaveBeenCalled()
+ expect(onError).toHaveBeenCalledTimes(1)
+
+ await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(onError).toHaveBeenCalledTimes(1)
+ expect(mocks.scriptProps?.src).not.toBe(initialScriptSrc)
+
+ mocks.render.mockReturnValue('widget-id')
+ Object.defineProperty(window, 'turnstile', {
+ configurable: true,
+ value: {
+ remove: mocks.remove,
+ render: mocks.render,
+ },
+ })
+ act(() => {
+ mocks.scriptProps?.onReady?.()
+ })
+
+ await waitFor(() => {
+ expect(mocks.render).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('recreates the widget after a challenge error without treating token expiry as a load failure', async () => {
+ const user = userEvent.setup()
+ const onInvalidate = vi.fn()
+ const onError = vi.fn()
+ mocks.render.mockImplementation((_container: HTMLElement, options: TurnstileOptions) => {
+ turnstileOptions = options
+ return 'widget-id'
+ })
+ Object.defineProperty(window, 'turnstile', {
+ configurable: true,
+ value: {
+ remove: mocks.remove,
+ render: mocks.render,
+ },
+ })
+ render(
+ ,
+ )
+
+ act(() => {
+ mocks.scriptProps?.onReady?.()
+ })
+ act(() => {
+ turnstileOptions?.['expired-callback']()
+ })
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(onInvalidate).toHaveBeenCalledTimes(1)
+ expect(onError).not.toHaveBeenCalled()
+
+ act(() => {
+ turnstileOptions?.['error-callback']('network-error')
+ })
+ expect(screen.getByRole('alert')).toHaveTextContent('login.turnstile.loadError')
+ expect(onInvalidate).toHaveBeenCalledTimes(1)
+ expect(onError).toHaveBeenCalledTimes(1)
+
+ await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
+
+ expect(mocks.remove).toHaveBeenCalledWith('widget-id')
+ expect(mocks.render).toHaveBeenCalledTimes(2)
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx
index 7d9de58c3f3..423c2ca831c 100644
--- a/web/app/signin/components/mail-and-code-auth.tsx
+++ b/web/app/signin/components/mail-and-code-auth.tsx
@@ -2,13 +2,16 @@ import { Button } from '@langgenius/dify-ui/button'
import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { toast } from '@langgenius/dify-ui/toast'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/signin/storage'
-import { emailRegex } from '@/config'
+import { emailRegex, TURNSTILE_SITE_KEY } from '@/config'
import { useLocale } from '@/context/i18n'
+import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter, useSearchParams } from '@/next/navigation'
import { sendEMailLoginCode } from '@/service/common'
+import Turnstile from './turnstile'
type MailAndCodeAuthProps = {
isInvite: boolean
@@ -18,13 +21,20 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink)
const [loading, setLoading] = useState(false)
+ const [turnstileToken, setTurnstileToken] = useState('')
+ const [turnstileGeneration, setTurnstileGeneration] = useState(0)
const locale = useLocale()
const setCountdownLeftTime = useSetCountdownLeftTime()
+ const turnstileSiteKey = TURNSTILE_SITE_KEY.trim()
+ const isTurnstileRequired = systemFeatures.deployment_edition === 'CLOUD'
+ const shouldRenderTurnstile = isTurnstileRequired && Boolean(turnstileSiteKey)
const handleGetEMailVerificationCode = async () => {
+ let shouldResetTurnstile = false
try {
if (!email) {
toast.error(t(($) => $['error.emailEmpty'], { ns: 'login' }))
@@ -36,18 +46,28 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
return
}
setLoading(true)
- const ret = await sendEMailLoginCode(email, locale)
+ shouldResetTurnstile = isTurnstileRequired
+ const ret = await sendEMailLoginCode(
+ email,
+ locale,
+ isTurnstileRequired ? turnstileToken : undefined,
+ )
if (ret.result === 'success') {
setCountdownLeftTime(`${COUNT_DOWN_TIME_MS}`)
const params = new URLSearchParams(searchParams)
params.set('email', encodeURIComponent(email))
params.set('token', encodeURIComponent(ret.data))
router.push(`/signin/check-code?${params.toString()}`)
+ shouldResetTurnstile = false
}
} catch (error) {
console.error(error)
} finally {
setLoading(false)
+ if (shouldResetTurnstile) {
+ setTurnstileToken('')
+ setTurnstileGeneration((value) => value + 1)
+ }
}
}
@@ -70,11 +90,24 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) as string}
onValueChange={setEmail}
/>
+ {shouldRenderTurnstile && (
+ {
+ setTurnstileToken('')
+ }}
+ onError={() => {
+ setTurnstileToken('')
+ }}
+ />
+ )}