diff --git a/README.md b/README.md index 6636a54..652637b 100644 --- a/README.md +++ b/README.md @@ -25,20 +25,21 @@ If you found anything that could be improved, please let me know. ## Repositories -By default, you should have access to all four repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue. +By default, you should have access to all 5 repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue. - [mksaas-template (ready)](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com - [mksaas-blog (ready)](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me - [mksaas-haitang (ready)](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app +- [mksaas-outfiai (ready)](https://github.com/MkSaaSHQ/mksaas-outfiai) - [mksaas-app (WIP)](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app ## Notice -> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com). +> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com), or join our [discord community](https://mksaas.link/discord) and ask for help there. > If you want to receive notifications whenever code changes, please click `Watch` button in the top right. -> When submitting any content to the issues or discussions of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports. +> When submitting any content to the issues of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports. ## License 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 c21df2c..d472a69 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,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 d56771e..5837676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,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) @@ -1579,6 +1582,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==} @@ -6738,6 +6747,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/actions/validate-captcha.ts b/src/actions/validate-captcha.ts new file mode 100644 index 0000000..691f3f5 --- /dev/null +++ b/src/actions/validate-captcha.ts @@ -0,0 +1,36 @@ +'use server'; + +import { validateTurnstileToken } from '@/lib/captcha'; +import { createSafeActionClient } from 'next-safe-action'; +import { z } from 'zod'; + +// Create a safe action client +const actionClient = createSafeActionClient(); + +// Captcha validation schema +const captchaSchema = z.object({ + captchaToken: z.string().min(1, { message: 'Captcha token is required' }), +}); + +// Create a safe action for captcha validation +export const validateCaptchaAction = actionClient + .schema(captchaSchema) + .action(async ({ parsedInput }) => { + const { captchaToken } = parsedInput; + + try { + const isValid = await validateTurnstileToken(captchaToken); + + return { + success: true, + valid: isValid, + }; + } catch (error) { + console.error('Captcha validation error:', error); + return { + success: false, + valid: false, + error: error instanceof Error ? error.message : 'Something went wrong', + }; + } + }); diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index 390f8ba..21c0f77 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -1,5 +1,6 @@ 'use client'; +import { validateCaptchaAction } from '@/actions/validate-captcha'; import { AuthCard } from '@/components/auth/auth-card'; import { FormError } from '@/components/shared/form-error'; import { FormSuccess } from '@/components/shared/form-success'; @@ -15,6 +16,7 @@ import { import { Input } from '@/components/ui/input'; import { websiteConfig } from '@/config/website'; import { authClient } from '@/lib/auth-client'; +import { isTurnstileEnabled } from '@/lib/captcha'; import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls'; import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -22,8 +24,9 @@ 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 +53,12 @@ export const RegisterForm = ({ const [isPending, setIsPending] = useState(false); const [showPassword, setShowPassword] = useState(false); + // turnstile captcha schema + const turnstileEnabled = isTurnstileEnabled(); + 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 +69,7 @@ export const RegisterForm = ({ name: z.string().min(1, { message: t('nameRequired'), }), + captchaToken: captchaSchema, }); const form = useForm>({ @@ -68,10 +78,30 @@ 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) { + const captchaResult = await validateCaptchaAction({ + captchaToken: values.captchaToken, + }); + + if (!captchaResult?.data?.success || !captchaResult?.data?.valid) { + console.error('register, captcha invalid:', values.captchaToken); + const errorMessage = captchaResult?.data?.error || t('captchaInvalid'); + setError(errorMessage); + 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 +230,12 @@ export const RegisterForm = ({ + form.setValue('captchaToken', token)} + validationError={form.formState.errors.captchaToken?.message} + />