0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

feat(experience): render captcha in register form (#7188)

This commit is contained in:
wangsijie 2025-03-31 13:53:23 +08:00 committed by GitHub
parent bb593896b9
commit 870fa0b915
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 159 additions and 21 deletions

View file

@ -75,8 +75,8 @@ export const signInWithPasswordIdentifier = async (payload: PasswordVerification
return identifyAndSubmitInteraction({ verificationId });
};
export const registerWithUsername = async (username: string) => {
await initInteraction(InteractionEvent.Register);
export const registerWithUsername = async (username: string, captchaToken?: string) => {
await initInteraction(InteractionEvent.Register, captchaToken);
return updateProfile({ type: SignInIdentifier.Username, value: username });
};

View file

@ -12,10 +12,11 @@ type SubmitInteractionResponse = {
redirectTo: string;
};
export const initInteraction = async (interactionEvent: InteractionEvent) =>
export const initInteraction = async (interactionEvent: InteractionEvent, captchaToken?: string) =>
api.put(`${experienceApiRoutes.prefix}`, {
json: {
interactionEvent,
captchaToken,
},
});

View file

@ -32,13 +32,14 @@ export const getInteractionEventFromState = (state: unknown) => {
export const sendVerificationCodeApi = async (
flow: UserFlow,
identifier: VerificationCodeIdentifier,
interactionEvent?: ContinueFlowInteractionEvent
interactionEvent?: ContinueFlowInteractionEvent,
captchaToken?: string
) => {
if (flow === UserFlow.Continue) {
return sendVerificationCode(interactionEvent ?? InteractionEvent.SignIn, identifier);
}
const event = userFlowToInteractionEventMap[flow];
await initInteraction(event);
await initInteraction(event, captchaToken);
return sendVerificationCode(event, identifier);
};

View file

@ -185,7 +185,7 @@ describe('<IdentifierRegisterForm />', () => {
});
await waitFor(() => {
expect(registerWithUsername).toBeCalledWith('username');
expect(registerWithUsername).toBeCalledWith('username', undefined);
});
});
});
@ -248,6 +248,7 @@ describe('<IdentifierRegisterForm />', () => {
type: SignInIdentifier.Email,
value: 'foo@logto.io',
},
undefined,
undefined
);
});
@ -312,6 +313,7 @@ describe('<IdentifierRegisterForm />', () => {
type: SignInIdentifier.Phone,
value: `${getDefaultCountryCallingCode()}8573333333`,
},
undefined,
undefined
);
});
@ -357,6 +359,7 @@ describe('<IdentifierRegisterForm />', () => {
type: SignInIdentifier.Email,
value: email,
},
undefined,
undefined
);
});
@ -398,6 +401,7 @@ describe('<IdentifierRegisterForm />', () => {
type: SignInIdentifier.Email,
value: email,
},
undefined,
undefined
);
});

View file

