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:
parent
bb593896b9
commit
870fa0b915
16 changed files with 159 additions and 21 deletions
|
@ -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 });
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -160,6 +160,7 @@ describe('IdentifierSignInForm', () => {
|
|||
? `${getDefaultCountryCallingCode()}${value}`
|
||||
: value,
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(mockedNavigate).not.toBeCalled();
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.turnstile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
66
packages/experience/src/components/Turnstile/index.tsx
Normal file
66
packages/experience/src/components/Turnstile/index.tsx
Normal 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;
|
27
packages/experience/src/containers/CaptchaBox/index.tsx
Normal file
27
packages/experience/src/containers/CaptchaBox/index.tsx
Normal 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;
|
21
packages/experience/src/hooks/use-captcha.ts
Normal file
21
packages/experience/src/hooks/use-captcha.ts
Normal 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;
|
|
@ -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) {
|
||||
|
|
|
@ -105,7 +105,8 @@ describe('continue with email or phone', () => {
|
|||
type: identifier,
|
||||
value: identifier === SignInIdentifier.Phone ? `${countryCode}${input}` : input,
|
||||
},
|
||||
InteractionEvent.Register
|
||||
InteractionEvent.Register,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -91,6 +91,7 @@ describe('ForgotPasswordForm', () => {
|
|||
type: identifier,
|
||||
value,
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue