mirror of
https://github.com/vernu/textbee.git
synced 2026-03-03 03:47:01 +00:00
chore: prevent bot form submissions with cloudflare turnstile
This commit is contained in:
@@ -28,3 +28,5 @@ MAIL_REPLY_TO=
|
||||
# SMS Queue Configuration
|
||||
USE_SMS_QUEUE=false
|
||||
REDIS_URL=redis://localhost:6379 # if queue is enabled, redis url is required
|
||||
|
||||
CLOUDFLARE_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
@@ -12,6 +12,9 @@ export class RegisterInputDTO {
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
password: string
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
turnstileToken: string
|
||||
}
|
||||
|
||||
export class LoginInputDTO {
|
||||
@@ -20,11 +23,17 @@ export class LoginInputDTO {
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
password: string
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
turnstileToken: string
|
||||
}
|
||||
|
||||
export class RequestResetPasswordInputDTO {
|
||||
@ApiProperty({ type: String, required: true })
|
||||
email: string
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
turnstileToken: string
|
||||
}
|
||||
|
||||
export class ResetPasswordInputDTO {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AuthService } from './auth.service'
|
||||
import { JwtStrategy } from './jwt.strategy'
|
||||
import { ApiKey, ApiKeySchema } from './schemas/api-key.schema'
|
||||
import { MailModule } from 'src/mail/mail.module'
|
||||
import { CommonModule } from '../common/common.module'
|
||||
import {
|
||||
PasswordReset,
|
||||
PasswordResetSchema,
|
||||
@@ -44,9 +45,16 @@ import { OptionalAuthGuard } from './guards/optional-auth.guard'
|
||||
signOptions: { expiresIn: process.env.JWT_EXPIRATION || '60d' },
|
||||
}),
|
||||
MailModule,
|
||||
CommonModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, AuthGuard, OptionalAuthGuard, MongooseModule],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
AuthGuard,
|
||||
OptionalAuthGuard,
|
||||
MongooseModule,
|
||||
],
|
||||
exports: [AuthService, JwtModule, AuthGuard, OptionalAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PasswordResetDocument,
|
||||
} from './schemas/password-reset.schema'
|
||||
import { MailService } from '../mail/mail.service'
|
||||
import { TurnstileService } from '../common/turnstile.service'
|
||||
import { RequestResetPasswordInputDTO, ResetPasswordInputDTO } from './auth.dto'
|
||||
import { AccessLog } from './schemas/access-log.schema'
|
||||
import {
|
||||
@@ -32,9 +33,12 @@ export class AuthService {
|
||||
@InjectModel(EmailVerification.name)
|
||||
private emailVerificationModel: Model<EmailVerificationDocument>,
|
||||
private readonly mailService: MailService,
|
||||
private readonly turnstileService: TurnstileService,
|
||||
) {}
|
||||
|
||||
async login(userData: any) {
|
||||
await this.turnstileService.verify(userData.turnstileToken)
|
||||
|
||||
const user = await this.usersService.findOne({ email: userData.email })
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
@@ -109,6 +113,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async register(userData: any) {
|
||||
await this.turnstileService.verify(userData.turnstileToken)
|
||||
|
||||
const existingUser = await this.usersService.findOne({
|
||||
email: userData.email,
|
||||
})
|
||||
@@ -123,8 +129,9 @@ export class AuthService {
|
||||
this.validatePassword(userData.password)
|
||||
|
||||
const hashedPassword = await bcrypt.hash(userData.password, 10)
|
||||
const { turnstileToken, ...sanitizedUserData } = userData
|
||||
const user = await this.usersService.create({
|
||||
...userData,
|
||||
...sanitizedUserData,
|
||||
password: hashedPassword,
|
||||
})
|
||||
|
||||
@@ -152,7 +159,12 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async requestResetPassword({ email }: RequestResetPasswordInputDTO) {
|
||||
async requestResetPassword({
|
||||
email,
|
||||
turnstileToken,
|
||||
}: RequestResetPasswordInputDTO) {
|
||||
await this.turnstileService.verify(turnstileToken)
|
||||
|
||||
const user = await this.usersService.findOne({ email })
|
||||
if (!user) {
|
||||
return {
|
||||
|
||||
9
api/src/common/common.module.ts
Normal file
9
api/src/common/common.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { TurnstileService } from './turnstile.service'
|
||||
|
||||
@Module({
|
||||
providers: [TurnstileService],
|
||||
exports: [TurnstileService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
51
api/src/common/turnstile.service.ts
Normal file
51
api/src/common/turnstile.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
|
||||
import axios from 'axios'
|
||||
|
||||
@Injectable()
|
||||
export class TurnstileService {
|
||||
async verify(token: string) {
|
||||
if (!token) {
|
||||
throw new HttpException(
|
||||
{ error: 'Bot verification is required' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
|
||||
if (!process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY) {
|
||||
throw new HttpException(
|
||||
{ error: 'Turnstile secret key is not configured' },
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('secret', process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY)
|
||||
params.append('response', token)
|
||||
|
||||
const response = await axios.post(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
params,
|
||||
{
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new HttpException(
|
||||
{ error: 'Bot verification failed. Please try again.' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
throw error
|
||||
}
|
||||
throw new HttpException(
|
||||
{ error: 'Unable to verify bot check. Please try again.' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ MAIL_USER=
|
||||
MAIL_PASS=
|
||||
MAIL_FROM=
|
||||
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_EMAIL=NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -17,10 +18,14 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Routes } from '@/config/routes'
|
||||
import { useTurnstile } from '@/lib/turnstile'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
password: z.string().min(1, { message: 'Password is required' }),
|
||||
turnstileToken: z
|
||||
.string()
|
||||
.min(1, { message: 'Please complete the bot verification' }),
|
||||
})
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>
|
||||
@@ -33,16 +38,54 @@ export default function LoginForm() {
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
turnstileToken: '',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
containerRef: turnstileRef,
|
||||
token: turnstileToken,
|
||||
error: turnstileError,
|
||||
} = useTurnstile({
|
||||
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
onToken: (token) =>
|
||||
form.setValue('turnstileToken', token, { shouldValidate: true }),
|
||||
onError: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
onExpire: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileToken) {
|
||||
form.clearErrors('turnstileToken')
|
||||
}
|
||||
}, [turnstileToken, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileError) {
|
||||
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
|
||||
}
|
||||
}, [turnstileError, form])
|
||||
|
||||
const onSubmit = async (data: LoginFormValues) => {
|
||||
form.clearErrors()
|
||||
|
||||
if (!data.turnstileToken) {
|
||||
form.setError('turnstileToken', {
|
||||
type: 'manual',
|
||||
message: 'Please complete the bot verification',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signIn('email-password-login', {
|
||||
redirect: true,
|
||||
callbackUrl: Routes.dashboard,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
turnstileToken: data.turnstileToken,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
@@ -97,6 +140,22 @@ export default function LoginForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='turnstileToken'
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-sm'>Bot verification</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={turnstileRef}
|
||||
className='min-h-[65px] w-full flex justify-center'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<p className='text-sm font-medium text-red-500'>
|
||||
{form.formState.errors.root.message}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Routes } from '@/config/routes'
|
||||
import { useTurnstile } from '@/lib/turnstile'
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z
|
||||
@@ -29,6 +31,9 @@ const registerSchema = z.object({
|
||||
.min(8, { message: 'Password must be at least 8 characters long' }),
|
||||
phone: z.string().optional(),
|
||||
marketingOptIn: z.boolean().optional().default(true),
|
||||
turnstileToken: z
|
||||
.string()
|
||||
.min(1, { message: 'Please complete the bot verification' }),
|
||||
})
|
||||
|
||||
type RegisterFormValues = z.infer<typeof registerSchema>
|
||||
@@ -44,12 +49,47 @@ export default function RegisterForm() {
|
||||
password: '',
|
||||
phone: '',
|
||||
marketingOptIn: true,
|
||||
turnstileToken: '',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
containerRef: turnstileRef,
|
||||
token: turnstileToken,
|
||||
error: turnstileError,
|
||||
} = useTurnstile({
|
||||
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
onToken: (token) =>
|
||||
form.setValue('turnstileToken', token, { shouldValidate: true }),
|
||||
onError: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
onExpire: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileToken) {
|
||||
form.clearErrors('turnstileToken')
|
||||
}
|
||||
}, [turnstileToken, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileError) {
|
||||
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
|
||||
}
|
||||
}, [turnstileError, form])
|
||||
|
||||
const onSubmit = async (data: RegisterFormValues) => {
|
||||
form.clearErrors()
|
||||
|
||||
if (!data.turnstileToken) {
|
||||
form.setError('turnstileToken', {
|
||||
type: 'manual',
|
||||
message: 'Please complete the bot verification',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signIn('email-password-register', {
|
||||
redirect: false,
|
||||
@@ -58,6 +98,7 @@ export default function RegisterForm() {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
marketingOptIn: data.marketingOptIn,
|
||||
turnstileToken: data.turnstileToken,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
@@ -133,6 +174,22 @@ export default function RegisterForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='turnstileToken'
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-sm'>Bot verification</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={turnstileRef}
|
||||
className='min-h-[65px] w-full flex justify-center'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<p className='text-sm font-medium text-red-500'>
|
||||
{form.formState.errors.root.message}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
@@ -28,9 +29,13 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import httpBrowserClient from '@/lib/httpBrowserClient'
|
||||
import { ApiEndpoints } from '@/config/api'
|
||||
import { Routes } from '@/config/routes'
|
||||
import { useTurnstile } from '@/lib/turnstile'
|
||||
|
||||
const requestPasswordResetSchema = z.object({
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
turnstileToken: z
|
||||
.string()
|
||||
.min(1, { message: 'Please complete the bot verification' }),
|
||||
})
|
||||
|
||||
type RequestPasswordResetFormValues = z.infer<typeof requestPasswordResetSchema>
|
||||
@@ -40,13 +45,49 @@ export default function RequestPasswordResetForm() {
|
||||
resolver: zodResolver(requestPasswordResetSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
turnstileToken: '',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
containerRef: turnstileRef,
|
||||
token: turnstileToken,
|
||||
error: turnstileError,
|
||||
} = useTurnstile({
|
||||
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
onToken: (token) =>
|
||||
form.setValue('turnstileToken', token, { shouldValidate: true }),
|
||||
onError: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
onExpire: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileToken) {
|
||||
form.clearErrors('turnstileToken')
|
||||
}
|
||||
}, [turnstileToken, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileError) {
|
||||
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
|
||||
}
|
||||
}, [turnstileError, form])
|
||||
|
||||
const onRequestPasswordReset = async (
|
||||
data: RequestPasswordResetFormValues
|
||||
) => {
|
||||
form.clearErrors()
|
||||
|
||||
if (!data.turnstileToken) {
|
||||
form.setError('turnstileToken', {
|
||||
type: 'manual',
|
||||
message: 'Please complete the bot verification',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await httpBrowserClient.post(
|
||||
ApiEndpoints.auth.requestPasswordReset(),
|
||||
@@ -89,6 +130,22 @@ export default function RequestPasswordResetForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='turnstileToken'
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-sm'>Bot verification</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={turnstileRef}
|
||||
className='min-h-[65px] w-full flex justify-center'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
className='w-full'
|
||||
type='submit'
|
||||
|
||||
@@ -29,13 +29,15 @@ export const authOptions = {
|
||||
credentials: {
|
||||
email: { label: 'email', type: 'text' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
turnstileToken: { label: 'Turnstile Token', type: 'text' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const { email, password } = credentials
|
||||
const { email, password, turnstileToken } = credentials
|
||||
try {
|
||||
const res = await httpServerClient.post(ApiEndpoints.auth.login(), {
|
||||
email,
|
||||
password,
|
||||
turnstileToken,
|
||||
})
|
||||
|
||||
const user = res.data.data.user
|
||||
@@ -60,9 +62,10 @@ export const authOptions = {
|
||||
password: { label: 'Password', type: 'password' },
|
||||
name: { label: 'Name', type: 'text' },
|
||||
phone: { label: 'Phone', type: 'text' },
|
||||
turnstileToken: { label: 'Turnstile Token', type: 'text' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const { email, password, name, phone } = credentials
|
||||
const { email, password, name, phone, turnstileToken } = credentials
|
||||
try {
|
||||
const res = await httpServerClient.post(
|
||||
ApiEndpoints.auth.register(),
|
||||
@@ -71,6 +74,7 @@ export const authOptions = {
|
||||
password,
|
||||
name,
|
||||
phone,
|
||||
turnstileToken,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
167
web/lib/turnstile.ts
Normal file
167
web/lib/turnstile.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { type RefObject, useEffect, useRef, useState } from 'react'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
sitekey: string
|
||||
callback?: (token: string) => void
|
||||
'error-callback'?: () => void
|
||||
'expired-callback'?: () => void
|
||||
},
|
||||
) => string
|
||||
reset: (widgetId?: string) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TURNSTILE_SCRIPT_ID = 'cf-turnstile-script'
|
||||
const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
|
||||
let loadTurnstilePromise: Promise<void> | null = null
|
||||
|
||||
const createLoadPromise = () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.id = TURNSTILE_SCRIPT_ID
|
||||
script.src = TURNSTILE_SCRIPT_SRC
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error('Failed to load Turnstile script'))
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
|
||||
const ensureTurnstileScript = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('Turnstile unavailable on server'))
|
||||
}
|
||||
if (window.turnstile) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (loadTurnstilePromise) {
|
||||
return loadTurnstilePromise
|
||||
}
|
||||
|
||||
const existing = document.getElementById(TURNSTILE_SCRIPT_ID)
|
||||
if (existing) {
|
||||
const scriptEl = existing as HTMLScriptElement
|
||||
loadTurnstilePromise = new Promise<void>((resolve) => {
|
||||
scriptEl.addEventListener('load', () => resolve(), { once: true })
|
||||
const readyStates = ['loaded', 'complete']
|
||||
const state = (scriptEl as any).readyState as string | undefined
|
||||
if (state && readyStates.includes(state)) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
return loadTurnstilePromise
|
||||
}
|
||||
|
||||
loadTurnstilePromise = createLoadPromise()
|
||||
return loadTurnstilePromise
|
||||
}
|
||||
|
||||
type UseTurnstileOptions = {
|
||||
siteKey?: string
|
||||
onToken?: (token: string) => void
|
||||
onError?: (message: string) => void
|
||||
onExpire?: (message: string) => void
|
||||
}
|
||||
|
||||
type UseTurnstileResult = {
|
||||
containerRef: RefObject<HTMLDivElement>
|
||||
token: string
|
||||
error: string | null
|
||||
isReady: boolean
|
||||
}
|
||||
|
||||
export const useTurnstile = ({
|
||||
siteKey,
|
||||
onToken,
|
||||
onError,
|
||||
onExpire,
|
||||
}: UseTurnstileOptions): UseTurnstileResult => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const widgetIdRef = useRef<string | null>(null)
|
||||
const onTokenRef = useRef(onToken)
|
||||
const onErrorRef = useRef(onError)
|
||||
const onExpireRef = useRef(onExpire)
|
||||
const [token, setToken] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
onTokenRef.current = onToken
|
||||
onErrorRef.current = onError
|
||||
onExpireRef.current = onExpire
|
||||
}, [onToken, onError, onExpire])
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey) {
|
||||
setError('Turnstile site key is not configured')
|
||||
onErrorRef.current?.('Turnstile site key is not configured')
|
||||
return
|
||||
}
|
||||
|
||||
ensureTurnstileScript()
|
||||
.then(() => setIsReady(true))
|
||||
.catch(() => {
|
||||
const message = 'Bot check failed to load. Please retry.'
|
||||
setError(message)
|
||||
onErrorRef.current?.(message)
|
||||
})
|
||||
}, [siteKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isReady ||
|
||||
!containerRef.current ||
|
||||
!window.turnstile ||
|
||||
!siteKey ||
|
||||
widgetIdRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Defensive: clear any existing content to avoid duplicate render in StrictMode.
|
||||
containerRef.current.innerHTML = ''
|
||||
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
callback: (receivedToken) => {
|
||||
setToken(receivedToken)
|
||||
setError(null)
|
||||
onTokenRef.current?.(receivedToken)
|
||||
},
|
||||
'error-callback': () => {
|
||||
setToken('')
|
||||
const message = 'Bot verification failed. Please retry.'
|
||||
setError(message)
|
||||
onErrorRef.current?.(message)
|
||||
},
|
||||
'expired-callback': () => {
|
||||
setToken('')
|
||||
const message = 'Bot check expired. Please try again.'
|
||||
setError(message)
|
||||
onExpireRef.current?.(message)
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.reset(widgetIdRef.current)
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isReady, siteKey])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
token,
|
||||
error,
|
||||
isReady,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user