Compare commits

..

1 Commits

Author SHA1 Message Date
sundasnoreen12
3355a014e6 feat: implemented restricted countries functionality 2025-02-24 13:44:39 +05:00
181 changed files with 11680 additions and 25882 deletions

2
.env
View File

@@ -41,5 +41,3 @@ BANNER_IMAGE_EXTRA_SMALL=''
# ***** Miscellaneous *****
APP_ID=''
MFE_CONFIG_API_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -41,5 +41,3 @@ APP_ID=''
MFE_CONFIG_API_URL=''
ZENDESK_KEY=''
ZENDESK_LOGO_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -18,4 +18,3 @@ SEGMENT_KEY=''
SITE_NAME='Your Platform Name Here'
APP_ID=''
MFE_CONFIG_API_URL=''
PARAGON_THEME_URLS={}

2
.nvmrc
View File

@@ -1 +1 @@
24
20

2
CODEOWNERS Normal file
View File

@@ -0,0 +1,2 @@
# The following users are the owners of all frontend-app-authn files
* @openedx/2u-infinity

View File

@@ -26,53 +26,32 @@ This is a micro-frontend application responsible for the login, registration and
Getting Started
***************
Prerequisites
=============
Installation
============
`Tutor`_ is currently recommended as a development environment for your new MFE. Please refer to the `relevant tutor-mfe documentation`_ to get started using it.
This MFE is bundled with `Devstack <https://github.com/openedx/devstack>`_, see the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ section for setup instructions.
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
1. Install Devstack using the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ instructions.
Cloning and Startup
===================
2. Start up LMS, if it's not already started.
1. Clone your new repo:
4. Within this project (frontend-app-authn), install requirements and start the development server:
.. code-block:: bash
.. code-block::
git clone https://github.com/edx/frontend-app-authn.git
npm install
npm start # The server will run on port 1999
2. Use the version of Node specified in the ``.nvmrc`` file.
5. Once the dev server is up, visit http://localhost:1999 to access the MFE
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes a ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
.. code-block:: bash
cd frontend-app-authn && npm install
4. Update the application port to use for local development:
The default port is 1999. If this does not work for you, update the line
``PORT=1999`` to your port in all ``.env.*`` files
5. Start the devserver. The app will be running at ``localhost:1999``, or whatever port you change it too.
.. code-block:: bash
npm run dev
.. image:: ./docs/images/frontend-app-authn-localhost-preview.png
**Note:** Follow `Enable social auth locally <docs/how_tos/enable_social_auth.rst>`_ for enabling Social Sign-on Buttons (SSO) locally
Environment Variables/Setup Notes
=================================
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`__.
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
The authentication micro-frontend also requires the following additional variable:
@@ -130,7 +109,7 @@ The authentication micro-frontend also requires the following additional variabl
* - ``MFE_CONFIG_API_URL``
- Link of the API to get runtime mfe configuration variables from the site configuration or django settings.
- ``/api/v1/mfe_config`` | ``''`` (empty strings are falsy)
- ``/api/v1/mfe_config`` | ``''`` (empty strings are falsy)
* - ``APP_ID``
- Name of MFE, this will be used by the API to get runtime configurations for the specific micro frontend. For a frontend repo `frontend-app-appName`, use `appName` as APP_ID.
@@ -160,16 +139,16 @@ Furthermore, there are several edX-specific environment variables that enable in
* - ``SHOW_CONFIGURABLE_EDX_FIELDS``
- For edX, country and honor code fields are required by default. This flag enables edX specific required fields.
- ``true`` | ``''`` (empty strings are falsy)
- ``true`` | ``''`` (empty strings are falsy)
For more information see the document: `Micro-frontend applications in Open
edX <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`__.
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
How To Contribute
=================
Contributions are very welcome, and strongly encouraged! We've
put together `some documentation that describes our contribution process <https://docs.openedx.org/en/latest/developers/references/developer_guide/process/index.html>`_.
put together `some documentation that describes our contribution process <https://edx.readthedocs.org/projects/edx-developer-guide/en/latest/process/index.html>`_.
Even though they were written with edx-platform in mind, the guidelines should be followed for Open edX code in general.

View File

@@ -3,7 +3,7 @@ Enable Social Auth Locally
Please follow the steps below to enable social auth (SSO) locally.
1. Follow `Enabling Third Party Authentication <https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/tpa/index.html>`_ for backend configuration.
1. Follow `Enabling Third Party Authentication <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/tpa/index.html>`_ for backend configuration.
2. Authn has a component for rendering Social Auth providers at frontend which goes through each provider.

23600
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,12 +13,15 @@
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/authn/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-authn#readme",
@@ -30,47 +33,53 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-platform": "^8.3.1",
"@edx/frontend-platform": "^8.0.0",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-brands-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.6",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@openedx/paragon": "^22.1.1",
"@optimizely/react-sdk": "^2.9.1",
"@tanstack/react-query": "^5.90.19",
"@testing-library/react": "^16.2.0",
"@redux-devtools/extension": "3.3.0",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"algoliasearch": "^4.14.3",
"algoliasearch-helper": "^3.26.0",
"algoliasearch-helper": "^3.14.0",
"classnames": "2.5.1",
"core-js": "3.43.0",
"core-js": "3.40.0",
"fastest-levenshtein": "1.0.16",
"form-urlencoded": "6.1.6",
"form-urlencoded": "6.1.5",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-helmet": "6.1.0",
"react-loading-skeleton": "3.5.0",
"react-redux": "7.2.9",
"react-responsive": "8.2.0",
"react-router": "6.30.3",
"react-router-dom": "6.30.3",
"react-router": "6.29.0",
"react-router-dom": "6.29.0",
"react-zendesk": "^0.1.13",
"redux": "4.2.1",
"redux-logger": "3.0.6",
"redux-mock-store": "1.5.5",
"redux-saga": "1.3.0",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.1",
"reselect": "5.1.1",
"universal-cookie": "7.2.2"
},
"devDependencies": {
"@edx/browserslist-config": "^1.1.1",
"@edx/typescript-config": "^1.1.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "^6.9.1",
"babel-plugin-formatjs": "10.5.41",
"eslint-plugin-import": "2.32.0",
"@edx/reactifex": "1.1.0",
"@openedx/frontend-build": "^14.0.3",
"babel-plugin-formatjs": "10.5.35",
"eslint-plugin-import": "2.31.0",
"glob": "7.2.3",
"history": "5.3.0",
"jest": "30.3.0",
"react-test-renderer": "^18.3.1",
"ts-jest": "^29.4.0"
"husky": "9.1.7",
"jest": "29.7.0",
"react-test-renderer": "^17.0.2"
}
}

View File

@@ -1,12 +1,14 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Helmet } from 'react-helmet';
import { Navigate, Route, Routes } from 'react-router-dom';
import {
EmbeddedRegistrationRoute, NotFoundPage, registerIcons, UnAuthOnlyRoute, Zendesk,
} from './common-components';
import configureStore from './data/configureStore';
import {
AUTHN_PROGRESSIVE_PROFILING,
LOGIN_PAGE,
@@ -29,48 +31,33 @@ import './index.scss';
registerIcons();
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
},
});
const MainApp = () => (
<QueryClientProvider client={queryClient}>
<AppProvider>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
{getConfig().ZENDESK_KEY && <Zendesk />}
<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 selectedPage={REGISTER_PAGE} /></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>
</AppProvider>
</QueryClientProvider>
<AppProvider store={configureStore()}>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
{getConfig().ZENDESK_KEY && <Zendesk />}
<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>
</AppProvider>
);
export default MainApp;

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen } from '@testing-library/react';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@openedx/paragon';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@openedx/paragon';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@openedx/paragon';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@openedx/paragon';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@openedx/paragon';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@openedx/paragon';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { breakpoints } from '@openedx/paragon';
import classNames from 'classnames';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { mergeConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render } from '@testing-library/react';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Navigate } from 'react-router-dom';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState } from 'react';
import {
Form, TransitionReplace,

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink, Icon } from '@openedx/paragon';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const NotFoundPage = () => (

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -11,31 +12,17 @@ import PropTypes from 'prop-types';
import messages from './messages';
import { LETTER_REGEX, NUMBER_REGEX } from '../data/constants';
import { useRegisterContext } from '../register/components/RegisterContext';
import { useFieldValidations } from '../register/data/apiHook';
import { clearRegistrationBackendError, fetchRealtimeValidations } from '../register/data/actions';
import { validatePasswordField } from '../register/data/utils';
const PasswordField = (props) => {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const validationApiRateLimited = useSelector(state => state.register.validationApiRateLimited);
const [isPasswordHidden, setHiddenTrue, setHiddenFalse] = useToggle(true);
const [showTooltip, setShowTooltip] = useState(false);
const {
setValidationsSuccess,
setValidationsFailure,
validationApiRateLimited,
clearRegistrationBackendError,
} = useRegisterContext();
const fieldValidationsMutation = useFieldValidations({
onSuccess: (data) => {
setValidationsSuccess(data);
},
onError: () => {
setValidationsFailure();
},
});
const handleBlur = (e) => {
const { name, value } = e.target;
if (name === props.name && e.relatedTarget?.name === 'passwordIcon') {
@@ -63,7 +50,7 @@ const PasswordField = (props) => {
if (fieldError) {
props.handleErrorChange('password', fieldError);
} else if (!validationApiRateLimited) {
fieldValidationsMutation.mutate({ password: passwordValue });
dispatch(fetchRealtimeValidations({ password: passwordValue }));
}
}
};
@@ -78,7 +65,7 @@ const PasswordField = (props) => {
}
if (props.handleErrorChange) {
props.handleErrorChange('password', '');
clearRegistrationBackendError('password');
dispatch(clearRegistrationBackendError('password'));
}
setTimeout(() => setShowTooltip(props.showRequirements && true), 150);
};

View File

@@ -22,6 +22,7 @@ const RedirectLogistration = (props) => {
host,
} = props;
let finalRedirectUrl = '';
if (success) {
// If we're in a third party auth pipeline, we must complete the pipeline
// once user has successfully logged in. Otherwise, redirect to the specified redirect url.

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@openedx/paragon';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import Zendesk from 'react-zendesk';

View File

@@ -1,61 +0,0 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ThirdPartyAuthProvider, useThirdPartyAuthContext } from './ThirdPartyAuthContext';
const TestComponent = () => {
const {
fieldDescriptions,
optionalFields,
thirdPartyAuthApiStatus,
thirdPartyAuthContext,
} = useThirdPartyAuthContext();
return (
<div>
<div>{fieldDescriptions ? 'FieldDescriptions Available' : 'FieldDescriptions Not Available'}</div>
<div>{optionalFields ? 'OptionalFields Available' : 'OptionalFields Not Available'}</div>
<div>{thirdPartyAuthApiStatus !== null ? 'AuthApiStatus Available' : 'AuthApiStatus Not Available'}</div>
<div>{thirdPartyAuthContext ? 'AuthContext Available' : 'AuthContext Not Available'}</div>
</div>
);
};
describe('ThirdPartyAuthContext', () => {
it('should render children', () => {
render(
<ThirdPartyAuthProvider>
<div>Test Child</div>
</ThirdPartyAuthProvider>,
);
expect(screen.getByText('Test Child')).toBeInTheDocument();
});
it('should provide all context values to children', () => {
render(
<ThirdPartyAuthProvider>
<TestComponent />
</ThirdPartyAuthProvider>,
);
expect(screen.getByText('FieldDescriptions Available')).toBeInTheDocument();
expect(screen.getByText('OptionalFields Available')).toBeInTheDocument();
expect(screen.getByText('AuthApiStatus Not Available')).toBeInTheDocument(); // Initially null
expect(screen.getByText('AuthContext Available')).toBeInTheDocument();
});
it('should render multiple children', () => {
render(
<ThirdPartyAuthProvider>
<div>First Child</div>
<div>Second Child</div>
<div>Third Child</div>
</ThirdPartyAuthProvider>,
);
expect(screen.getByText('First Child')).toBeInTheDocument();
expect(screen.getByText('Second Child')).toBeInTheDocument();
expect(screen.getByText('Third Child')).toBeInTheDocument();
});
});

View File

@@ -1,133 +0,0 @@
import {
createContext, FC, ReactNode, useCallback, useContext, useMemo, useState,
} from 'react';
import { COMPLETE_STATE, FAILURE_STATE, PENDING_STATE } from '../../data/constants';
interface ThirdPartyAuthContextType {
fieldDescriptions: any;
optionalFields: {
fields: any;
extended_profile: any[];
};
thirdPartyAuthApiStatus: string | null;
thirdPartyAuthContext: {
platformName: string | null;
autoSubmitRegForm: boolean;
currentProvider: string | null;
finishAuthUrl: string | null;
countryCode: string | null;
providers: any[];
secondaryProviders: any[];
pipelineUserDetails: any | null;
errorMessage: string | null;
welcomePageRedirectUrl: string | null;
};
setThirdPartyAuthContextBegin: () => void;
setThirdPartyAuthContextSuccess: (fieldDescData: any, optionalFieldsData: any, contextData: any) => void;
setThirdPartyAuthContextFailure: () => void;
clearThirdPartyAuthErrorMessage: () => void;
}
const ThirdPartyAuthContext = createContext<ThirdPartyAuthContextType | undefined>(undefined);
interface ThirdPartyAuthProviderProps {
children: ReactNode;
}
export const ThirdPartyAuthProvider: FC<ThirdPartyAuthProviderProps> = ({ children }) => {
const [fieldDescriptions, setFieldDescriptions] = useState({});
const [optionalFields, setOptionalFields] = useState({
fields: {},
extended_profile: [],
});
const [thirdPartyAuthApiStatus, setThirdPartyAuthApiStatus] = useState<string | null>(null);
const [thirdPartyAuthContext, setThirdPartyAuthContext] = useState({
platformName: null,
autoSubmitRegForm: false,
currentProvider: null,
finishAuthUrl: null,
countryCode: null,
providers: [],
secondaryProviders: [],
pipelineUserDetails: null,
errorMessage: null,
welcomePageRedirectUrl: null,
});
// Function to handle begin state - mirrors THIRD_PARTY_AUTH_CONTEXT.BEGIN
const setThirdPartyAuthContextBegin = useCallback(() => {
setThirdPartyAuthApiStatus(PENDING_STATE);
}, []);
// Function to handle success - mirrors THIRD_PARTY_AUTH_CONTEXT.SUCCESS
const setThirdPartyAuthContextSuccess = useCallback((fieldDescData, optionalFieldsData, contextData) => {
setFieldDescriptions(fieldDescData?.fields || {});
setOptionalFields(optionalFieldsData || { fields: {}, extended_profile: [] });
setThirdPartyAuthContext(contextData || {
platformName: null,
autoSubmitRegForm: false,
currentProvider: null,
finishAuthUrl: null,
countryCode: null,
providers: [],
secondaryProviders: [],
pipelineUserDetails: null,
errorMessage: null,
welcomePageRedirectUrl: null,
});
setThirdPartyAuthApiStatus(COMPLETE_STATE);
}, []);
// Function to handle failure - mirrors THIRD_PARTY_AUTH_CONTEXT.FAILURE
const setThirdPartyAuthContextFailure = useCallback(() => {
setThirdPartyAuthApiStatus(FAILURE_STATE);
setThirdPartyAuthContext(prev => ({
...prev,
errorMessage: null,
}));
}, []);
// Function to clear error message - mirrors THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG
const clearThirdPartyAuthErrorMessage = useCallback(() => {
setThirdPartyAuthApiStatus(PENDING_STATE);
setThirdPartyAuthContext(prev => ({
...prev,
errorMessage: null,
}));
}, []);
const value = useMemo(() => ({
fieldDescriptions,
optionalFields,
thirdPartyAuthApiStatus,
thirdPartyAuthContext,
setThirdPartyAuthContextBegin,
setThirdPartyAuthContextSuccess,
setThirdPartyAuthContextFailure,
clearThirdPartyAuthErrorMessage,
}), [
fieldDescriptions,
optionalFields,
thirdPartyAuthApiStatus,
thirdPartyAuthContext,
setThirdPartyAuthContextBegin,
setThirdPartyAuthContextSuccess,
setThirdPartyAuthContextFailure,
clearThirdPartyAuthErrorMessage,
]);
return (
<ThirdPartyAuthContext.Provider value={value}>
{children}
</ThirdPartyAuthContext.Provider>
);
};
export const useThirdPartyAuthContext = (): ThirdPartyAuthContextType => {
const context = useContext(ThirdPartyAuthContext);
if (context === undefined) {
throw new Error('useThirdPartyAuthContext must be used within a ThirdPartyAuthProvider');
}
return context;
};

View File

@@ -0,0 +1,33 @@
import { AsyncActionType } from '../../data/utils';
export const THIRD_PARTY_AUTH_CONTEXT = new AsyncActionType('THIRD_PARTY_AUTH', 'GET_THIRD_PARTY_AUTH_CONTEXT');
export const THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG = 'THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG';
// Third party auth context
export const getThirdPartyAuthContext = (urlParams) => ({
type: THIRD_PARTY_AUTH_CONTEXT.BASE,
payload: { urlParams },
});
export const getThirdPartyAuthContextBegin = () => ({
type: THIRD_PARTY_AUTH_CONTEXT.BEGIN,
});
export const getThirdPartyAuthContextSuccess = (
fieldDescriptions,
optionalFields,
thirdPartyAuthContext,
countries) => ({
type: THIRD_PARTY_AUTH_CONTEXT.SUCCESS,
payload: {
fieldDescriptions, optionalFields, thirdPartyAuthContext, countries,
},
});
export const getThirdPartyAuthContextFailure = () => ({
type: THIRD_PARTY_AUTH_CONTEXT.FAILURE,
});
export const clearThirdPartyAuthContextErrorMessage = () => ({
type: THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG,
});

View File

@@ -1,25 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getThirdPartyAuthContext = async (urlParams : string) => {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
params: urlParams,
isPublic: true,
};
const { data } = await getAuthenticatedHttpClient()
.get(
`${getConfig().LMS_BASE_URL}/api/mfe_context`,
requestConfig,
);
return {
fieldDescriptions: data.registrationFields || {},
optionalFields: data.optionalFields || {},
thirdPartyAuthContext: data.contextData || {},
};
};
export {
getThirdPartyAuthContext,
};

View File

@@ -1,17 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { getThirdPartyAuthContext } from './api';
import { ThirdPartyAuthQueryKeys } from './queryKeys';
// Error constants
export const THIRD_PARTY_AUTH_ERROR = 'third-party-auth-error';
const useThirdPartyAuthHook = (pageId, payload) => useQuery({
queryKey: ThirdPartyAuthQueryKeys.byPage(pageId),
queryFn: () => getThirdPartyAuthContext(payload),
retry: false,
});
export {
useThirdPartyAuthHook,
};

View File

@@ -1,6 +0,0 @@
import { appId } from '../../constants';
export const ThirdPartyAuthQueryKeys = {
all: [appId, 'ThirdPartyAuth'] as const,
byPage: (pageId: string) => [appId, 'ThirdPartyAuth', pageId] as const,
};

View File

@@ -0,0 +1,64 @@
import { THIRD_PARTY_AUTH_CONTEXT, THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG } from './actions';
import { COMPLETE_STATE, FAILURE_STATE, PENDING_STATE } from '../../data/constants';
export const defaultState = {
fieldDescriptions: {},
optionalFields: {
fields: {},
extended_profile: [],
},
thirdPartyAuthApiStatus: null,
thirdPartyAuthContext: {
autoSubmitRegForm: false,
currentProvider: null,
finishAuthUrl: null,
countryCode: null,
providers: [],
secondaryProviders: [],
pipelineUserDetails: null,
errorMessage: null,
welcomePageRedirectUrl: null,
},
};
const reducer = (state = defaultState, action = {}) => {
switch (action.type) {
case THIRD_PARTY_AUTH_CONTEXT.BEGIN:
return {
...state,
thirdPartyAuthApiStatus: PENDING_STATE,
};
case THIRD_PARTY_AUTH_CONTEXT.SUCCESS: {
return {
...state,
fieldDescriptions: action.payload.fieldDescriptions?.fields,
optionalFields: action.payload.optionalFields,
thirdPartyAuthContext: action.payload.thirdPartyAuthContext,
thirdPartyAuthApiStatus: COMPLETE_STATE,
countries: action.payload.countries,
};
}
case THIRD_PARTY_AUTH_CONTEXT.FAILURE:
return {
...state,
thirdPartyAuthApiStatus: FAILURE_STATE,
thirdPartyAuthContext: {
...state.thirdPartyAuthContext,
errorMessage: null,
},
};
case THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG:
return {
...state,
thirdPartyAuthApiStatus: PENDING_STATE,
thirdPartyAuthContext: {
...state.thirdPartyAuthContext,
errorMessage: null,
},
};
default:
return state;
}
};
export default reducer;

View File

@@ -0,0 +1,34 @@
import { logError } from '@edx/frontend-platform/logging';
import { call, put, takeEvery } from 'redux-saga/effects';
import {
getThirdPartyAuthContextBegin,
getThirdPartyAuthContextFailure,
getThirdPartyAuthContextSuccess,
THIRD_PARTY_AUTH_CONTEXT,
} from './actions';
import {
getCountryList,
getThirdPartyAuthContext,
} from './service';
import { setCountryFromThirdPartyAuthContext } from '../../register/data/actions';
export function* fetchThirdPartyAuthContext(action) {
try {
yield put(getThirdPartyAuthContextBegin());
const {
fieldDescriptions, optionalFields, thirdPartyAuthContext,
} = yield call(getThirdPartyAuthContext, action.payload.urlParams);
const countries = (yield call(getCountryList)) || [];
yield put(setCountryFromThirdPartyAuthContext(thirdPartyAuthContext.countryCode));
yield put(getThirdPartyAuthContextSuccess(fieldDescriptions, optionalFields, thirdPartyAuthContext, countries));
} catch (e) {
yield put(getThirdPartyAuthContextFailure());
logError(e);
}
}
export default function* saga() {
yield takeEvery(THIRD_PARTY_AUTH_CONTEXT.BASE, fetchThirdPartyAuthContext);
}

View File

@@ -0,0 +1,28 @@
import { createSelector } from 'reselect';
export const storeName = 'commonComponents';
export const commonComponentsSelector = state => ({ ...state[storeName] });
export const thirdPartyAuthContextSelector = createSelector(
commonComponentsSelector,
commonComponents => commonComponents.thirdPartyAuthContext,
);
export const fieldDescriptionSelector = createSelector(
commonComponentsSelector,
commonComponents => commonComponents.fieldDescriptions,
);
export const optionalFieldsSelector = createSelector(
commonComponentsSelector,
commonComponents => commonComponents.optionalFields,
);
export const tpaProvidersSelector = createSelector(
commonComponentsSelector,
commonComponents => ({
providers: commonComponents.thirdPartyAuthContext.providers,
secondaryProviders: commonComponents.thirdPartyAuthContext.secondaryProviders,
}),
);

View File

@@ -0,0 +1,53 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { FIELD_LABELS } from '../../data/constants';
// eslint-disable-next-line import/prefer-default-export
export async function getThirdPartyAuthContext(urlParams) {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
params: urlParams,
isPublic: true,
};
const { data } = await getAuthenticatedHttpClient()
.get(
`${getConfig().LMS_BASE_URL}/api/mfe_context`,
requestConfig,
)
.catch((e) => {
throw (e);
});
return {
fieldDescriptions: data.registrationFields || {},
optionalFields: data.optionalFields || {},
thirdPartyAuthContext: data.contextData || {},
};
}
function extractCountryList(data) {
return data?.fields
.find(({ name }) => name === FIELD_LABELS.COUNTRY)
?.options?.map(({ value, name }) => ({ code: value, name })) || [];
}
export async function getCountryList() {
try {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
};
const { data } = await getAuthenticatedHttpClient()
.get(
`${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`,
requestConfig,
);
return extractCountryList(data);
} catch (e) {
logError(e);
return [];
}
}