@ -10,7 +10,9 @@ import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import { SmartInputField } from '@/components/InputFields';
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import CaptchaBox from '@/containers/CaptchaBox';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
import useCaptcha from '@/hooks/use-captcha';
import usePrefilledIdentifier from '@/hooks/use-prefilled-identifier';
import useSingleSignOnWatch from '@/hooks/use-single-sign-on-watch';
import useTerms from '@/hooks/use-terms';
@ -33,6 +35,7 @@ type FormState = {
const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) => {
const { t } = useTranslation();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const { setCaptchaToken, isCaptchaRequired, captchaToken, captchaConfig } = useCaptcha();
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
@ -84,7 +87,7 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
return;
}
await onSubmit(type, value);
await onSubmit(type, value, captchaToken);
})(event);
},
[
@ -96,6 +99,7 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
setIdentifierInputValue,
showSingleSignOnForm,
termsValidation,
captchaToken,
]
);
@ -134,13 +138,13 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
/>
)}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{showSingleSignOnForm && (
<div className={styles.message}>{t('description.single_sign_on_enabled')}</div>
)}
{isCaptchaRequired && (
<CaptchaBox setCaptchaToken={setCaptchaToken} captchaConfig={captchaConfig} />
)}
{/**
* Have to use css to hide the terms element.
* Remove element from dom will trigger a form re-render.
@ -157,15 +161,14 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
agreeToTermsPolicy === AgreeToTermsPolicy.Automatic && styles.hidden
)}
/>
<Button
name="submit"
title={showSingleSignOnForm ? 'action.single_sign_on' : 'action.create_account'}
icon={showSingleSignOnForm ? <LockIcon /> : undefined}
htmlType="submit"
isLoading={isSubmitting}
disabled={isCaptchaRequired && !captchaToken}
/>
<input hidden type="submit" />
</form>
);

View file

@ -30,9 +30,9 @@ const useOnSubmit = () => {
}, [clearSendVerificationCodeErrorMessage, clearUsernameRegisterErrorMessage]);
const onSubmit = useCallback(
async (identifier: SignInIdentifier, value: string) => {
async (identifier: SignInIdentifier, value: string, captchaToken?: string) => {
if (identifier === SignInIdentifier.Username) {
await registerWithUsername(value);
await registerWithUsername(value, captchaToken);
return;
}
@ -46,7 +46,7 @@ const useOnSubmit = () => {
}
}
await sendVerificationCode({ identifier, value });
await sendVerificationCode({ identifier, value }, undefined, captchaToken);
},
[checkSingleSignOn, registerWithUsername, sendVerificationCode, ssoConnectors.length]
);

View file

@ -53,8 +53,8 @@ const useRegisterWithUsername = () => {
}, [asyncSubmitInteraction, handleError, preRegisterErrorHandler, redirectTo]);
const onSubmit = useCallback(
async (username: string) => {
const [error] = await asyncRegister(username);
async (username: string, captchaToken?: string) => {
const [error] = await asyncRegister(username, captchaToken);
if (error) {
await handleError(error, usernameErrorHandlers);

View file

@ -160,6 +160,7 @@ describe('IdentifierSignInForm', () => {
? `${getDefaultCountryCallingCode()}${value}`
: value,
},
undefined,
undefined
);
expect(mockedNavigate).not.toBeCalled();

View file

@ -0,0 +1,7 @@
@use '@/scss/underscore' as _;
.turnstile {
display: flex;
justify-content: center;
margin-bottom: _.unit(2);
}

View file

@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react';
import styles from './index.module.scss';
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
turnstile?: {
render: (
element: HTMLElement,
options: { sitekey: string; callback: (token: string) => void }
) => void;
};
}
}
type Props = {
readonly siteKey: string;
readonly onVerify: (token: string) => void;
};
const scriptId = 'cf-turnstile-script';
const Turnstile = ({ siteKey, onVerify }: Props) => {
const turnstileRef = useRef<HTMLDivElement>(null);
const isRendered = useRef(false);
/* eslint-disable @silverhand/fp/no-mutation */
useEffect(() => {
const render = () => {
if (!window.turnstile || !turnstileRef.current || isRendered.current) {
return;
}
window.turnstile.render(turnstileRef.current, {
sitekey: siteKey,
callback: (token: string) => {
onVerify(token);
},
});
isRendered.current = true;
};
// Check if script already exists
if (document.querySelector(`#${scriptId}`)) {
render();
return;
}
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.id = scriptId;
script.async = true;
script.addEventListener('load', () => {
render();
});
document.body.append(script);
}, [siteKey, onVerify]);
/* eslint-enable @silverhand/fp/no-mutation */
return <div ref={turnstileRef} className={styles.turnstile} />;
};
export default Turnstile;

View file

@ -0,0 +1,27 @@
import { CaptchaType } from '@logto/schemas';
import { useCallback } from 'react';
import Turnstile from '@/components/Turnstile';
import { type SignInExperienceResponse } from '@/types';
type Props = {
readonly captchaConfig?: SignInExperienceResponse['captchaConfig'];
readonly setCaptchaToken: (token: string) => void;
};
const CaptchaBox = ({ setCaptchaToken, captchaConfig }: Props) => {
const onVerify = useCallback(
(token: string) => {
setCaptchaToken(token);
},
[setCaptchaToken]
);
if (captchaConfig?.type === CaptchaType.Turnstile) {
return <Turnstile siteKey={captchaConfig.siteKey} onVerify={onVerify} />;
}
return null;
};
export default CaptchaBox;

View file

@ -0,0 +1,21 @@
import { useContext, useState } from 'react';
import PageContext from '@/Providers/PageContextProvider/PageContext';
import { isDevFeaturesEnabled } from '@/constants/env';
const useCaptcha = () => {
const { experienceSettings } = useContext(PageContext);
const [token, setToken] = useState<string>();
const captchaPolicy = experienceSettings?.captchaPolicy;
const captchaConfig = experienceSettings?.captchaConfig;
return {
isCaptchaRequired: isDevFeaturesEnabled && captchaPolicy?.enabled,
captchaConfig,
captchaToken: token,
setCaptchaToken: setToken,
};
};
export default useCaptcha;

View file

@ -34,14 +34,19 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
}, []);
const onSubmit = useCallback(
async ({ identifier, value }: Payload, interactionEvent?: ContinueFlowInteractionEvent) => {
async (
{ identifier, value }: Payload,
interactionEvent?: ContinueFlowInteractionEvent,
captchaToken?: string
) => {
const [error, result] = await asyncSendVerificationCode(
flow,
{
type: identifier,
value,
},
interactionEvent
interactionEvent,
captchaToken
);
if (error) {

View file

@ -105,7 +105,8 @@ describe('continue with email or phone', () => {
type: identifier,
value: identifier === SignInIdentifier.Phone ? `${countryCode}${input}` : input,
},
InteractionEvent.Register
InteractionEvent.Register,
undefined
);
});
}

View file

@ -91,6 +91,7 @@ describe('ForgotPasswordForm', () => {
type: identifier,
value,
},
undefined,
undefined
);
expect(mockedNavigate).toBeCalledWith(

View file

@ -90,7 +90,7 @@ describe('PasswordSignInForm', () => {
});
await waitFor(() => {
expect(initInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(initInteraction).toBeCalledWith(InteractionEvent.SignIn, undefined);
expect(sendVerificationCode).toBeCalledWith(InteractionEvent.SignIn, {
type: identifier,
value,