Captcha Refactoring (#397)

* refactor: replace useCaptchaToken with useCaptcha hook and integrate CaptchaField across forms
This commit is contained in:
Giancarlo Buomprisco
2025-10-21 20:46:35 +09:00
committed by GitHub
parent 9eccb319af
commit ea0c1dde80
17 changed files with 303 additions and 178 deletions

View File

@@ -0,0 +1,125 @@
'use client';
import { useRef } from 'react';
import {
Turnstile,
TurnstileInstance,
TurnstileProps,
} from '@marsidev/react-turnstile';
import type { Control, FieldPath, FieldValues } from 'react-hook-form';
import { useController } from 'react-hook-form';
interface BaseCaptchaFieldProps {
siteKey: string | undefined;
options?: TurnstileProps;
nonce?: string;
}
interface StandaloneCaptchaFieldProps extends BaseCaptchaFieldProps {
onTokenChange: (token: string) => void;
onInstanceChange?: (instance: TurnstileInstance | null) => void;
control?: never;
name?: never;
}
interface ReactHookFormCaptchaFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends BaseCaptchaFieldProps {
control: Control<TFieldValues>;
name: TName;
onTokenChange?: never;
onInstanceChange?: never;
}
type CaptchaFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> =
| StandaloneCaptchaFieldProps
| ReactHookFormCaptchaFieldProps<TFieldValues, TName>;
/**
* @name CaptchaField
* @description Self-contained captcha component with two modes:
*
* **Standalone mode** - For use outside react-hook-form:
* ```tsx
* <CaptchaField
* siteKey={siteKey}
* onTokenChange={setToken}
* />
* ```
*
* **React Hook Form mode** - Automatic form integration:
* ```tsx
* <CaptchaField
* siteKey={siteKey}
* control={form.control}
* name="captchaToken"
* />
* ```
*/
export function CaptchaField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: CaptchaFieldProps<TFieldValues, TName>) {
const { siteKey, options, nonce } = props;
const instanceRef = useRef<TurnstileInstance | null>(null);
// React Hook Form integration
const controller =
'control' in props && props.control
? // eslint-disable-next-line react-hooks/rules-of-hooks
useController({
control: props.control,
name: props.name,
})
: null;
if (!siteKey) {
return null;
}
const defaultOptions: Partial<TurnstileProps> = {
options: {
size: 'invisible',
},
};
const handleSuccess = (token: string) => {
if (controller) {
// React Hook Form mode - use setValue from controller
controller.field.onChange(token);
} else if ('onTokenChange' in props && props.onTokenChange) {
// Standalone mode
props.onTokenChange(token);
}
};
const handleInstanceChange = (instance: TurnstileInstance | null) => {
instanceRef.current = instance;
if ('onInstanceChange' in props && props.onInstanceChange) {
props.onInstanceChange(instance);
}
};
return (
<Turnstile
ref={(instance) => {
if (instance) {
handleInstanceChange(instance);
}
}}
siteKey={siteKey}
onSuccess={handleSuccess}
scriptOptions={{
nonce,
}}
{...defaultOptions}
{...options}
/>
);
}

View File

@@ -1,39 +0,0 @@
'use client';
import { createContext, useCallback, useRef, useState } from 'react';
import { TurnstileInstance } from '@marsidev/react-turnstile';
export const Captcha = createContext<{
token: string;
setToken: (token: string) => void;
instance: TurnstileInstance | null;
setInstance: (ref: TurnstileInstance) => void;
}>({
token: '',
instance: null,
setToken: (_: string) => {
// do nothing
return '';
},
setInstance: () => {
// do nothing
},
});
export function CaptchaProvider(props: { children: React.ReactNode }) {
const [token, setToken] = useState<string>('');
const instanceRef = useRef<TurnstileInstance | null>(null);
const setInstance = useCallback((ref: TurnstileInstance) => {
instanceRef.current = ref;
}, []);
return (
<Captcha.Provider
value={{ token, setToken, instance: instanceRef.current, setInstance }}
>
{props.children}
</Captcha.Provider>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
import { useContext } from 'react';
import { Turnstile, TurnstileProps } from '@marsidev/react-turnstile';
import { Captcha } from './captcha-provider';
export function CaptchaTokenSetter(props: {
siteKey: string | undefined;
options?: TurnstileProps;
nonce?: string;
}) {
const { setToken, setInstance } = useContext(Captcha);
if (!props.siteKey) {
return null;
}
const options = props.options ?? {
options: {
size: 'invisible',
},
};
return (
<Turnstile
ref={(instance) => {
if (instance) {
setInstance(instance);
}
}}
siteKey={props.siteKey}
onSuccess={setToken}
scriptOptions={{
nonce: props.nonce,
}}
{...options}
/>
);
}

View File

@@ -1,3 +1,2 @@
export * from './captcha-token-setter';
export * from './use-captcha-token';
export * from './captcha-provider';
export * from './captcha-field';
export * from './use-captcha';

View File

@@ -1,23 +0,0 @@
import { useContext, useMemo } from 'react';
import { Captcha } from './captcha-provider';
/**
* @name useCaptchaToken
* @description A hook to get the captcha token and reset function
* @returns The captcha token and reset function
*/
export function useCaptchaToken() {
const context = useContext(Captcha);
if (!context) {
throw new Error(`useCaptchaToken must be used within a CaptchaProvider`);
}
return useMemo(() => {
return {
captchaToken: context.token,
resetCaptchaToken: () => context.instance?.reset(),
};
}, [context]);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { CaptchaField } from './captcha-field';
/**
* @name useCaptcha
* @description Zero-boilerplate hook for captcha integration.
* Manages token state and instance internally, exposing a clean API.
*
* @example
* ```tsx
* function SignInForm({ captchaSiteKey }) {
* const captcha = useCaptcha({ siteKey: captchaSiteKey });
*
* const handleSubmit = async (data) => {
* await signIn({ ...data, captchaToken: captcha.token });
* captcha.reset();
* };
*
* return (
* <form onSubmit={handleSubmit}>
* {captcha.field}
* <button>Submit</button>
* </form>
* );
* }
* ```
*/
export function useCaptcha(
{ siteKey, nonce }: { siteKey?: string; nonce?: string } = {
siteKey: undefined,
nonce: undefined,
},
) {
const [token, setToken] = useState('');
const instanceRef = useRef<TurnstileInstance | null>(null);
const reset = useCallback(() => {
instanceRef.current?.reset();
setToken('');
}, []);
const handleTokenChange = useCallback((newToken: string) => {
setToken(newToken);
}, []);
const handleInstanceChange = useCallback(
(instance: TurnstileInstance | null) => {
instanceRef.current = instance;
},
[],
);
const field = useMemo(
() => (
<CaptchaField
siteKey={siteKey}
onTokenChange={handleTokenChange}
onInstanceChange={handleInstanceChange}
nonce={nonce}
/>
),
[siteKey, nonce, handleTokenChange, handleInstanceChange],
);
return useMemo(
() => ({
/** The current captcha token */
token,
/** Reset the captcha (clears token and resets widget) */
reset,
/** The captcha field component to render */
field,
}),
[token, reset, field],
);
}