Compare commits

...

8 Commits

Author SHA1 Message Date
Awais Ansari
83d350f8c5 refactor: resolved conflict issues 2025-09-15 19:19:34 +05:00
sundasnoreen12
7a267e7200 test: added failed captcha test case 2025-09-15 19:00:14 +05:00
sundasnoreen12
6b5d4a9898 test: added failed captcha test case 2025-09-15 19:00:14 +05:00
sundasnoreen12
646b5d5f57 test: fixed test suit 2025-09-15 19:00:09 +05:00
sundasnoreen12
14eb973e13 refactor: refactor code 2025-09-15 18:56:23 +05:00
sundasnoreen12
23e6f0baf4 feat: added env varaible for captcha v3 2025-09-15 18:56:20 +05:00
sundasnoreen12
d427067f57 test: fixed test cases 2025-09-15 18:54:39 +05:00
sundasnoreen12
da0755467d feat: added captcha on authn mfe 2025-09-15 18:51:03 +05:00
11 changed files with 164 additions and 57 deletions

1
.env
View File

@@ -46,3 +46,4 @@ APP_ID=''
MFE_CONFIG_API_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}
RECAPTCHA_SITE_KEY_WEB=''

View File

@@ -46,3 +46,4 @@ ZENDESK_KEY=''
ZENDESK_LOGO_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}
RECAPTCHA_SITE_KEY_WEB=''

View File

@@ -21,3 +21,4 @@ MFE_CONFIG_API_URL=''
COHESION_WRITE_KEY=''
COHESION_SOURCE_KEY=''
PARAGON_THEME_URLS={}
RECAPTCHA_SITE_KEY_WEB=''

13
package-lock.json generated
View File

@@ -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",
@@ -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",

View File

@@ -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",

View File

@@ -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 = () => (
<AppProvider store={configureStore()}>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
<Routes>
<Route path="/" element={<Navigate replace to={updatePathWithQueryParams(REGISTER_PAGE)} />} />
<Route
path={REGISTER_EMBEDDED_PAGE}
element={<EmbeddedRegistrationRoute><RegistrationPage /></EmbeddedRegistrationRoute>}
/>
<Route
path={LOGIN_PAGE}
element={
<UnAuthOnlyRoute><Logistration selectedPage={LOGIN_PAGE} /></UnAuthOnlyRoute>
}
/>
<Route path={REGISTER_PAGE} element={<UnAuthOnlyRoute><Logistration /></UnAuthOnlyRoute>} />
<Route path={RESET_PAGE} element={<UnAuthOnlyRoute><ForgotPasswordPage /></UnAuthOnlyRoute>} />
<Route path={PASSWORD_RESET_CONFIRM} element={<ResetPasswordPage />} />
<Route path={AUTHN_PROGRESSIVE_PROFILING} element={<ProgressiveProfiling />} />
<Route path={RECOMMENDATIONS} element={<RecommendationsPage />} />
<Route path={PAGE_NOT_FOUND} element={<NotFoundPage />} />
<Route path="*" element={<Navigate replace to={PAGE_NOT_FOUND} />} />
</Routes>
<RouteTracker />
<MainAppSlot />
</AppProvider>
);
const MainApp = () => {
const recaptchaSiteKeyWeb = getConfig().RECAPTCHA_SITE_KEY_WEB;
return (
<GoogleReCaptchaProvider
reCaptchaKey={recaptchaSiteKeyWeb}
useEnterprise
>
<AppProvider store={configureStore()}>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
<Routes>
<Route path="/" element={<Navigate replace to={updatePathWithQueryParams(REGISTER_PAGE)} />} />
<Route
path={REGISTER_EMBEDDED_PAGE}
element={<EmbeddedRegistrationRoute><RegistrationPage /></EmbeddedRegistrationRoute>}
/>
<Route
path={LOGIN_PAGE}
element={
<UnAuthOnlyRoute><Logistration selectedPage={LOGIN_PAGE} /></UnAuthOnlyRoute>
}
/>
<Route path={REGISTER_PAGE} element={<UnAuthOnlyRoute><Logistration /></UnAuthOnlyRoute>} />
<Route path={RESET_PAGE} element={<UnAuthOnlyRoute><ForgotPasswordPage /></UnAuthOnlyRoute>} />
<Route path={PASSWORD_RESET_CONFIRM} element={<ResetPasswordPage />} />
<Route path={AUTHN_PROGRESSIVE_PROFILING} element={<ProgressiveProfiling />} />
<Route path={RECOMMENDATIONS} element={<RecommendationsPage />} />
<Route path={PAGE_NOT_FOUND} element={<NotFoundPage />} />
<Route path="*" element={<Navigate replace to={PAGE_NOT_FOUND} />} />
</Routes>
<RouteTracker />
<MainAppSlot />
</AppProvider>
</GoogleReCaptchaProvider>
);
};
export default MainApp;