View File

@@ -0,0 +1,82 @@
import { PENDING_STATE } from '../../../data/constants';
import { THIRD_PARTY_AUTH_CONTEXT, THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG } from '../actions';
import reducer from '../reducers';
describe('common components reducer', () => {
it('test mfe context response', () => {
const state = {
fieldDescriptions: {},
optionalFields: {},
thirdPartyAuthApiStatus: null,
thirdPartyAuthContext: {
currentProvider: null,
finishAuthUrl: null,
countryCode: null,
providers: [],
secondaryProviders: [],
pipelineUserDetails: null,
errorMessage: null,
},
};
const fieldDescriptions = {
fields: [],
};
const optionalFields = {
fields: [],
extended_profile: {},
};
const thirdPartyAuthContext = { ...state.thirdPartyAuthContext };
const action = {
type: THIRD_PARTY_AUTH_CONTEXT.SUCCESS,
payload: { fieldDescriptions, optionalFields, thirdPartyAuthContext },
};
expect(
reducer(state, action),
).toEqual(
{
...state,
fieldDescriptions: [],
optionalFields: {
fields: [],
extended_profile: {},
},
thirdPartyAuthApiStatus: 'complete',
},
);
});
it('should clear tpa context error message', () => {
const state = {
fieldDescriptions: {},
optionalFields: {},
thirdPartyAuthApiStatus: null,
thirdPartyAuthContext: {
currentProvider: null,
finishAuthUrl: null,
countryCode: null,
providers: [],
secondaryProviders: [],
pipelineUserDetails: null,
errorMessage: 'An error occurred',
},
};
const action = {
type: THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG,
};
expect(
reducer(state, action),
).toEqual(
{
...state,
thirdPartyAuthApiStatus: PENDING_STATE,
thirdPartyAuthContext: {
...state.thirdPartyAuthContext,
errorMessage: null,
},
},
);
});
});

View File

@@ -0,0 +1,77 @@
import { runSaga } from 'redux-saga';
import { setCountryFromThirdPartyAuthContext } from '../../../register/data/actions';
import initializeMockLogging from '../../../setupTest';
import * as actions from '../actions';
import { fetchThirdPartyAuthContext } from '../sagas';
import * as api from '../service';
const { loggingService } = initializeMockLogging();
jest.mock('../service', () => ({
getCountryList: jest.fn(),
getThirdPartyAuthContext: jest.fn(),
}));
describe('fetchThirdPartyAuthContext', () => {
const params = {
payload: { urlParams: {} },
};
const data = {
currentProvider: null,
providers: [],
secondaryProviders: [],
finishAuthUrl: null,
pipelineUserDetails: {},
};
beforeEach(() => {
loggingService.logError.mockReset();
});
it('should call service and dispatch success action', async () => {
const getThirdPartyAuthContext = jest.spyOn(api, 'getThirdPartyAuthContext')
.mockImplementation(() => Promise.resolve({
thirdPartyAuthContext: data,
fieldDescriptions: {},
optionalFields: {},
countries: [],
}));
const dispatched = [];
await runSaga(
{ dispatch: (action) => dispatched.push(action) },
fetchThirdPartyAuthContext,
params,
);
expect(getThirdPartyAuthContext).toHaveBeenCalledTimes(1);
expect(dispatched).toEqual([
actions.getThirdPartyAuthContextBegin(),
setCountryFromThirdPartyAuthContext(),
actions.getThirdPartyAuthContextSuccess({}, {}, data, []),
]);
getThirdPartyAuthContext.mockClear();
});
it('should call service and dispatch error action', async () => {
const getThirdPartyAuthContext = jest.spyOn(api, 'getThirdPartyAuthContext')
.mockImplementation(() => Promise.reject(new Error('something went wrong')));
const dispatched = [];
await runSaga(
{ dispatch: (action) => dispatched.push(action) },
fetchThirdPartyAuthContext,
params,
);
expect(getThirdPartyAuthContext).toHaveBeenCalledTimes(1);
expect(loggingService.logError).toHaveBeenCalled();
expect(dispatched).toEqual([
actions.getThirdPartyAuthContextBegin(),
actions.getThirdPartyAuthContextFailure(),
]);
getThirdPartyAuthContext.mockClear();
});
});

View File

@@ -7,6 +7,9 @@ export { default as SocialAuthProviders } from './SocialAuthProviders';
export { default as ThirdPartyAuthAlert } from './ThirdPartyAuthAlert';
export { default as InstitutionLogistration } from './InstitutionLogistration';
export { RenderInstitutionButton } from './InstitutionLogistration';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { storeName } from './data/selectors';
export { default as FormGroup } from './FormGroup';
export { default as PasswordField } from './PasswordField';
export { default as Zendesk } from './Zendesk';

View File

