Captcha Refactoring (#397)
* refactor: replace useCaptchaToken with useCaptcha hook and integrate CaptchaField across forms
This commit is contained in:
committed by
GitHub
parent
9eccb319af
commit
ea0c1dde80
125
packages/features/auth/src/captcha/client/captcha-field.tsx
Normal file
125
packages/features/auth/src/captcha/client/captcha-field.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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]);
|
||||
}
|
||||
81
packages/features/auth/src/captcha/client/use-captcha.tsx
Normal file
81
packages/features/auth/src/captcha/client/use-captcha.tsx
Normal 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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user