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> </p>
</div> </div>
<SignInMethodsContainer paths={paths} providers={authConfig.providers} /> <SignInMethodsContainer
paths={paths}
providers={authConfig.providers}
captchaSiteKey={authConfig.captchaTokenSiteKey}
/>
<div className={'flex justify-center'}> <div className={'flex justify-center'}>
<Button asChild variant={'link'} size={'sm'}> <Button asChild variant={'link'} size={'sm'}>

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-supabase-saas-kit-turbo", "name": "next-supabase-saas-kit-turbo",
"version": "2.18.2", "version": "2.18.3",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"engines": { "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 './captcha-field';
export * from './use-captcha-token'; export * from './use-captcha';
export * from './captcha-provider';

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

View File

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

View File

@@ -20,7 +20,7 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client'; import { useCaptcha } from '../captcha/client';
import { AuthErrorAlert } from './auth-error-alert'; import { AuthErrorAlert } from './auth-error-alert';
const PasswordResetSchema = z.object({ const PasswordResetSchema = z.object({
@@ -29,10 +29,11 @@ const PasswordResetSchema = z.object({
export function PasswordResetRequestContainer(params: { export function PasswordResetRequestContainer(params: {
redirectPath: string; redirectPath: string;
captchaSiteKey?: string;
}) { }) {
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const resetPasswordMutation = useRequestResetPassword(); const resetPasswordMutation = useRequestResetPassword();
const { captchaToken, resetCaptchaToken } = useCaptchaToken(); const captcha = useCaptcha({ siteKey: params.captchaSiteKey });
const error = resetPasswordMutation.error; const error = resetPasswordMutation.error;
const success = resetPasswordMutation.data; const success = resetPasswordMutation.data;
@@ -67,10 +68,10 @@ export function PasswordResetRequestContainer(params: {
.mutateAsync({ .mutateAsync({
email, email,
redirectTo, redirectTo,
captchaToken, captchaToken: captcha.token,
}) })
.catch(() => { .catch(() => {
resetCaptchaToken(); captcha.reset();
}); });
})} })}
className={'w-full'} className={'w-full'}
@@ -78,6 +79,8 @@ export function PasswordResetRequestContainer(params: {
<div className={'flex flex-col gap-y-4'}> <div className={'flex flex-col gap-y-4'}>
<AuthErrorAlert error={error} /> <AuthErrorAlert error={error} />
{captcha.field}
<FormField <FormField
name={'email'} name={'email'}
render={({ field }) => ( 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 { 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 { useLastAuthMethod } from '../hooks/use-last-auth-method';
import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema'; import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
import { AuthErrorAlert } from './auth-error-alert'; import { AuthErrorAlert } from './auth-error-alert';
@@ -14,10 +14,12 @@ import { PasswordSignInForm } from './password-sign-in-form';
export function PasswordSignInContainer({ export function PasswordSignInContainer({
onSignIn, onSignIn,
captchaSiteKey,
}: { }: {
onSignIn?: (userId?: string) => unknown; onSignIn?: (userId?: string) => unknown;
captchaSiteKey?: string;
}) { }) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken(); const captcha = useCaptcha({ siteKey: captchaSiteKey });
const signInMutation = useSignInWithEmailPassword(); const signInMutation = useSignInWithEmailPassword();
const { recordAuthMethod } = useLastAuthMethod(); const { recordAuthMethod } = useLastAuthMethod();
const isLoading = signInMutation.isPending; const isLoading = signInMutation.isPending;
@@ -28,7 +30,7 @@ export function PasswordSignInContainer({
try { try {
const data = await signInMutation.mutateAsync({ const data = await signInMutation.mutateAsync({
...credentials, ...credentials,
options: { captchaToken }, options: { captchaToken: captcha.token },
}); });
// Record successful password sign-in // Record successful password sign-in
@@ -42,22 +44,18 @@ export function PasswordSignInContainer({
} catch { } catch {
// wrong credentials, do nothing // wrong credentials, do nothing
} finally { } finally {
resetCaptchaToken(); captcha.reset();
} }
}, },
[ [captcha, onSignIn, signInMutation, recordAuthMethod],
captchaToken,
onSignIn,
resetCaptchaToken,
signInMutation,
recordAuthMethod,
],
); );
return ( return (
<> <>
<AuthErrorAlert error={signInMutation.error} /> <AuthErrorAlert error={signInMutation.error} />
{captcha.field}
<PasswordSignInForm <PasswordSignInForm
onSubmit={onSubmit} onSubmit={onSubmit}
loading={isLoading} loading={isLoading}

View File

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

View File

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

View File

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

View File

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