feat: support cloudflare turnstile
This commit is contained in:
parent
b3180e617d
commit
958852335d
@ -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=""
|
||||
|
@ -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",
|
||||
|
@ -203,7 +203,9 @@
|
||||
"hidePassword": "隐藏密码",
|
||||
"nameRequired": "请输入姓名",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"passwordRequired": "请输入密码"
|
||||
"passwordRequired": "请输入密码",
|
||||
"captchaInvalid": "验证码验证失败",
|
||||
"captchaError": "验证码验证出错"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "忘记密码",
|
||||
|
@ -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",
|
||||
|
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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<z.infer<typeof RegisterSchema>>({
|
||||
@ -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<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,
|
||||
// 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 = ({
|
||||
</div>
|
||||
<FormError message={error} />
|
||||
<FormSuccess message={success} />
|
||||
<Captcha
|
||||
onSuccess={(token) => form.setValue('captchaToken', token)}
|
||||
validationError={form.formState.errors.captchaToken?.message}
|
||||
/>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
disabled={isPending || (turnstileEnabled && !captchaToken)}
|
||||
size="lg"
|
||||
type="submit"
|
||||
className="cursor-pointer w-full flex items-center justify-center gap-2"
|
||||
|
48
src/components/shared/captcha.tsx
Normal file
48
src/components/shared/captcha.tsx
Normal 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;
|
||||
};
|
@ -38,6 +38,7 @@ export const websiteConfig: WebsiteConfig = {
|
||||
enableAffonsoAffiliate: false,
|
||||
enablePromotekitAffiliate: false,
|
||||
enableDatafastRevenueTrack: false,
|
||||
enableTurnstileCaptcha: true,
|
||||
},
|
||||
routes: {
|
||||
defaultLoginRedirect: '/dashboard',
|
||||
|
34
src/lib/validate-captcha.ts
Normal file
34
src/lib/validate-captcha.ts
Normal 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;
|
||||
}
|
1
src/types/index.d.ts
vendored
1
src/types/index.d.ts
vendored
@ -71,6 +71,7 @@ export interface FeaturesConfig {
|
||||
enableAffonsoAffiliate?: boolean; // Whether to enable affonso affiliate
|
||||
enablePromotekitAffiliate?: boolean; // Whether to enable promotekit affiliate
|
||||
enableDatafastRevenueTrack?: boolean; // Whether to enable datafast revenue tracking
|
||||
enableTurnstileCaptcha?: boolean; // Whether to enable turnstile captcha
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user