Add captcha support to authentication features

The update includes the implementation of captcha support during the sign-in and sign-up process for user accounts. The process ensures a better level of security against bot-based attacks. Also, the code has been refactored to separate error and success alerts and unnecessary useEffect hooks have been removed. Moreover, some logic concerning the authentication rendering has been simplified.
This commit is contained in:
giancarlo
2024-04-07 10:56:33 +08:00
parent 36bcf64f52
commit 0a9c1f35c6
16 changed files with 209 additions and 78 deletions

View File

@@ -0,0 +1,24 @@
'use client';
import { createContext, useState } from 'react';
export const Captcha = createContext<{
token: string;
setToken: (token: string) => void;
}>({
token: '',
setToken: (_: string) => {
// do nothing
return '';
},
});
export function CaptchaProvider(props: { children: React.ReactNode }) {
const [token, setToken] = useState<string>('');
return (
<Captcha.Provider value={{ token, setToken }}>
{props.children}
</Captcha.Provider>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
import { useContext } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { Captcha } from './captcha-provider';
export function CaptchaTokenSetter(props: { siteKey: string | undefined }) {
const { setToken } = useContext(Captcha);
if (!props.siteKey) {
return null;
}
return <Turnstile siteKey={props.siteKey} onSuccess={setToken} />;
}

View File

@@ -0,0 +1,3 @@
export * from './captchaTokenSetter';
export * from './use-captcha-token';
export * from './captcha-provider';

View File

@@ -0,0 +1,13 @@
import { useContext } from 'react';
import { Captcha } from './captcha-provider';
export function useCaptchaToken() {
const context = useContext(Captcha);
if (!context) {
throw new Error(`useCaptchaToken must be used within a CaptchaProvider`);
}
return context.token;
}

View File

@@ -1,7 +1,7 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon } from '@radix-ui/react-icons';
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -25,8 +25,10 @@ import { Trans } from '@kit/ui/trans';
export function MagicLinkAuthContainer({
inviteToken,
redirectUrl,
captchaToken,
}: {
inviteToken?: string;
captchaToken?: string;
redirectUrl: string;
}) {
const { t } = useTranslation();
@@ -52,6 +54,7 @@ export function MagicLinkAuthContainer({
email,
options: {
emailRedirectTo,
captchaToken,
},
});
@@ -63,34 +66,14 @@ export function MagicLinkAuthContainer({
};
if (signInWithOtpMutation.data) {
return (
<Alert variant={'success'}>
<CheckIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth:sendLinkSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
</AlertDescription>
</Alert>
);
return <SuccessAlert />;
}
return (
<Form {...form}>
<form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}>
<If condition={signInWithOtpMutation.error}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'auth:errors.generic'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:errors.link'} />
</AlertDescription>
</Alert>
<ErrorAlert />
</If>
<div className={'flex flex-col space-y-4'}>
@@ -130,3 +113,35 @@ export function MagicLinkAuthContainer({
</Form>
);
}
function SuccessAlert() {
return (
<Alert variant={'success'}>
<CheckIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth:sendLinkSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
</AlertDescription>
</Alert>
);
}
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth:errors.generic'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:errors.link'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { Check } from 'lucide-react';
@@ -12,30 +12,22 @@ import { Trans } from '@kit/ui/trans';
import { AuthErrorAlert } from './auth-error-alert';
import { PasswordSignUpForm } from './password-sign-up-form';
interface EmailPasswordSignUpContainerProps {
onSignUp?: (userId?: string) => unknown;
emailRedirectTo: string;
captchaToken?: string;
}
export function EmailPasswordSignUpContainer({
onSignUp,
onError,
emailRedirectTo,
}: React.PropsWithChildren<{
onSignUp?: (userId?: string) => unknown;
onError?: (error?: unknown) => unknown;
emailRedirectTo: string;
}>) {
captchaToken,
}: EmailPasswordSignUpContainerProps) {
const signUpMutation = useSignUpWithEmailAndPassword();
const redirecting = useRef(false);
const loading = signUpMutation.isPending || redirecting.current;
const [showVerifyEmailAlert, setShowVerifyEmailAlert] = useState(false);
const callOnErrorCallback = useCallback(() => {
if (signUpMutation.error && onError) {
onError(signUpMutation.error);
}
}, [signUpMutation.error, onError]);
useEffect(() => {
callOnErrorCallback();
}, [callOnErrorCallback]);
const onSignupRequested = useCallback(
async (credentials: { email: string; password: string }) => {
if (loading) {
@@ -46,6 +38,7 @@ export function EmailPasswordSignUpContainer({
const data = await signUpMutation.mutateAsync({
...credentials,
emailRedirectTo,
captchaToken,
});
setShowVerifyEmailAlert(true);
@@ -54,28 +47,16 @@ export function EmailPasswordSignUpContainer({
onSignUp(data.user?.id);
}
} catch (error) {
if (onError) {
onError(error);
}
console.error(error);
}
},
[emailRedirectTo, loading, onError, onSignUp, signUpMutation],
[emailRedirectTo, loading, onSignUp, signUpMutation],
);
return (
<>
<If condition={showVerifyEmailAlert}>
<Alert variant={'success'}>
<Check className={'w-4'} />
<AlertTitle>
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
</AlertTitle>
<AlertDescription data-test={'email-confirmation-alert'}>
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
</AlertDescription>
</Alert>
<SuccessAlert />
</If>
<If condition={!showVerifyEmailAlert}>
@@ -86,3 +67,19 @@ export function EmailPasswordSignUpContainer({
</>
);
}
function SuccessAlert() {
return (
<Alert variant={'success'}>
<Check className={'w-4'} />
<AlertTitle>
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
</AlertTitle>
<AlertDescription data-test={'email-confirmation-alert'}>
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -6,7 +6,9 @@ import { isBrowser } from '@kit/shared/utils';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from './captcha';
import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers';
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
@@ -26,27 +28,26 @@ export function SignUpMethodsContainer(props: {
inviteToken?: string;
}) {
const redirectUrl = getCallbackUrl(props);
const captchaToken = useCaptchaToken();
return (
<>
<If condition={props.inviteToken}>
<Alert variant={'info'}>
<AlertTitle>You have been invited to join a team</AlertTitle>
<AlertDescription>
Please sign up to continue with the invitation and create your
account.
</AlertDescription>
</Alert>
<InviteAlert />
</If>
<If condition={props.providers.password}>
<EmailPasswordSignUpContainer emailRedirectTo={redirectUrl} />
<EmailPasswordSignUpContainer
captchaToken={captchaToken}
emailRedirectTo={redirectUrl}
/>
</If>
<If condition={props.providers.magicLink}>
<MagicLinkAuthContainer
inviteToken={props.inviteToken}
redirectUrl={redirectUrl}
captchaToken={captchaToken}
/>
</If>
@@ -88,3 +89,17 @@ function getCallbackUrl(props: {
return url.href;
}
function InviteAlert() {
return (
<Alert variant={'info'}>
<AlertTitle>
<Trans i18nKey={'auth:inviteAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:inviteAlertBody'} />
</AlertDescription>
</Alert>
);
}