diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx
index 4210fa80..ff9df024 100644
--- a/src/register/RegistrationPage.jsx
+++ b/src/register/RegistrationPage.jsx
@@ -8,7 +8,6 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Form, Spinner, StatefulButton } from '@openedx/paragon';
import classNames from 'classnames';
import PropTypes from 'prop-types';
-import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { Helmet } from 'react-helmet';
import Skeleton from 'react-loading-skeleton';
@@ -32,6 +31,7 @@ import {
FORM_SUBMISSION_ERROR,
TPA_AUTHENTICATION_FAILURE,
} from './data/constants';
+import useRecaptchaSubmission from './data/hooks';
import { AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION, NOT_INITIALIZED } from './data/optimizelyExperiment/helper';
import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation';
import getBackendValidations from './data/selectors';
@@ -59,10 +59,10 @@ import { trackRegistrationPageViewed, trackRegistrationSuccess } from '../tracki
* Main Registration Page component
*/
const RegistrationPage = (props) => {
- const { executeRecaptcha } = useGoogleReCaptcha();
const { formatMessage } = useIntl();
const dispatch = useDispatch();
+ const { executeWithFallback } = useRecaptchaSubmission('submit_registration_form');
const registrationEmbedded = isHostAvailableInQueryParams();
const platformName = getConfig().SITE_NAME;
const flags = {
@@ -252,7 +252,7 @@ const RegistrationPage = (props) => {
fieldDescriptions,
formatMessage,
);
- setErrors({ ...fieldErrors });
+ setErrors({ ...fieldErrors, captchaError: '' });
dispatch(setEmailSuggestionInStore(emailSuggestion));
// returning if not valid
@@ -261,38 +261,29 @@ const RegistrationPage = (props) => {
return;
}
- if (executeRecaptcha) {
- const recaptchaToken = await executeRecaptcha('submit_registration_form');
- if (!recaptchaToken) {
- setErrors(prevErrors => ({
- ...prevErrors,
- captchaError: formatMessage(messages['registration.captcha.verification.label']),
- }));
- return;
- }
- setErrors(prevErrors => ({
- ...prevErrors,
- captchaError: '',
+ let recaptchaToken = null;
+ try {
+ recaptchaToken = await executeWithFallback();
+ } catch (err) {
+ setErrors(prev => ({
+ ...prev,
+ captchaError: formatMessage(messages['registration.captcha.verification.label']) || err.message,
}));
-
- // Preparing payload for submission
- if (recaptchaToken) {
- payload = prepareRegistrationPayload(
- payload,
- configurableFormFields,
- flags.showMarketingEmailOptInCheckbox,
- totalRegistrationTime,
- queryParams,
- );
-
- const updatedpayload = {
- ...payload,
- captcha_token: recaptchaToken,
- };
- // making register call
- dispatch(registerNewUser(updatedpayload));
- }
+ return;
}
+
+ payload = prepareRegistrationPayload(
+ payload,
+ configurableFormFields,
+ flags.showMarketingEmailOptInCheckbox,
+ totalRegistrationTime,
+ queryParams,
+ );
+ if (recaptchaToken) {
+ payload = { ...payload, captcha_token: recaptchaToken };
+ }
+
+ dispatch(registerNewUser(payload));
};
const handleSubmit = (e) => {
diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx
index 1918b6ac..a047bd2e 100644
--- a/src/register/RegistrationPage.test.jsx
+++ b/src/register/RegistrationPage.test.jsx
@@ -17,6 +17,7 @@ import {
} from './data/actions';
import { INTERNAL_SERVER_ERROR } from './data/constants';
import mockTagular from '../cohesion/utils';
+import useRecaptchaSubmission from './data/hooks';
import { NOT_INITIALIZED } from './data/optimizelyExperiment/helper';
import useAutoGeneratedUsernameExperimentVariation
from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation';
@@ -34,6 +35,12 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
getLocale: jest.fn(),
}));
jest.mock('./data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn());
+jest.mock('./data/hooks', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ executeWithFallback: jest.fn(),
+ })),
+}));
const mockStore = configureStore();
mockTagular();
@@ -134,6 +141,11 @@ describe('RegistrationPage', () => {
};
window.location = { search: '' };
useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED);
+ useRecaptchaSubmission.mockReturnValue({
+ executeWithFallback: jest.fn().mockResolvedValue(null),
+ isReady: true,
+ isLoading: false,
+ });
});
afterEach(() => {
@@ -175,7 +187,7 @@ describe('RegistrationPage', () => {
// ******** test registration form submission ********
- it('should submit form for valid input', () => {
+ it('should submit form for valid input', async () => {
getLocale.mockImplementation(() => ('en-us'));
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
@@ -200,10 +212,12 @@ describe('RegistrationPage', () => {
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
- expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
+ await waitFor(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
+ });
});
- it('should submit form without password field when current provider is present', () => {
+ it('should submit form without password field when current provider is present', async () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
const formPayload = {
@@ -233,7 +247,9 @@ describe('RegistrationPage', () => {
populateRequiredFields(getByLabelText, formPayload, true);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
- expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' }));
+ await waitFor(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' }));
+ });
});
it('should display an error when form is submitted with an invalid email', () => {
@@ -284,7 +300,7 @@ describe('RegistrationPage', () => {
expect(validationErrors.textContent).toContain(usernameError);
});
- it('should submit form with marketing email opt in value', () => {
+ it('should submit form with marketing email opt in value', async () => {
mergeConfig({
MARKETING_EMAILS_OPT_IN: 'true',
});
@@ -308,14 +324,16 @@ describe('RegistrationPage', () => {
populateRequiredFields(getByLabelText, payload);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
- expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
+ await waitFor(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
+ });
mergeConfig({
MARKETING_EMAILS_OPT_IN: '',
});
});
- it('should submit form without UsernameField when autoGeneratedUsernameEnabled is true', () => {
+ it('should submit form without UsernameField when autoGeneratedUsernameEnabled is true', async () => {
mergeConfig({
ENABLE_AUTO_GENERATED_USERNAME: true,
});
@@ -335,7 +353,9 @@ describe('RegistrationPage', () => {
populateRequiredFields(getByLabelText, payload, false, true);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
- expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
+ await waitFor(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
+ });
mergeConfig({
ENABLE_AUTO_GENERATED_USERNAME: false,
});
@@ -367,6 +387,124 @@ describe('RegistrationPage', () => {
// ******** test registration form validations ********
+ it('should submit form with valid reCAPTCHA token', async () => {
+ getLocale.mockImplementation(() => ('en-us'));
+ jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
+
+ useRecaptchaSubmission.mockReturnValue({
+ executeWithFallback: jest.fn().mockResolvedValue('mock-recaptcha-token'),
+ isReady: true,
+ isLoading: false,
+ });
+
+ const payload = {
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john.doe@gmail.com',
+ password: 'password1',
+ country: 'Pakistan',
+ honor_code: true,
+ total_registration_time: 0,
+ app_name: APP_NAME,
+ };
+
+ store.dispatch = jest.fn(store.dispatch);
+ const { getByLabelText, container } = render(routerWrapper(reduxWrapper()));
+ populateRequiredFields(getByLabelText, payload);
+
+ const button = container.querySelector('button.btn-brand');
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ const actions = store.dispatch.mock.calls.map(call => call[0]);
+ const registerAction = actions.find(a => a.type === registerNewUser().type);
+
+ expect(registerAction).toBeTruthy();
+ expect(registerAction.payload).toMatchObject({
+ registrationInfo: {
+ ...payload,
+ country: 'PK',
+ captcha_token: 'mock-recaptcha-token',
+ },
+ });
+ });
+ });
+
+ it('should display error when reCAPTCHA verification fails', async () => {
+ getLocale.mockImplementation(() => ('en-us'));
+ jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
+
+ useRecaptchaSubmission.mockReturnValue({
+ executeWithFallback: jest.fn().mockRejectedValue(new Error('reCAPTCHA verification failed')),
+ isReady: true,
+ isLoading: false,
+ });
+
+ const payload = {
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john.doe@gmail.com',
+ password: 'password1',
+ country: 'Pakistan',
+ honor_code: true,
+ total_registration_time: 0,
+ };
+
+ store.dispatch = jest.fn(store.dispatch);
+ const { getByLabelText, container } = render(routerWrapper(reduxWrapper()));
+ populateRequiredFields(getByLabelText, payload);
+
+ const button = container.querySelector('button.btn-brand');
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ const captchaError = container.querySelector('.pgn__form-text-invalid');
+ expect(captchaError.textContent).toContain('CAPTCHA verification failed.');
+ });
+
+ expect(store.dispatch).not.toHaveBeenCalledWith(expect.objectContaining({
+ type: registerNewUser().type,
+ }));
+ });
+
+ it('should submit without reCAPTCHA token if reCAPTCHA is disabled', async () => {
+ getLocale.mockImplementation(() => ('en-us'));
+ jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
+
+ useRecaptchaSubmission.mockReturnValue({
+ executeWithFallback: jest.fn().mockResolvedValue(null),
+ isReady: true,
+ isLoading: false,
+ });
+
+ const payload = {
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john.doe@gmail.com',
+ password: 'password1',
+ country: 'Pakistan',
+ honor_code: true,
+ total_registration_time: 0,
+ app_name: APP_NAME,
+ };
+
+ store.dispatch = jest.fn(store.dispatch);
+ const { getByLabelText, container } = render(routerWrapper(reduxWrapper()));
+ populateRequiredFields(getByLabelText, payload);
+
+ const button = container.querySelector('button.btn-brand');
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(
+ registerNewUser({
+ ...payload,
+ country: 'PK',
+ }),
+ );
+ });
+ });
+
it('should show error messages for required fields on empty form submission', () => {
const { container } = render(routerWrapper(reduxWrapper()));
@@ -846,7 +984,7 @@ describe('RegistrationPage', () => {
expect(registrationFormElement).toBeFalsy();
});
- it('should auto register if autoSubmitRegForm is true and pipeline details are loaded', () => {
+ it('should auto register if autoSubmitRegForm is true and pipeline details are loaded', async () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
getLocale.mockImplementation(() => ('en-us'));
@@ -890,15 +1028,17 @@ describe('RegistrationPage', () => {
store.dispatch = jest.fn(store.dispatch);
render(routerWrapper(reduxWrapper()));
- expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({
- name: 'John Doe',
- username: 'john_doe',
- email: 'john.doe@example.com',
- country: 'PK',
- social_auth_provider: 'Apple',
- total_registration_time: 0,
- app_name: APP_NAME,
- }));
+ await waitFor(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({
+ name: 'John Doe',
+ username: 'john_doe',
+ email: 'john.doe@example.com',
+ country: 'PK',
+ social_auth_provider: 'Apple',
+ total_registration_time: 0,
+ app_name: APP_NAME,
+ }));
+ });
});
});
});
diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx
index 38e692e3..97589b2d 100644
--- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx
+++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx
@@ -4,7 +4,7 @@ import { mergeConfig } from '@edx/frontend-platform';
import {
getLocale, IntlProvider,
} from '@edx/frontend-platform/i18n';
-import { fireEvent, render } from '@testing-library/react';
+import { fireEvent, render, waitFor } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -225,7 +225,7 @@ describe('ConfigurableRegistrationForm', () => {
expect(document.querySelector('#tos')).toBeTruthy();
});
- it('should submit form with fields returned by backend in payload', () => {
+ it('should submit form with fields returned by backend in payload', async () => {
mergeConfig({
SHOW_CONFIGURABLE_EDX_FIELDS: true,
});
@@ -265,7 +265,9 @@ describe('ConfigurableRegistrationForm', () => {
fireEvent.click(submitButton);
- expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK', app_name: APP_NAME }));
+ await waitFor(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK', app_name: APP_NAME }));
+ });
});
it('should show error messages for required fields on empty form submission', () => {
diff --git a/src/register/data/hooks.js b/src/register/data/hooks.js
new file mode 100644
index 00000000..dab8461f
--- /dev/null
+++ b/src/register/data/hooks.js
@@ -0,0 +1,36 @@
+import { useCallback } from 'react';
+
+import { getConfig } from '@edx/frontend-platform';
+import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
+
+const useRecaptchaSubmission = (actionName = 'submit') => {
+ const { executeRecaptcha } = useGoogleReCaptcha();
+ const recaptchaKey = getConfig().RECAPTCHA_SITE_KEY_WEB;
+
+ const isReady = !!executeRecaptcha || !recaptchaKey;
+
+ const executeWithFallback = useCallback(async () => {
+ if (executeRecaptcha && recaptchaKey) {
+ const token = await executeRecaptcha(actionName);
+ if (!token) {
+ throw new Error('reCAPTCHA verification failed. Please try again.');
+ }
+ return token;
+ }
+
+ // Fallback: no reCAPTCHA or not ready
+ if (recaptchaKey) {
+ // eslint-disable-next-line no-console
+ console.warn(`reCAPTCHA not ready for action: ${actionName}. Proceeding without token.`);
+ }
+ return null;
+ }, [executeRecaptcha, recaptchaKey, actionName]);
+
+ return {
+ executeWithFallback,
+ isReady,
+ isLoading: recaptchaKey && !executeRecaptcha,
+ };
+};
+
+export default useRecaptchaSubmission;