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/
|
# 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=""
|
||||||
|
@ -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",
|
||||||
|
@ -203,7 +203,9 @@
|
|||||||
"hidePassword": "隐藏密码",
|
"hidePassword": "隐藏密码",
|
||||||
"nameRequired": "请输入姓名",
|
"nameRequired": "请输入姓名",
|
||||||
"emailRequired": "请输入邮箱",
|
"emailRequired": "请输入邮箱",
|
||||||
"passwordRequired": "请输入密码"
|
"passwordRequired": "请输入密码",
|
||||||
|
"captchaInvalid": "验证码验证失败",
|
||||||
|
"captchaError": "验证码验证出错"
|
||||||
},
|
},
|
||||||
"forgotPassword": {
|
"forgotPassword": {
|
||||||
"title": "忘记密码",
|
"title": "忘记密码",
|
||||||
|
@ -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
14
pnpm-lock.yaml
generated
@ -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
|
||||||
|
@ -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"
|
||||||
|
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,
|
enableAffonsoAffiliate: false,
|
||||||
enablePromotekitAffiliate: false,
|
enablePromotekitAffiliate: false,
|
||||||
enableDatafastRevenueTrack: false,
|
enableDatafastRevenueTrack: false,
|
||||||
|
enableTurnstileCaptcha: true,
|
||||||
},
|
},
|
||||||
routes: {
|
routes: {
|
||||||
defaultLoginRedirect: '/dashboard',
|
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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user