@@ -1,21 +1,16 @@
import React from 'react';
import { Provider } from 'react-redux';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { fireEvent, render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { RegisterProvider } from '../../register/components/RegisterContext';
import { useFieldValidations } from '../../register/data/apiHook';
import { fetchRealtimeValidations } from '../../register/data/actions';
import FormGroup from '../FormGroup';
import PasswordField from '../PasswordField';
// Mock the useFieldValidations hook
jest.mock('../../register/data/apiHook', () => ({
useFieldValidations: jest.fn(),
}));
describe('FormGroup', () => {
const props = {
floatingLabel: 'Email',
@@ -41,52 +36,37 @@ describe('FormGroup', () => {
});
describe('PasswordField', () => {
const mockStore = configureStore();
const IntlPasswordField = injectIntl(PasswordField);
let props = {};
let queryClient;
let mockMutate;
let store = {};
const renderWrapper = (children) => (
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter>
<RegisterProvider>
{children}
</RegisterProvider>
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>
const reduxWrapper = children => (
<IntlProvider locale="en">
<MemoryRouter>
<Provider store={store}>{children}</Provider>
</MemoryRouter>
</IntlProvider>
);
const initialState = {
register: {
validationApiRateLimited: false,
},
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
mockMutate = jest.fn();
useFieldValidations.mockReturnValue({
mutate: mockMutate,
isPending: false,
});
store = mockStore(initialState);
props = {
floatingLabel: 'Password',
name: 'password',
value: 'password123',
handleFocus: jest.fn(),
};
jest.clearAllMocks();
});
it('should show/hide password on icon click', () => {
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordInput = getByLabelText('Password');
const showPasswordButton = getByLabelText('Show password');
@@ -99,7 +79,7 @@ describe('PasswordField', () => {
});
it('should show password requirement tooltip on focus', async () => {
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordInput = getByLabelText('Password');
jest.useFakeTimers();
await act(async () => {
@@ -116,7 +96,7 @@ describe('PasswordField', () => {
...props,
value: '',
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordInput = getByLabelText('Password');
jest.useFakeTimers();
await act(async () => {
@@ -139,7 +119,7 @@ describe('PasswordField', () => {
});
it('should update password requirement checks', async () => {
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordInput = getByLabelText('Password');
jest.useFakeTimers();
await act(async () => {
@@ -162,7 +142,7 @@ describe('PasswordField', () => {
});
it('should not run validations when blur is fired on password icon click', () => {
const { container, getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { container, getByLabelText } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordInput = container.querySelector('input[name="password"]');
const passwordIcon = getByLabelText('Show password');
@@ -183,7 +163,7 @@ describe('PasswordField', () => {
...props,
handleBlur: jest.fn(),
};
const { container } = render(renderWrapper(<PasswordField {...props} />));
const { container } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordInput = container.querySelector('input[name="password"]');
fireEvent.blur(passwordInput, {
@@ -201,7 +181,7 @@ describe('PasswordField', () => {
...props,
handleErrorChange: jest.fn(),
};
const { container } = render(renderWrapper(<PasswordField {...props} />));
const { container } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordInput = container.querySelector('input[name="password"]');
fireEvent.blur(passwordInput, {
@@ -224,7 +204,7 @@ describe('PasswordField', () => {
handleErrorChange: jest.fn(),
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordIcon = getByLabelText('Show password');
@@ -244,7 +224,7 @@ describe('PasswordField', () => {
handleErrorChange: jest.fn(),
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordIcon = getByLabelText('Show password');
@@ -263,11 +243,12 @@ describe('PasswordField', () => {
});
it('should run backend validations when frontend validations pass on blur when rendered from register page', () => {
store.dispatch = jest.fn(store.dispatch);
props = {
...props,
handleErrorChange: jest.fn(),
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordField = getByLabelText('Password');
fireEvent.blur(passwordField, {
target: {
@@ -276,17 +257,18 @@ describe('PasswordField', () => {
},
});
expect(mockMutate).toHaveBeenCalledWith({ password: 'password123' });
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ password: 'password123' }));
});
it('should use password value from prop when password icon is focused out (blur due to icon)', () => {
store.dispatch = jest.fn(store.dispatch);
props = {
...props,
value: 'testPassword',
handleErrorChange: jest.fn(),
handleBlur: jest.fn(),
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(reduxWrapper(<IntlPasswordField {...props} />));
const passwordIcon = getByLabelText('Show password');

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import renderer from 'react-test-renderer';

View File

@@ -1,7 +1,9 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import renderer from 'react-test-renderer';
import { PENDING_STATE, REGISTER_PAGE } from '../../data/constants';
import { REGISTER_PAGE } from '../../data/constants';
import ThirdPartyAuthAlert from '../ThirdPartyAuthAlert';
describe('ThirdPartyAuthAlert', () => {
@@ -36,19 +38,4 @@ describe('ThirdPartyAuthAlert', () => {
).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders skeleton for pending third-party auth', () => {
props = {
...props,
thirdPartyAuthApiStatus: PENDING_STATE,
isThirdPartyAuthActive: true,
};
const tree = renderer.create(
<IntlProvider locale="en">
<ThirdPartyAuthAlert {...props} />
</IntlProvider>,
).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -1,5 +1,7 @@
/* eslint-disable import/no-import-module-exports */
/* eslint-disable react/function-component-definition */
import React from 'react';
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';

View File

@@ -1,25 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThirdPartyAuthAlert renders skeleton for pending third-party auth 1`] = `
<div
className="fade alert-content alert-warning mt-n2 mb-5 alert show"
id="tpa-alert"
role="alert"
>
<div
className="pgn__alert-message-wrapper"
>
<div
className="alert-message-content"
>
<p>
You have successfully signed into Google, but your Google account does not have a linked Your Platform Name Here account. To link your accounts, sign in now using your Your Platform Name Here password.
</p>
</div>
</div>
</div>
`;
exports[`ThirdPartyAuthAlert should match login page third party auth alert message snapshot 1`] = `
<div
className="fade alert-content alert-warning mt-n2 mb-5 alert show"

View File

@@ -1 +0,0 @@
export const appId = 'org.openedx.frontend.app.authn';

View File

@@ -0,0 +1,33 @@
import { getConfig } from '@edx/frontend-platform';
import { composeWithDevTools } from '@redux-devtools/extension';
import { applyMiddleware, compose, createStore } from 'redux';
import { createLogger } from 'redux-logger';
import createSagaMiddleware from 'redux-saga';
import thunkMiddleware from 'redux-thunk';
import createRootReducer from './reducers';
import rootSaga from './sagas';
const sagaMiddleware = createSagaMiddleware();
function composeMiddleware() {
if (getConfig().ENVIRONMENT === 'development') {
const loggerMiddleware = createLogger({
collapsed: true,
});
return composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware));
}
return compose(applyMiddleware(thunkMiddleware, sagaMiddleware));
}
export default function configureStore(initialState = {}) {
const store = createStore(
createRootReducer(),
initialState,
composeMiddleware(),
);
sagaMiddleware.run(rootSaga);
return store;
}

View File

@@ -37,3 +37,6 @@ export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+
// things like auto-enrollment upon login and registration.
export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow', 'next', 'register_for_free', 'track', 'is_account_recovery', 'variant', 'host', 'cta'];
export const REDIRECT = 'redirect';
export const FIELD_LABELS = {
COUNTRY: 'country',
};

36
src/data/reducers.js Executable file
View File

@@ -0,0 +1,36 @@
import { combineReducers } from 'redux';
import {
reducer as commonComponentsReducer,
storeName as commonComponentsStoreName,
} from '../common-components';
import {
reducer as forgotPasswordReducer,
storeName as forgotPasswordStoreName,
} from '../forgot-password';
import {
reducer as loginReducer,
storeName as loginStoreName,
} from '../login';
import {
reducer as authnProgressiveProfilingReducers,
storeName as authnProgressiveProfilingStoreName,
} from '../progressive-profiling';
import {
reducer as registerReducer,
storeName as registerStoreName,
} from '../register';
import {
reducer as resetPasswordReducer,
storeName as resetPasswordStoreName,
} from '../reset-password';
const createRootReducer = () => combineReducers({
[loginStoreName]: loginReducer,
[registerStoreName]: registerReducer,
[commonComponentsStoreName]: commonComponentsReducer,
[forgotPasswordStoreName]: forgotPasswordReducer,
[resetPasswordStoreName]: resetPasswordReducer,
[authnProgressiveProfilingStoreName]: authnProgressiveProfilingReducers,
});
export default createRootReducer;

19
src/data/sagas.js Normal file
View File

@@ -0,0 +1,19 @@
import { all } from 'redux-saga/effects';
import { saga as commonComponentsSaga } from '../common-components';
import { saga as forgotPasswordSaga } from '../forgot-password';
import { saga as loginSaga } from '../login';
import { saga as authnProgressiveProfilingSaga } from '../progressive-profiling';
import { saga as registrationSaga } from '../register';
import { saga as resetPasswordSaga } from '../reset-password';
export default function* rootSaga() {
yield all([
loginSaga(),
registrationSaga(),
commonComponentsSaga(),
forgotPasswordSaga(),
resetPasswordSaga(),
authnProgressiveProfilingSaga(),
]);
}

View File

@@ -0,0 +1,14 @@
import AsyncActionType from '../utils/reduxUtils';
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
expect(actionType.FORBIDDEN).toBe('HOUSE_CATS__START_THE_RACE__FORBIDDEN');
});
});

View File

@@ -7,4 +7,5 @@ export {
updatePathWithQueryParams,
windowScrollTo,
} from './dataUtils';
export { default as AsyncActionType } from './reduxUtils';
export { default as setCookie } from './cookies';

View File

@@ -0,0 +1,34 @@
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*/
export default class AsyncActionType {
constructor(topic, name) {
this.topic = topic;
this.name = name;
}
get BASE() {
return `${this.topic}__${this.name}`;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
get FORBIDDEN() {
return `${this.topic}__${this.name}__FORBIDDEN`;
}
}

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { Form, Icon } from '@openedx/paragon';
import { ExpandMore } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { fireEvent, render } from '@testing-library/react';

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@openedx/paragon';
@@ -41,7 +43,7 @@ const ForgotPasswordAlert = (props) => {
}}
/>
);
break;
break;
case INTERNAL_SERVER_ERROR:
message = formatMessage(messages['internal.server.error']);
break;

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
@@ -12,39 +13,42 @@ import {
Tabs,
} from '@openedx/paragon';
import { ChevronLeft } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useLocation, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useForgotPassword } from './data/apiHook';
import { forgotPassword, setForgotPasswordFormData } from './data/actions';
import { forgotPasswordResultSelector } from './data/selectors';
import ForgotPasswordAlert from './ForgotPasswordAlert';
import messages from './messages';
import BaseContainer from '../base-container';
import { FormGroup } from '../common-components';
import { LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants';
import { DEFAULT_STATE, LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants';
import { updatePathWithQueryParams, windowScrollTo } from '../data/utils';
const ForgotPasswordPage = () => {
const ForgotPasswordPage = (props) => {
const platformName = getConfig().SITE_NAME;
const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
const {
status, submitState, emailValidationError,
} = props;
const { formatMessage } = useIntl();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState('');
const [email, setEmail] = useState(props.email);
const [bannerEmail, setBannerEmail] = useState('');
const [formErrors, setFormErrors] = useState('');
const [validationError, setValidationError] = useState('');
const [status, setStatus] = useState(location.state?.status || null);
// React Query hook for forgot password
const { mutate: sendForgotPassword, isPending: isSending } = useForgotPassword();
const submitState = isSending ? 'pending' : 'default';
const [validationError, setValidationError] = useState(emailValidationError);
const navigate = useNavigate();
useEffect(() => {
sendPageEvent('login_and_registration', 'reset');
sendTrackEvent('edx.bi.password_reset_form.viewed', { category: 'user-engagement' });
}, []);
useEffect(() => {
setValidationError(emailValidationError);
}, [emailValidationError]);
useEffect(() => {
if (status === 'complete') {
setEmail('');
@@ -64,38 +68,22 @@ const ForgotPasswordPage = () => {
};
const handleBlur = () => {
setValidationError(getValidationMessage(email));
props.setForgotPasswordFormData({ email, emailValidationError: getValidationMessage(email) });
};
const handleFocus = () => {
setValidationError('');
};
const handleFocus = () => props.setForgotPasswordFormData({ emailValidationError: '' });
const handleSubmit = (e) => {
e.preventDefault();
setBannerEmail(email);
const validateError = getValidationMessage(email);
if (validateError) {
setFormErrors(validateError);
setValidationError(validateError);
const error = getValidationMessage(email);
if (error) {
setFormErrors(error);
props.setForgotPasswordFormData({ email, emailValidationError: error });
windowScrollTo({ left: 0, top: 0, behavior: 'smooth' });
} else {
setFormErrors('');
sendForgotPassword(email, {
onSuccess: (data, emailUsed) => {
setStatus('complete');
setBannerEmail(emailUsed);
setFormErrors('');
},
onError: (error) => {
if (error.response && error.response.status === 403) {
setStatus('forbidden');
} else {
setStatus('server-error');
}
},
});
props.forgotPassword(email);
}
};
@@ -165,7 +153,7 @@ const ForgotPasswordPage = () => {
)}
<p className="mt-5.5 small text-gray-700">
{formatMessage(messages['additional.help.text'], { platformName })}
<span className="mx-1">
<span>
<Hyperlink isInline destination={`mailto:${getConfig().INFO_EMAIL}`}>{getConfig().INFO_EMAIL}</Hyperlink>
</span>
</p>
@@ -176,4 +164,26 @@ const ForgotPasswordPage = () => {
);
};
export default ForgotPasswordPage;
ForgotPasswordPage.propTypes = {
email: PropTypes.string,
emailValidationError: PropTypes.string,
forgotPassword: PropTypes.func.isRequired,
setForgotPasswordFormData: PropTypes.func.isRequired,
status: PropTypes.string,
submitState: PropTypes.string,
};
ForgotPasswordPage.defaultProps = {
email: '',
emailValidationError: '',
status: null,
submitState: DEFAULT_STATE,
};
export default connect(
forgotPasswordResultSelector,
{
forgotPassword,
setForgotPasswordFormData,
},
)(ForgotPasswordPage);

View File

@@ -0,0 +1,32 @@
import { AsyncActionType } from '../../data/utils';
export const FORGOT_PASSWORD = new AsyncActionType('FORGOT', 'PASSWORD');
export const FORGOT_PASSWORD_PERSIST_FORM_DATA = 'FORGOT_PASSWORD_PERSIST_FORM_DATA';
// Forgot Password
export const forgotPassword = email => ({
type: FORGOT_PASSWORD.BASE,
payload: { email },
});
export const forgotPasswordBegin = () => ({
type: FORGOT_PASSWORD.BEGIN,
});
export const forgotPasswordSuccess = email => ({
type: FORGOT_PASSWORD.SUCCESS,
payload: { email },
});
export const forgotPasswordForbidden = () => ({
type: FORGOT_PASSWORD.FORBIDDEN,
});
export const forgotPasswordServerError = () => ({
type: FORGOT_PASSWORD.FAILURE,
});
export const setForgotPasswordFormData = (forgotPasswordFormData) => ({
type: FORGOT_PASSWORD_PERSIST_FORM_DATA,
payload: { forgotPasswordFormData },
});

View File

@@ -1,144 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import formurlencoded from 'form-urlencoded';
import { forgotPassword } from './api';
// Mock the platform dependencies
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
jest.mock('form-urlencoded', () => jest.fn());
const mockGetConfig = getConfig as jest.MockedFunction<typeof getConfig>;
const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as
jest.MockedFunction<typeof getAuthenticatedHttpClient>;
const mockFormurlencoded = formurlencoded as jest.MockedFunction<typeof formurlencoded>;
describe('forgot-password api', () => {
const mockHttpClient = {
post: jest.fn(),
};
const mockConfig = {
LMS_BASE_URL: 'http://localhost:18000',
};
beforeEach(() => {
jest.clearAllMocks();
mockGetConfig.mockReturnValue(mockConfig);
mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any);
mockFormurlencoded.mockImplementation((data) => `encoded=${JSON.stringify(data)}`);
});
describe('forgotPassword', () => {
const testEmail = 'test@example.com';
const expectedUrl = `${mockConfig.LMS_BASE_URL}/account/password`;
const expectedConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
};
it('should send forgot password request successfully', async () => {
const mockResponse = {
data: {
message: 'Password reset email sent successfully',
success: true,
},
};
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
const result = await forgotPassword(testEmail);
expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled();
expect(mockFormurlencoded).toHaveBeenCalledWith({ email: testEmail });
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
`encoded=${JSON.stringify({ email: testEmail })}`,
expectedConfig
);
expect(result).toEqual(mockResponse.data);
});
it('should handle empty email address', async () => {
const emptyEmail = '';
const mockResponse = {
data: {
message: 'Email is required',
success: false,
}
};
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
const result = await forgotPassword(emptyEmail);
expect(mockFormurlencoded).toHaveBeenCalledWith({ email: emptyEmail });
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
`encoded=${JSON.stringify({ email: emptyEmail })}`,
expectedConfig,
);
expect(result).toEqual(mockResponse.data);
});
it('should handle network errors without response', async () => {
const networkError = new Error('Network Error');
networkError.name = 'NetworkError';
mockHttpClient.post.mockRejectedValueOnce(networkError);
await expect(forgotPassword(testEmail)).rejects.toThrow('Network Error');
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
expect.any(String),
expectedConfig
);
});
it('should handle timeout errors', async () => {
const timeoutError = new Error('Request timeout');
timeoutError.name = 'TimeoutError';
mockHttpClient.post.mockRejectedValueOnce(timeoutError);
await expect(forgotPassword(testEmail)).rejects.toThrow('Request timeout');
});
it('should handle response with no data field', async () => {
const mockResponse = {
// No data field
status: 200,
statusText: 'OK',
};
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
const result = await forgotPassword(testEmail);
expect(result).toBeUndefined();
});
it('should return exactly the data field from response', async () => {
const expectedData = {
message: 'Password reset email sent successfully',
success: true,
timestamp: '2026-02-05T10:00:00Z',
};
const mockResponse = {
data: expectedData,
status: 200,
headers: {},
};
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
const result = await forgotPassword(testEmail);
expect(result).toEqual(expectedData);
expect(result).not.toHaveProperty('status');
expect(result).not.toHaveProperty('headers');
});
});
});

View File

@@ -1,175 +0,0 @@
import React from 'react';
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import * as api from './api';
import { useForgotPassword } from './apiHook';
// Mock the logging functions
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
logInfo: jest.fn(),
}));
// Mock the API function
jest.mock('./api', () => ({
forgotPassword: jest.fn(),
}));
const mockForgotPassword = api.forgotPassword as jest.MockedFunction<typeof api.forgotPassword>;
const mockLogError = logError as jest.MockedFunction<typeof logError>;
const mockLogInfo = logInfo as jest.MockedFunction<typeof logInfo>;
// Test wrapper component
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function TestWrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useForgotPassword', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useForgotPassword(), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(false);
expect(result.current.isError).toBe(false);
expect(result.current.isSuccess).toBe(false);
expect(result.current.error).toBe(null);
});
it('should send forgot password email successfully and log success', async () => {
const testEmail = 'test@example.com';
const mockResponse = {
message: 'Password reset email sent successfully',
success: true,
};
mockForgotPassword.mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useForgotPassword(), {
wrapper: createWrapper(),
});
result.current.mutate(testEmail);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockForgotPassword).toHaveBeenCalledWith(testEmail);
expect(result.current.data).toEqual(mockResponse);
});
it('should handle 403 forbidden error and log as info', async () => {
const testEmail = 'blocked@example.com';
const mockError = {
response: {
status: 403,
data: {
detail: 'Too many password reset attempts',
},
},
message: 'Forbidden',
};
mockForgotPassword.mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useForgotPassword(), {
wrapper: createWrapper(),
});
result.current.mutate(testEmail);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(mockForgotPassword).toHaveBeenCalledWith(testEmail);
expect(mockLogInfo).toHaveBeenCalledWith(mockError);
expect(mockLogError).not.toHaveBeenCalled();
expect(result.current.error).toEqual(mockError);
});
it('should handle network errors without response and log as error', async () => {
const testEmail = 'test@example.com';
const networkError = new Error('Network Error');
networkError.name = 'NetworkError';
mockForgotPassword.mockRejectedValueOnce(networkError);
const { result } = renderHook(() => useForgotPassword(), {
wrapper: createWrapper(),
});
result.current.mutate(testEmail);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(mockForgotPassword).toHaveBeenCalledWith(testEmail);
expect(mockLogError).toHaveBeenCalledWith(networkError);
expect(mockLogInfo).not.toHaveBeenCalled();
expect(result.current.error).toEqual(networkError);
});
it('should handle empty email address', async () => {
const testEmail = '';
const mockResponse = {
message: 'Email sent',
success: true,
};
mockForgotPassword.mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useForgotPassword(), {
wrapper: createWrapper(),
});
result.current.mutate(testEmail);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockForgotPassword).toHaveBeenCalledWith('');
});
it('should handle email with special characters', async () => {
const testEmail = 'user+test@example-domain.co.uk';
const mockResponse = {
message: 'Password reset email sent',
success: true,
};
mockForgotPassword.mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useForgotPassword(), {
wrapper: createWrapper(),
});
result.current.mutate(testEmail);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockForgotPassword).toHaveBeenCalledWith(testEmail);
expect(result.current.data).toEqual(mockResponse);
});
});

View File

@@ -1,47 +0,0 @@
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { useMutation } from '@tanstack/react-query';
import { forgotPassword } from './api';
interface ForgotPasswordResult {
success: boolean;
message?: string;
}
interface UseForgotPasswordOptions {
onSuccess?: (data: ForgotPasswordResult, email: string) => void;
onError?: (error: Error) => void;
}
interface ApiError {
response?: {
status: number;
data: Record<string, unknown>;
};
}
const useForgotPassword = (options: UseForgotPasswordOptions = {}) => useMutation({
mutationFn: (email: string) => (
forgotPassword(email)
),
onSuccess: (data: ForgotPasswordResult, email: string) => {
if (options.onSuccess) {
options.onSuccess(data, email);
}
},
onError: (error: ApiError) => {
// Handle different error types like the saga did
if (error.response && error.response.status === 403) {
logInfo(error);
} else {
logError(error);
}
if (options.onError) {
options.onError(error as Error);
}
},
});
export {
useForgotPassword,
};

View File

@@ -0,0 +1,58 @@
import { FORGOT_PASSWORD, FORGOT_PASSWORD_PERSIST_FORM_DATA } from './actions';
import { INTERNAL_SERVER_ERROR, PENDING_STATE } from '../../data/constants';
import { PASSWORD_RESET_FAILURE } from '../../reset-password/data/actions';
export const defaultState = {
status: '',
submitState: '',
email: '',
emailValidationError: '',
};
const reducer = (state = defaultState, action = null) => {
if (action !== null) {
switch (action.type) {
case FORGOT_PASSWORD.BEGIN:
return {
email: state.email,
status: 'pending',
submitState: PENDING_STATE,
};
case FORGOT_PASSWORD.SUCCESS:
return {
...defaultState,
status: 'complete',
};
case FORGOT_PASSWORD.FORBIDDEN:
return {
email: state.email,
status: 'forbidden',
};
case FORGOT_PASSWORD.FAILURE:
return {
email: state.email,
status: INTERNAL_SERVER_ERROR,
};
case PASSWORD_RESET_FAILURE:
return {
status: action.payload.errorCode,
};
case FORGOT_PASSWORD_PERSIST_FORM_DATA: {
const { forgotPasswordFormData } = action.payload;
return {
...state,
...forgotPasswordFormData,
};
}
default:
return {
...defaultState,
email: state.email,
emailValidationError: state.emailValidationError,
};
}
}
return state;
};
export default reducer;

View File

@@ -0,0 +1,35 @@
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { call, put, takeEvery } from 'redux-saga/effects';
// Actions
import {
FORGOT_PASSWORD,
forgotPasswordBegin,
forgotPasswordForbidden,
forgotPasswordServerError,
forgotPasswordSuccess,
} from './actions';
import { forgotPassword } from './service';
// Services
export function* handleForgotPassword(action) {
try {
yield put(forgotPasswordBegin());
yield call(forgotPassword, action.payload.email);
yield put(forgotPasswordSuccess(action.payload.email));
} catch (e) {
if (e.response && e.response.status === 403) {
yield put(forgotPasswordForbidden());
logInfo(e);
} else {
yield put(forgotPasswordServerError());
logError(e);
}
}
}
export default function* saga() {
yield takeEvery(FORGOT_PASSWORD.BASE, handleForgotPassword);
}

View File

@@ -0,0 +1,10 @@
import { createSelector } from 'reselect';
export const storeName = 'forgotPassword';
export const forgotPasswordSelector = state => ({ ...state[storeName] });
export const forgotPasswordResultSelector = createSelector(
forgotPasswordSelector,
forgotPassword => forgotPassword,
);

View File

@@ -2,7 +2,8 @@ import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import formurlencoded from 'form-urlencoded';
const forgotPassword = async (email: string) => {
// eslint-disable-next-line import/prefer-default-export
export async function forgotPassword(email) {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
@@ -19,8 +20,4 @@ const forgotPassword = async (email: string) => {
});
return data;
};
export {
forgotPassword,
};
}

View File

@@ -0,0 +1,34 @@
import {
FORGOT_PASSWORD_PERSIST_FORM_DATA,
} from '../actions';
import reducer from '../reducers';
describe('forgot password reducer', () => {
it('should set email and emailValidationError', () => {
const state = {
status: '',
submitState: '',
email: '',
emailValidationError: '',
};
const forgotPasswordFormData = {
email: 'test@gmail',
emailValidationError: 'Enter a valid email address',
};
const action = {
type: FORGOT_PASSWORD_PERSIST_FORM_DATA,
payload: { forgotPasswordFormData },
};
expect(
reducer(state, action),
).toEqual(
{
status: '',
submitState: '',
email: 'test@gmail',
emailValidationError: 'Enter a valid email address',
},
);
});
});

View File

@@ -0,0 +1,67 @@
import { runSaga } from 'redux-saga';
import initializeMockLogging from '../../../setupTest';
import * as actions from '../actions';
import { handleForgotPassword } from '../sagas';
import * as api from '../service';
const { loggingService } = initializeMockLogging();
describe('handleForgotPassword', () => {
const params = {
payload: {
forgotPasswordFormData: {
email: 'test@test.com',
},
},
};
beforeEach(() => {
loggingService.logError.mockReset();
loggingService.logInfo.mockReset();
});
it('should handle 500 error code', async () => {
const passwordErrorResponse = { response: { status: 500 } };
const forgotPasswordRequest = jest.spyOn(api, 'forgotPassword').mockImplementation(
() => Promise.reject(passwordErrorResponse),
);
const dispatched = [];
await runSaga(
{ dispatch: (action) => dispatched.push(action) },
handleForgotPassword,
params,
);
expect(loggingService.logError).toHaveBeenCalled();
expect(dispatched).toEqual([
actions.forgotPasswordBegin(),
actions.forgotPasswordServerError(),
]);
forgotPasswordRequest.mockClear();
});
it('should handle rate limit error', async () => {
const forbiddenErrorResponse = { response: { status: 403 } };
const forbiddenPasswordRequest = jest.spyOn(api, 'forgotPassword').mockImplementation(
() => Promise.reject(forbiddenErrorResponse),
);
const dispatched = [];
await runSaga(
{ dispatch: (action) => dispatched.push(action) },
handleForgotPassword,
params,
);
expect(loggingService.logInfo).toHaveBeenCalled();
expect(dispatched).toEqual([
actions.forgotPasswordBegin(),
actions.forgotPasswordForbidden(null),
]);
forbiddenPasswordRequest.mockClear();
});
});

View File

@@ -1 +1,5 @@
export { default as ForgotPasswordPage } from './ForgotPasswordPage';
export { default as reducer } from './data/reducers';
export { FORGOT_PASSWORD } from './data/actions';
export { default as saga } from './data/sagas';
export { storeName, forgotPasswordResultSelector } from './data/selectors';

View File

@@ -74,7 +74,7 @@ const messages = defineMessages({
},
'additional.help.text': {
id: 'additional.help.text',
defaultMessage: 'For additional help, contact {platformName} support at',
defaultMessage: 'For additional help, contact {platformName} support at ',
description: 'additional help text on forgot password page',
},
'sign.in.text': {

View File

@@ -1,17 +1,17 @@
import React from 'react';
import { Provider } from 'react-redux';
import { mergeConfig } from '@edx/frontend-platform';
import { configure, IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { configure, injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import {
fireEvent, render, screen, waitFor,
fireEvent, render, screen,
} from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import {
FORBIDDEN_STATE, FORM_SUBMISSION_ERROR, INTERNAL_SERVER_ERROR, LOGIN_PAGE,
} from '../../data/constants';
import { INTERNAL_SERVER_ERROR, LOGIN_PAGE } from '../../data/constants';
import { PASSWORD_RESET } from '../../reset-password/data/constants';
import { useForgotPassword } from '../data/apiHook';
import ForgotPasswordAlert from '../ForgotPasswordAlert';
import { setForgotPasswordFormData } from '../data/actions';
import ForgotPasswordPage from '../ForgotPasswordPage';
const mockedNavigator = jest.fn();
@@ -26,9 +26,14 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigator,
}));
jest.mock('../data/apiHook', () => ({
useForgotPassword: jest.fn(),
}));
const IntlForgotPasswordPage = injectIntl(ForgotPasswordPage);
const mockStore = configureStore();
const initialState = {
forgotPassword: {
status: '',
},
};
describe('ForgotPasswordPage', () => {
mergeConfig({
@@ -36,55 +41,19 @@ describe('ForgotPasswordPage', () => {
INFO_EMAIL: '',
});
let queryClient;
let mockMutate;
let mockIsPending;
let props = {};
let store = {};
const renderWrapper = (component, options = {}) => {
const {
status = null,
isPending = false,
mutateImplementation = jest.fn(),
} = options;
mockMutate = jest.fn((email, callbacks) => {
if (mutateImplementation && typeof mutateImplementation === 'function') {
mutateImplementation(email, callbacks);
}
});
mockIsPending = isPending;
useForgotPassword.mockReturnValue({
mutate: mockMutate,
isPending: mockIsPending,
isError: status === 'error' || status === 'server-error',
isSuccess: status === 'complete',
});
return (
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter>
{component}
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>
);
};
const reduxWrapper = children => (
<IntlProvider locale="en">
<MemoryRouter>
<Provider store={store}>{children}</Provider>
</MemoryRouter>
</IntlProvider>
);
beforeEach(() => {
// Create a fresh QueryClient for each test
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
store = mockStore(initialState);
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({
userId: 3,
@@ -99,13 +68,17 @@ describe('ForgotPasswordPage', () => {
},
messages: { 'es-419': {}, de: {}, 'en-us': {} },
});
// Clear mock calls between tests
jest.clearAllMocks();
props = {
forgotPassword: jest.fn(),
status: null,
};
});
const findByTextContent = (container, text) => Array.from(container.querySelectorAll('*')).find(
element => element.textContent === text,
);
it('not should display need other help signing in button', () => {
const { queryByTestId } = render(renderWrapper(<ForgotPasswordPage />));
const { queryByTestId } = render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const forgotPasswordButton = queryByTestId('forgot-password');
expect(forgotPasswordButton).toBeNull();
});
@@ -114,14 +87,14 @@ describe('ForgotPasswordPage', () => {
mergeConfig({
LOGIN_ISSUE_SUPPORT_LINK: '/support',
});
render(renderWrapper(<ForgotPasswordPage />));
render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const forgotPasswordButton = screen.findByText('Need help signing in?');
expect(forgotPasswordButton).toBeDefined();
});
it('should display email validation error message', async () => {
const validationMessage = 'We were unable to contact you.Enter a valid email address below.';
const { container } = render(renderWrapper(<ForgotPasswordPage />));
const { container } = render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const emailInput = screen.getByLabelText('Email');
@@ -135,28 +108,23 @@ describe('ForgotPasswordPage', () => {
expect(validationErrors).toBe(validationMessage);
});
it('should show alert on server error', async () => {
it('should show alert on server error', () => {
store = mockStore({
forgotPassword: { status: INTERNAL_SERVER_ERROR },
});
const expectedMessage = 'We were unable to contact you.'
+ 'An error has occurred. Try refreshing the page, or check your internet connection.';
// Create a component with server-error status to simulate the error state
const { container } = render(renderWrapper(<ForgotPasswordPage />, {
status: 'server-error',
}));
const { container } = render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
// The ForgotPasswordAlert should render with server error status
await waitFor(() => {
const alertElements = container.querySelectorAll('.alert-danger');
if (alertElements.length > 0) {
const validationErrors = alertElements[0].textContent;
expect(validationErrors).toBe(expectedMessage);
}
});
const alertElements = container.querySelectorAll('.alert-danger');
const validationErrors = alertElements[0].textContent;
expect(validationErrors).toBe(expectedMessage);
});
it('should display empty email validation message', () => {
it('should display empty email validation message', async () => {
const validationMessage = 'We were unable to contact you.Enter your email below.';
const { container } = render(renderWrapper(<ForgotPasswordPage />));
const { container } = render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const submitButton = screen.getByText('Submit');
fireEvent.click(submitButton);
@@ -167,25 +135,21 @@ describe('ForgotPasswordPage', () => {
expect(validationErrors).toBe(validationMessage);
});
it('should display request in progress error message', async () => {
it('should display request in progress error message', () => {
const rateLimitMessage = 'An error occurred.Your previous request is in progress, please try again in a few moments.';
// Create component with forbidden status to simulate rate limit error
const { container } = render(renderWrapper(<ForgotPasswordPage />, {
status: 'forbidden',
}));
await waitFor(() => {
const alertElements = container.querySelectorAll('.alert-danger');
if (alertElements.length > 0) {
const validationErrors = alertElements[0].textContent;
expect(validationErrors).toBe(rateLimitMessage);
}
store = mockStore({
forgotPassword: { status: 'forbidden' },
});
const { container } = render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const alertElements = container.querySelectorAll('.alert-danger');
const validationErrors = alertElements[0].textContent;
expect(validationErrors).toBe(rateLimitMessage);
});
it('should not display any error message on change event', () => {
render(renderWrapper(<ForgotPasswordPage />));
render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const emailInput = screen.getByLabelText('Email');
@@ -195,248 +159,115 @@ describe('ForgotPasswordPage', () => {
expect(errorElement).toBeNull();
});
it('should not cause errors when blur event occurs', () => {
render(renderWrapper(<ForgotPasswordPage />));
it('should set error in redux store on onBlur', () => {
const forgotPasswordFormData = {
email: 'test@gmail',
emailValidationError: 'Enter a valid email address',
};
props = {
...props,
email: 'test@gmail',
emailValidationError: '',
};
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const emailInput = screen.getByLabelText('Email');
// Simply test that blur event doesn't cause errors
fireEvent.blur(emailInput);
// No error assertions needed as we're just testing stability
expect(store.dispatch).toHaveBeenCalledWith(setForgotPasswordFormData(forgotPasswordFormData));
});
it('should display validation error message when invalid email is submitted', () => {
it('should display error message if available in props', async () => {
const validationMessage = 'Enter your email';
const { container } = render(renderWrapper(<ForgotPasswordPage />));
const submitButton = screen.getByText('Submit');
fireEvent.click(submitButton);
props = {
...props,
emailValidationError: validationMessage,
email: '',
};
const { container } = render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const validationElement = container.querySelector('.pgn__form-text-invalid');
expect(validationElement.textContent).toEqual(validationMessage);
});
it('should not cause errors when focus event occurs', () => {
render(renderWrapper(<ForgotPasswordPage />));
it('should clear error in redux store on onFocus', () => {
const forgotPasswordFormData = {
emailValidationError: '',
};
props = {
...props,
email: 'test@gmail',
emailValidationError: 'Enter a valid email address',
};
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const emailInput = screen.getByLabelText('Email');
fireEvent.focus(emailInput);
expect(store.dispatch).toHaveBeenCalledWith(setForgotPasswordFormData(forgotPasswordFormData));
});
it('should not display error message initially', async () => {
render(renderWrapper(<ForgotPasswordPage />));
it('should clear error message when cleared in props on focus', async () => {
props = {
...props,
emailValidationError: '',
email: '',
};
render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const errorElement = screen.queryByTestId('email-invalid-feedback');
expect(errorElement).toBeNull();
});
it('should display success message after email is sent', async () => {
const testEmail = 'test@example.com';
const { container } = render(renderWrapper(<ForgotPasswordPage />, {
status: 'complete',
}));
const emailInput = screen.getByLabelText('Email');
const submitButton = screen.getByText('Submit');
fireEvent.change(emailInput, { target: { value: testEmail } });
fireEvent.click(submitButton);
await waitFor(() => {
const successElements = container.querySelectorAll('.alert-success');
if (successElements.length > 0) {
const successMessage = successElements[0].textContent;
expect(successMessage).toContain('Check your email');
expect(successMessage).toContain('We sent an email');
}
it('should display success message after email is sent', () => {
store = mockStore({
...initialState,
forgotPassword: {
status: 'complete',
},
});
const successMessage = 'Check your emailWe sent an email to with instructions to reset your password. If you do not '
+ 'receive a password reset message after 1 minute, verify that you entered the correct email address,'
+ ' or check your spam folder. If you need further assistance, contact technical support.';
const { container } = render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const successElement = findByTextContent(container, successMessage);
expect(successElement).toBeDefined();
expect(successElement.textContent).toEqual(successMessage);
});
it('should call mutation on form submission with valid email', async () => {
render(renderWrapper(<ForgotPasswordPage />));
const emailInput = screen.getByLabelText('Email');
const submitButton = screen.getByText('Submit');
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.click(submitButton);
// Verify the mutation was called with the correct email and callbacks
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith('test@example.com', expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}));
it('should display invalid password reset link error', () => {
store = mockStore({
...initialState,
forgotPassword: {
status: PASSWORD_RESET.INVALID_TOKEN,
},
});
});
const successMessage = 'Invalid password reset link'
+ 'This password reset link is invalid. It may have been used already. '
+ 'Enter your email below to receive a new link.';
it('should call mutation with success callback', async () => {
const successMutation = (email, { onSuccess }) => {
onSuccess({}, email);
};
const { container } = render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const successElement = findByTextContent(container, successMessage);
render(renderWrapper(<ForgotPasswordPage />, {
mutateImplementation: successMutation,
}));
const emailInput = screen.getByLabelText('Email');
const submitButton = screen.getByText('Submit');
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith('test@example.com', expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}));
});
expect(successElement).toBeDefined();
expect(successElement.textContent).toEqual(successMessage);
});
it('should redirect onto login page', async () => {
const { container } = render(renderWrapper(<ForgotPasswordPage />));
const { container } = render(reduxWrapper(<IntlForgotPasswordPage {...props} />));
const navElement = container.querySelector('nav');
const anchorElement = navElement.querySelector('a');
fireEvent.click(anchorElement);
expect(mockedNavigator).toHaveBeenCalledWith(expect.stringContaining(LOGIN_PAGE));
});
it('should display token validation rate limit error message', async () => {
const expectedHeading = 'Too many requests';
const expectedMessage = 'An error has occurred because of too many requests. Please try again after some time.';
const { container } = render(renderWrapper(<ForgotPasswordPage />, {
status: PASSWORD_RESET.FORBIDDEN_REQUEST,
}));
await waitFor(() => {
const alertElements = container.querySelectorAll('.alert-danger');
if (alertElements.length > 0) {
const alertContent = alertElements[0].textContent;
expect(alertContent).toContain(expectedHeading);
expect(alertContent).toContain(expectedMessage);
}
});
});
it('should display invalid token error message', async () => {
const expectedHeading = 'Invalid password reset link';
const expectedMessage = 'This password reset link is invalid. It may have been used already. Enter your email below to receive a new link.';
const { container } = render(renderWrapper(<ForgotPasswordAlert />, {
status: PASSWORD_RESET.INVALID_TOKEN,
}));
await waitFor(() => {
const alertElements = container.querySelectorAll('.alert-danger');
if (alertElements.length > 0) {
const alertContent = alertElements[0].textContent;
expect(alertContent).toContain(expectedHeading);
expect(alertContent).toContain(expectedMessage);
}
});
});
it('should display token validation internal server error message', async () => {
const expectedHeading = 'Token validation failure';
const expectedMessage = 'An error has occurred. Try refreshing the page, or check your internet connection.';
const { container } = render(renderWrapper(<ForgotPasswordAlert />, {
status: PASSWORD_RESET.INTERNAL_SERVER_ERROR,
}));
await waitFor(() => {
const alertElements = container.querySelectorAll('.alert-danger');
if (alertElements.length > 0) {
const alertContent = alertElements[0].textContent;
expect(alertContent).toContain(expectedHeading);
expect(alertContent).toContain(expectedMessage);
}
});
});
});
describe('ForgotPasswordAlert', () => {
const renderAlertWrapper = (props) => {
const queryClient = new QueryClient();
return render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter>
<ForgotPasswordAlert {...props} />
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>,
);
};
it('should display internal server error message', () => {
const { container } = renderAlertWrapper({
status: INTERNAL_SERVER_ERROR,
email: 'test@example.com',
emailError: '',
});
const alertElement = container.querySelector('.alert-danger');
expect(alertElement).toBeTruthy();
expect(alertElement.textContent).toContain('We were unable to contact you.');
expect(alertElement.textContent).toContain('An error has occurred. Try refreshing the page, or check your internet connection.');
});
it('should display forbidden state error message', () => {
const { container } = renderAlertWrapper({
status: FORBIDDEN_STATE,
email: 'test@example.com',
emailError: '',
});
const alertElement = container.querySelector('.alert-danger');
expect(alertElement).toBeTruthy();
expect(alertElement.textContent).toContain('An error occurred.');
expect(alertElement.textContent).toContain('Your previous request is in progress, please try again in a few moments.');
});
it('should display form submission error message', () => {
const emailError = 'Enter a valid email address';
const { container } = renderAlertWrapper({
status: FORM_SUBMISSION_ERROR,
email: 'test@example.com',
emailError,
});
const alertElement = container.querySelector('.alert-danger');
expect(alertElement).toBeTruthy();
expect(alertElement.textContent).toContain('We were unable to contact you.');
expect(alertElement.textContent).toContain(`${emailError} below.`);
});
it('should display password reset invalid token error message', () => {
const { container } = renderAlertWrapper({
status: PASSWORD_RESET.INVALID_TOKEN,
email: 'test@example.com',
emailError: '',
});
const alertElement = container.querySelector('.alert-danger');
expect(alertElement).toBeTruthy();
expect(alertElement.textContent).toContain('Invalid password reset link');
expect(alertElement.textContent).toContain('This password reset link is invalid. It may have been used already. Enter your email below to receive a new link.');
});
it('should display password reset forbidden request error message', () => {
const { container } = renderAlertWrapper({
status: PASSWORD_RESET.FORBIDDEN_REQUEST,
email: 'test@example.com',
emailError: '',
});
const alertElement = container.querySelector('.alert-danger');
expect(alertElement).toBeTruthy();
expect(alertElement.textContent).toContain('Too many requests');
expect(alertElement.textContent).toContain('An error has occurred because of too many requests. Please try again after some time.');
});
it('should display password reset internal server error message', () => {
const { container } = renderAlertWrapper({
status: PASSWORD_RESET.INTERNAL_SERVER_ERROR,
email: 'test@example.com',
emailError: '',
});
const alertElement = container.querySelector('.alert-danger');
expect(alertElement).toBeTruthy();
expect(alertElement.textContent).toContain('Token validation failure');
expect(alertElement.textContent).toContain('An error has occurred. Try refreshing the page, or check your internet connection.');
expect(mockedNavigator).toHaveBeenCalledWith(LOGIN_PAGE);
});
});

View File

@@ -1,36 +1,27 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import { StrictMode } from 'react';
import React from 'react';
import ReactDOM from 'react-dom';
import {
APP_INIT_ERROR, APP_READY, initialize, mergeConfig, subscribe,
} from '@edx/frontend-platform';
import { ErrorPage } from '@edx/frontend-platform/react';
import { createRoot } from 'react-dom/client';
import configuration from './config';
import messages from './i18n';
import MainApp from './MainApp';
subscribe(APP_READY, () => {
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<MainApp />
</StrictMode>,
ReactDOM.render(
<MainApp />,
document.getElementById('root'),
);
});
subscribe(APP_INIT_ERROR, (error) => {
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<ErrorPage message={error.message} />
</StrictMode>,
);
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
});
initialize({

View File

@@ -1,75 +1,6 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@import "sass/style";
//commit color - Andal Learning Brand Colors - Override Paragon variables
:root {
--pgn-color-primary: #ff4f00;
--pgn-color-primary-100: #ffe6cc;
--pgn-color-primary-200: #ffcc99;
--pgn-color-primary-300: #ffb366;
--pgn-color-primary-400: #ff9933;
--pgn-color-primary-500: #ff4f00;
--pgn-color-primary-600: #cc3f00;
--pgn-color-primary-700: #992f00;
--pgn-color-primary-800: #661f00;
--pgn-color-primary-900: #330f00;
--pgn-color-action-primary: #ff4f00;
--pgn-color-action-primary-hover: #cc3f00;
--pgn-color-action-primary-focus: #992f00;
--pgn-color-action-primary-active: #992f00;
--pgn-color-primary-base: #ff4f00;
}
// Override all button variants to use Andal orange
.btn-primary {
background-color: #ff4f00 !important;
border-color: #ff4f00 !important;
&:hover {
background-color: #cc3f00 !important;
border-color: #cc3f00 !important;
}
}
.btn-outline-primary {
color: #ff4f00 !important;
border-color: #ff4f00 !important;
&:hover {
background-color: #ff4f00 !important;
color: #fff !important;
}
}
.btn-brand {
background-color: #ff4f00 !important;
border-color: #ff4f00 !important;
color: #fff !important;
}
// Override link colors
a {
color: #ff4f00;
&:hover {
color: #cc3f00;
}
}
// Override active states
.active {
background-color: #ff4f00 !important;
color: #fff !important;
}
// Hide Register tab, only show Sign in
.nav-tabs {
.nav-link[href*="register"] {
display: none !important;
}
}
// Change "with Andal LND" text color from blue to black
.text-accent-a {
color: #000000 !important;
}

View File

@@ -1,3 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@openedx/paragon';

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -26,7 +26,7 @@ const ChangePasswordPrompt = ({ variant, redirectUrl }) => {
}
},
};
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
// eslint-disable-next-line no-unused-vars
const [isOpen, open, close] = useToggle(true, handlers);
const { formatMessage } = useIntl();
const navigate = useNavigate();

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getAuthService } from '@edx/frontend-platform/auth';

View File

@@ -1,14 +1,26 @@
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form, StatefulButton } from '@openedx/paragon';
import { injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import {
Form, StatefulButton,
} from '@openedx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import Skeleton from 'react-loading-skeleton';
import { Link, useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
import AccountActivationMessage from './AccountActivationMessage';
import {
backupLoginFormBegin,
dismissPasswordResetBanner,
loginRequest,
} from './data/actions';
import { INVALID_FORM, TPA_AUTHENTICATION_FAILURE } from './data/constants';
import LoginFailureMessage from './LoginFailure';
import messages from './messages';
import {
FormGroup,
InstitutionLogistration,
@@ -16,12 +28,13 @@ import {
RedirectLogistration,
ThirdPartyAuthAlert,
} from '../common-components';
import AccountActivationMessage from './AccountActivationMessage';
import { useThirdPartyAuthContext } from '../common-components/components/ThirdPartyAuthContext';
import { useThirdPartyAuthHook } from '../common-components/data/apiHook';
import { getThirdPartyAuthContext } from '../common-components/data/actions';
import { thirdPartyAuthContextSelector } from '../common-components/data/selectors';
import EnterpriseSSO from '../common-components/EnterpriseSSO';
import ThirdPartyAuth from '../common-components/ThirdPartyAuth';
import { LOGIN_PAGE, PENDING_STATE, RESET_PAGE } from '../data/constants';
import {
DEFAULT_STATE, PENDING_STATE, RESET_PAGE,
} from '../data/constants';
import {
getActivationStatus,
getAllPossibleQueryParams,
@@ -30,93 +43,72 @@ import {
updatePathWithQueryParams,
} from '../data/utils';
import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess';
import { useLoginContext } from './components/LoginContext';
import { useLogin } from './data/apiHook';
import { INVALID_FORM, TPA_AUTHENTICATION_FAILURE } from './data/constants';
import LoginFailureMessage from './LoginFailure';
import messages from './messages';
const LoginPage = ({
institutionLogin,
handleInstitutionLogin,
}) => {
// Context for third-party auth
const LoginPage = (props) => {
const {
backedUpFormData,
loginErrorCode,
loginErrorContext,
loginResult,
shouldBackupState,
thirdPartyAuthContext: {
providers,
currentProvider,
secondaryProviders,
finishAuthUrl,
platformName,
errorMessage: thirdPartyErrorMessage,
},
thirdPartyAuthApiStatus,
thirdPartyAuthContext,
setThirdPartyAuthContextBegin,
setThirdPartyAuthContextSuccess,
setThirdPartyAuthContextFailure,
} = useThirdPartyAuthContext();
const location = useLocation();
const {
formFields,
setFormFields,
errors,
setErrors,
} = useLoginContext();
// React Query for server state
const [loginResult, setLoginResult] = useState({ success: false, redirectUrl: '' });
const [errorCode, setErrorCode] = useState({
type: '',
count: 0,
context: {},
});
const { mutate: loginUser, isPending: isLoggingIn } = useLogin({
onSuccess: (data) => {
setLoginResult({ success: true, redirectUrl: data.redirectUrl || '' });
},
onError: (formattedError) => {
setErrorCode(prev => ({
type: formattedError.type,
count: prev.count + 1,
context: formattedError.context,
}));
},
});
const [showResetPasswordSuccessBanner,
setShowResetPasswordSuccessBanner] = useState(location.state?.showResetPasswordSuccessBanner || null);
const {
providers,
currentProvider,
secondaryProviders,
finishAuthUrl,
platformName,
errorMessage: thirdPartyErrorMessage,
} = thirdPartyAuthContext;
institutionLogin,
showResetPasswordSuccessBanner,
submitState,
// Actions
backupFormState,
handleInstitutionLogin,
getTPADataFromBackend,
} = props;
const { formatMessage } = useIntl();
const activationMsgType = getActivationStatus();
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
const tpaHint = useMemo(() => getTpaHint(), []);
const params = { ...queryParams };
if (tpaHint) {
params.tpa_hint = tpaHint;
}
const { data, isSuccess, error } = useThirdPartyAuthHook(LOGIN_PAGE, params);
const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields });
const [errorCode, setErrorCode] = useState({ type: '', count: 0, context: {} });
const [errors, setErrors] = useState({ ...backedUpFormData.errors });
const tpaHint = getTpaHint();
useEffect(() => {
sendPageEvent('login_and_registration', 'login');
}, []);
// Fetch third-party auth context data
useEffect(() => {
setThirdPartyAuthContextBegin();
if (isSuccess && data) {
setThirdPartyAuthContextSuccess(
data.fieldDescriptions,
data.optionalFields,
data.thirdPartyAuthContext,
);
const payload = { ...queryParams };
if (tpaHint) {
payload.tpa_hint = tpaHint;
}
if (error) {
setThirdPartyAuthContextFailure();
getTPADataFromBackend(payload);
}, [getTPADataFromBackend, queryParams, tpaHint]);
/**
* Backup the login form in redux when login page is toggled.
*/
useEffect(() => {
if (shouldBackupState) {
backupFormState({
formFields: { ...formFields },
errors: { ...errors },
});
}
}, [tpaHint, queryParams, isSuccess, data, error,
setThirdPartyAuthContextBegin, setThirdPartyAuthContextSuccess, setThirdPartyAuthContextFailure]);
}, [shouldBackupState, formFields, errors, backupFormState]);
useEffect(() => {
if (loginErrorCode) {
setErrorCode(prevState => ({
type: loginErrorCode,
count: prevState.count + 1,
context: { ...loginErrorContext },
}));
}
}, [loginErrorCode, loginErrorContext]);
useEffect(() => {
if (thirdPartyErrorMessage) {
@@ -131,10 +123,7 @@ const LoginPage = ({
}, [thirdPartyErrorMessage]);
const validateFormFields = (payload) => {
const {
emailOrUsername,
password,
} = payload;
const { emailOrUsername, password } = payload;
const fieldErrors = { ...errors };
if (emailOrUsername === '') {
@@ -152,18 +141,14 @@ const LoginPage = ({
const handleSubmit = (event) => {
event.preventDefault();
if (showResetPasswordSuccessBanner) {
setShowResetPasswordSuccessBanner(false);
props.dismissPasswordResetBanner();
}
const formData = { ...formFields };
const validationErrors = validateFormFields(formData);
if (validationErrors.emailOrUsername || validationErrors.password) {
setErrors(validationErrors);
setErrorCode(prev => ({
type: INVALID_FORM,
count: prev.count + 1,
context: {},
}));
setErrors({ ...validationErrors });
setErrorCode(prevState => ({ type: INVALID_FORM, count: prevState.count + 1, context: {} }));
return;
}
@@ -173,36 +158,23 @@ const LoginPage = ({
password: formData.password,
...queryParams,
};
loginUser(payload);
props.loginRequest(payload);
};
const handleOnChange = (event) => {
const {
name,
value,
} = event.target;
// Save to context for persistence across tab switches
setFormFields(prevState => ({
...prevState,
[name]: value,
}));
const { name, value } = event.target;
setFormFields(prevState => ({ ...prevState, [name]: value }));
};
const handleOnFocus = (event) => {
const { name } = event.target;
setErrors(prevErrors => ({
...prevErrors,
[name]: '',
}));
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
};
const trackForgotPasswordLinkClick = () => {
sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement' });
};
const {
provider,
skipHintedLogin,
} = getTpaProvider(tpaHint, providers, secondaryProviders);
const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders);
if (tpaHint) {
if (thirdPartyAuthApiStatus === PENDING_STATE) {
@@ -227,7 +199,6 @@ const LoginPage = ({
/>
);
}
return (
<>
<Helmet>
@@ -279,10 +250,10 @@ const LoginPage = ({
type="submit"
variant="brand"
className="login-button-width"
state={(isLoggingIn ? PENDING_STATE : 'default')}
state={submitState}
labels={{
default: formatMessage(messages['sign.in.button']),
pending: 'pending',
pending: '',
}}
onClick={handleSubmit}
onMouseDown={(event) => event.preventDefault()}
@@ -310,9 +281,88 @@ const LoginPage = ({
);
};
const mapStateToProps = state => {
const loginPageState = state.login;
return {
backedUpFormData: loginPageState.loginFormData,
loginErrorCode: loginPageState.loginErrorCode,
loginErrorContext: loginPageState.loginErrorContext,
loginResult: loginPageState.loginResult,
shouldBackupState: loginPageState.shouldBackupState,
showResetPasswordSuccessBanner: loginPageState.showResetPasswordSuccessBanner,
submitState: loginPageState.submitState,
thirdPartyAuthContext: thirdPartyAuthContextSelector(state),
thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus,
};
};
LoginPage.propTypes = {
backedUpFormData: PropTypes.shape({
formFields: PropTypes.shape({}),
errors: PropTypes.shape({}),
}),
loginErrorCode: PropTypes.string,
loginErrorContext: PropTypes.shape({
email: PropTypes.string,
redirectUrl: PropTypes.string,
context: PropTypes.shape({}),
}),
loginResult: PropTypes.shape({
redirectUrl: PropTypes.string,
success: PropTypes.bool,
}),
shouldBackupState: PropTypes.bool,
showResetPasswordSuccessBanner: PropTypes.bool,
submitState: PropTypes.string,
thirdPartyAuthApiStatus: PropTypes.string,
institutionLogin: PropTypes.bool.isRequired,
thirdPartyAuthContext: PropTypes.shape({
currentProvider: PropTypes.string,
errorMessage: PropTypes.string,
platformName: PropTypes.string,
providers: PropTypes.arrayOf(PropTypes.shape({})),
secondaryProviders: PropTypes.arrayOf(PropTypes.shape({})),
finishAuthUrl: PropTypes.string,
}),
// Actions
backupFormState: PropTypes.func.isRequired,
dismissPasswordResetBanner: PropTypes.func.isRequired,
loginRequest: PropTypes.func.isRequired,
getTPADataFromBackend: PropTypes.func.isRequired,
handleInstitutionLogin: PropTypes.func.isRequired,
};
export default LoginPage;
LoginPage.defaultProps = {
backedUpFormData: {
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
loginErrorCode: null,
loginErrorContext: {},
loginResult: {},
shouldBackupState: false,
showResetPasswordSuccessBanner: false,
submitState: DEFAULT_STATE,
thirdPartyAuthApiStatus: PENDING_STATE,
thirdPartyAuthContext: {
currentProvider: null,
errorMessage: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [],
},
};
export default connect(
mapStateToProps,
{
backupFormState: backupLoginFormBegin,
dismissPasswordResetBanner,
loginRequest,
getTPADataFromBackend: getThirdPartyAuthContext,
},
)(injectIntl(LoginPage));

View File

@@ -1,63 +0,0 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { LoginProvider, useLoginContext } from './LoginContext';
const TestComponent = () => {
const {
formFields,
errors,
} = useLoginContext();
return (
<div>
<div>{formFields ? 'FormFields Available' : 'FormFields Not Available'}</div>
<div>{formFields.emailOrUsername !== undefined ? 'EmailOrUsername Field Available' : 'EmailOrUsername Field Not Available'}</div>
<div>{formFields.password !== undefined ? 'Password Field Available' : 'Password Field Not Available'}</div>
<div>{errors ? 'Errors Available' : 'Errors Not Available'}</div>
<div>{errors.emailOrUsername !== undefined ? 'EmailOrUsername Error Available' : 'EmailOrUsername Error Not Available'}</div>
<div>{errors.password !== undefined ? 'Password Error Available' : 'Password Error Not Available'}</div>
</div>
);
};
describe('LoginContext', () => {
it('should render children', () => {
render(
<LoginProvider>
<div>Test Child</div>
</LoginProvider>,
);
expect(screen.getByText('Test Child')).toBeInTheDocument();
});
it('should provide all context values to children', () => {
render(
<LoginProvider>
<TestComponent />
</LoginProvider>,
);
expect(screen.getByText('FormFields Available')).toBeInTheDocument();
expect(screen.getByText('EmailOrUsername Field Available')).toBeInTheDocument();
expect(screen.getByText('Password Field Available')).toBeInTheDocument();
expect(screen.getByText('Errors Available')).toBeInTheDocument();
expect(screen.getByText('EmailOrUsername Error Available')).toBeInTheDocument();
expect(screen.getByText('Password Error Available')).toBeInTheDocument();
});
it('should render multiple children', () => {
render(
<LoginProvider>
<div>First Child</div>
<div>Second Child</div>
<div>Third Child</div>
</LoginProvider>,
);
expect(screen.getByText('First Child')).toBeInTheDocument();
expect(screen.getByText('Second Child')).toBeInTheDocument();
expect(screen.getByText('Third Child')).toBeInTheDocument();
});
});

View File

@@ -1,58 +0,0 @@
import {
createContext, FC, ReactNode, useContext, useMemo, useState,
} from 'react';
export interface FormFields {
emailOrUsername: string;
password: string;
}
export interface FormErrors {
emailOrUsername: string;
password: string;
}
interface LoginContextType {
formFields: FormFields;
setFormFields: (fields: FormFields) => void;
errors: FormErrors;
setErrors: (errors: FormErrors) => void;
}
const LoginContext = createContext<LoginContextType | undefined>(undefined);
interface LoginProviderProps {
children: ReactNode;
}
export const LoginProvider: FC<LoginProviderProps> = ({ children }) => {
const [formFields, setFormFields] = useState({
emailOrUsername: '',
password: '',
});
const [errors, setErrors] = useState({
emailOrUsername: '',
password: '',
});
const contextValue = useMemo(() => ({
formFields,
setFormFields,
errors,
setErrors,
}), [formFields, errors]);
return (
<LoginContext.Provider value={contextValue}>
{children}
</LoginContext.Provider>
);
};
export const useLoginContext = () => {
const context = useContext(LoginContext);
if (context === undefined) {
throw new Error('useLoginContext must be used within a LoginProvider');
}
return context;
};

39
src/login/data/actions.js Normal file
View File

@@ -0,0 +1,39 @@
import { AsyncActionType } from '../../data/utils';
export const BACKUP_LOGIN_DATA = new AsyncActionType('LOGIN', 'BACKUP_LOGIN_DATA');
export const LOGIN_REQUEST = new AsyncActionType('LOGIN', 'REQUEST');
export const DISMISS_PASSWORD_RESET_BANNER = 'DISMISS_PASSWORD_RESET_BANNER';
// Backup login form data
export const backupLoginForm = () => ({
type: BACKUP_LOGIN_DATA.BASE,
});
export const backupLoginFormBegin = (data) => ({
type: BACKUP_LOGIN_DATA.BEGIN,
payload: { ...data },
});
// Login
export const loginRequest = creds => ({
type: LOGIN_REQUEST.BASE,
payload: { creds },
});
export const loginRequestBegin = () => ({
type: LOGIN_REQUEST.BEGIN,
});
export const loginRequestSuccess = (redirectUrl, success) => ({
type: LOGIN_REQUEST.SUCCESS,
payload: { redirectUrl, success },
});
export const loginRequestFailure = (loginError) => ({
type: LOGIN_REQUEST.FAILURE,
payload: { loginError },
});
export const dismissPasswordResetBanner = () => ({
type: DISMISS_PASSWORD_RESET_BANNER,
});

View File

@@ -1,208 +0,0 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import * as QueryString from 'query-string';
import { login } from './api';
// Mock the platform dependencies
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
camelCaseObject: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
jest.mock('query-string', () => ({
stringify: jest.fn(),
}));
const mockGetConfig = getConfig as jest.MockedFunction<typeof getConfig>;
const mockCamelCaseObject = camelCaseObject as jest.MockedFunction<typeof camelCaseObject>;
const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as
jest.MockedFunction<typeof getAuthenticatedHttpClient>;
const mockQueryStringify = QueryString.stringify as jest.MockedFunction<typeof QueryString.stringify>;
describe('login api', () => {
const mockHttpClient = {
post: jest.fn(),
};
const mockConfig = {
LMS_BASE_URL: 'http://localhost:18000',
};
beforeEach(() => {
jest.clearAllMocks();
mockGetConfig.mockReturnValue(mockConfig);
mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any);
mockCamelCaseObject.mockImplementation((obj) => obj);
mockQueryStringify.mockImplementation((obj) => `stringified=${JSON.stringify(obj)}`);
});
describe('login', () => {
const mockCredentials = {
email_or_username: 'testuser@example.com',
password: 'password123',
};
const expectedUrl = `${mockConfig.LMS_BASE_URL}/api/user/v2/account/login_session/`;
const expectedConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
};
it('should login successfully with redirect URL', async () => {
const mockResponseData = {
redirect_url: 'http://localhost:18000/courses',
success: true,
};
const mockResponse = { data: mockResponseData };
const expectedResult = {
redirectUrl: 'http://localhost:18000/courses',
success: true,
};
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
mockCamelCaseObject.mockReturnValueOnce(expectedResult);
const result = await login(mockCredentials);
expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled();
expect(mockQueryStringify).toHaveBeenCalledWith(mockCredentials);
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
`stringified=${JSON.stringify(mockCredentials)}`,
expectedConfig,
);
expect(mockCamelCaseObject).toHaveBeenCalledWith({
redirectUrl: 'http://localhost:18000/courses',
success: true,
});
expect(result).toEqual(expectedResult);
});
it('should handle login failure with success false', async () => {
const mockResponseData = {
redirect_url: 'http://localhost:18000/login',
success: false,
};
const mockResponse = { data: mockResponseData };
const expectedResult = {
redirectUrl: 'http://localhost:18000/login',
success: false,
};
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
mockCamelCaseObject.mockReturnValueOnce(expectedResult);
const result = await login(mockCredentials);
expect(mockCamelCaseObject).toHaveBeenCalledWith({
redirectUrl: 'http://localhost:18000/login',
success: false,
});
expect(result).toEqual(expectedResult);
});
it('should properly stringify credentials using QueryString', async () => {
const complexCredentials = {
email_or_username: 'user@example.com',
password: 'pass word!@#$',
remember_me: true,
next: '/courses/course-v1:edX+DemoX+Demo_Course/courseware',
};
const mockResponse = { data: { success: true } };
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
await login(complexCredentials);
expect(mockQueryStringify).toHaveBeenCalledWith(complexCredentials);
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
`stringified=${JSON.stringify(complexCredentials)}`,
expectedConfig,
);
});
it('should use correct request configuration', async () => {
const mockResponse = { data: { success: true } };
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
await login(mockCredentials);
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
expect.any(String),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
},
);
});
it('should handle API error during login', async () => {
const mockError = new Error('Login API error');
mockHttpClient.post.mockRejectedValueOnce(mockError);
await expect(login(mockCredentials)).rejects.toThrow('Login API error');
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
`stringified=${JSON.stringify(mockCredentials)}`,
expectedConfig
);
});
it('should handle network errors', async () => {
const networkError = new Error('Network Error');
networkError.name = 'NetworkError';
mockHttpClient.post.mockRejectedValueOnce(networkError);
await expect(login(mockCredentials)).rejects.toThrow('Network Error');
});
it('should properly transform camelCase response', async () => {
const mockResponseData = {
redirect_url: 'http://localhost:18000/dashboard',
success: true,
user_id: 12345,
extra_data: { some: 'value' },
};
const mockResponse = { data: mockResponseData };
const expectedCamelCaseInput = {
redirectUrl: 'http://localhost:18000/dashboard',
success: true,
};
const expectedResult = {
redirectUrl: 'http://localhost:18000/dashboard',
success: true,
};
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
mockCamelCaseObject.mockReturnValueOnce(expectedResult);
const result = await login(mockCredentials);
expect(mockCamelCaseObject).toHaveBeenCalledWith(expectedCamelCaseInput);
expect(result).toEqual(expectedResult);
});
it('should handle empty credentials object', async () => {
const emptyCredentials = {};
const mockResponse = { data: { success: false } };
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
await login(emptyCredentials);
expect(mockQueryStringify).toHaveBeenCalledWith(emptyCredentials);
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
`stringified=${JSON.stringify(emptyCredentials)}`,
expectedConfig,
);
});
});
});

View File

@@ -1,236 +0,0 @@
import React from 'react';
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import * as api from './api';
import {
useLogin,
} from './apiHook';
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from './constants';
// Mock the dependencies
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
logInfo: jest.fn(),
}));
jest.mock('@edx/frontend-platform/utils', () => ({
camelCaseObject: jest.fn(),
}));
jest.mock('./api', () => ({
login: jest.fn(),
}));
const mockLogin = api.login as jest.MockedFunction<typeof api.login>;
const mockLogError = logError as jest.MockedFunction<typeof logError>;
const mockLogInfo = logInfo as jest.MockedFunction<typeof logInfo>;
const mockCamelCaseObject = camelCaseObject as jest.MockedFunction<typeof camelCaseObject>;
// Test wrapper component
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function TestWrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useLogin', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCamelCaseObject.mockImplementation((obj) => obj);
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(false);
expect(result.current.isError).toBe(false);
expect(result.current.isSuccess).toBe(false);
expect(result.current.error).toBe(null);
});
it('should login successfully and log success', async () => {
const mockLoginData = {
email_or_username: 'testuser@example.com',
password: 'password123',
};
const mockResponse = {
redirectUrl: 'http://localhost:18000/dashboard',
success: true,
};
mockLogin.mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
result.current.mutate(mockLoginData);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockLogin).toHaveBeenCalledWith(mockLoginData);
expect(mockLogInfo).toHaveBeenCalledWith('Login successful', mockResponse);
expect(result.current.data).toEqual(mockResponse);
});
it('should handle 400 validation error and transform to FORBIDDEN_REQUEST', async () => {
const mockLoginData = {
email_or_username: '',
password: 'password123',
};
const mockErrorResponse = {
errorCode: FORBIDDEN_REQUEST,
context: {
email_or_username: ['This field is required'],
password: ['Password is too weak'],
},
};
const mockCamelCasedResponse = {
errorCode: FORBIDDEN_REQUEST,
context: {
emailOrUsername: ['This field is required'],
password: ['Password is too weak'],
},
};
const mockError = {
response: {
status: 400,
data: mockErrorResponse,
},
};
// Mock onError callback to test formatted error
const mockOnError = jest.fn();
mockLogin.mockRejectedValueOnce(mockError);
mockCamelCaseObject.mockReturnValueOnce({
status: 400,
data: mockCamelCasedResponse,
});
const { result } = renderHook(() => useLogin({ onError: mockOnError }), {
wrapper: createWrapper(),
});
result.current.mutate(mockLoginData);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(mockLogin).toHaveBeenCalledWith(mockLoginData);
expect(mockCamelCaseObject).toHaveBeenCalledWith({
status: 400,
data: mockErrorResponse,
});
expect(mockLogInfo).toHaveBeenCalledWith('Login failed with validation error', mockError);
expect(mockOnError).toHaveBeenCalledWith({
type: FORBIDDEN_REQUEST,
context: {
emailOrUsername: ['This field is required'],
password: ['Password is too weak'],
},
count: 0,
});
});
it('should handle timeout errors', async () => {
const mockLoginData = {
email_or_username: 'testuser@example.com',
password: 'password123',
};
const timeoutError = new Error('Request timeout');
timeoutError.name = 'TimeoutError';
// Mock onError callback to test formatted error
const mockOnError = jest.fn();
mockLogin.mockRejectedValueOnce(timeoutError);
const { result } = renderHook(() => useLogin({ onError: mockOnError }), {
wrapper: createWrapper(),
});
result.current.mutate(mockLoginData);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(mockLogError).toHaveBeenCalledWith('Login failed', timeoutError);
expect(mockOnError).toHaveBeenCalledWith({
type: INTERNAL_SERVER_ERROR,
context: {},
count: 0,
});
});
it('should handle successful login with custom redirect URL', async () => {
const mockLoginData = {
email_or_username: 'testuser@example.com',
password: 'password123',
};
const mockResponse = {
redirectUrl: 'http://localhost:18000/courses',
success: true,
};
mockLogin.mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
result.current.mutate(mockLoginData);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockLogInfo).toHaveBeenCalledWith('Login successful', mockResponse);
expect(result.current.data).toEqual(mockResponse);
});
it('should handle login with empty credentials', async () => {
const mockLoginData = {
email_or_username: '',
password: '',
};
const mockResponse = {
redirectUrl: 'http://localhost:18000/dashboard',
success: false,
};
mockLogin.mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
result.current.mutate(mockLoginData);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockResponse);
expect(mockLogInfo).toHaveBeenCalledWith('Login successful', mockResponse);
});
});

View File

@@ -1,64 +0,0 @@
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { useMutation } from '@tanstack/react-query';
import { login } from './api';
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from './constants';
// Type definitions
interface LoginData {
email_or_username: string;
password: string;
}
interface LoginResponse {
redirectUrl?: string;
}
interface UseLoginOptions {
onSuccess?: (data: LoginResponse) => void;
onError?: (error: unknown) => void;
}
const useLogin = (options: UseLoginOptions = {}) => useMutation<LoginResponse, unknown, LoginData>({
mutationFn: async (loginData: LoginData) => login(loginData) as Promise<LoginResponse>,
onSuccess: (data: LoginResponse) => {
logInfo('Login successful', data);
if (options.onSuccess) {
options.onSuccess(data);
}
},
onError: (error: unknown) => {
logError('Login failed', error);
let formattedError = {
type: INTERNAL_SERVER_ERROR,
context: {},
count: 0,
};
if (error && typeof error === 'object' && 'response' in error && error.response) {
const response = error.response as { status?: number; data?: unknown };
const { status, data } = camelCaseObject(response);
if (data && typeof data === 'object') {
const errorData = data as { errorCode?: string; context?: { failureCount?: number } };
formattedError = {
type: errorData.errorCode || FORBIDDEN_REQUEST,
context: errorData.context || {},
count: errorData.context?.failureCount || 0,
};
if (status === 400) {
logInfo('Login failed with validation error', error);
} else if (status === 403) {
logInfo('Login failed with forbidden error', error);
} else {
logError('Login failed with server error', error);
}
}
}
if (options.onError) {
options.onError(formattedError);
}
},
});
export {
useLogin,
};

View File

@@ -0,0 +1,76 @@
import {
BACKUP_LOGIN_DATA,
DISMISS_PASSWORD_RESET_BANNER,
LOGIN_REQUEST,
} from './actions';
import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants';
import { RESET_PASSWORD } from '../../reset-password';
export const defaultState = {
loginErrorCode: '',
loginErrorContext: {},
loginResult: {},
loginFormData: {
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
shouldBackupState: false,
showResetPasswordSuccessBanner: false,
submitState: DEFAULT_STATE,
};
const reducer = (state = defaultState, action = {}) => {
switch (action.type) {
case BACKUP_LOGIN_DATA.BASE:
return {
...state,
shouldBackupState: true,
};
case BACKUP_LOGIN_DATA.BEGIN:
return {
...defaultState,
loginFormData: { ...action.payload },
};
case LOGIN_REQUEST.BEGIN:
return {
...state,
showResetPasswordSuccessBanner: false,
submitState: PENDING_STATE,
};
case LOGIN_REQUEST.SUCCESS:
return {
...state,
loginResult: action.payload,
};
case LOGIN_REQUEST.FAILURE: {
const { email, loginError, redirectUrl } = action.payload;
return {
...state,
loginErrorCode: loginError.errorCode,
loginErrorContext: { ...loginError.context, email, redirectUrl },
submitState: DEFAULT_STATE,
};
}
case RESET_PASSWORD.SUCCESS:
return {
...state,
showResetPasswordSuccessBanner: true,
};
case DISMISS_PASSWORD_RESET_BANNER: {
return {
...state,
showResetPasswordSuccessBanner: false,
};
}
default:
return {
...state,
};
}
};
export default reducer;

46
src/login/data/sagas.js Normal file
View File

@@ -0,0 +1,46 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { call, put, takeEvery } from 'redux-saga/effects';
import {
LOGIN_REQUEST,
loginRequestBegin,
loginRequestFailure,
loginRequestSuccess,
} from './actions';
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from './constants';
import {
loginRequest,
} from './service';
export function* handleLoginRequest(action) {
try {
yield put(loginRequestBegin());
const { redirectUrl, success } = yield call(loginRequest, action.payload.creds);
yield put(loginRequestSuccess(
redirectUrl,
success,
));
} catch (e) {
const statusCodes = [400];
if (e.response) {
const { status } = e.response;
if (statusCodes.includes(status)) {
yield put(loginRequestFailure(camelCaseObject(e.response.data)));
logInfo(e);
} else if (status === 403) {
yield put(loginRequestFailure({ errorCode: FORBIDDEN_REQUEST }));
logInfo(e);
} else {
yield put(loginRequestFailure({ errorCode: INTERNAL_SERVER_ERROR }));
logError(e);
}
}
}
}
export default function* saga() {
yield takeEvery(LOGIN_REQUEST.BASE, handleLoginRequest);
}

View File

@@ -1,21 +1,26 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import * as QueryString from 'query-string';
const login = async (creds) => {
// eslint-disable-next-line import/prefer-default-export
export async function loginRequest(creds) {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
};
const url = `${getConfig().LMS_BASE_URL}/api/user/v2/account/login_session/`;
const { data } = await getAuthenticatedHttpClient()
.post(url, QueryString.stringify(creds), requestConfig);
return camelCaseObject({
.post(
`${getConfig().LMS_BASE_URL}/api/user/v2/account/login_session/`,
QueryString.stringify(creds),
requestConfig,
)
.catch((e) => {
throw (e);
});
return {
redirectUrl: data.redirect_url || `${getConfig().LMS_BASE_URL}/dashboard`,
success: data.success || false,
});
};
export {
login,
};
};
}

View File

@@ -0,0 +1,155 @@
import { getConfig } from '@edx/frontend-platform';
import { DEFAULT_REDIRECT_URL, DEFAULT_STATE, PENDING_STATE } from '../../../data/constants';
import { RESET_PASSWORD } from '../../../reset-password';
import { BACKUP_LOGIN_DATA, DISMISS_PASSWORD_RESET_BANNER, LOGIN_REQUEST } from '../actions';
import reducer from '../reducers';
describe('login reducer', () => {
const defaultState = {
loginErrorCode: '',
loginErrorContext: {},
loginResult: {},
loginFormData: {
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
shouldBackupState: false,
showResetPasswordSuccessBanner: false,
submitState: DEFAULT_STATE,
};
it('should update state to show reset password success banner', () => {
const action = {
type: RESET_PASSWORD.SUCCESS,
};
expect(
reducer(defaultState, action),
).toEqual(
{
...defaultState,
showResetPasswordSuccessBanner: true,
},
);
});
it('should set the flag which keeps the login form data in redux state', () => {
const action = {
type: BACKUP_LOGIN_DATA.BASE,
};
expect(
reducer(defaultState, action),
).toEqual(
{
...defaultState,
shouldBackupState: true,
},
);
});
it('should backup the login form data', () => {
const payload = {
formFields: {
emailOrUsername: 'test@exmaple.com',
password: 'test1',
},
errors: {
emailOrUsername: '', password: '',
},
};
const action = {
type: BACKUP_LOGIN_DATA.BEGIN,
payload,
};
expect(
reducer(defaultState, action),
).toEqual(
{
...defaultState,
loginFormData: payload,
},
);
});
it('should update state to dismiss reset password banner', () => {
const action = {
type: DISMISS_PASSWORD_RESET_BANNER,
};
expect(
reducer(defaultState, action),
).toEqual(
{
...defaultState,
showResetPasswordSuccessBanner: false,
},
);
});
it('should start the login request', () => {
const action = {
type: LOGIN_REQUEST.BEGIN,
};
expect(reducer(defaultState, action)).toEqual(
{
...defaultState,
showResetPasswordSuccessBanner: false,
submitState: PENDING_STATE,
},
);
});
it('should set redirect url on login success action', () => {
const payload = {
redirectUrl: `${getConfig().BASE_URL}${DEFAULT_REDIRECT_URL}`,
success: true,
};
const action = {
type: LOGIN_REQUEST.SUCCESS,
payload,
};
expect(reducer(defaultState, action)).toEqual(
{
...defaultState,
loginResult: payload,
},
);
});
it('should set the error data on login request failure', () => {
const payload = {
loginError: {
success: false,
value: 'Email or password is incorrect.',
errorCode: 'incorrect-email-or-password',
context: {
failureCount: 0,
},
},
email: 'test@example.com',
redirectUrl: '',
};
const action = {
type: LOGIN_REQUEST.FAILURE,
payload,
};
expect(reducer(defaultState, action)).toEqual(
{
...defaultState,
loginErrorCode: payload.loginError.errorCode,
loginErrorContext: { ...payload.loginError.context, email: payload.email, redirectUrl: payload.redirectUrl },
submitState: DEFAULT_STATE,
},
);
});
});

View File

@@ -0,0 +1,110 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { runSaga } from 'redux-saga';
import initializeMockLogging from '../../../setupTest';
import * as actions from '../actions';
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from '../constants';
import { handleLoginRequest } from '../sagas';
import * as api from '../service';
const { loggingService } = initializeMockLogging();
describe('handleLoginRequest', () => {
const params = {
payload: {
loginFormData: {
email: 'test@test.com',
password: 'test-password',
},
},
};
const testErrorResponse = async (loginErrorResponse, expectedLogFunc, expectedDispatchers) => {
const loginRequest = jest.spyOn(api, 'loginRequest').mockImplementation(() => Promise.reject(loginErrorResponse));
const dispatched = [];
await runSaga(
{ dispatch: (action) => dispatched.push(action) },
handleLoginRequest,
params,
);
expect(loginRequest).toHaveBeenCalledTimes(1);
expect(expectedLogFunc).toHaveBeenCalled();
expect(dispatched).toEqual(expectedDispatchers);
loginRequest.mockClear();
};
beforeEach(() => {
loggingService.logError.mockReset();
loggingService.logInfo.mockReset();
});
it('should call service and dispatch success action', async () => {
const data = { redirectUrl: '/dashboard', success: true };
const loginRequest = jest.spyOn(api, 'loginRequest')
.mockImplementation(() => Promise.resolve(data));
const dispatched = [];
await runSaga(
{ dispatch: (action) => dispatched.push(action) },
handleLoginRequest,
params,
);
expect(loginRequest).toHaveBeenCalledTimes(1);
expect(dispatched).toEqual([
actions.loginRequestBegin(),
actions.loginRequestSuccess(data.redirectUrl, data.success),
]);
loginRequest.mockClear();
});
it('should call service and dispatch error action', async () => {
const loginErrorResponse = {
response: {
status: 400,
data: {
login_error: 'something went wrong',
},
},
};
await testErrorResponse(loginErrorResponse, loggingService.logInfo, [
actions.loginRequestBegin(),
actions.loginRequestFailure(camelCaseObject(loginErrorResponse.response.data)),
]);
});
it('should handle rate limit error code', async () => {
const loginErrorResponse = {
response: {
status: 403,
data: {
errorCode: FORBIDDEN_REQUEST,
},
},
};
await testErrorResponse(loginErrorResponse, loggingService.logInfo, [
actions.loginRequestBegin(),
actions.loginRequestFailure(loginErrorResponse.response.data),
]);
});
it('should handle 500 error code', async () => {
const loginErrorResponse = {
response: {
status: 500,
data: {
errorCode: INTERNAL_SERVER_ERROR,
},
},
};
await testErrorResponse(loginErrorResponse, loggingService.logError, [
actions.loginRequestBegin(),
actions.loginRequestFailure(loginErrorResponse.response.data),
]);
});
});

View File

@@ -1,3 +1,5 @@
export const storeName = 'login';
export { default as LoginPage } from './LoginPage';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';

View File

@@ -1,5 +1,7 @@
import React from 'react';
import { mergeConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import {
render, screen,
} from '@testing-library/react';
@@ -7,6 +9,8 @@ import {
import AccountActivationMessage from '../AccountActivationMessage';
import { ACCOUNT_ACTIVATION_MESSAGE } from '../data/constants';
const IntlAccountActivationMessage = injectIntl(AccountActivationMessage);
describe('AccountActivationMessage', () => {
beforeEach(() => {
mergeConfig({
@@ -17,7 +21,7 @@ describe('AccountActivationMessage', () => {
it('should match account already activated message', () => {
render(
<IntlProvider locale="en">
<AccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.INFO} />
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.INFO} />
</IntlProvider>,
);
@@ -32,7 +36,7 @@ describe('AccountActivationMessage', () => {
it('should match account activated success message', () => {
render(
<IntlProvider locale="en">
<AccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.SUCCESS} />
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.SUCCESS} />
</IntlProvider>,
);
@@ -49,7 +53,7 @@ describe('AccountActivationMessage', () => {
it('should match account activation error message', () => {
render(
<IntlProvider locale="en">
<AccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.ERROR} />
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.ERROR} />
</IntlProvider>,
);
@@ -65,7 +69,7 @@ describe('AccountActivationMessage', () => {
it('should not display anything for invalid message type', () => {
const { container } = render(
<IntlProvider locale="en">
<AccountActivationMessage messageType="invalid-message" />
<IntlAccountActivationMessage messageType="invalid-message" />
</IntlProvider>,
);
@@ -84,7 +88,7 @@ describe('EmailConfirmationMessage', () => {
it('should match email already confirmed message', () => {
render(
<IntlProvider locale="en">
<AccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.INFO} />
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.INFO} />
</IntlProvider>,
);
@@ -99,7 +103,7 @@ describe('EmailConfirmationMessage', () => {
it('should match email confirmation success message', () => {
render(
<IntlProvider locale="en">
<AccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.SUCCESS} />
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.SUCCESS} />
</IntlProvider>,
);
const expectedMessage = 'Success! You have confirmed your email.Sign in to continue.';
@@ -113,7 +117,7 @@ describe('EmailConfirmationMessage', () => {
it('should match email confirmation error message', () => {
render(
<IntlProvider locale="en">
<AccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.ERROR} />
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.ERROR} />
</IntlProvider>,
);
const expectedMessage = 'Your email could not be confirmed'

View File

@@ -1,5 +1,7 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import {
fireEvent, render, screen,
} from '@testing-library/react';
@@ -9,6 +11,7 @@ import { MemoryRouter } from 'react-router-dom';
import { RESET_PAGE } from '../../data/constants';
import ChangePasswordPrompt from '../ChangePasswordPrompt';
const IntlChangePasswordPrompt = injectIntl(ChangePasswordPrompt);
const mockedNavigator = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -41,7 +44,7 @@ describe('ChangePasswordPromptTests', () => {
render(
<IntlProvider locale="en">
<MemoryRouter>
<ChangePasswordPrompt {...props} />
<IntlChangePasswordPrompt {...props} />
</MemoryRouter>
</IntlProvider>,
);
@@ -58,7 +61,7 @@ describe('ChangePasswordPromptTests', () => {
render(
<IntlProvider locale="en">
<MemoryRouter>
<ChangePasswordPrompt {...props} />
<IntlChangePasswordPrompt {...props} />
</MemoryRouter>
</IntlProvider>,
);

View File

@@ -1,4 +1,6 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import {
render, screen,
} from '@testing-library/react';
@@ -24,6 +26,8 @@ jest.mock('@edx/frontend-platform/auth', () => ({
getAuthService: jest.fn(),
}));
const IntlLoginFailureMessage = injectIntl(LoginFailureMessage);
describe('LoginFailureMessage', () => {
let props = {};
@@ -44,7 +48,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -72,7 +76,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -102,7 +106,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -128,7 +132,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -148,7 +152,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -172,7 +176,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -192,7 +196,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -212,7 +216,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -232,7 +236,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -251,7 +255,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -271,7 +275,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
@@ -297,7 +301,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<MemoryRouter>
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</MemoryRouter>
</IntlProvider>,
);
@@ -323,7 +327,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<MemoryRouter>
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</MemoryRouter>
</IntlProvider>,
);
@@ -355,7 +359,7 @@ describe('LoginFailureMessage', () => {
render(
<IntlProvider locale="en">
<LoginFailureMessage {...props} />
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);

View File

@@ -1,27 +1,21 @@
import React from 'react';
import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { useThirdPartyAuthContext } from '../../common-components/components/ThirdPartyAuthContext';
import { useThirdPartyAuthHook } from '../../common-components/data/apiHook';
import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE } from '../../data/constants';
import { RegisterProvider } from '../../register/components/RegisterContext';
import { LoginProvider } from '../components/LoginContext';
import { useLogin } from '../data/apiHook';
import { backupLoginFormBegin, dismissPasswordResetBanner, loginRequest } from '../data/actions';
import { INTERNAL_SERVER_ERROR } from '../data/constants';
import LoginPage from '../LoginPage';
// Mock React Query hooks
jest.mock('../data/apiHook');
jest.mock('../../common-components/data/apiHook');
jest.mock('../../common-components/components/ThirdPartyAuthContext');
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
sendTrackEvent: jest.fn(),
@@ -30,28 +24,40 @@ jest.mock('@edx/frontend-platform/auth', () => ({
getAuthService: jest.fn(),
}));
const IntlLoginPage = injectIntl(LoginPage);
const mockStore = configureStore();
describe('LoginPage', () => {
let props = {};
let mockLoginMutate;
let mockThirdPartyAuthContext;
let queryClient;
let store = {};
const emptyFieldValidation = { emailOrUsername: 'Enter your username or email', password: 'Enter your password' };
const queryWrapper = children => (
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter>
<RegisterProvider>
<LoginProvider>
{children}
</LoginProvider>
</RegisterProvider>
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>
const reduxWrapper = children => (
<IntlProvider locale="en">
<MemoryRouter>
<Provider store={store}>{children}</Provider>
</MemoryRouter>
</IntlProvider>
);
const initialState = {
login: {
loginResult: { success: false, redirectUrl: '' },
},
commonComponents: {
thirdPartyAuthApiStatus: null,
thirdPartyAuthContext: {
currentProvider: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [],
},
},
register: {
validationApiRateLimited: false,
},
};
const secondaryProviders = {
id: 'saml-test',
name: 'Test University',
@@ -69,121 +75,98 @@ describe('LoginPage', () => {
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
mockLoginMutate = jest.fn();
mockLoginMutate.mockRejected = false; // Reset flag
const loginMutation = {
mutate: mockLoginMutate,
isPending: false,
};
useLogin.mockImplementation((options) => ({
...loginMutation,
mutate: jest.fn().mockImplementation((data) => {
// Call the mocked function for testing assertions
mockLoginMutate(data);
// Simulate can call success or error based on test needs
if (options?.onSuccess && !mockLoginMutate.mockRejected) {
options.onSuccess({ redirectUrl: 'https://test.com/dashboard' });
}
}),
}));
useThirdPartyAuthHook.mockReturnValue({
data: {
fieldDescriptions: {},
optionalFields: { fields: {}, extended_profile: [] },
thirdPartyAuthContext: {},
},
isSuccess: true,
error: null,
isLoading: false,
});
mockThirdPartyAuthContext = {
thirdPartyAuthApiStatus: null,
thirdPartyAuthContext: {
currentProvider: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [],
platformName: '',
errorMessage: '',
},
setThirdPartyAuthContextBegin: jest.fn(),
setThirdPartyAuthContextSuccess: jest.fn(),
setThirdPartyAuthContextFailure: jest.fn(),
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore(initialState);
props = {
institutionLogin: false,
loginRequest: jest.fn(),
handleInstitutionLogin: jest.fn(),
institutionLogin: false,
};
});
// ******** test login form submission ********
it('should submit form for valid input', () => {
render(queryWrapper(<LoginPage {...props} />));
store.dispatch = jest.fn(store.dispatch);
fireEvent.change(screen.getByLabelText(/username or email/i), {
target: { value: 'test', name: 'emailOrUsername' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'test-password', name: 'password' },
});
render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
fireEvent.change(screen.getByText(
'',
{ selector: '#emailOrUsername' },
), { target: { value: 'test', name: 'emailOrUsername' } });
fireEvent.change(screen.getByText(
'',
{ selector: '#password' },
), { target: { value: 'test-password', name: 'password' } });
expect(mockLoginMutate).toHaveBeenCalledWith({ email_or_username: 'test', password: 'test-password' });
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(store.dispatch).toHaveBeenCalledWith(loginRequest({ email_or_username: 'test', password: 'test-password' }));
});
it('should not call login mutation on empty form submission', () => {
render(queryWrapper(<LoginPage {...props} />));
it('should not dispatch loginRequest on empty form submission', () => {
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(mockLoginMutate).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(store.dispatch).not.toHaveBeenCalledWith(loginRequest({}));
});
it('should dismiss reset password banner on form submission', () => {
delete window.location;
window.location = {
href: getConfig().BASE_URL.concat(LOGIN_PAGE),
search: '?reset=success',
pathname: '/login',
};
store = mockStore({
...initialState,
login: {
...initialState.login,
showResetPasswordSuccessBanner: true,
},
});
const { container } = render(queryWrapper(<LoginPage {...props} />));
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(container.querySelector('.alert-success, [role="alert"].alert-success')).toBeFalsy();
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(store.dispatch).toHaveBeenCalledWith(dismissPasswordResetBanner());
});
// ******** test login form validations ********
it('should match state for invalid email (less than 2 characters), on form submission', () => {
render(queryWrapper(<LoginPage {...props} />));
store.dispatch = jest.fn(store.dispatch);
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'test', name: 'password' },
});
fireEvent.change(screen.getByLabelText(/username or email/i), {
target: { value: 't', name: 'emailOrUsername' },
});
render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
fireEvent.change(screen.getByText(
'',
{ selector: '#password' },
), { target: { value: 'test' } });
fireEvent.change(screen.getByText(
'',
{ selector: '#emailOrUsername' },
), { target: { value: 't' } });
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(screen.getByText('Username or email must have at least 2 characters.')).toBeDefined();
});
it('should show error messages for required fields on empty form submission', () => {
const { container } = render(queryWrapper(<LoginPage {...props} />));
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(container.querySelector('div[feedback-for="emailOrUsername"]').textContent).toEqual(emptyFieldValidation.emailOrUsername);
expect(container.querySelector('div[feedback-for="password"]').textContent).toEqual(emptyFieldValidation.password);
@@ -193,28 +176,43 @@ describe('LoginPage', () => {
});
it('should run frontend validations for emailOrUsername field on form submission', () => {
const { container } = render(queryWrapper(<LoginPage {...props} />));
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.change(screen.getByLabelText(/username or email/i), {
target: { value: 't', name: 'emailOrUsername' },
});
fireEvent.change(screen.getByText(
'',
{ selector: '#emailOrUsername' },
), { target: { value: 't', name: 'emailOrUsername' } });
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(container.querySelector('div[feedback-for="emailOrUsername"]').textContent).toEqual('Username or email must have at least 2 characters.');
});
// ******** test field focus in functionality ********
it('should reset field related error messages on onFocus event', async () => {
render(queryWrapper(<LoginPage {...props} />));
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
await act(async () => {
// clicking submit button with empty fields to make the errors appear
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
// focusing the fields to verify that the errors are cleared
fireEvent.focus(screen.getByLabelText('Password'));
fireEvent.focus(screen.getByLabelText(/username or email/i));
fireEvent.focus(screen.getByText(
'',
{ selector: '#password' },
));
fireEvent.focus(screen.getByText(
'',
{ selector: '#emailOrUsername' },
));
});
// verifying that the errors are cleared
@@ -226,17 +224,20 @@ describe('LoginPage', () => {
// ******** test form buttons and links ********
it('should match default button state', () => {
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText('Sign in')).toBeDefined();
});
it('should match pending button state', () => {
useLogin.mockReturnValue({
mutate: mockLoginMutate,
isPending: true,
store = mockStore({
...initialState,
login: {
...initialState.login,
submitState: PENDING_STATE,
},
});
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'pending',
@@ -244,7 +245,7 @@ describe('LoginPage', () => {
});
it('should show forgot password link', () => {
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'Forgot password',
@@ -253,10 +254,18 @@ describe('LoginPage', () => {
});
it('should show single sign on provider button', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext.providers = [ssoProvider];
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
},
},
});
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: `#${ssoProvider.id}` },
@@ -268,27 +277,37 @@ describe('LoginPage', () => {
});
it('should display sign-in header only when primary or secondary providers are available.', () => {
// Reset mocks to empty providers
mockThirdPartyAuthContext.thirdPartyAuthContext.providers = [];
mockThirdPartyAuthContext.thirdPartyAuthContext.secondaryProviders = [];
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
},
},
});
const { queryByText } = render(queryWrapper(<LoginPage {...props} />));
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Company or school credentials')).toBeNull();
expect(queryByText('Or sign in with:')).toBeNull();
expect(queryByText('Institution/campus credentials')).toBeNull();
});
it('should hide sign-in header and enterprise login upon successful SSO authentication', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
currentProvider: 'Apple',
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
currentProvider: 'Apple',
},
},
});
const { queryByText } = render(queryWrapper(<LoginPage {...props} />));
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Company or school credentials')).toBeNull();
expect(queryByText('Or sign in with:')).toBeNull();
});
@@ -296,14 +315,19 @@ describe('LoginPage', () => {
// ******** test enterprise login enabled scenarios ********
it('should show sign-in header for enterprise login', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
},
},
});
const { queryByText } = render(queryWrapper(<LoginPage {...props} />));
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeDefined();
expect(queryByText('Company or school credentials')).toBeDefined();
expect(queryByText('Institution/campus credentials')).toBeDefined();
@@ -316,14 +340,19 @@ describe('LoginPage', () => {
DISABLE_ENTERPRISE_LOGIN: true,
});
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
},
},
});
const { queryByText } = render(queryWrapper(<LoginPage {...props} />));
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeDefined();
expect(queryByText('Company or school credentials')).toBeNull();
expect(queryByText('Institution/campus credentials')).toBeDefined();
@@ -338,15 +367,20 @@ describe('LoginPage', () => {
DISABLE_ENTERPRISE_LOGIN: true,
});
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
secondaryProviders: [{
...secondaryProviders,
}],
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
secondaryProviders: [{
...secondaryProviders,
}],
},
},
});
const { queryByText } = render(queryWrapper(<LoginPage {...props} />));
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeDefined();
expect(queryByText('Institution/campus credentials')).toBeDefined();
@@ -356,21 +390,35 @@ describe('LoginPage', () => {
});
it('should not show sign-in header without primary or secondary providers', () => {
// Already mocked with empty providers in beforeEach
const { queryByText } = render(queryWrapper(<LoginPage {...props} />));
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
},
},
});
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeNull();
expect(queryByText('Institution/campus credentials')).toBeNull();
expect(queryByText('Company or school credentials')).toBeNull();
});
it('should show enterprise login if even if only secondary providers are available', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
secondaryProviders: [secondaryProviders],
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
secondaryProviders: [secondaryProviders],
},
},
});
const { queryByText } = render(queryWrapper(<LoginPage {...props} />));
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeDefined();
expect(queryByText('Company or school credentials')).toBeNull();
expect(queryByText('Institution/campus credentials')).toBeDefined();
@@ -382,55 +430,42 @@ describe('LoginPage', () => {
// ******** test alert messages ********
// Login error handling is now managed by React Query hooks and context
// We'll test that error messages appear when login fails
it('should show error message when login fails', async () => {
// Mock the login hook to simulate error
mockLoginMutate.mockRejected = true;
useLogin.mockImplementation((options) => ({
mutate: jest.fn().mockImplementation((data) => {
mockLoginMutate(data);
if (options?.onError) {
options.onError({ type: INTERNAL_SERVER_ERROR, context: {}, count: 0 });
}
}),
isPending: false,
}));
useLogin.mockReturnValue({
mutate: mockLoginMutate,
isPending: false,
it('should match login internal server error message', () => {
const expectedMessage = 'We couldn\'t sign you in.'
+ 'An error has occurred. Try refreshing the page, or check your internet connection.';
store = mockStore({
...initialState,
login: {
...initialState.login,
loginErrorCode: INTERNAL_SERVER_ERROR,
},
});
render(queryWrapper(<LoginPage {...props} />));
// Fill in valid form data
fireEvent.change(screen.getByLabelText(/username or email/i), {
target: { value: 'test@example.com', name: 'emailOrUsername' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'password123', name: 'password' },
});
// Submit form
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
// The error should be handled by the login hook
expect(mockLoginMutate).toHaveBeenCalled();
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toEqual(`${expectedMessage}`);
});
it('should match third party auth alert', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
currentProvider: 'Apple',
platformName: 'openedX',
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
currentProvider: 'Apple',
platformName: 'openedX',
},
},
});
const expectedMessage = `${'You have successfully signed into Apple, but your Apple account does not have a '
+ 'linked '}${ getConfig().SITE_NAME } account. To link your accounts, sign in now using your ${getConfig().SITE_NAME } password.`;
+ 'linked '}${ getConfig().SITE_NAME } account. To link your accounts, sign in now using your ${
getConfig().SITE_NAME } password.`;
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#tpa-alert' },
@@ -438,96 +473,105 @@ describe('LoginPage', () => {
});
it('should show third party authentication failure message', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
currentProvider: null,
errorMessage: 'An error occurred',
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
render(queryWrapper(<LoginPage {...props} />));
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
currentProvider: null,
errorMessage: 'An error occurred',
},
},
});
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toContain('An error occurred');
});
// Form validation errors are now handled by context
it('should show form validation error', () => {
render(queryWrapper(<LoginPage {...props} />));
it('should match invalid login form error message', () => {
const errorMessage = 'Please fill in the fields below.';
store = mockStore({
...initialState,
login: {
...initialState.login,
loginErrorCode: 'invalid-form',
},
});
// Submit form without filling fields
fireEvent.click(screen.getByText('Sign in'));
// Should show validation errors
expect(screen.getByText('Please fill in the fields below.')).toBeDefined();
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toContain(errorMessage);
});
// ******** test redirection ********
// Login success and redirection is now handled by React Query hooks
it('should handle successful login', () => {
// Mock successful login
useLogin.mockImplementation((options) => ({
mutate: jest.fn().mockImplementation((data) => {
mockLoginMutate(data);
if (options?.onSuccess) {
options.onSuccess({ success: true, redirectUrl: 'https://test.com/testing-dashboard/' });
}
}),
isPending: false,
}));
useLogin.mockReturnValue({
mutate: mockLoginMutate,
isPending: false,
it('should redirect to url returned by login endpoint after successful authentication', () => {
const dashboardURL = 'https://test.com/testing-dashboard/';
store = mockStore({
...initialState,
login: {
...initialState.login,
loginResult: {
success: true,
redirectUrl: dashboardURL,
},
},
});
render(queryWrapper(<LoginPage {...props} />));
// Fill in valid form data
fireEvent.change(screen.getByLabelText('Username or email'), {
target: { value: 'test@example.com', name: 'emailOrUsername' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'password123', name: 'password' },
});
// Submit form
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(mockLoginMutate).toHaveBeenCalled();
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(dashboardURL);
});
it('should handle SSO login success', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
finishAuthUrl: '/auth/complete/google-oauth2/',
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
// Mock successful login with no redirect URL (SSO case)
mockLoginMutate.mockImplementation((payload, { onSuccess }) => {
onSuccess({ success: true, redirectUrl: '' });
it('should redirect to finishAuthUrl upon successful login via SSO', () => {
const authCompleteUrl = '/auth/complete/google-oauth2/';
store = mockStore({
...initialState,
login: {
...initialState.login,
loginResult: {
success: true,
redirectUrl: '',
},
},
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
finishAuthUrl: authCompleteUrl,
},
},
});
render(queryWrapper(<LoginPage {...props} />));
// The component should handle SSO success
expect(mockThirdPartyAuthContext.thirdPartyAuthContext.finishAuthUrl).toBe('/auth/complete/google-oauth2/');
});
it('should redirect to social auth provider url on SSO button click', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
providers: [ssoProvider],
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl);
});
it('should redirect to social auth provider url on SSO button click', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
},
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'',
@@ -536,34 +580,49 @@ describe('LoginPage', () => {
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + ssoProvider.loginUrl);
});
it('should handle successful authentication via SSO', () => {
it('should redirect to finishAuthUrl upon successful authentication via SSO', () => {
const finishAuthUrl = '/auth/complete/google-oauth2/';
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
finishAuthUrl,
};
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
login: {
...initialState.login,
loginResult: { success: true, redirectUrl: '' },
},
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
finishAuthUrl,
},
},
});
render(queryWrapper(<LoginPage {...props} />));
delete window.location;
window.location = { href: getConfig().BASE_URL };
// Verify the finish auth URL is available
expect(mockThirdPartyAuthContext.thirdPartyAuthContext.finishAuthUrl).toBe(finishAuthUrl);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + finishAuthUrl);
});
// ******** test hinted third party auth ********
it('should render tpa button for tpa_hint id matching one of the primary providers', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
providers: [ssoProvider],
};
mockThirdPartyAuthContext.thirdPartyAuthApiStatus = COMPLETE_STATE;
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
},
thirdPartyAuthApiStatus: COMPLETE_STATE,
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: `#${ssoProvider.id}` },
@@ -575,49 +634,64 @@ describe('LoginPage', () => {
});
it('should render the skeleton when third party status is pending', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
providers: [ssoProvider],
};
mockThirdPartyAuthContext.thirdPartyAuthApiStatus = PENDING_STATE;
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
},
thirdPartyAuthApiStatus: PENDING_STATE,
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
const { container } = render(queryWrapper(<LoginPage {...props} />));
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(container.querySelector('.react-loading-skeleton')).toBeTruthy();
});
it('should render tpa button for tpa_hint id matching one of the secondary providers', () => {
secondaryProviders.skipHintedLogin = true;
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
secondaryProviders: [secondaryProviders],
};
mockThirdPartyAuthContext.thirdPartyAuthApiStatus = COMPLETE_STATE;
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
secondaryProviders: [secondaryProviders],
},
thirdPartyAuthApiStatus: COMPLETE_STATE,
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` };
secondaryProviders.iconImage = null;
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toEqual(getConfig().LMS_BASE_URL + secondaryProviders.loginUrl);
});
it('should render regular tpa button for invalid tpa_hint value', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
providers: [ssoProvider],
};
mockThirdPartyAuthContext.thirdPartyAuthApiStatus = COMPLETE_STATE;
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
},
thirdPartyAuthApiStatus: COMPLETE_STATE,
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: '?next=/dashboard&tpa_hint=invalid' };
const { container } = render(queryWrapper(<LoginPage {...props} />));
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(container.querySelector(`#${ssoProvider.id}`).querySelector('#provider-name').textContent).toEqual(`${ssoProvider.name}`);
mergeConfig({
@@ -626,17 +700,22 @@ describe('LoginPage', () => {
});
it('should render "other ways to sign in" button on the tpa_hint page', () => {
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
providers: [ssoProvider],
};
mockThirdPartyAuthContext.thirdPartyAuthApiStatus = COMPLETE_STATE;
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
},
thirdPartyAuthApiStatus: COMPLETE_STATE,
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?tpa_hint=${ssoProvider.id}` };
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'Show me other ways to sign in or register',
).textContent).toBeDefined();
@@ -647,17 +726,22 @@ describe('LoginPage', () => {
ALLOW_PUBLIC_ACCOUNT_CREATION: false,
});
mockThirdPartyAuthContext.thirdPartyAuthContext = {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
providers: [ssoProvider],
};
mockThirdPartyAuthContext.thirdPartyAuthApiStatus = COMPLETE_STATE;
useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
},
thirdPartyAuthApiStatus: COMPLETE_STATE,
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?tpa_hint=${ssoProvider.id}` };
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'Show me other ways to sign in',
).textContent).toBeDefined();
@@ -666,25 +750,35 @@ describe('LoginPage', () => {
// ******** miscellaneous tests ********
it('should send page event when login page is rendered', () => {
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'login');
});
it('should handle form field changes', () => {
render(queryWrapper(<LoginPage {...props} />));
it('tests that form is in invalid state when it is submitted', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
shouldBackupState: true,
},
});
const emailInput = screen.getByLabelText(/username or email/i);
const passwordInput = screen.getByLabelText('Password');
fireEvent.change(emailInput, { target: { value: 'test@example.com', name: 'emailOrUsername' } });
fireEvent.change(passwordInput, { target: { value: 'password123', name: 'password' } });
expect(emailInput.value).toBe('test@example.com');
expect(passwordInput.value).toBe('password123');
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(store.dispatch).toHaveBeenCalledWith(backupLoginFormBegin(
{
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
));
});
it('should send track event when forgot password link is clicked', () => {
render(queryWrapper(<LoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'Forgot password',
{ selector: '#forgot-password' },
@@ -693,91 +787,47 @@ describe('LoginPage', () => {
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.password-reset_form.toggled', { category: 'user-engagement' });
});
it('should persist and load form fields using sessionStorage', () => {
const { container, rerender } = render(queryWrapper(<LoginPage {...props} />));
fireEvent.change(container.querySelector('input#emailOrUsername'), {
target: { value: 'john_doe', name: 'emailOrUsername' },
});
fireEvent.change(container.querySelector('input#password'), {
target: { value: 'test-password', name: 'password' },
});
expect(container.querySelector('input#emailOrUsername').value).toEqual('john_doe');
expect(container.querySelector('input#password').value).toEqual('test-password');
rerender(queryWrapper(<LoginPage {...props} />));
expect(container.querySelector('input#emailOrUsername').value).toEqual('john_doe');
expect(container.querySelector('input#password').value).toEqual('test-password');
});
it('should prevent default on mouseDown event for sign-in button', () => {
const { container } = render(queryWrapper(<LoginPage {...props} />));
const signInButton = container.querySelector('#sign-in');
const preventDefaultSpy = jest.fn();
const event = new Event('mousedown', { bubbles: true });
event.preventDefault = preventDefaultSpy;
signInButton.dispatchEvent(event);
expect(preventDefaultSpy).toHaveBeenCalled();
});
it('should call setThirdPartyAuthContextSuccess on successful third party auth fetch', async () => {
render(queryWrapper(<LoginPage {...props} />));
await waitFor(() => {
expect(mockThirdPartyAuthContext.setThirdPartyAuthContextSuccess).toHaveBeenCalled();
}, { timeout: 1000 });
});
it('should call setThirdPartyAuthContextFailure on failed third party auth fetch', async () => {
useThirdPartyAuthHook.mockReturnValue({
data: null,
isSuccess: false,
error: new Error('Network error'),
isLoading: false,
});
render(queryWrapper(<LoginPage {...props} />));
await waitFor(() => {
expect(mockThirdPartyAuthContext.setThirdPartyAuthContextFailure).toHaveBeenCalled();
});
});
it('should set error code when third party error message is present', async () => {
const contextWithError = {
...mockThirdPartyAuthContext,
thirdPartyAuthContext: {
...mockThirdPartyAuthContext.thirdPartyAuthContext,
errorMessage: 'Third party authentication failed',
it('should backup the login form state when shouldBackupState is true', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
shouldBackupState: true,
},
};
useThirdPartyAuthContext.mockReturnValue(contextWithError);
const { container } = render(queryWrapper(<LoginPage {...props} />));
await waitFor(() => {
expect(container.querySelector('.alert-danger, .alert, [role="alert"]')).toBeTruthy();
});
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(store.dispatch).toHaveBeenCalledWith(backupLoginFormBegin(
{
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
));
});
it('should set error code on login failure', async () => {
mockLoginMutate.mockRejected = true;
useLogin.mockImplementation((options) => ({
mutate: jest.fn().mockImplementation((data) => {
mockLoginMutate(data);
if (options?.onError) {
options.onError({ type: INTERNAL_SERVER_ERROR, context: {}, count: 0 });
}
}),
isPending: false,
}));
it('should update form fields state if updated in redux store', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
loginFormData: {
formFields: {
emailOrUsername: 'john_doe', password: 'test-password',
},
errors: {
emailOrUsername: '', password: '',
},
},
},
});
const { container } = render(queryWrapper(<LoginPage {...props} />));
fireEvent.change(screen.getByLabelText(/username or email/i), {
target: { value: 'test', name: 'emailOrUsername' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'test-password', name: 'password' },
});
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(container.querySelector('.alert-danger, .alert, [role="alert"]')).toBeTruthy();
});
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(container.querySelector('input#emailOrUsername').value).toEqual('john_doe');
expect(container.querySelector('input#password').value).toEqual('test-password');
});
});

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
@@ -14,46 +15,39 @@ import PropTypes from 'prop-types';
import { Navigate, useNavigate } from 'react-router-dom';
import BaseContainer from '../base-container';
import { ThirdPartyAuthProvider, useThirdPartyAuthContext } from '../common-components/components/ThirdPartyAuthContext';
import { clearThirdPartyAuthContextErrorMessage } from '../common-components/data/actions';
import {
tpaProvidersSelector,
} from '../common-components/data/selectors';
import messages from '../common-components/messages';
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
import {
getTpaHint, getTpaProvider, updatePathWithQueryParams,
} from '../data/utils';
import { LoginProvider } from '../login/components/LoginContext';
import LoginComponentSlot from '../plugin-slots/LoginComponentSlot';
import { LoginPage } from '../login';
import { backupLoginForm } from '../login/data/actions';
import { RegistrationPage } from '../register';
import { RegisterProvider } from '../register/components/RegisterContext';
import { backupRegistrationForm } from '../register/data/actions';
const LogistrationPageInner = ({
selectedPage,
}) => {
const Logistration = (props) => {
const { selectedPage, tpaProviders } = props;
const tpaHint = getTpaHint();
const {
thirdPartyAuthContext,
clearThirdPartyAuthErrorMessage,
} = useThirdPartyAuthContext();
const {
providers,
secondaryProviders,
} = thirdPartyAuthContext;
providers, secondaryProviders,
} = tpaProviders;
const { formatMessage } = useIntl();
const [institutionLogin, setInstitutionLogin] = useState(false);
const [key, setKey] = useState('');
const navigate = useNavigate();
//commit color - Force hide registration - Andal Learning only allows sign in
const disablePublicAccountCreation = true;
const hideRegistrationLink = true;
const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false;
const hideRegistrationLink = getConfig().SHOW_REGISTRATION_LINKS === false;
useEffect(() => {
const authService = getAuthService();
if (authService) {
authService.getCsrfTokenService()
.getCsrfToken(getConfig().LMS_BASE_URL);
authService.getCsrfTokenService().getCsrfToken(getConfig().LMS_BASE_URL);
}
}, []);
});
useEffect(() => {
if (disablePublicAccountCreation) {
@@ -68,6 +62,7 @@ const LogistrationPageInner = ({
} else {
sendPageEvent('login_and_registration', e.target.dataset.eventName);
}
setInstitutionLogin(!institutionLogin);
};
@@ -76,7 +71,12 @@ const LogistrationPageInner = ({
return;
}
sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement' });
clearThirdPartyAuthErrorMessage();
props.clearThirdPartyAuthContextErrorMessage();
if (tabKey === LOGIN_PAGE) {
props.backupRegistrationForm();
} else if (tabKey === REGISTER_PAGE) {
props.backupLoginForm();
}
setKey(tabKey);
};
@@ -111,10 +111,7 @@ const LogistrationPageInner = ({
{!institutionLogin && (
<h3 className="mb-4.5">{formatMessage(messages['logistration.sign.in'])}</h3>
)}
<LoginComponentSlot
institutionLogin={institutionLogin}
handleInstitutionLogin={handleInstitutionLogin}
/>
<LoginPage institutionLogin={institutionLogin} handleInstitutionLogin={handleInstitutionLogin} />
</div>
</>
)
@@ -127,16 +124,12 @@ const LogistrationPageInner = ({
</Tabs>
)
: (!isValidTpaHint() && !hideRegistrationLink && (
<Tabs
defaultActiveKey={selectedPage}
id="controlled-tab"
onSelect={(tabKey) => handleOnSelect(tabKey, selectedPage)}
>
<Tabs defaultActiveKey={selectedPage} id="controlled-tab" onSelect={(tabKey) => handleOnSelect(tabKey, selectedPage)}>
<Tab title={formatMessage(messages['logistration.register'])} eventKey={REGISTER_PAGE} />
<Tab title={formatMessage(messages['logistration.sign.in'])} eventKey={LOGIN_PAGE} />
</Tabs>
))}
{key && (
{ key && (
<Navigate to={updatePathWithQueryParams(key)} replace />
)}
<div id="main-content" className="main-content">
@@ -146,12 +139,7 @@ const LogistrationPageInner = ({
</h3>
)}
{selectedPage === LOGIN_PAGE
? (
<LoginComponentSlot
institutionLogin={institutionLogin}
handleInstitutionLogin={handleInstitutionLogin}
/>
)
? <LoginPage institutionLogin={institutionLogin} handleInstitutionLogin={handleInstitutionLogin} />
: (
<RegistrationPage
institutionLogin={institutionLogin}
@@ -166,21 +154,37 @@ const LogistrationPageInner = ({
);
};
LogistrationPageInner.propTypes = {
selectedPage: PropTypes.string.isRequired,
Logistration.propTypes = {
selectedPage: PropTypes.string,
backupLoginForm: PropTypes.func.isRequired,
backupRegistrationForm: PropTypes.func.isRequired,
clearThirdPartyAuthContextErrorMessage: PropTypes.func.isRequired,
tpaProviders: PropTypes.shape({
providers: PropTypes.arrayOf(PropTypes.shape({})),
secondaryProviders: PropTypes.arrayOf(PropTypes.shape({})),
}),
};
/**
* Main Logistration Page component wrapped with providers
*/
const LogistrationPage = (props) => (
<ThirdPartyAuthProvider>
<RegisterProvider>
<LoginProvider>
<LogistrationPageInner {...props} />
</LoginProvider>
</RegisterProvider>
</ThirdPartyAuthProvider>
);
Logistration.defaultProps = {
tpaProviders: {
providers: [],
secondaryProviders: [],
},
};
export default LogistrationPage;
Logistration.defaultProps = {
selectedPage: REGISTER_PAGE,
};
const mapStateToProps = state => ({
tpaProviders: tpaProvidersSelector(state),
});
export default connect(
mapStateToProps,
{
backupLoginForm,
backupRegistrationForm,
clearThirdPartyAuthContextErrorMessage,
},
)(Logistration);

View File

@@ -1,166 +1,89 @@
import React from 'react';
import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { configure, IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { configure, injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import Logistration from './Logistration';
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
// Mock the navigate function
const mockNavigate = jest.fn();
const mockGetCsrfToken = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
Navigate: ({ to }) => {
mockNavigate(to);
return null;
},
}));
import { clearThirdPartyAuthContextErrorMessage } from '../common-components/data/actions';
import {
COMPLETE_STATE, LOGIN_PAGE, REGISTER_PAGE,
} from '../data/constants';
import { backupLoginForm } from '../login/data/actions';
import { backupRegistrationForm } from '../register/data/actions';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
sendTrackEvent: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthService: () => ({
getCsrfTokenService: () => ({
getCsrfToken: mockGetCsrfToken,
}),
}),
}));
jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
getConfig: jest.fn(() => ({
ALLOW_PUBLIC_ACCOUNT_CREATION: 'true',
DISABLE_ENTERPRISE_LOGIN: 'true',
SHOW_REGISTRATION_LINKS: 'true',
PROVIDERS: [],
SECONDARY_PROVIDERS: [{
id: 'saml-test_university',
name: 'Test University',
iconClass: 'fa-university',
iconImage: null,
loginUrl: '/auth/login/saml-test_university/?auth_entry=login&next=%2Fdashboard',
registerUrl: '/auth/login/saml-test_university/?auth_entry=register&next=%2Fdashboard',
}],
TPA_HINT: '',
TPA_PROVIDER_ID: '',
})),
}));
jest.mock('@edx/frontend-platform/auth');
// Mock the apiHook to prevent logging errors
jest.mock('../common-components/data/apiHook', () => ({
useLoginMutation: jest.fn(() => ({
mutate: jest.fn(),
isLoading: false,
error: null,
})),
useThirdPartyAuthMutation: jest.fn(() => ({
mutate: jest.fn(),
isLoading: false,
error: null,
})),
useThirdPartyAuthHook: jest.fn(() => ({
mutate: jest.fn(),
isLoading: false,
error: null,
})),
}));
const secondaryProviders = {
id: 'saml-test_university',
name: 'Test University',
iconClass: 'fa-university',
iconImage: null,
loginUrl: '/auth/login/saml-test_university/?auth_entry=login&next=%2Fdashboard',
registerUrl: '/auth/login/saml-test_university/?auth_entry=register&next=%2Fdashboard',
};
// Mock the ThirdPartyAuthContext
const mockClearThirdPartyAuthErrorMessage = jest.fn();
jest.mock('../common-components/components/ThirdPartyAuthContext.tsx', () => ({
useThirdPartyAuthContext: jest.fn(() => ({
fieldDescriptions: {},
optionalFields: {
fields: {},
extended_profile: [],
},
thirdPartyAuthApiStatus: null,
thirdPartyAuthContext: {
autoSubmitRegForm: false,
currentProvider: null,
finishAuthUrl: null,
countryCode: null,
providers: [{
id: 'oa2-facebook',
name: 'Facebook',
iconClass: 'fa-facebook',
iconImage: null,
skipHintedLogin: false,
skipRegistrationForm: false,
loginUrl: '/auth/login/facebook-oauth2/?auth_entry=login&next=%2Fdashboard',
registerUrl: '/auth/login/facebook-oauth2/?auth_entry=register&next=%2Fdashboard',
}],
secondaryProviders: [{
id: 'saml-test',
name: 'Test University',
iconClass: 'fa-sign-in',
iconImage: null,
skipHintedLogin: false,
skipRegistrationForm: false,
loginUrl: '/auth/login/tpa-saml/?auth_entry=login&next=%2Fdashboard',
registerUrl: '/auth/login/tpa-saml/?auth_entry=register&next=%2Fdashboard',
}],
pipelineUserDetails: null,
errorMessage: null,
welcomePageRedirectUrl: null,
},
setThirdPartyAuthContextBegin: jest.fn(),
setThirdPartyAuthContextSuccess: jest.fn(),
setThirdPartyAuthContextFailure: jest.fn(),
clearThirdPartyAuthErrorMessage: mockClearThirdPartyAuthErrorMessage,
})),
ThirdPartyAuthProvider: ({ children }) => children,
}));
let queryClient;
const mockStore = configureStore();
const IntlLogistration = injectIntl(Logistration);
describe('Logistration', () => {
const renderWrapper = (children) => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
let store = {};
const secondaryProviders = {
id: 'saml-test',
name: 'Test University',
loginUrl: '/dummy-auth',
registerUrl: '/dummy_auth',
};
const reduxWrapper = children => (
<IntlProvider locale="en">
<MemoryRouter>
<Provider store={store}>{children}</Provider>
</MemoryRouter>
</IntlProvider>
);
const initialState = {
register: {
registrationFormData: {
configurableFormFields: {
marketingEmailsOptIn: true,
},
mutations: {
retry: false,
formFields: {
name: '', email: '', username: '', password: '',
},
emailSuggestion: {
suggestion: '', type: '',
},
errors: {
name: '', email: '', username: '', password: '',
},
},
});
return (
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter>
{children}
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>
);
registrationResult: { success: false, redirectUrl: '' },
registrationError: {},
usernameSuggestions: [],
validationApiRateLimited: false,
},
commonComponents: {
thirdPartyAuthContext: {
providers: [],
secondaryProviders: [],
},
},
login: {
loginResult: { success: false, redirectUrl: '' },
},
};
beforeEach(() => {
// Avoid jest open handle error
jest.clearAllMocks();
mockNavigate.mockClear();
mockGetCsrfToken.mockClear();
store = mockStore(initialState);
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({
userId: 3,
username: 'test-user',
})),
}));
// Configure i18n for testing
configure({
loggingService: { logError: jest.fn() },
config: {
@@ -169,25 +92,10 @@ describe('Logistration', () => {
},
messages: { 'es-419': {}, de: {}, 'en-us': {} },
});
// Set up default configuration for tests
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: 'true',
ALLOW_PUBLIC_ACCOUNT_CREATION: 'true',
SHOW_REGISTRATION_LINKS: 'true',
TPA_HINT: '',
TPA_PROVIDER_ID: '',
THIRD_PARTY_AUTH_HINT: '',
PROVIDERS: [secondaryProviders],
SECONDARY_PROVIDERS: [secondaryProviders],
CURRENT_PROVIDER: null,
FINISHED_AUTH_PROVIDERS: [],
DISABLE_TPA_ON_FORM: false,
});
});
it('should do nothing when user clicks on the same tab (login/register) again', () => {
const { container } = render(renderWrapper(<Logistration selectedPage={REGISTER_PAGE} />));
const { container } = render(reduxWrapper(<IntlLogistration />));
// While staying on the registration form, clicking the register tab again
fireEvent.click(container.querySelector('a[data-rb-event-key="/register"]'));
@@ -199,14 +107,14 @@ describe('Logistration', () => {
ALLOW_PUBLIC_ACCOUNT_CREATION: true,
});
const { container } = render(renderWrapper(<Logistration />));
const { container } = render(reduxWrapper(<IntlLogistration />));
expect(container.querySelector('RegistrationPage')).toBeDefined();
});
it('should render login page', () => {
const props = { selectedPage: LOGIN_PAGE };
const { container } = render(renderWrapper(<Logistration {...props} />));
const { container } = render(reduxWrapper(<IntlLogistration {...props} />));
expect(container.querySelector('LoginPage')).toBeDefined();
});
@@ -217,18 +125,18 @@ describe('Logistration', () => {
});
let props = { selectedPage: LOGIN_PAGE };
const { rerender } = render(renderWrapper(<Logistration {...props} />));
const { rerender } = render(reduxWrapper(<IntlLogistration {...props} />));
// verifying sign in tab
expect(screen.getByRole('tab', { name: 'Sign in' })).toBeDefined();
// verifying sign in heading
expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Sign in');
// register page is still accessible when SHOW_REGISTRATION_LINKS is false
// but it needs to be accessed directly
props = { selectedPage: REGISTER_PAGE };
rerender(renderWrapper(<Logistration {...props} />));
rerender(reduxWrapper(<IntlLogistration {...props} />));
// verifying register button
expect(screen.getByRole('button', { name: 'Create an account for free' })).toBeDefined();
// verifying register heading
expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Register');
});
it('should render only login page when public account creation is disabled', () => {
@@ -238,11 +146,24 @@ describe('Logistration', () => {
SHOW_REGISTRATION_LINKS: 'true',
});
const props = { selectedPage: LOGIN_PAGE };
const { container } = render(renderWrapper(<Logistration {...props} />));
store = mockStore({
...initialState,
commonComponents: {
thirdPartyAuthContext: {
currentProvider: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [secondaryProviders],
},
thirdPartyAuthApiStatus: COMPLETE_STATE,
},
});
// verifying sign in tab for institution login false
expect(screen.getByRole('tab', { name: 'Sign in' })).toBeDefined();
const props = { selectedPage: LOGIN_PAGE };
const { container } = render(reduxWrapper(<IntlLogistration {...props} />));
// verifying sign in heading for institution login false
expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Sign in');
// verifying tabs heading for institution login true
fireEvent.click(screen.getByRole('link'));
@@ -255,8 +176,21 @@ describe('Logistration', () => {
ALLOW_PUBLIC_ACCOUNT_CREATION: 'true',
});
store = mockStore({
...initialState,
commonComponents: {
thirdPartyAuthContext: {
currentProvider: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [secondaryProviders],
},
thirdPartyAuthApiStatus: COMPLETE_STATE,
},
});
const props = { selectedPage: LOGIN_PAGE };
render(renderWrapper(<Logistration {...props} />));
render(reduxWrapper(<IntlLogistration {...props} />));
expect(screen.getByText('Institution/campus credentials')).toBeDefined();
// on clicking "Institution/campus credentials" button, it should display institution login page
@@ -273,8 +207,21 @@ describe('Logistration', () => {
DISABLE_ENTERPRISE_LOGIN: 'true',
});
store = mockStore({
...initialState,
commonComponents: {
thirdPartyAuthContext: {
currentProvider: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [secondaryProviders],
},
thirdPartyAuthApiStatus: COMPLETE_STATE,
},
});
const props = { selectedPage: LOGIN_PAGE };
render(renderWrapper(<Logistration {...props} />));
render(reduxWrapper(<IntlLogistration {...props} />));
fireEvent.click(screen.getByText('Institution/campus credentials'));
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement' });
@@ -290,10 +237,23 @@ describe('Logistration', () => {
DISABLE_ENTERPRISE_LOGIN: 'true',
});
store = mockStore({
...initialState,
commonComponents: {
thirdPartyAuthContext: {
currentProvider: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [secondaryProviders],
},
thirdPartyAuthApiStatus: COMPLETE_STATE,
},
});
delete window.location;
window.location = { hostname: getConfig().SITE_NAME, href: getConfig().BASE_URL };
render(renderWrapper(<Logistration />));
render(reduxWrapper(<IntlLogistration />));
fireEvent.click(screen.getByText('Institution/campus credentials'));
expect(screen.getByText('Test University')).toBeDefined();
@@ -302,52 +262,25 @@ describe('Logistration', () => {
});
});
it('should switch to login tab when login tab is clicked', () => {
const { container } = render(renderWrapper(<Logistration />));
it('should fire action to backup registration form on tab click', () => {
store.dispatch = jest.fn(store.dispatch);
const { container } = render(reduxWrapper(<IntlLogistration />));
fireEvent.click(container.querySelector('a[data-rb-event-key="/login"]'));
// Verify the tab switch occurred - check for active login tab
expect(container.querySelector('a[data-rb-event-key="/login"].active')).toBeTruthy();
expect(store.dispatch).toHaveBeenCalledWith(backupRegistrationForm());
});
it('should switch to register tab when register tab is clicked', () => {
it('should fire action to backup login form on tab click', () => {
store.dispatch = jest.fn(store.dispatch);
const props = { selectedPage: LOGIN_PAGE };
const { container } = render(renderWrapper(<Logistration {...props} />));
const { container } = render(reduxWrapper(<IntlLogistration {...props} />));
fireEvent.click(container.querySelector('a[data-rb-event-key="/register"]'));
// Verify the tab switch occurred - check for active register tab
expect(container.querySelector('a[data-rb-event-key="/register"].active')).toBeTruthy();
expect(store.dispatch).toHaveBeenCalledWith(backupLoginForm());
});
it('should clear tpa context errorMessage tab click', () => {
const { container } = render(renderWrapper(<Logistration />));
store.dispatch = jest.fn(store.dispatch);
const { container } = render(reduxWrapper(<IntlLogistration />));
fireEvent.click(container.querySelector('a[data-rb-event-key="/login"]'));
expect(mockClearThirdPartyAuthErrorMessage).toHaveBeenCalled();
});
it('should call authService getCsrfTokenService on component mount', () => {
render(renderWrapper(<Logistration selectedPage={LOGIN_PAGE} />));
expect(mockGetCsrfToken).toHaveBeenCalledWith(getConfig().LMS_BASE_URL);
});
it('should send correct page events for login and register when handling institution login', () => {
render(renderWrapper(<Logistration selectedPage={LOGIN_PAGE} />));
const institutionButton = screen.getByText('Institution/campus credentials');
fireEvent.click(institutionButton);
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login');
const { container: registerContainer } = render(renderWrapper(<Logistration selectedPage={REGISTER_PAGE} />));
const registerInstitutionButton = registerContainer.querySelector('#institution-login');
if (registerInstitutionButton) {
fireEvent.click(registerInstitutionButton);
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login');
}
});
it('should handle institution login with string parameters correctly', () => {
render(renderWrapper(<Logistration selectedPage={LOGIN_PAGE} />));
const institutionButton = screen.getByText('Institution/campus credentials');
sendPageEvent.mockClear();
fireEvent.click(institutionButton);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement' });
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login');
expect(store.dispatch).toHaveBeenCalledWith(clearThirdPartyAuthContextErrorMessage());
});
});

View File

@@ -1,47 +0,0 @@
# Login Component Slot
### Slot ID: `org.openedx.frontend.authn.login_component.v1`
## Description
This slot is used to replace/modify/hide the login component.
## Example
### Default content
![Default Login Page](./default_component.png)
### With a prepended message
![Login Page with ](./component_with_prefix.png)
The following `env.config.jsx` will add a message before the login component.
```js
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
// Load environment variables from .env file
const config = {
...process.env,
pluginSlots: {
'org.openedx.frontend.authn.login_component.v1': {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'test_plugin',
type: DIRECT_PLUGIN,
priority: 1,
RenderWidget: () => (
<h2>You're logging into TEST Instance.</h2>
)
},
},
],
},
},
};
export default config;
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Some files were not shown because too many files have changed in this diff Show More