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

@@ -46,7 +46,11 @@ async function SignInPage({ searchParams }: SignInPageProps) {
</p>
</div>
<SignInMethodsContainer paths={paths} providers={authConfig.providers} />
<SignInMethodsContainer
paths={paths}
providers={authConfig.providers}
captchaSiteKey={authConfig.captchaTokenSiteKey}
/>
<div className={'flex justify-center'}>
<Button asChild variant={'link'} size={'sm'}>

View File

@@ -2,11 +2,8 @@
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import { ThemeProvider } from 'next-themes';
import { CaptchaProvider } from '@kit/auth/captcha/client';
import { I18nProvider } from '@kit/i18n/provider';
import { MonitoringProvider } from '@kit/monitoring/components';
import { AppEventsProvider } from '@kit/shared/events';
@@ -16,27 +13,12 @@ import { VersionUpdater } from '@kit/ui/version-updater';
import { AnalyticsProvider } from '~/components/analytics-provider';
import { AuthProvider } from '~/components/auth-provider';
import appConfig from '~/config/app.config';
import authConfig from '~/config/auth.config';
import featuresFlagConfig from '~/config/feature-flags.config';
import { i18nResolver } from '~/lib/i18n/i18n.resolver';
import { getI18nSettings } from '~/lib/i18n/i18n.settings';
import { ReactQueryProvider } from './react-query-provider';
const captchaSiteKey = authConfig.captchaTokenSiteKey;
const CaptchaTokenSetter = dynamic(async () => {
if (!captchaSiteKey) {
return Promise.resolve(() => null);
}
const { CaptchaTokenSetter } = await import('@kit/auth/captcha/client');
return {
default: CaptchaTokenSetter,
};
});
type RootProvidersProps = React.PropsWithChildren<{
// The language to use for the app (optional)
lang?: string;
@@ -60,9 +42,6 @@ export function RootProviders({
<AnalyticsProvider>
<ReactQueryProvider>
<I18nProvider settings={i18nSettings} resolver={i18nResolver}>
<CaptchaProvider>
<CaptchaTokenSetter siteKey={captchaSiteKey} nonce={nonce} />
<AuthProvider>
<ThemeProvider
attribute="class"
@@ -75,7 +54,6 @@ export function RootProviders({
{children}
</ThemeProvider>
</AuthProvider>
</CaptchaProvider>
<If condition={featuresFlagConfig.enableVersionUpdater}>
<VersionUpdater />

View File

@@ -1,6 +1,6 @@
{
"name": "next-supabase-saas-kit-turbo",
"version": "2.18.2",
"version": "2.18.3",
"private": true,
"sideEffects": false,
"engines": {

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],
);
}

View File

@@ -23,7 +23,7 @@ import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client';
import { useCaptcha } from '../captcha/client';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
@@ -32,16 +32,18 @@ export function MagicLinkAuthContainer({
shouldCreateUser,
defaultValues,
displayTermsCheckbox,
captchaSiteKey,
}: {
redirectUrl: string;
shouldCreateUser: boolean;
displayTermsCheckbox?: boolean;
captchaSiteKey?: string;
defaultValues?: {
email: string;
};
}) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
const captcha = useCaptcha({ siteKey: captchaSiteKey });
const { t } = useTranslation();
const signInWithOtpMutation = useSignInWithOtp();
const appEvents = useAppEvents();
@@ -68,7 +70,7 @@ export function MagicLinkAuthContainer({
email,
options: {
emailRedirectTo,
captchaToken,
captchaToken: captcha.token,
shouldCreateUser,
},
});
@@ -91,7 +93,7 @@ export function MagicLinkAuthContainer({
error: t(`auth:errors.linkTitle`),
});
resetCaptchaToken();
captcha.reset();
};
if (signInWithOtpMutation.data) {
@@ -106,6 +108,8 @@ export function MagicLinkAuthContainer({
<ErrorAlert />
</If>
{captcha.field}
<FormField
render={({ field }) => (
<FormItem>

View File

@@ -27,7 +27,7 @@ import {
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client';
import { useCaptcha } from '../captcha/client';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { AuthErrorAlert } from './auth-error-alert';
@@ -36,6 +36,7 @@ const OtpSchema = z.object({ token: z.string().min(6).max(6) });
type OtpSignInContainerProps = {
shouldCreateUser: boolean;
captchaSiteKey?: string;
};
export function OtpSignInContainer(props: OtpSignInContainerProps) {
@@ -88,6 +89,7 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
return (
<OtpEmailForm
shouldCreateUser={shouldCreateUser}
captchaSiteKey={props.captchaSiteKey}
onSendOtp={(email) => {
otpForm.setValue('email', email, {
shouldValidate: true,
@@ -174,12 +176,14 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
function OtpEmailForm({
shouldCreateUser,
captchaSiteKey,
onSendOtp,
}: {
shouldCreateUser: boolean;
captchaSiteKey?: string;
onSendOtp: (email: string) => void;
}) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
const captcha = useCaptcha({ siteKey: captchaSiteKey });
const signInMutation = useSignInWithOtp();
const emailForm = useForm({
@@ -190,10 +194,10 @@ function OtpEmailForm({
const handleSendOtp = async ({ email }: z.infer<typeof EmailSchema>) => {
await signInMutation.mutateAsync({
email,
options: { captchaToken, shouldCreateUser },
options: { captchaToken: captcha.token, shouldCreateUser },
});
resetCaptchaToken();
captcha.reset();
onSendOtp(email);
};
@@ -205,6 +209,8 @@ function OtpEmailForm({
>
<AuthErrorAlert error={signInMutation.error} />
{captcha.field}
<FormField
name="email"
render={({ field }) => (

View File

@@ -20,7 +20,7 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client';
import { useCaptcha } from '../captcha/client';
import { AuthErrorAlert } from './auth-error-alert';
const PasswordResetSchema = z.object({
@@ -29,10 +29,11 @@ const PasswordResetSchema = z.object({
export function PasswordResetRequestContainer(params: {
redirectPath: string;
captchaSiteKey?: string;
}) {
const { t } = useTranslation('auth');
const resetPasswordMutation = useRequestResetPassword();
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
const captcha = useCaptcha({ siteKey: params.captchaSiteKey });
const error = resetPasswordMutation.error;
const success = resetPasswordMutation.data;
@@ -67,10 +68,10 @@ export function PasswordResetRequestContainer(params: {
.mutateAsync({
email,
redirectTo,
captchaToken,
captchaToken: captcha.token,
})
.catch(() => {
resetCaptchaToken();
captcha.reset();
});
})}
className={'w-full'}
@@ -78,6 +79,8 @@ export function PasswordResetRequestContainer(params: {
<div className={'flex flex-col gap-y-4'}>
<AuthErrorAlert error={error} />
{captcha.field}
<FormField
name={'email'}
render={({ field }) => (

View File

@@ -6,7 +6,7 @@ import type { z } from 'zod';
import { useSignInWithEmailPassword } from '@kit/supabase/hooks/use-sign-in-with-email-password';
import { useCaptchaToken } from '../captcha/client';
import { useCaptcha } from '../captcha/client';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
import { AuthErrorAlert } from './auth-error-alert';
@@ -14,10 +14,12 @@ import { PasswordSignInForm } from './password-sign-in-form';
export function PasswordSignInContainer({
onSignIn,
captchaSiteKey,
}: {
onSignIn?: (userId?: string) => unknown;
captchaSiteKey?: string;
}) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
const captcha = useCaptcha({ siteKey: captchaSiteKey });
const signInMutation = useSignInWithEmailPassword();
const { recordAuthMethod } = useLastAuthMethod();
const isLoading = signInMutation.isPending;
@@ -28,7 +30,7 @@ export function PasswordSignInContainer({
try {
const data = await signInMutation.mutateAsync({
...credentials,
options: { captchaToken },
options: { captchaToken: captcha.token },
});
// Record successful password sign-in
@@ -42,22 +44,18 @@ export function PasswordSignInContainer({
} catch {
// wrong credentials, do nothing
} finally {
resetCaptchaToken();
captcha.reset();
}
},
[
captchaToken,
onSignIn,
resetCaptchaToken,
signInMutation,
recordAuthMethod,
],
[captcha, onSignIn, signInMutation, recordAuthMethod],
);
return (
<>
<AuthErrorAlert error={signInMutation.error} />
{captcha.field}
<PasswordSignInForm
onSubmit={onSubmit}
loading={isLoading}

View File

@@ -6,7 +6,7 @@ import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client';
import { useCaptcha } from '../captcha/client';
import { usePasswordSignUpFlow } from '../hooks/use-sign-up-flow';
import { AuthErrorAlert } from './auth-error-alert';
import { PasswordSignUpForm } from './password-sign-up-form';
@@ -18,6 +18,7 @@ interface EmailPasswordSignUpContainerProps {
};
onSignUp?: (userId?: string) => unknown;
emailRedirectTo: string;
captchaSiteKey?: string;
}
export function EmailPasswordSignUpContainer({
@@ -25,8 +26,9 @@ export function EmailPasswordSignUpContainer({
onSignUp,
emailRedirectTo,
displayTermsCheckbox,
captchaSiteKey,
}: EmailPasswordSignUpContainerProps) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
const captcha = useCaptcha({ siteKey: captchaSiteKey });
const {
signUp: onSignupRequested,
@@ -36,8 +38,8 @@ export function EmailPasswordSignUpContainer({
} = usePasswordSignUpFlow({
emailRedirectTo,
onSignUp,
captchaToken,
resetCaptchaToken,
captchaToken: captcha.token,
resetCaptchaToken: captcha.reset,
});
return (
@@ -49,6 +51,8 @@ export function EmailPasswordSignUpContainer({
<If condition={!showVerifyEmailAlert}>
<AuthErrorAlert error={error} />
{captcha.field}
<PasswordSignUpForm
onSubmit={onSignupRequested}
loading={loading}

View File

@@ -18,10 +18,14 @@ import {
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client';
import { useCaptcha } from '../captcha/client';
export function ResendAuthLinkForm(props: { redirectPath?: string }) {
const resendLink = useResendLink();
export function ResendAuthLinkForm(props: {
redirectPath?: string;
captchaSiteKey?: string;
}) {
const captcha = useCaptcha({ siteKey: props.captchaSiteKey });
const resendLink = useResendLink(captcha.token);
const form = useForm({
resolver: zodResolver(z.object({ email: z.string().email() })),
@@ -52,12 +56,20 @@ export function ResendAuthLinkForm(props: { redirectPath?: string }) {
<form
className={'flex flex-col space-y-2'}
onSubmit={form.handleSubmit((data) => {
return resendLink.mutate({
const promise = resendLink.mutateAsync({
email: data.email,
redirectPath: props.redirectPath,
});
promise.finally(() => {
captcha.reset();
});
return promise;
})}
>
{captcha.field}
<FormField
render={({ field }) => {
return (
@@ -83,9 +95,8 @@ export function ResendAuthLinkForm(props: { redirectPath?: string }) {
);
}
function useResendLink() {
function useResendLink(captchaToken: string) {
const supabase = useSupabase();
const { captchaToken } = useCaptchaToken();
const mutationFn = async (props: {
email: string;

View File

@@ -30,6 +30,8 @@ export function SignInMethodsContainer(props: {
otp: boolean;
oAuth: Provider[];
};
captchaSiteKey?: string;
}) {
const router = useRouter();
@@ -48,18 +50,25 @@ export function SignInMethodsContainer(props: {
<LastAuthMethodHint />
<If condition={props.providers.password}>
<PasswordSignInContainer onSignIn={onSignIn} />
<PasswordSignInContainer
onSignIn={onSignIn}
captchaSiteKey={props.captchaSiteKey}
/>
</If>
<If condition={props.providers.magicLink}>
<MagicLinkAuthContainer
redirectUrl={redirectUrl}
shouldCreateUser={false}
captchaSiteKey={props.captchaSiteKey}
/>
</If>
<If condition={props.providers.otp}>
<OtpSignInContainer shouldCreateUser={false} />
<OtpSignInContainer
shouldCreateUser={false}
captchaSiteKey={props.captchaSiteKey}
/>
</If>
<If condition={props.providers.oAuth.length}>

View File

@@ -27,6 +27,7 @@ export function SignUpMethodsContainer(props: {
};
displayTermsCheckbox?: boolean;
captchaSiteKey?: string;
}) {
const redirectUrl = getCallbackUrl(props);
const defaultValues = getDefaultValues();
@@ -41,11 +42,15 @@ export function SignUpMethodsContainer(props: {
emailRedirectTo={redirectUrl}
defaultValues={defaultValues}
displayTermsCheckbox={props.displayTermsCheckbox}
captchaSiteKey={props.captchaSiteKey}
/>
</If>
<If condition={props.providers.otp}>
<OtpSignInContainer shouldCreateUser={true} />
<OtpSignInContainer
shouldCreateUser={true}
captchaSiteKey={props.captchaSiteKey}
/>
</If>
<If condition={props.providers.magicLink}>
@@ -54,6 +59,7 @@ export function SignUpMethodsContainer(props: {
shouldCreateUser={true}
defaultValues={defaultValues}
displayTermsCheckbox={props.displayTermsCheckbox}
captchaSiteKey={props.captchaSiteKey}
/>
</If>