diff --git a/.env b/.env
index ce7ca5d0..e884a7de 100644
--- a/.env
+++ b/.env
@@ -46,3 +46,4 @@ APP_ID=''
MFE_CONFIG_API_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}
+RECAPTCHA_SITE_KEY_WEB=''
diff --git a/.env.development b/.env.development
index 5263135b..bbd1c0f5 100644
--- a/.env.development
+++ b/.env.development
@@ -46,3 +46,4 @@ ZENDESK_KEY=''
ZENDESK_LOGO_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}
+RECAPTCHA_SITE_KEY_WEB=''
diff --git a/.env.test b/.env.test
index ac296154..56971a87 100644
--- a/.env.test
+++ b/.env.test
@@ -21,3 +21,4 @@ MFE_CONFIG_API_URL=''
COHESION_WRITE_KEY=''
COHESION_SOURCE_KEY=''
PARAGON_THEME_URLS={}
+RECAPTCHA_SITE_KEY_WEB=''
diff --git a/package-lock.json b/package-lock.json
index 6447d089..019e3868 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
+ "react-google-recaptcha-v3": "^1.11.0",
"react-helmet": "6.1.0",
"react-loading-skeleton": "3.5.0",
"react-redux": "7.2.9",
@@ -2956,9 +2957,9 @@
}
},
"node_modules/@formatjs/ts-transformer/node_modules/@types/node": {
- "version": "22.18.3",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.3.tgz",
- "integrity": "sha512-gTVM8js2twdtqM+AE2PdGEe9zGQY4UvmFjan9rZcVb6FGdStfjWoWejdmy4CfWVO9rh5MiYQGZloKAGkJt8lMw==",
+ "version": "22.18.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.4.tgz",
+ "integrity": "sha512-UJdblFqXymSBhmZf96BnbisoFIr8ooiiBRMolQgg77Ea+VM37jXw76C2LQr9n8wm9+i/OvlUlW6xSvqwzwqznw==",
"devOptional": true,
"dependencies": {
"undici-types": "~6.21.0"
@@ -5435,9 +5436,9 @@
}
},
"node_modules/@jsonjoy.com/json-pack": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.11.0.tgz",
- "integrity": "sha512-nLqSTAYwpk+5ZQIoVp7pfd/oSKNWlEdvTq2LzVA4r2wtWZg6v+5u0VgBOaDJuUfNOuw/4Ysq6glN5QKSrOCgrA==",
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.12.0.tgz",
+ "integrity": "sha512-qPwWjCmcwtDiY9MZ4hz3YY4UYkVleWq5rXt+EEx4Jtl22byaLlfBEn8vXze43P2/30DkwzQvdA56EB3TH7t3Pg==",
"dependencies": {
"@jsonjoy.com/base64": "^1.1.2",
"@jsonjoy.com/buffers": "^1.0.0",
@@ -8199,12 +8200,12 @@
}
},
"node_modules/@types/node": {
- "version": "24.4.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz",
- "integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==",
+ "version": "24.5.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz",
+ "integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==",
"devOptional": true,
"dependencies": {
- "undici-types": "~7.11.0"
+ "undici-types": "~7.12.0"
}
},
"node_modules/@types/node-forge": {
@@ -10161,9 +10162,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.26.0",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz",
- "integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==",
+ "version": "4.26.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
+ "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==",
"devOptional": true,
"funding": [
{
@@ -10180,7 +10181,7 @@
}
],
"dependencies": {
- "baseline-browser-mapping": "^2.8.2",
+ "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
"electron-to-chromium": "^1.5.218",
"node-releases": "^2.0.21",
@@ -10345,9 +10346,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001741",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz",
- "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==",
+ "version": "1.0.30001743",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz",
+ "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==",
"devOptional": true,
"funding": [
{
@@ -12562,9 +12563,9 @@
}
},
"node_modules/error-ex": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
- "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"devOptional": true,
"dependencies": {
"is-arrayish": "^0.2.1"
@@ -22541,6 +22542,18 @@
}
}
},
+ "node_modules/react-google-recaptcha-v3": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/react-google-recaptcha-v3/-/react-google-recaptcha-v3-1.11.0.tgz",
+ "integrity": "sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==",
+ "dependencies": {
+ "hoist-non-react-statics": "^3.3.2"
+ },
+ "peerDependencies": {
+ "react": "^16.3 || ^17.0 || ^18.0 || ^19.0",
+ "react-dom": "^17.0 || ^18.0 || ^19.0"
+ }
+ },
"node_modules/react-helmet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
@@ -25162,9 +25175,9 @@
}
},
"node_modules/ts-jest": {
- "version": "29.4.1",
- "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz",
- "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==",
+ "version": "29.4.2",
+ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.2.tgz",
+ "integrity": "sha512-pBNOkn4HtuLpNrXTMVRC9b642CBaDnKqWXny4OzuoULT9S7Kf8MMlaRe2veKax12rjf5WcpMBhVPbQurlWGNxA==",
"dev": true,
"dependencies": {
"bs-logger": "^0.2.6",
@@ -25534,9 +25547,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.11.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz",
- "integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==",
+ "version": "7.12.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
+ "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
"devOptional": true
},
"node_modules/unicode-canonical-property-names-ecmascript": {
diff --git a/package.json b/package.json
index 42406448..698a60fb 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"react-error-boundary": "^4.0.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-google-recaptcha-v3": "^1.11.0",
"react-helmet": "6.1.0",
"react-loading-skeleton": "3.5.0",
"react-redux": "7.2.9",
diff --git a/src/MainApp.jsx b/src/MainApp.jsx
index 7beaa3d1..f866f3cb 100755
--- a/src/MainApp.jsx
+++ b/src/MainApp.jsx
@@ -2,6 +2,7 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
+import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import { Helmet } from 'react-helmet';
import { Navigate, Route, Routes } from 'react-router-dom';
@@ -32,34 +33,43 @@ import './index.scss';
registerIcons();
-const MainApp = () => (
-
-
-
-
-
- } />
- }
- />
-
- }
- />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
-
-);
+const MainApp = () => {
+ const recaptchaKey = getConfig().RECAPTCHA_SITE_KEY_WEB;
+
+ return (
+
+
+
+
+
+
+ } />
+ }
+ />
+
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+ );
+};
export default MainApp;
diff --git a/src/config/index.js b/src/config/index.js
index 6399b549..61239dbd 100644
--- a/src/config/index.js
+++ b/src/config/index.js
@@ -37,6 +37,7 @@ const configuration = {
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '',
ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '',
AUTO_GENERATED_USERNAME_EXPERIMENT_ID: process.env.AUTO_GENERATED_USERNAME_EXPERIMENT_ID || '',
+ RECAPTCHA_SITE_KEY_WEB: process.env.RECAPTCHA_SITE_KEY_WEB || '',
};
export default configuration;
diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx
index 50236e80..639c5b3e 100644
--- a/src/register/RegistrationPage.jsx
+++ b/src/register/RegistrationPage.jsx
@@ -31,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';
@@ -61,6 +62,7 @@ const RegistrationPage = (props) => {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
+ const { executeWithFallback } = useRecaptchaSubmission('submit_registration_form');
const registrationEmbedded = isHostAvailableInQueryParams();
const platformName = getConfig().SITE_NAME;
const flags = {
@@ -230,7 +232,7 @@ const RegistrationPage = (props) => {
}
};
- const registerUser = () => {
+ const registerUser = async () => {
const totalRegistrationTime = (Date.now() - formStartTime) / 1000;
let payload = { ...formFields, app_name: APP_NAME };
@@ -250,7 +252,7 @@ const RegistrationPage = (props) => {
fieldDescriptions,
formatMessage,
);
- setErrors({ ...fieldErrors });
+ setErrors({ ...fieldErrors, captchaError: '' });
dispatch(setEmailSuggestionInStore(emailSuggestion));
// returning if not valid
@@ -259,15 +261,28 @@ const RegistrationPage = (props) => {
return;
}
- // Preparing payload for submission
+ let recaptchaToken = null;
+ try {
+ recaptchaToken = await executeWithFallback();
+ } catch (err) {
+ setErrors(prev => ({
+ ...prev,
+ captchaError: err.message,
+ }));
+ return;
+ }
+
payload = prepareRegistrationPayload(
payload,
configurableFormFields,
flags.showMarketingEmailOptInCheckbox,
totalRegistrationTime,
- queryParams);
+ queryParams,
+ );
+ if (recaptchaToken) {
+ payload = { ...payload, captcha_token: recaptchaToken };
+ }
- // making register call
dispatch(registerNewUser(payload));
};
@@ -395,6 +410,11 @@ const RegistrationPage = (props) => {
fieldDescriptions={fieldDescriptions}
countriesCodesList={countriesCodesList}
/>
+ {errors?.captchaError && (
+
+ {errors.captchaError}
+
+ )}
({
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('CAPTCHA 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..4eabd015
--- /dev/null
+++ b/src/register/data/hooks.js
@@ -0,0 +1,40 @@
+import { useCallback } from 'react';
+
+import { getConfig } from '@edx/frontend-platform';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
+
+import messages from '../messages';
+
+const useRecaptchaSubmission = (actionName = 'submit') => {
+ const { formatMessage } = useIntl();
+ 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(formatMessage(messages['registration.captcha.verification.label']));
+ }
+ 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, formatMessage]);
+
+ return {
+ executeWithFallback,
+ isReady,
+ isLoading: recaptchaKey && !executeRecaptcha,
+ };
+};
+
+export default useRecaptchaSubmission;
diff --git a/src/register/data/hooks.test.jsx b/src/register/data/hooks.test.jsx
new file mode 100644
index 00000000..e47760a1
--- /dev/null
+++ b/src/register/data/hooks.test.jsx
@@ -0,0 +1,90 @@
+import { getConfig } from '@edx/frontend-platform';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { renderHook } from '@testing-library/react';
+import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
+
+import useRecaptchaSubmission from './hooks';
+
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(),
+}));
+
+jest.mock('react-google-recaptcha-v3', () => ({
+ useGoogleReCaptcha: jest.fn(),
+}));
+
+jest.mock('@edx/frontend-platform/i18n', () => ({
+ ...jest.requireActual('@edx/frontend-platform/i18n'),
+ useIntl: () => ({ formatMessage: (msg) => msg.defaultMessage || msg }),
+}));
+
+describe('useRecaptchaSubmission', () => {
+ beforeEach(() => {
+ getConfig.mockReturnValue({ RECAPTCHA_SITE_KEY_WEB: 'test-key' });
+ useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: jest.fn() });
+ });
+
+ it('should throw error if reCAPTCHA returns empty token', async () => {
+ useGoogleReCaptcha.mockReturnValue({
+ executeRecaptcha: jest.fn().mockResolvedValue(null),
+ });
+
+ const { result } = renderHook(() => useRecaptchaSubmission('test_action'), {
+ wrapper: ({ children }) => {children},
+ });
+
+ await expect(result.current.executeWithFallback()).rejects.toThrow(
+ 'CAPTCHA verification failed.',
+ );
+ });
+
+ it('should warn and return null if reCAPTCHA key exists but executeRecaptcha is not ready', async () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ useGoogleReCaptcha.mockReturnValue({
+ executeRecaptcha: undefined,
+ });
+
+ const { result } = renderHook(() => useRecaptchaSubmission('test_action'), {
+ wrapper: ({ children }) => {children},
+ });
+
+ const token = await result.current.executeWithFallback();
+
+ expect(token).toBeNull();
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'reCAPTCHA not ready for action: test_action. Proceeding without token.',
+ );
+
+ warnSpy.mockRestore();
+ });
+
+ it('should handle undefined RECAPTCHA_SITE_KEY_WEB gracefully', async () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ getConfig.mockReturnValue({ RECAPTCHA_SITE_KEY_WEB: undefined });
+
+ const { result } = renderHook(() => useRecaptchaSubmission('test_action'), {
+ wrapper: ({ children }) => {children},
+ });
+
+ const token = await result.current.executeWithFallback();
+
+ expect(token).toBeNull();
+ expect(warnSpy).not.toHaveBeenCalled();
+ warnSpy.mockRestore();
+ });
+
+ it('should return token if reCAPTCHA succeeds', async () => {
+ useGoogleReCaptcha.mockReturnValue({
+ executeRecaptcha: jest.fn().mockResolvedValue('valid-token'),
+ });
+
+ const { result } = renderHook(() => useRecaptchaSubmission('test_action'), {
+ wrapper: ({ children }) => {children},
+ });
+
+ const token = await result.current.executeWithFallback();
+ expect(token).toBe('valid-token');
+ });
+});
diff --git a/src/register/messages.jsx b/src/register/messages.jsx
index f1750e6e..fe15394d 100644
--- a/src/register/messages.jsx
+++ b/src/register/messages.jsx
@@ -206,6 +206,11 @@ const messages = defineMessages({
defaultMessage: 'Did you mean',
description: 'Did you mean alert suggestion',
},
+ 'registration.captcha.verification.label': {
+ id: 'registration.captcha.verification.label',
+ defaultMessage: 'CAPTCHA verification failed.',
+ description: 'CAPTCHA verification failed',
+ },
});
export default messages;