diff --git a/env.example b/env.example index 6519c16..ba23ec3 100644 --- a/env.example +++ b/env.example @@ -148,3 +148,10 @@ NEXT_PUBLIC_AFFILIATE_AFFONSO_ID="" # https://www.promotekit.com/ # ----------------------------------------------------------------------------- NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID="" + +# ----------------------------------------------------------------------------- +# Captcha (Cloudflare Turnstile) +# https://mksaas.com/docs/captcha#setup +# ----------------------------------------------------------------------------- +NEXT_PUBLIC_TURNSTILE_SITE_KEY="" +TURNSTILE_SECRET_KEY="" diff --git a/messages/en.json b/messages/en.json index b8e58ee..7429307 100644 --- a/messages/en.json +++ b/messages/en.json @@ -202,7 +202,9 @@ "hidePassword": "Hide password", "nameRequired": "Please enter your name", "emailRequired": "Please enter your email", - "passwordRequired": "Please enter your password" + "passwordRequired": "Please enter your password", + "captchaInvalid": "Captcha verification failed", + "captchaError": "Captcha verification error" }, "forgotPassword": { "title": "Forgot Password", diff --git a/messages/zh.json b/messages/zh.json index b68a5d1..cf11293 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -203,7 +203,9 @@ "hidePassword": "隐藏密码", "nameRequired": "请输入姓名", "emailRequired": "请输入邮箱", - "passwordRequired": "请输入密码" + "passwordRequired": "请输入密码", + "captchaInvalid": "验证码验证失败", + "captchaError": "验证码验证出错" }, "forgotPassword": { "title": "忘记密码", diff --git a/package.json b/package.json index eae19b8..15d6c99 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^4.1.0", + "@marsidev/react-turnstile": "^1.1.0", "@next/third-parties": "^15.3.0", "@openpanel/nextjs": "^1.0.7", "@orama/orama": "^3.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9348237..f7c3a38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@hookform/resolvers': specifier: ^4.1.0 version: 4.1.0(react-hook-form@7.54.2(react@19.0.0)) + '@marsidev/react-turnstile': + specifier: ^1.1.0 + version: 1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@next/third-parties': specifier: ^15.3.0 version: 15.3.0(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) @@ -1515,6 +1518,12 @@ packages: '@levischuck/tiny-cbor@0.2.11': resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@marsidev/react-turnstile@1.1.0': + resolution: {integrity: sha512-X7bP9ZYutDd+E+klPYF+/BJHqEyyVkN4KKmZcNRr84zs3DcMoftlMAuoKqNSnqg0HE7NQ1844+TLFSJoztCdSA==} + peerDependencies: + react: ^17.0.2 || ^18.0.0 || ^19.0 + react-dom: ^17.0.2 || ^18.0.0 || ^19.0 + '@mdx-js/mdx@3.1.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} @@ -6542,6 +6551,11 @@ snapshots: '@levischuck/tiny-cbor@0.2.11': {} + '@marsidev/react-turnstile@1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + '@mdx-js/mdx@3.1.0(acorn@8.14.0)': dependencies: '@types/estree': 1.0.6 diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index 390f8ba..66bb123 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -16,14 +16,16 @@ import { Input } from '@/components/ui/input'; import { websiteConfig } from '@/config/website'; import { authClient } from '@/lib/auth-client'; import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls'; +import { validateTurnstileToken } from '@/lib/validate-captcha'; import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes'; import { zodResolver } from '@hookform/resolvers/zod'; import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react'; import { useLocale, useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; import { useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import * as z from 'zod'; +import { Captcha } from '../shared/captcha'; import { SocialLoginButton } from './social-login-button'; interface RegisterFormProps { @@ -50,6 +52,12 @@ export const RegisterForm = ({ const [isPending, setIsPending] = useState(false); const [showPassword, setShowPassword] = useState(false); + // turnstile captcha schema + const turnstileEnabled = websiteConfig.features.enableTurnstileCaptcha; + const captchaSchema = turnstileEnabled + ? z.string().min(1, 'Please complete the captcha') + : z.string().optional(); + const RegisterSchema = z.object({ email: z.string().email({ message: t('emailRequired'), @@ -60,6 +68,7 @@ export const RegisterForm = ({ name: z.string().min(1, { message: t('nameRequired'), }), + captchaToken: captchaSchema, }); const form = useForm>({ @@ -68,10 +77,34 @@ export const RegisterForm = ({ email: '', password: '', name: '', + captchaToken: '', }, }); + const captchaToken = useWatch({ + control: form.control, + name: 'captchaToken', + }); + const onSubmit = async (values: z.infer) => { + // Validate captcha token if turnstile is enabled + if (turnstileEnabled && values.captchaToken) { + try { + const isCaptchaValid = await validateTurnstileToken( + values.captchaToken + ); + if (!isCaptchaValid) { + console.log('register, captcha invalid:', values.captchaToken); + setError(t('captchaInvalid') || 'Captcha verification failed'); + return; + } + } catch (error) { + console.error('register, captcha validation error:', error); + setError(t('captchaError') || 'Captcha verification error'); + return; + } + } + // 1. if requireEmailVerification is true, callbackURL will be used in the verification email, // the user will be redirected to the callbackURL after the email is verified. // 2. if requireEmailVerification is false, the user will not be redirected to the callbackURL, @@ -200,8 +233,12 @@ export const RegisterForm = ({ + form.setValue('captchaToken', token)} + validationError={form.formState.errors.captchaToken?.message} + />