feat: support cloudflare turnstile

This commit is contained in:
javayhu 2025-06-27 23:50:56 +08:00
parent b3180e617d
commit 958852335d
10 changed files with 151 additions and 4 deletions

View File

@ -148,3 +148,10 @@ NEXT_PUBLIC_AFFILIATE_AFFONSO_ID=""
# https://www.promotekit.com/ # https://www.promotekit.com/
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID="" NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID=""
# -----------------------------------------------------------------------------
# Captcha (Cloudflare Turnstile)
# https://mksaas.com/docs/captcha#setup
# -----------------------------------------------------------------------------
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""

View File

@ -202,7 +202,9 @@
"hidePassword": "Hide password", "hidePassword": "Hide password",
"nameRequired": "Please enter your name", "nameRequired": "Please enter your name",
"emailRequired": "Please enter your email", "emailRequired": "Please enter your email",
"passwordRequired": "Please enter your password" "passwordRequired": "Please enter your password",
"captchaInvalid": "Captcha verification failed",
"captchaError": "Captcha verification error"
}, },
"forgotPassword": { "forgotPassword": {
"title": "Forgot Password", "title": "Forgot Password",

View File

@ -203,7 +203,9 @@
"hidePassword": "隐藏密码", "hidePassword": "隐藏密码",
"nameRequired": "请输入姓名", "nameRequired": "请输入姓名",
"emailRequired": "请输入邮箱", "emailRequired": "请输入邮箱",
"passwordRequired": "请输入密码" "passwordRequired": "请输入密码",
"captchaInvalid": "验证码验证失败",
"captchaError": "验证码验证出错"
}, },
"forgotPassword": { "forgotPassword": {
"title": "忘记密码", "title": "忘记密码",

View File

@ -32,6 +32,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^4.1.0", "@hookform/resolvers": "^4.1.0",
"@marsidev/react-turnstile": "^1.1.0",
"@next/third-parties": "^15.3.0", "@next/third-parties": "^15.3.0",
"@openpanel/nextjs": "^1.0.7", "@openpanel/nextjs": "^1.0.7",
"@orama/orama": "^3.1.4", "@orama/orama": "^3.1.4",

14
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(react-hook-form@7.54.2(react@19.0.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': '@next/third-parties':
specifier: ^15.3.0 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) 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': '@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} 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': '@mdx-js/mdx@3.1.0':
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
@ -6542,6 +6551,11 @@ snapshots:
'@levischuck/tiny-cbor@0.2.11': {} '@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)': '@mdx-js/mdx@3.1.0(acorn@8.14.0)':
dependencies: dependencies:
'@types/estree': 1.0.6 '@types/estree': 1.0.6

View File

@ -16,14 +16,16 @@ import { Input } from '@/components/ui/input';
import { websiteConfig } from '@/config/website'; import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls'; import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
import { validateTurnstileToken } from '@/lib/validate-captcha';
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes'; import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react'; import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { Captcha } from '../shared/captcha';
import { SocialLoginButton } from './social-login-button'; import { SocialLoginButton } from './social-login-button';
interface RegisterFormProps { interface RegisterFormProps {
@ -50,6 +52,12 @@ export const RegisterForm = ({
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
const [showPassword, setShowPassword] = 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({ const RegisterSchema = z.object({
email: z.string().email({ email: z.string().email({
message: t('emailRequired'), message: t('emailRequired'),
@ -60,6 +68,7 @@ export const RegisterForm = ({
name: z.string().min(1, { name: z.string().min(1, {
message: t('nameRequired'), message: t('nameRequired'),
}), }),
captchaToken: captchaSchema,
}); });
const form = useForm<z.infer<typeof RegisterSchema>>({ const form = useForm<z.infer<typeof RegisterSchema>>({
@ -68,10 +77,34 @@ export const RegisterForm = ({
email: '', email: '',
password: '', password: '',
name: '', name: '',
captchaToken: '',
}, },
}); });
const captchaToken = useWatch({
control: form.control,
name: 'captchaToken',
});
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => { const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
// 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, // 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. // 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, // 2. if requireEmailVerification is false, the user will not be redirected to the callbackURL,
@ -200,8 +233,12 @@ export const RegisterForm = ({
</div> </div>
<FormError message={error} /> <FormError message={error} />
<FormSuccess message={success} /> <FormSuccess message={success} />
<Captcha
onSuccess={(token) => form.setValue('captchaToken', token)}
validationError={form.formState.errors.captchaToken?.message}
/>
<Button <Button
disabled={isPending} disabled={isPending || (turnstileEnabled && !captchaToken)}
size="lg" size="lg"
type="submit" type="submit"
className="cursor-pointer w-full flex items-center justify-center gap-2" className="cursor-pointer w-full flex items-center justify-center gap-2"

View File

@ -0,0 +1,48 @@
'use client';
import { FormMessage } from '@/components/ui/form';
import { websiteConfig } from '@/config/website';
import { useLocale } from 'next-intl';
import { useTheme } from 'next-themes';
import dynamic from 'next/dynamic';
import type { ComponentProps } from 'react';
const Turnstile = dynamic(
() => import('@marsidev/react-turnstile').then((mod) => mod.Turnstile),
{
ssr: false,
}
);
type Props = Omit<ComponentProps<typeof Turnstile>, 'siteKey'> & {
validationError?: string;
};
/**
* Captcha component for Cloudflare Turnstile
*/
export const Captcha = ({ validationError, ...props }: Props) => {
const isTurnstileEnabled = websiteConfig.features.enableTurnstileCaptcha;
const theme = useTheme();
const locale = useLocale();
return isTurnstileEnabled ? (
<>
<Turnstile
options={{
size: 'flexible',
language: locale,
theme: theme.theme === 'dark' ? 'dark' : 'light',
}}
{...props}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''}
/>
{validationError && (
<FormMessage className="text-red-500 mt-2">
{validationError}
</FormMessage>
)}
</>
) : null;
};

View File

@ -38,6 +38,7 @@ export const websiteConfig: WebsiteConfig = {
enableAffonsoAffiliate: false, enableAffonsoAffiliate: false,
enablePromotekitAffiliate: false, enablePromotekitAffiliate: false,
enableDatafastRevenueTrack: false, enableDatafastRevenueTrack: false,
enableTurnstileCaptcha: true,
}, },
routes: { routes: {
defaultLoginRedirect: '/dashboard', defaultLoginRedirect: '/dashboard',

View File

@ -0,0 +1,34 @@
import { websiteConfig } from '@/config/website';
interface TurnstileResponse {
success: boolean;
'error-codes'?: string[];
}
/**
* https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
*/
export async function validateTurnstileToken(token: string) {
const isTurnstileEnabled = websiteConfig.features.enableTurnstileCaptcha;
if (!isTurnstileEnabled) {
console.log('validateTurnstileToken, turnstile is disabled');
return true;
}
const response = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
}),
}
);
const data = (await response.json()) as TurnstileResponse;
return data.success;
}

View File

@ -71,6 +71,7 @@ export interface FeaturesConfig {
enableAffonsoAffiliate?: boolean; // Whether to enable affonso affiliate enableAffonsoAffiliate?: boolean; // Whether to enable affonso affiliate
enablePromotekitAffiliate?: boolean; // Whether to enable promotekit affiliate enablePromotekitAffiliate?: boolean; // Whether to enable promotekit affiliate
enableDatafastRevenueTrack?: boolean; // Whether to enable datafast revenue tracking enableDatafastRevenueTrack?: boolean; // Whether to enable datafast revenue tracking
enableTurnstileCaptcha?: boolean; // Whether to enable turnstile captcha
} }
/** /**