View File

@@ -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;

View File

@@ -8,6 +8,7 @@ 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';
@@ -60,6 +61,7 @@ import { trackRegistrationPageViewed, trackRegistrationSuccess } from '../tracki
const RegistrationPage = (props) => {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const { executeRecaptcha } = useGoogleReCaptcha();
const registrationEmbedded = isHostAvailableInQueryParams();
const platformName = getConfig().SITE_NAME;
@@ -106,6 +108,8 @@ const RegistrationPage = (props) => {
const [formStartTime, setFormStartTime] = useState(null);
// temporary error state for embedded experience because we don't want to show errors on blur
const [temporaryErrors, setTemporaryErrors] = useState({ ...backedUpFormData.errors });
const [captchaError, setCaptchaError] = useState('');
const intl = useIntl();
const { cta, host } = queryParams;
const buttonLabel = cta
@@ -230,7 +234,7 @@ const RegistrationPage = (props) => {
}
};
const registerUser = () => {
const registerUser = async () => {
const totalRegistrationTime = (Date.now() - formStartTime) / 1000;
let payload = { ...formFields, app_name: APP_NAME };
@@ -259,16 +263,30 @@ const RegistrationPage = (props) => {
return;
}
// Preparing payload for submission
payload = prepareRegistrationPayload(
payload,
configurableFormFields,
flags.showMarketingEmailOptInCheckbox,
totalRegistrationTime,
queryParams);
if (executeRecaptcha) {
const recaptchaToken = await executeRecaptcha('submit_post');
if (!recaptchaToken) {
setCaptchaError(intl.formatMessage(messages['discussions.captcha.verification.label']));
return;
}
setCaptchaError('');
// Preparing payload for submission
if (recaptchaToken) {
payload = prepareRegistrationPayload(
payload,
configurableFormFields,
flags.showMarketingEmailOptInCheckbox,
totalRegistrationTime,
queryParams);
// making register call
dispatch(registerNewUser(payload));
const updatedpayload = {
...payload,
captcha_token: recaptchaToken,
};
// making register call
dispatch(registerNewUser(updatedpayload));
}
}
};
const handleSubmit = (e) => {
@@ -395,6 +413,11 @@ const RegistrationPage = (props) => {
fieldDescriptions={fieldDescriptions}
countriesCodesList={countriesCodesList}
/>
{captchaError && (
<div className="mt-3 pgn__form-text-invalid pgn__form-text">
{captchaError}
</div>
)}
<StatefulButton
id="register-user"
name="register-user"

View File

@@ -5,7 +5,10 @@ import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'
import {
configure, getLocale, IntlProvider,
} from '@edx/frontend-platform/i18n';
import { fireEvent, render, waitFor } from '@testing-library/react';
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { mockNavigate, BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -25,6 +28,12 @@ import {
APP_NAME, AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE,
} from '../data/constants';
jest.mock('react-google-recaptcha-v3', () => ({
useGoogleReCaptcha: jest.fn(),
// eslint-disable-next-line react/prop-types
GoogleReCaptchaProvider: ({ children }) => <div>{children}</div>,
}));
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
sendTrackEvent: jest.fn(),
@@ -133,7 +142,8 @@ describe('RegistrationPage', () => {
institutionLogin: false,
};
window.location = { search: '' };
useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED);
const mockExecuteRecaptchaNew = jest.fn(() => Promise.resolve('mock-token'));
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptchaNew });
});
afterEach(() => {
@@ -175,6 +185,35 @@ describe('RegistrationPage', () => {
// ******** test registration form submission ********
it('should show captcha error if executeRecaptcha returns null token', async () => {
const mockExecuteRecaptcha = jest.fn().mockResolvedValue(null);
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
getLocale.mockImplementation(() => ('en-us'));
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
delete window.location;
window.location = { href: getConfig().BASE_URL, search: '?next=/course/demo-course-url' };
const payload = {
name: 'John Doe',
username: 'john_doe',
email: 'john.doe@gmail.com',
password: 'password1',
country: 'Pakistan',
honor_code: true,
total_registration_time: 0,
next: '/course/demo-course-url',
};
store.dispatch = jest.fn(store.dispatch);
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<RegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, payload);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
waitFor(() => expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument());
});
it('should submit form for valid input', () => {
getLocale.mockImplementation(() => ('en-us'));
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
@@ -200,7 +239,7 @@ describe('RegistrationPage', () => {
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
waitFor(() => { expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); });
});
it('should submit form without password field when current provider is present', () => {
@@ -233,7 +272,7 @@ describe('RegistrationPage', () => {
populateRequiredFields(getByLabelText, formPayload, true);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' }));
waitFor(() => { expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' })); });
});
it('should display an error when form is submitted with an invalid email', () => {
@@ -308,7 +347,7 @@ describe('RegistrationPage', () => {
populateRequiredFields(getByLabelText, payload);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
waitFor(() => { expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); });
mergeConfig({
MARKETING_EMAILS_OPT_IN: '',
@@ -335,7 +374,7 @@ 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' }));
waitFor(() => { expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); });
mergeConfig({
ENABLE_AUTO_GENERATED_USERNAME: false,
});
@@ -890,15 +929,16 @@ describe('RegistrationPage', () => {
store.dispatch = jest.fn(store.dispatch);
render(routerWrapper(reduxWrapper(<RegistrationPage {...props} />)));
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,
}));
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,
}));
});
});
});
});

View File

@@ -4,11 +4,11 @@ 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 { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { APP_NAME } from '../../../data/constants';
import { registerNewUser } from '../../data/actions';
import { FIELDS } from '../../data/constants';
import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper';
@@ -26,6 +26,11 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
getLocale: jest.fn(),
}));
jest.mock('../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn());
jest.mock('react-google-recaptcha-v3', () => ({
useGoogleReCaptcha: jest.fn(),
// eslint-disable-next-line react/prop-types
GoogleReCaptchaProvider: ({ children }) => <div>{children}</div>,
}));
const mockStore = configureStore();
@@ -220,6 +225,8 @@ describe('ConfigurableRegistrationForm', () => {
},
},
});
const mockExecuteRecaptchaNew = jest.fn(() => Promise.resolve('mock-token'));
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptchaNew });
render(routerWrapper(reduxWrapper(<RegistrationPage {...props} />)));
expect(document.querySelector('#profession')).toBeTruthy();
expect(document.querySelector('#tos')).toBeTruthy();
@@ -230,6 +237,8 @@ describe('ConfigurableRegistrationForm', () => {
SHOW_CONFIGURABLE_EDX_FIELDS: true,
});
getLocale.mockImplementation(() => ('en-us'));
const mockExecuteRecaptchaNew = jest.fn(() => Promise.resolve('mock-token'));
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptchaNew });
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
store = mockStore({
...initialState,
@@ -265,7 +274,9 @@ describe('ConfigurableRegistrationForm', () => {
fireEvent.click(submitButton);
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK', app_name: APP_NAME }));
waitFor(() => {
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
});
});
it('should show error messages for required fields on empty form submission', () => {

View File

@@ -206,6 +206,11 @@ const messages = defineMessages({
defaultMessage: 'Did you mean',
description: 'Did you mean alert suggestion',
},
'discussions.captcha.verification.label': {
id: 'discussions.captcha.verification.label',
defaultMessage: 'CAPTCHA verification failed.',
description: 'CAPTCHA verification failed',
},
});
export default messages;