Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd02f8b3f4 | ||
|
|
5ba6adcd7d | ||
|
|
527bc5aa37 | ||
|
|
fd5122bc10 | ||
|
|
97c9a09efa | ||
|
|
cf3e8b5c7f | ||
|
|
764d5f51e1 | ||
|
|
c4ef3dbbd9 | ||
|
|
a552d025b6 | ||
|
|
2cb62ca6d4 | ||
|
|
f3f14fb3e7 | ||
|
|
6c8b3835b6 | ||
|
|
8cbe6ce02e | ||
|
|
a53334d3bf | ||
|
|
0172c79dd9 | ||
|
|
5d4abcbab3 | ||
|
|
c9987eb2f4 | ||
|
|
a2ad9d5248 | ||
|
|
cca87bd16a | ||
|
|
206c4c887b | ||
|
|
09dc21eb0e | ||
|
|
945af2bdfd | ||
|
|
0dca6f3fdc | ||
|
|
189a67c9dc | ||
|
|
3dceb63b9c | ||
|
|
9385174b93 | ||
|
|
86ed8e2361 | ||
|
|
f5a6ece6b1 | ||
|
|
0d71e31ffb | ||
|
|
38dd2944b8 | ||
|
|
f4708ed274 | ||
|
|
cb7300441c | ||
|
|
244b9e68e6 | ||
|
|
c04ed9aa43 | ||
|
|
354c73bb2a | ||
|
|
76a5a5dffa | ||
|
|
5efe9d8344 | ||
|
|
cd2003921b | ||
|
|
7339aec7c2 | ||
|
|
025870a3b9 | ||
|
|
8dc77d5db6 | ||
|
|
40a1f4ce6b | ||
|
|
bb9fcd91c0 | ||
|
|
0fa00290da | ||
|
|
b18caa2da0 | ||
|
|
5ca86f9183 | ||
|
|
2a9dbe9d30 | ||
|
|
62508e3bc7 | ||
|
|
ceb489753b | ||
|
|
5035a07e0a | ||
|
|
f086a165e2 | ||
|
|
9239df3620 | ||
|
|
009125c3ef | ||
|
|
b69ed6e422 | ||
|
|
07ee2392e9 | ||
|
|
2bfce01772 | ||
|
|
1477ed33d7 | ||
|
|
c4f1a97316 | ||
|
|
47b0501e1c | ||
|
|
e496bb62c5 | ||
|
|
b41fca3605 | ||
|
|
ac2548913f | ||
|
|
cd9b3bd084 | ||
|
|
efc07aac67 | ||
|
|
2d50ed224f | ||
|
|
d10f9b932b | ||
|
|
05aa85a5fb | ||
|
|
56bd6d835e | ||
|
|
afd4d24360 | ||
|
|
4898864416 | ||
|
|
739f94d624 | ||
|
|
1819edc9b7 | ||
|
|
ad0d75ab0d | ||
|
|
a90ebb7d4d | ||
|
|
f8290adab5 | ||
|
|
788a42b341 | ||
|
|
4f48e82959 | ||
|
|
99850574fb | ||
|
|
d66afe98f0 | ||
|
|
e2cdfce832 | ||
|
|
c1e63da778 | ||
|
|
ecf4c3ae53 | ||
|
|
2428b4c389 | ||
|
|
099fe8d717 | ||
|
|
4755540be8 | ||
|
|
9a30f053c7 | ||
|
|
6b983e18d3 | ||
|
|
327210192c | ||
|
|
0d603b5fa1 | ||
|
|
efaa83a1bc | ||
|
|
bd63bb1f15 | ||
|
|
5754c2961a | ||
|
|
dcbd644a25 | ||
|
|
52e438652c | ||
|
|
d8947a4c0a | ||
|
|
03d1666c2c | ||
|
|
3782503983 | ||
|
|
b219fe3683 | ||
|
|
90f650ce3e | ||
|
|
6f325c20c3 | ||
|
|
de12dfbf9e | ||
|
|
c663f6fa30 | ||
|
|
dba93333fd | ||
|
|
611af07326 | ||
|
|
564ec70d9e | ||
|
|
65e95a4d1b | ||
|
|
cf2b50005b | ||
|
|
faf4ff8488 | ||
|
|
7d64220852 |
4
.env
4
.env
@@ -16,6 +16,9 @@ SITE_NAME=null
|
|||||||
INFO_EMAIL=''
|
INFO_EMAIL=''
|
||||||
# ***** Cookies *****
|
# ***** Cookies *****
|
||||||
USER_RETENTION_COOKIE_NAME=null
|
USER_RETENTION_COOKIE_NAME=null
|
||||||
|
# ***** Cohesion Keys *****
|
||||||
|
COHESION_WRITE_KEY=''
|
||||||
|
COHESION_SOURCE_KEY=''
|
||||||
# ***** Links *****
|
# ***** Links *****
|
||||||
LOGIN_ISSUE_SUPPORT_LINK=''
|
LOGIN_ISSUE_SUPPORT_LINK=''
|
||||||
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK=null
|
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK=null
|
||||||
@@ -43,3 +46,4 @@ APP_ID=''
|
|||||||
MFE_CONFIG_API_URL=''
|
MFE_CONFIG_API_URL=''
|
||||||
# Fallback in local style files
|
# Fallback in local style files
|
||||||
PARAGON_THEME_URLS={}
|
PARAGON_THEME_URLS={}
|
||||||
|
RECAPTCHA_SITE_KEY_WEB=''
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN='true'
|
|||||||
# ***** Cookies *****
|
# ***** Cookies *****
|
||||||
SESSION_COOKIE_DOMAIN='localhost'
|
SESSION_COOKIE_DOMAIN='localhost'
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
|
# ***** Cohesion Keys *****
|
||||||
|
COHESION_WRITE_KEY=''
|
||||||
|
COHESION_SOURCE_KEY=''
|
||||||
# ***** Links *****
|
# ***** Links *****
|
||||||
LOGIN_ISSUE_SUPPORT_LINK='http://localhost:18000/login-issue-support-url'
|
LOGIN_ISSUE_SUPPORT_LINK='http://localhost:18000/login-issue-support-url'
|
||||||
TOS_AND_HONOR_CODE='http://localhost:18000/honor'
|
TOS_AND_HONOR_CODE='http://localhost:18000/honor'
|
||||||
@@ -43,3 +46,4 @@ ZENDESK_KEY=''
|
|||||||
ZENDESK_LOGO_URL=''
|
ZENDESK_LOGO_URL=''
|
||||||
# Fallback in local style files
|
# Fallback in local style files
|
||||||
PARAGON_THEME_URLS={}
|
PARAGON_THEME_URLS={}
|
||||||
|
RECAPTCHA_SITE_KEY_WEB=''
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ SEGMENT_KEY=''
|
|||||||
SITE_NAME='Your Platform Name Here'
|
SITE_NAME='Your Platform Name Here'
|
||||||
APP_ID=''
|
APP_ID=''
|
||||||
MFE_CONFIG_API_URL=''
|
MFE_CONFIG_API_URL=''
|
||||||
|
COHESION_WRITE_KEY=''
|
||||||
|
COHESION_SOURCE_KEY=''
|
||||||
PARAGON_THEME_URLS={}
|
PARAGON_THEME_URLS={}
|
||||||
|
RECAPTCHA_SITE_KEY_WEB=''
|
||||||
|
|||||||
13
.eslintrc.js
13
.eslintrc.js
@@ -1,7 +1,7 @@
|
|||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
const { createConfig } = require('@openedx/frontend-build');
|
const { createConfig } = require('@openedx/frontend-build');
|
||||||
|
|
||||||
module.exports = createConfig('eslint', {
|
const config = createConfig('eslint', {
|
||||||
rules: {
|
rules: {
|
||||||
// Temporarily update the 'indent', 'template-curly-spacing' and
|
// Temporarily update the 'indent', 'template-curly-spacing' and
|
||||||
// 'no-multiple-empty-lines' rules since they are causing eslint
|
// 'no-multiple-empty-lines' rules since they are causing eslint
|
||||||
@@ -50,3 +50,14 @@ module.exports = createConfig('eslint', {
|
|||||||
'function-paren-newline': 'off',
|
'function-paren-newline': 'off',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
config.settings = {
|
||||||
|
'import/resolver': {
|
||||||
|
node: {
|
||||||
|
paths: ['src', 'node_modules'],
|
||||||
|
extensions: ['.js', '.jsx'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
|
|||||||
43
README.rst
43
README.rst
@@ -26,46 +26,31 @@ This is a micro-frontend application responsible for the login, registration and
|
|||||||
Getting Started
|
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.
|
`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.
|
||||||
|
|
||||||
.. _Tutor: https://github.com/overhangio/tutor
|
.. _Tutor: https://github.com/overhangio/tutor
|
||||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
|
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
|
||||||
|
|
||||||
Cloning and Startup
|
Devstack (Deprecated) instructions
|
||||||
===================
|
==================================
|
||||||
|
|
||||||
1. Clone your new repo:
|
1. Install Devstack using the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ instructions.
|
||||||
|
|
||||||
.. code-block:: bash
|
2. Start up LMS, if it's not already started.
|
||||||
|
|
||||||
git clone https://github.com/edx/frontend-app-authn.git
|
4. Within this project (frontend-app-authn), install requirements and start the development server:
|
||||||
|
|
||||||
2. Use the version of Node specified in the ``.nvmrc`` file.
|
.. code-block::
|
||||||
|
|
||||||
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
|
npm install
|
||||||
Using other major versions of node *may* work, but this is unsupported. For
|
npm start # The server will run on port 1999
|
||||||
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:
|
5. Once the dev server is up, visit http://localhost:1999 to access the MFE
|
||||||
|
|
||||||
.. code-block:: bash
|
.. image:: ./docs/images/frontend-app-authn-localhost-preview.png
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
**Note:** Follow `Enable social auth locally <docs/how_tos/enable_social_auth.rst>`_ for enabling Social Sign-on Buttons (SSO) locally
|
**Note:** Follow `Enable social auth locally <docs/how_tos/enable_social_auth.rst>`_ for enabling Social Sign-on Buttons (SSO) locally
|
||||||
|
|
||||||
@@ -130,7 +115,7 @@ The authentication micro-frontend also requires the following additional variabl
|
|||||||
|
|
||||||
* - ``MFE_CONFIG_API_URL``
|
* - ``MFE_CONFIG_API_URL``
|
||||||
- Link of the API to get runtime mfe configuration variables from the site configuration or django settings.
|
- 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``
|
* - ``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.
|
- 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,7 +145,7 @@ Furthermore, there are several edX-specific environment variables that enable in
|
|||||||
|
|
||||||
* - ``SHOW_CONFIGURABLE_EDX_FIELDS``
|
* - ``SHOW_CONFIGURABLE_EDX_FIELDS``
|
||||||
- For edX, country and honor code fields are required by default. This flag enables edX specific required 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
|
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://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`__.
|
||||||
|
|||||||
60
example.env.config.js
Normal file
60
example.env.config.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
Authn MFE is now able to handle JS-based configuration!
|
||||||
|
|
||||||
|
For the time being, the `.env.*` files are still made available when cloning down this repo or pulling from
|
||||||
|
the master branch. To switch to using `env.config.js`, make a copy of `example.env.config.js` and configure as needed.
|
||||||
|
|
||||||
|
For testing with Jest Snapshot, there is a mock in `/src/setupTest.jsx` for `getConfig` that will need to be
|
||||||
|
uncommented.
|
||||||
|
|
||||||
|
Note: having both .env and env.config.js files will follow a predictable order, in which non-empty values in the
|
||||||
|
JS-based config will overwrite the .env environment variables.
|
||||||
|
|
||||||
|
frontend-platform's getConfig loads configuration in the following sequence:
|
||||||
|
- .env file config
|
||||||
|
- optional handlers (commonly used to merge MFE-specific config in via additional process.env variables)
|
||||||
|
- env.config.js file config
|
||||||
|
- runtime config
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
NODE_PATH: './src',
|
||||||
|
PORT: 1999,
|
||||||
|
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
|
||||||
|
BASE_URL: 'http://localhost:1999',
|
||||||
|
CREDENTIALS_BASE_URL: 'http://localhost:18150',
|
||||||
|
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
|
||||||
|
ECOMMERCE_BASE_URL: 'http://localhost:18130',
|
||||||
|
LANGUAGE_PREFERENCE_COOKIE_NAME: 'openedx-language-preference',
|
||||||
|
LMS_BASE_URL: 'http://localhost:18000',
|
||||||
|
LOGIN_URL: 'http://localhost:1999/login',
|
||||||
|
LOGOUT_URL: 'http://localhost:18000/logout',
|
||||||
|
LOGO_URL: 'https://edx-cdn.org/v3/default/logo.svg',
|
||||||
|
LOGO_TRADEMARK_URL: 'https://edx-cdn.org/v3/default/logo-trademark.svg',
|
||||||
|
LOGO_WHITE_URL: 'https://edx-cdn.org/v3/default/logo-white.svg',
|
||||||
|
FAVICON_URL: 'https://edx-cdn.org/v3/default/favicon.ico',
|
||||||
|
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
|
||||||
|
ORDER_HISTORY_URL: 'http://localhost:1996/orders',
|
||||||
|
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh',
|
||||||
|
SEGMENT_KEY: '',
|
||||||
|
SITE_NAME: 'Your Platform Name Here',
|
||||||
|
INFO_EMAIL: 'info@example.com',
|
||||||
|
ENABLE_DYNAMIC_REGISTRATION_FIELDS: 'true',
|
||||||
|
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: 'true',
|
||||||
|
SESSION_COOKIE_DOMAIN: 'localhost',
|
||||||
|
USER_INFO_COOKIE_NAME: 'edx-user-info',
|
||||||
|
LOGIN_ISSUE_SUPPORT_LINK: 'http://localhost:18000/login-issue-support-url',
|
||||||
|
TOS_AND_HONOR_CODE: 'http://localhost:18000/honor',
|
||||||
|
TOS_LINK: 'http://localhost:18000/tos',
|
||||||
|
PRIVACY_POLICY: 'http://localhost:18000/privacy',
|
||||||
|
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: 'http://localhost:1999/welcome',
|
||||||
|
BANNER_IMAGE_LARGE: '',
|
||||||
|
BANNER_IMAGE_MEDIUM: '',
|
||||||
|
BANNER_IMAGE_SMALL: '',
|
||||||
|
BANNER_IMAGE_EXTRA_SMALL: '',
|
||||||
|
APP_ID: '',
|
||||||
|
MFE_CONFIG_API_URL: '',
|
||||||
|
ZENDESK_KEY: '',
|
||||||
|
ZENDESK_LOGO_URL: '',
|
||||||
|
};
|
||||||
5694
package-lock.json
generated
5694
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -35,11 +35,11 @@
|
|||||||
"@fortawesome/fontawesome-svg-core": "6.7.2",
|
"@fortawesome/fontawesome-svg-core": "6.7.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "6.7.2",
|
"@fortawesome/free-brands-svg-icons": "6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "6.7.2",
|
"@fortawesome/free-solid-svg-icons": "6.7.2",
|
||||||
|
"@openedx/frontend-plugin-framework": "^1.3.0",
|
||||||
"@fortawesome/react-fontawesome": "0.2.6",
|
"@fortawesome/react-fontawesome": "0.2.6",
|
||||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
|
||||||
"@openedx/paragon": "^23.4.2",
|
"@openedx/paragon": "^23.4.2",
|
||||||
"@optimizely/react-sdk": "^2.9.1",
|
"@optimizely/react-sdk": "^2.9.1",
|
||||||
"@tanstack/react-query": "^5.90.19",
|
"@redux-devtools/extension": "3.3.0",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"algoliasearch": "^4.14.3",
|
"algoliasearch": "^4.14.3",
|
||||||
"algoliasearch-helper": "^3.26.0",
|
"algoliasearch-helper": "^3.26.0",
|
||||||
@@ -49,27 +49,36 @@
|
|||||||
"form-urlencoded": "6.1.6",
|
"form-urlencoded": "6.1.6",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"query-string": "7.1.3",
|
"query-string": "7.1.3",
|
||||||
|
"react-error-boundary": "^4.0.13",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-google-recaptcha-v3": "^1.11.0",
|
||||||
"react-helmet": "6.1.0",
|
"react-helmet": "6.1.0",
|
||||||
"react-loading-skeleton": "3.5.0",
|
"react-loading-skeleton": "3.5.0",
|
||||||
|
"react-redux": "7.2.9",
|
||||||
"react-responsive": "8.2.0",
|
"react-responsive": "8.2.0",
|
||||||
"react-router": "6.30.3",
|
"react-router": "6.30.1",
|
||||||
"react-router-dom": "6.30.3",
|
"react-router-dom": "6.30.1",
|
||||||
"react-zendesk": "^0.1.13",
|
"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",
|
"regenerator-runtime": "0.14.1",
|
||||||
|
"reselect": "5.1.1",
|
||||||
"universal-cookie": "7.2.2"
|
"universal-cookie": "7.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/browserslist-config": "^1.1.1",
|
"@edx/browserslist-config": "^1.1.1",
|
||||||
"@edx/typescript-config": "^1.1.0",
|
"@edx/reactifex": "1.1.0",
|
||||||
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
"@openedx/frontend-build": "^14.6.2",
|
"@openedx/frontend-build": "^14.6.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"babel-plugin-formatjs": "10.5.39",
|
||||||
"babel-plugin-formatjs": "10.5.41",
|
|
||||||
"eslint-plugin-import": "2.32.0",
|
"eslint-plugin-import": "2.32.0",
|
||||||
"glob": "7.2.3",
|
"glob": "7.2.3",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"jest": "30.3.0",
|
"jest": "30.1.3",
|
||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^18.3.1",
|
||||||
"ts-jest": "^29.4.0"
|
"ts-jest": "^29.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en-us">
|
<html lang="en-us">
|
||||||
<head>
|
<head>
|
||||||
<title><%= (process.env.SITE_NAME && process.env.SITE_NAME != 'null') ? 'Authentication | ' + process.env.SITE_NAME : 'Authentication' %></title>
|
<title>
|
||||||
<meta charset="utf-8">
|
<%= (process.env.SITE_NAME && process.env.SITE_NAME !='null' ) ?
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
'Authentication | ' + process.env.SITE_NAME : 'Authentication' %>
|
||||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon"/>
|
</title>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.4.4/iframeResizer.contentWindow.min.js"
|
<meta charset="utf-8" />
|
||||||
integrity="sha512-IWwZFBvHzN41wNI6etRLLuLrDDj/6AwJcPt7cmKJAzluYTIHHQ1PF8wh0rSy05jxEvvjflVvH2MxeV6riyEEXg=="
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
crossorigin="anonymous"
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
referrerpolicy="no-referrer">
|
<link
|
||||||
</script>
|
rel="shortcut icon"
|
||||||
<% if (process.env.OPTIMIZELY_URL) { %>
|
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
|
||||||
<script
|
type="image/x-icon"
|
||||||
src="<%= process.env.OPTIMIZELY_URL %>"
|
/>
|
||||||
></script>
|
<script defer src="https://www.edx.org/beam-wrapper.js" ></script>
|
||||||
<% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %>
|
<script
|
||||||
<script
|
src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.4.4/iframeResizer.contentWindow.min.js"
|
||||||
src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"
|
integrity="sha512-IWwZFBvHzN41wNI6etRLLuLrDDj/6AwJcPt7cmKJAzluYTIHHQ1PF8wh0rSy05jxEvvjflVvH2MxeV6riyEEXg=="
|
||||||
></script>
|
crossorigin="anonymous"
|
||||||
<% } %>
|
referrerpolicy="no-referrer"
|
||||||
|
></script>
|
||||||
|
<% if (process.env.OPTIMIZELY_URL) { %>
|
||||||
|
<script src="<%= process.env.OPTIMIZELY_URL %>"></script>
|
||||||
|
<% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %>
|
||||||
|
<script src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"></script>
|
||||||
|
<% } %>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmbeddedRegistrationRoute, NotFoundPage, registerIcons, UnAuthOnlyRoute, Zendesk,
|
EmbeddedRegistrationRoute, NotFoundPage, registerIcons, RouteTracker, UnAuthOnlyRoute,
|
||||||
} from './common-components';
|
} from './common-components';
|
||||||
|
import configureStore from './data/configureStore';
|
||||||
import {
|
import {
|
||||||
AUTHN_PROGRESSIVE_PROFILING,
|
AUTHN_PROGRESSIVE_PROFILING,
|
||||||
LOGIN_PAGE,
|
LOGIN_PAGE,
|
||||||
@@ -20,6 +23,7 @@ import {
|
|||||||
import { updatePathWithQueryParams } from './data/utils';
|
import { updatePathWithQueryParams } from './data/utils';
|
||||||
import { ForgotPasswordPage } from './forgot-password';
|
import { ForgotPasswordPage } from './forgot-password';
|
||||||
import Logistration from './logistration/Logistration';
|
import Logistration from './logistration/Logistration';
|
||||||
|
import MainAppSlot from './plugin-slots/MainAppSlot';
|
||||||
import { ProgressiveProfiling } from './progressive-profiling';
|
import { ProgressiveProfiling } from './progressive-profiling';
|
||||||
import { RecommendationsPage } from './recommendations';
|
import { RecommendationsPage } from './recommendations';
|
||||||
import { RegistrationPage } from './register';
|
import { RegistrationPage } from './register';
|
||||||
@@ -29,48 +33,43 @@ import './index.scss';
|
|||||||
|
|
||||||
registerIcons();
|
registerIcons();
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const MainApp = () => {
|
||||||
defaultOptions: {
|
const recaptchaKey = getConfig().RECAPTCHA_SITE_KEY_WEB;
|
||||||
mutations: {
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const MainApp = () => (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<GoogleReCaptchaProvider
|
||||||
<AppProvider>
|
reCaptchaKey={recaptchaKey}
|
||||||
<Helmet>
|
useEnterprise
|
||||||
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
>
|
||||||
</Helmet>
|
<AppProvider store={configureStore()}>
|
||||||
{getConfig().ZENDESK_KEY && <Zendesk />}
|
<Helmet>
|
||||||
<Routes>
|
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
||||||
<Route path="/" element={<Navigate replace to={updatePathWithQueryParams(REGISTER_PAGE)} />} />
|
</Helmet>
|
||||||
<Route
|
<Routes>
|
||||||
path={REGISTER_EMBEDDED_PAGE}
|
<Route path="/" element={<Navigate replace to={updatePathWithQueryParams(REGISTER_PAGE)} />} />
|
||||||
element={<EmbeddedRegistrationRoute><RegistrationPage /></EmbeddedRegistrationRoute>}
|
<Route
|
||||||
/>
|
path={REGISTER_EMBEDDED_PAGE}
|
||||||
<Route
|
element={<EmbeddedRegistrationRoute><RegistrationPage /></EmbeddedRegistrationRoute>}
|
||||||
path={LOGIN_PAGE}
|
/>
|
||||||
element={
|
<Route
|
||||||
<UnAuthOnlyRoute><Logistration selectedPage={LOGIN_PAGE} /></UnAuthOnlyRoute>
|
path={LOGIN_PAGE}
|
||||||
}
|
element={
|
||||||
/>
|
<UnAuthOnlyRoute><Logistration selectedPage={LOGIN_PAGE} /></UnAuthOnlyRoute>
|
||||||
<Route
|
}
|
||||||
path={REGISTER_PAGE}
|
/>
|
||||||
element={
|
<Route path={REGISTER_PAGE} element={<UnAuthOnlyRoute><Logistration /></UnAuthOnlyRoute>} />
|
||||||
<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={RESET_PAGE} element={<UnAuthOnlyRoute><ForgotPasswordPage /></UnAuthOnlyRoute>} />
|
<Route path={RECOMMENDATIONS} element={<RecommendationsPage />} />
|
||||||
<Route path={PASSWORD_RESET_CONFIRM} element={<ResetPasswordPage />} />
|
<Route path={PAGE_NOT_FOUND} element={<NotFoundPage />} />
|
||||||
<Route path={AUTHN_PROGRESSIVE_PROFILING} element={<ProgressiveProfiling />} />
|
<Route path="*" element={<Navigate replace to={PAGE_NOT_FOUND} />} />
|
||||||
<Route path={RECOMMENDATIONS} element={<RecommendationsPage />} />
|
</Routes>
|
||||||
<Route path={PAGE_NOT_FOUND} element={<NotFoundPage />} />
|
<RouteTracker />
|
||||||
<Route path="*" element={<Navigate replace to={PAGE_NOT_FOUND} />} />
|
<MainAppSlot />
|
||||||
</Routes>
|
</AppProvider>
|
||||||
</AppProvider>
|
</GoogleReCaptchaProvider>
|
||||||
</QueryClientProvider>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export default MainApp;
|
export default MainApp;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink, Image } from '@openedx/paragon';
|
import { Hyperlink, Image } from '@openedx/paragon';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink, Image } from '@openedx/paragon';
|
import { Hyperlink, Image } from '@openedx/paragon';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink, Image } from '@openedx/paragon';
|
import { Hyperlink, Image } from '@openedx/paragon';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink, Image } from '@openedx/paragon';
|
import { Hyperlink, Image } from '@openedx/paragon';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink, Image } from '@openedx/paragon';
|
import { Hyperlink, Image } from '@openedx/paragon';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink, Image } from '@openedx/paragon';
|
import { Hyperlink, Image } from '@openedx/paragon';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { breakpoints } from '@openedx/paragon';
|
import { breakpoints } from '@openedx/paragon';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { mergeConfig } from '@edx/frontend-platform';
|
import { mergeConfig } from '@edx/frontend-platform';
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
|
|||||||
22
src/cohesion/constants.js
Normal file
22
src/cohesion/constants.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const PAGE_TYPES = {
|
||||||
|
ACCOUNT_CREATION: 'account-creation',
|
||||||
|
SIGN_IN: 'sign-in',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ELEMENT_TYPES = {
|
||||||
|
BUTTON: 'BUTTON',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_TYPES = { ElementClicked: 'redventures.usertracking.v3.ElementClicked' };
|
||||||
|
|
||||||
|
export const ELEMENT_TEXT = {
|
||||||
|
CREATE_ACCOUNT: 'create-account',
|
||||||
|
OPT_IN_TEXT: 'I agree that edx may send me marketing messages',
|
||||||
|
SIGN_IN: 'Sign In',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ELEMENT_NAME = {
|
||||||
|
SIGN_IN: PAGE_TYPES.SIGN_IN,
|
||||||
|
OPT_OUT: 'opt-out',
|
||||||
|
CREATE_ACCOUNT: 'Create an account for free',
|
||||||
|
};
|
||||||
6
src/cohesion/data/actions.js
Normal file
6
src/cohesion/data/actions.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const SET_COHESION_EVENT_ELEMENT_STATES = 'SET_COHESION_EVENT_ELEMENT_STATES';
|
||||||
|
|
||||||
|
export const setCohesionEventStates = (eventData) => ({
|
||||||
|
type: SET_COHESION_EVENT_ELEMENT_STATES,
|
||||||
|
payload: eventData,
|
||||||
|
});
|
||||||
17
src/cohesion/data/reducers.js
Normal file
17
src/cohesion/data/reducers.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { SET_COHESION_EVENT_ELEMENT_STATES } from './actions';
|
||||||
|
|
||||||
|
export const storeName = 'cohesion';
|
||||||
|
|
||||||
|
export const defaultState = {
|
||||||
|
eventData: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state = defaultState, action = {}) => {
|
||||||
|
if (action.type === SET_COHESION_EVENT_ELEMENT_STATES) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
eventData: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
24
src/cohesion/trackers.js
Normal file
24
src/cohesion/trackers.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { EVENT_TYPES } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks cohesion events by setting the page type and tracking a click event.
|
||||||
|
*
|
||||||
|
* @param {string} pageType - The type of page where the event occurred.
|
||||||
|
* @param {string} elementType - The type of the web element (e.g., 'BUTTON', 'LINK').
|
||||||
|
* @param {string} webElementText - The text content of the web element.
|
||||||
|
* @param {string} webElementName - The name of the web element.
|
||||||
|
*/
|
||||||
|
const trackCohesionEvent = (eventData) => {
|
||||||
|
window.chsn_pageType = eventData.pageType;
|
||||||
|
const webElement = {
|
||||||
|
elementType: eventData.elementType,
|
||||||
|
text: eventData.webElementText,
|
||||||
|
name: eventData.webElementName,
|
||||||
|
};
|
||||||
|
window.tagular?.('beam', {
|
||||||
|
'@type': EVENT_TYPES.ElementClicked,
|
||||||
|
webElement,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default trackCohesionEvent;
|
||||||
6
src/cohesion/utils.js
Normal file
6
src/cohesion/utils.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const mockTagular = () => {
|
||||||
|
const getTagular = jest.fn();
|
||||||
|
window.tagular = getTagular;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mockTagular;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Form, TransitionReplace,
|
Form, TransitionReplace,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Hyperlink, Icon } from '@openedx/paragon';
|
import { Button, Hyperlink, Icon } from '@openedx/paragon';
|
||||||
@@ -58,7 +60,7 @@ const InstitutionLogistration = props => {
|
|||||||
className="btn nav-item p-0 mb-1 institutions--provider-link"
|
className="btn nav-item p-0 mb-1 institutions--provider-link"
|
||||||
destination={lmsBaseUrl + provider.loginUrl}
|
destination={lmsBaseUrl + provider.loginUrl}
|
||||||
>
|
>
|
||||||
{provider.name}
|
{provider?.name}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
const NotFoundPage = () => (
|
const NotFoundPage = () => (
|
||||||
|
|||||||
@@ -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 { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -11,31 +12,17 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { LETTER_REGEX, NUMBER_REGEX } from '../data/constants';
|
import { LETTER_REGEX, NUMBER_REGEX } from '../data/constants';
|
||||||
import { useRegisterContext } from '../register/components/RegisterContext';
|
import { clearRegistrationBackendError, fetchRealtimeValidations } from '../register/data/actions';
|
||||||
import { useFieldValidations } from '../register/data/apiHook';
|
|
||||||
import { validatePasswordField } from '../register/data/utils';
|
import { validatePasswordField } from '../register/data/utils';
|
||||||
|
|
||||||
const PasswordField = (props) => {
|
const PasswordField = (props) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const validationApiRateLimited = useSelector(state => state.register.validationApiRateLimited);
|
||||||
const [isPasswordHidden, setHiddenTrue, setHiddenFalse] = useToggle(true);
|
const [isPasswordHidden, setHiddenTrue, setHiddenFalse] = useToggle(true);
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
|
||||||
const {
|
|
||||||
setValidationsSuccess,
|
|
||||||
setValidationsFailure,
|
|
||||||
validationApiRateLimited,
|
|
||||||
clearRegistrationBackendError,
|
|
||||||
} = useRegisterContext();
|
|
||||||
|
|
||||||
const fieldValidationsMutation = useFieldValidations({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setValidationsSuccess(data);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setValidationsFailure();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleBlur = (e) => {
|
const handleBlur = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
if (name === props.name && e.relatedTarget?.name === 'passwordIcon') {
|
if (name === props.name && e.relatedTarget?.name === 'passwordIcon') {
|
||||||
@@ -63,7 +50,7 @@ const PasswordField = (props) => {
|
|||||||
if (fieldError) {
|
if (fieldError) {
|
||||||
props.handleErrorChange('password', fieldError);
|
props.handleErrorChange('password', fieldError);
|
||||||
} else if (!validationApiRateLimited) {
|
} else if (!validationApiRateLimited) {
|
||||||
fieldValidationsMutation.mutate({ password: passwordValue });
|
dispatch(fetchRealtimeValidations({ password: passwordValue }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -78,7 +65,7 @@ const PasswordField = (props) => {
|
|||||||
}
|
}
|
||||||
if (props.handleErrorChange) {
|
if (props.handleErrorChange) {
|
||||||
props.handleErrorChange('password', '');
|
props.handleErrorChange('password', '');
|
||||||
clearRegistrationBackendError('password');
|
dispatch(clearRegistrationBackendError('password'));
|
||||||
}
|
}
|
||||||
setTimeout(() => setShowTooltip(props.showRequirements && true), 150);
|
setTimeout(() => setShowTooltip(props.showRequirements && true), 150);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import trackCohesionEvent from '../cohesion/trackers';
|
||||||
import {
|
import {
|
||||||
AUTHN_PROGRESSIVE_PROFILING, RECOMMENDATIONS, REDIRECT,
|
AUTHN_PROGRESSIVE_PROFILING, RECOMMENDATIONS, REDIRECT,
|
||||||
} from '../data/constants';
|
} from '../data/constants';
|
||||||
import { setCookie } from '../data/utils';
|
import setCookie from '../data/utils/cookies';
|
||||||
|
import { redirectWithDelay } from '../data/utils/dataUtils';
|
||||||
|
|
||||||
const RedirectLogistration = (props) => {
|
const RedirectLogistration = (props) => {
|
||||||
const {
|
const {
|
||||||
@@ -20,9 +24,16 @@ const RedirectLogistration = (props) => {
|
|||||||
userId,
|
userId,
|
||||||
registrationEmbedded,
|
registrationEmbedded,
|
||||||
host,
|
host,
|
||||||
|
currectProvider,
|
||||||
} = props;
|
} = props;
|
||||||
|
const cohesionEventData = useSelector(state => state.cohesion.eventData);
|
||||||
let finalRedirectUrl = '';
|
let finalRedirectUrl = '';
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
// This event is used by cohesion upon successful login and registration
|
||||||
|
if (!currectProvider) {
|
||||||
|
trackCohesionEvent(cohesionEventData);
|
||||||
|
}
|
||||||
// If we're in a third party auth pipeline, we must complete the pipeline
|
// 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.
|
// once user has successfully logged in. Otherwise, redirect to the specified redirect url.
|
||||||
// Note: For multiple enterprise use case, we need to make sure that user first visits the
|
// Note: For multiple enterprise use case, we need to make sure that user first visits the
|
||||||
@@ -74,8 +85,7 @@ const RedirectLogistration = (props) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
redirectWithDelay(finalRedirectUrl);
|
||||||
window.location.href = finalRedirectUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -93,6 +103,7 @@ RedirectLogistration.defaultProps = {
|
|||||||
userId: null,
|
userId: null,
|
||||||
registrationEmbedded: false,
|
registrationEmbedded: false,
|
||||||
host: '',
|
host: '',
|
||||||
|
currectProvider: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
RedirectLogistration.propTypes = {
|
RedirectLogistration.propTypes = {
|
||||||
@@ -107,6 +118,7 @@ RedirectLogistration.propTypes = {
|
|||||||
userId: PropTypes.number,
|
userId: PropTypes.number,
|
||||||
registrationEmbedded: PropTypes.bool,
|
registrationEmbedded: PropTypes.bool,
|
||||||
host: PropTypes.string,
|
host: PropTypes.string,
|
||||||
|
currectProvider: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RedirectLogistration;
|
export default RedirectLogistration;
|
||||||
|
|||||||
15
src/common-components/RouteTracker.jsx
Normal file
15
src/common-components/RouteTracker.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
const RouteTracker = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.tagular?.('pageView');
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RouteTracker;
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
@@ -6,17 +9,35 @@ import { Login } from '@openedx/paragon/icons';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
|
import { ELEMENT_TYPES, PAGE_TYPES } from '../cohesion/constants';
|
||||||
|
import trackCohesionEvent from '../cohesion/trackers';
|
||||||
|
import {
|
||||||
|
LOGIN_PAGE, REGISTER_PAGE, SUPPORTED_ICON_CLASSES,
|
||||||
|
} from '../data/constants';
|
||||||
|
import { setCookie } from '../data/utils';
|
||||||
|
import { redirectWithDelay } from '../data/utils/dataUtils';
|
||||||
|
|
||||||
const SocialAuthProviders = (props) => {
|
const SocialAuthProviders = (props) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const { referrer, socialAuthProviders } = props;
|
const { referrer, socialAuthProviders } = props;
|
||||||
|
const registrationFields = useSelector(state => state.register.registrationFormData);
|
||||||
|
|
||||||
function handleSubmit(e) {
|
function handleSubmit(e, providerName) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const eventData = {
|
||||||
|
pageType: referrer === LOGIN_PAGE ? PAGE_TYPES.SIGN_IN : PAGE_TYPES.ACCOUNT_CREATION,
|
||||||
|
elementType: ELEMENT_TYPES.BUTTON,
|
||||||
|
webElementText: providerName,
|
||||||
|
webElementName: providerName.toLowerCase(),
|
||||||
|
};
|
||||||
|
// This event is used by cohesion upon successful login
|
||||||
|
trackCohesionEvent(eventData);
|
||||||
|
|
||||||
|
if (referrer === REGISTER_PAGE) {
|
||||||
|
setCookie('marketingEmailsOptIn', registrationFields?.configurableFormFields?.marketingEmailsOptIn);
|
||||||
|
}
|
||||||
const url = e.currentTarget.dataset.providerUrl;
|
const url = e.currentTarget.dataset.providerUrl;
|
||||||
window.location.href = getConfig().LMS_BASE_URL + url;
|
redirectWithDelay(getConfig().LMS_BASE_URL + url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const socialAuth = socialAuthProviders.map((provider, index) => (
|
const socialAuth = socialAuthProviders.map((provider, index) => (
|
||||||
@@ -26,7 +47,7 @@ const SocialAuthProviders = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className={`btn-social btn-${provider.id} ${index % 2 === 0 ? 'mr-3' : ''}`}
|
className={`btn-social btn-${provider.id} ${index % 2 === 0 ? 'mr-3' : ''}`}
|
||||||
data-provider-url={referrer === LOGIN_PAGE ? provider.loginUrl : provider.registerUrl}
|
data-provider-url={referrer === LOGIN_PAGE ? provider.loginUrl : provider.registerUrl}
|
||||||
onClick={handleSubmit}
|
onClick={(event) => handleSubmit(event, provider?.name)}
|
||||||
>
|
>
|
||||||
{provider.iconImage ? (
|
{provider.iconImage ? (
|
||||||
<div aria-hidden="true">
|
<div aria-hidden="true">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert } from '@openedx/paragon';
|
import { Alert } from '@openedx/paragon';
|
||||||
@@ -5,6 +7,7 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
|
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
|
||||||
|
import setCookie from '../data/utils/cookies';
|
||||||
|
|
||||||
const ThirdPartyAuthAlert = (props) => {
|
const ThirdPartyAuthAlert = (props) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
@@ -18,7 +21,10 @@ const ThirdPartyAuthAlert = (props) => {
|
|||||||
message = formatMessage(messages['register.third.party.auth.account.not.linked'], { currentProvider, platformName });
|
message = formatMessage(messages['register.third.party.auth.account.not.linked'], { currentProvider, platformName });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentProvider) {
|
if (currentProvider) {
|
||||||
|
// Setting this cookie to capture marketingEmailsOptIn for SSO flow on the onboarding component
|
||||||
|
setCookie('ssoPipelineRedirectionDone', true);
|
||||||
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { getConfig } from '@edx/frontend-platform';
|
|||||||
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import {
|
import { RESET_PAGE } from '../data/constants';
|
||||||
DEFAULT_REDIRECT_URL,
|
import { updatePathWithQueryParams } from '../data/utils';
|
||||||
} from '../data/constants';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This wrapper redirects the requester to our default redirect url if they are
|
* This wrapper redirects the requester to our default redirect url if they are
|
||||||
@@ -25,7 +24,12 @@ const UnAuthOnlyRoute = ({ children }) => {
|
|||||||
|
|
||||||
if (isReady) {
|
if (isReady) {
|
||||||
if (authUser && authUser.username) {
|
if (authUser && authUser.username) {
|
||||||
global.location.href = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
const updatedPath = updatePathWithQueryParams(window.location.pathname);
|
||||||
|
if (updatedPath.startsWith(RESET_PAGE)) {
|
||||||
|
global.location.href = getConfig().LMS_BASE_URL;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
global.location.href = getConfig().LMS_BASE_URL.concat(updatedPath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import Zendesk from 'react-zendesk';
|
import Zendesk from 'react-zendesk';
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
33
src/common-components/data/actions.js
Normal file
33
src/common-components/data/actions.js
Normal 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,
|
||||||
|
countriesCodesList) => ({
|
||||||
|
type: THIRD_PARTY_AUTH_CONTEXT.SUCCESS,
|
||||||
|
payload: {
|
||||||
|
fieldDescriptions, optionalFields, thirdPartyAuthContext, countriesCodesList,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getThirdPartyAuthContextFailure = () => ({
|
||||||
|
type: THIRD_PARTY_AUTH_CONTEXT.FAILURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clearThirdPartyAuthContextErrorMessage = () => ({
|
||||||
|
type: THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG,
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
83
src/common-components/data/constants.js
Normal file
83
src/common-components/data/constants.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export const registerFields = {
|
||||||
|
fields: {
|
||||||
|
country: {
|
||||||
|
name: 'country',
|
||||||
|
error_message: 'Select your country or region of residence',
|
||||||
|
},
|
||||||
|
honor_code: {
|
||||||
|
name: 'honor_code',
|
||||||
|
type: 'tos_and_honor_code',
|
||||||
|
error_message: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const progressiveProfilingFields = {
|
||||||
|
extended_profile: [],
|
||||||
|
fields: {
|
||||||
|
level_of_education: {
|
||||||
|
name: 'level_of_education',
|
||||||
|
type: 'select',
|
||||||
|
label: 'Highest level of education completed',
|
||||||
|
error_message: '',
|
||||||
|
options: [
|
||||||
|
[
|
||||||
|
'p',
|
||||||
|
'Doctorate',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'm',
|
||||||
|
"Master's or professional degree",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'b',
|
||||||
|
"Bachelor's degree",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a',
|
||||||
|
'Associate degree',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'hs',
|
||||||
|
'Secondary/high school',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'jhs',
|
||||||
|
'Junior secondary/junior high/middle school',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'none',
|
||||||
|
'No formal education',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'other',
|
||||||
|
'Other education',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gender: {
|
||||||
|
name: 'gender',
|
||||||
|
type: 'select',
|
||||||
|
label: 'Gender',
|
||||||
|
error_message: '',
|
||||||
|
options: [
|
||||||
|
[
|
||||||
|
'm',
|
||||||
|
'Male',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'f',
|
||||||
|
'Female',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'o',
|
||||||
|
'Other/Prefer Not to Say',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIELD_LABELS = {
|
||||||
|
COUNTRY: 'country',
|
||||||
|
};
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { appId } from '../../constants';
|
|
||||||
|
|
||||||
export const ThirdPartyAuthQueryKeys = {
|
|
||||||
all: [appId, 'ThirdPartyAuth'] as const,
|
|
||||||
byPage: (pageId: string) => [appId, 'ThirdPartyAuth', pageId] as const,
|
|
||||||
};
|
|
||||||
64
src/common-components/data/reducers.js
Normal file
64
src/common-components/data/reducers.js
Normal 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,
|
||||||
|
countriesCodesList: action.payload.countriesCodesList,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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;
|
||||||
51
src/common-components/data/sagas.js
Normal file
51
src/common-components/data/sagas.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
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 { progressiveProfilingFields, registerFields } from './constants';
|
||||||
|
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 countriesCodesList = (yield call(getCountryList)) || [];
|
||||||
|
|
||||||
|
yield put(setCountryFromThirdPartyAuthContext(thirdPartyAuthContext.countryCode));
|
||||||
|
// hard code country field, level of education and gender fields
|
||||||
|
if (getConfig().ENABLE_HARD_CODE_OPTIONAL_FIELDS) {
|
||||||
|
yield put(getThirdPartyAuthContextSuccess(
|
||||||
|
registerFields,
|
||||||
|
progressiveProfilingFields,
|
||||||
|
thirdPartyAuthContext,
|
||||||
|
countriesCodesList,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
yield put(getThirdPartyAuthContextSuccess(
|
||||||
|
fieldDescriptions,
|
||||||
|
optionalFields,
|
||||||
|
thirdPartyAuthContext,
|
||||||
|
countriesCodesList,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
yield put(getThirdPartyAuthContextFailure());
|
||||||
|
logError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function* saga() {
|
||||||
|
yield takeEvery(THIRD_PARTY_AUTH_CONTEXT.BASE, fetchThirdPartyAuthContext);
|
||||||
|
}
|
||||||
28
src/common-components/data/selectors.js
Normal file
28
src/common-components/data/selectors.js
Normal 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
53
src/common-components/data/service.js
Normal file
53
src/common-components/data/service.js
Normal 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 './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 }) => (value)) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/common-components/data/tests/reducer.test.js
Normal file
82
src/common-components/data/tests/reducer.test.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/common-components/data/tests/sagas.test.js
Normal file
77
src/common-components/data/tests/sagas.test.js
Normal 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: {},
|
||||||
|
countriesCodesList: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,15 @@ export { default as RedirectLogistration } from './RedirectLogistration';
|
|||||||
export { default as registerIcons } from './RegisterFaIcons';
|
export { default as registerIcons } from './RegisterFaIcons';
|
||||||
export { default as EmbeddedRegistrationRoute } from './EmbeddedRegistrationRoute';
|
export { default as EmbeddedRegistrationRoute } from './EmbeddedRegistrationRoute';
|
||||||
export { default as UnAuthOnlyRoute } from './UnAuthOnlyRoute';
|
export { default as UnAuthOnlyRoute } from './UnAuthOnlyRoute';
|
||||||
|
export { default as RouteTracker } from './RouteTracker';
|
||||||
export { default as NotFoundPage } from './NotFoundPage';
|
export { default as NotFoundPage } from './NotFoundPage';
|
||||||
export { default as SocialAuthProviders } from './SocialAuthProviders';
|
export { default as SocialAuthProviders } from './SocialAuthProviders';
|
||||||
export { default as ThirdPartyAuthAlert } from './ThirdPartyAuthAlert';
|
export { default as ThirdPartyAuthAlert } from './ThirdPartyAuthAlert';
|
||||||
export { default as InstitutionLogistration } from './InstitutionLogistration';
|
export { default as InstitutionLogistration } from './InstitutionLogistration';
|
||||||
export { RenderInstitutionButton } 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 FormGroup } from './FormGroup';
|
||||||
export { default as PasswordField } from './PasswordField';
|
export { default as PasswordField } from './PasswordField';
|
||||||
export { default as Zendesk } from './Zendesk';
|
export { default as Zendesk } from './Zendesk';
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import React from 'react';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { fireEvent, render } from '@testing-library/react';
|
import { fireEvent, render } from '@testing-library/react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import configureStore from 'redux-mock-store';
|
||||||
|
|
||||||
import { RegisterProvider } from '../../register/components/RegisterContext';
|
import { fetchRealtimeValidations } from '../../register/data/actions';
|
||||||
import { useFieldValidations } from '../../register/data/apiHook';
|
|
||||||
import FormGroup from '../FormGroup';
|
import FormGroup from '../FormGroup';
|
||||||
import PasswordField from '../PasswordField';
|
import PasswordField from '../PasswordField';
|
||||||
|
|
||||||
// Mock the useFieldValidations hook
|
|
||||||
jest.mock('../../register/data/apiHook', () => ({
|
|
||||||
useFieldValidations: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('FormGroup', () => {
|
describe('FormGroup', () => {
|
||||||
const props = {
|
const props = {
|
||||||
floatingLabel: 'Email',
|
floatingLabel: 'Email',
|
||||||
@@ -41,52 +35,36 @@ describe('FormGroup', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('PasswordField', () => {
|
describe('PasswordField', () => {
|
||||||
|
const mockStore = configureStore();
|
||||||
let props = {};
|
let props = {};
|
||||||
let queryClient;
|
let store = {};
|
||||||
let mockMutate;
|
|
||||||
|
|
||||||
const renderWrapper = (children) => (
|
const reduxWrapper = children => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<IntlProvider locale="en">
|
||||||
<IntlProvider locale="en">
|
<MemoryRouter>
|
||||||
<MemoryRouter>
|
<Provider store={store}>{children}</Provider>
|
||||||
<RegisterProvider>
|
</MemoryRouter>
|
||||||
{children}
|
</IntlProvider>
|
||||||
</RegisterProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
</IntlProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
register: {
|
||||||
|
validationApiRateLimited: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
queryClient = new QueryClient({
|
store = mockStore(initialState);
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mockMutate = jest.fn();
|
|
||||||
useFieldValidations.mockReturnValue({
|
|
||||||
mutate: mockMutate,
|
|
||||||
isPending: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
props = {
|
props = {
|
||||||
floatingLabel: 'Password',
|
floatingLabel: 'Password',
|
||||||
name: 'password',
|
name: 'password',
|
||||||
value: 'password123',
|
value: 'password123',
|
||||||
handleFocus: jest.fn(),
|
handleFocus: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show/hide password on icon click', () => {
|
it('should show/hide password on icon click', () => {
|
||||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
const { getByLabelText } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
const passwordInput = getByLabelText('Password');
|
const passwordInput = getByLabelText('Password');
|
||||||
|
|
||||||
const showPasswordButton = getByLabelText('Show password');
|
const showPasswordButton = getByLabelText('Show password');
|
||||||
@@ -99,7 +77,7 @@ describe('PasswordField', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show password requirement tooltip on focus', async () => {
|
it('should show password requirement tooltip on focus', async () => {
|
||||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
const { getByLabelText } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
const passwordInput = getByLabelText('Password');
|
const passwordInput = getByLabelText('Password');
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -116,7 +94,7 @@ describe('PasswordField', () => {
|
|||||||
...props,
|
...props,
|
||||||
value: '',
|
value: '',
|
||||||
};
|
};
|
||||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
const { getByLabelText } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
const passwordInput = getByLabelText('Password');
|
const passwordInput = getByLabelText('Password');
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -139,7 +117,7 @@ describe('PasswordField', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should update password requirement checks', async () => {
|
it('should update password requirement checks', async () => {
|
||||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
const { getByLabelText } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
const passwordInput = getByLabelText('Password');
|
const passwordInput = getByLabelText('Password');
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -162,7 +140,7 @@ describe('PasswordField', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not run validations when blur is fired on password icon click', () => {
|
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(<PasswordField {...props} />));
|
||||||
const passwordInput = container.querySelector('input[name="password"]');
|
const passwordInput = container.querySelector('input[name="password"]');
|
||||||
|
|
||||||
const passwordIcon = getByLabelText('Show password');
|
const passwordIcon = getByLabelText('Show password');
|
||||||
@@ -183,7 +161,7 @@ describe('PasswordField', () => {
|
|||||||
...props,
|
...props,
|
||||||
handleBlur: jest.fn(),
|
handleBlur: jest.fn(),
|
||||||
};
|
};
|
||||||
const { container } = render(renderWrapper(<PasswordField {...props} />));
|
const { container } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
const passwordInput = container.querySelector('input[name="password"]');
|
const passwordInput = container.querySelector('input[name="password"]');
|
||||||
|
|
||||||
fireEvent.blur(passwordInput, {
|
fireEvent.blur(passwordInput, {
|
||||||
@@ -201,7 +179,7 @@ describe('PasswordField', () => {
|
|||||||
...props,
|
...props,
|
||||||
handleErrorChange: jest.fn(),
|
handleErrorChange: jest.fn(),
|
||||||
};
|
};
|
||||||
const { container } = render(renderWrapper(<PasswordField {...props} />));
|
const { container } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
const passwordInput = container.querySelector('input[name="password"]');
|
const passwordInput = container.querySelector('input[name="password"]');
|
||||||
|
|
||||||
fireEvent.blur(passwordInput, {
|
fireEvent.blur(passwordInput, {
|
||||||
@@ -224,7 +202,7 @@ describe('PasswordField', () => {
|
|||||||
handleErrorChange: jest.fn(),
|
handleErrorChange: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
const { getByLabelText } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
|
|
||||||
const passwordIcon = getByLabelText('Show password');
|
const passwordIcon = getByLabelText('Show password');
|
||||||
|
|
||||||
@@ -244,7 +222,7 @@ describe('PasswordField', () => {
|
|||||||
handleErrorChange: jest.fn(),
|
handleErrorChange: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
const { getByLabelText } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
|
|
||||||
const passwordIcon = getByLabelText('Show password');
|
const passwordIcon = getByLabelText('Show password');
|
||||||
|
|
||||||
@@ -263,11 +241,12 @@ describe('PasswordField', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should run backend validations when frontend validations pass on blur when rendered from register page', () => {
|
it('should run backend validations when frontend validations pass on blur when rendered from register page', () => {
|
||||||
|
store.dispatch = jest.fn(store.dispatch);
|
||||||
props = {
|
props = {
|
||||||
...props,
|
...props,
|
||||||
handleErrorChange: jest.fn(),
|
handleErrorChange: jest.fn(),
|
||||||
};
|
};
|
||||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
const { getByLabelText } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
const passwordField = getByLabelText('Password');
|
const passwordField = getByLabelText('Password');
|
||||||
fireEvent.blur(passwordField, {
|
fireEvent.blur(passwordField, {
|
||||||
target: {
|
target: {
|
||||||
@@ -276,17 +255,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)', () => {
|
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 = {
|
||||||
...props,
|
...props,
|
||||||
value: 'testPassword',
|
value: 'testPassword',
|
||||||
handleErrorChange: jest.fn(),
|
handleErrorChange: jest.fn(),
|
||||||
handleBlur: jest.fn(),
|
handleBlur: jest.fn(),
|
||||||
};
|
};
|
||||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
const { getByLabelText } = render(reduxWrapper(<PasswordField {...props} />));
|
||||||
|
|
||||||
const passwordIcon = getByLabelText('Show password');
|
const passwordIcon = getByLabelText('Show password');
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
|
import configureStore from 'redux-mock-store';
|
||||||
|
|
||||||
import registerIcons from '../RegisterFaIcons';
|
import registerIcons from '../RegisterFaIcons';
|
||||||
import SocialAuthProviders from '../SocialAuthProviders';
|
import SocialAuthProviders from '../SocialAuthProviders';
|
||||||
|
|
||||||
registerIcons();
|
registerIcons();
|
||||||
|
const mockStore = configureStore();
|
||||||
|
|
||||||
describe('SocialAuthProviders', () => {
|
describe('SocialAuthProviders', () => {
|
||||||
let props = {};
|
let props = {};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
register: {
|
||||||
|
registrationFormData: {
|
||||||
|
configurableFormFields: {
|
||||||
|
marketingEmailsOptIn: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = mockStore(initialState);
|
||||||
|
const reduxWrapper = children => (
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<Provider store={store}>{children}</Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
const appleProvider = {
|
const appleProvider = {
|
||||||
id: 'oa2-apple-id',
|
id: 'oa2-apple-id',
|
||||||
name: 'Apple',
|
name: 'Apple',
|
||||||
@@ -28,11 +49,11 @@ describe('SocialAuthProviders', () => {
|
|||||||
it('should match social auth provider with iconImage snapshot', () => {
|
it('should match social auth provider with iconImage snapshot', () => {
|
||||||
props = { socialAuthProviders: [appleProvider, facebookProvider] };
|
props = { socialAuthProviders: [appleProvider, facebookProvider] };
|
||||||
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(reduxWrapper(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<SocialAuthProviders {...props} />
|
<SocialAuthProviders {...props} />
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
).toJSON();
|
)).toJSON();
|
||||||
|
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
@@ -46,11 +67,11 @@ describe('SocialAuthProviders', () => {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(reduxWrapper(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<SocialAuthProviders {...props} />
|
<SocialAuthProviders {...props} />
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
).toJSON();
|
)).toJSON();
|
||||||
|
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
@@ -64,11 +85,11 @@ describe('SocialAuthProviders', () => {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(reduxWrapper(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<SocialAuthProviders {...props} />
|
<SocialAuthProviders {...props} />
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
).toJSON();
|
)).toJSON();
|
||||||
|
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
|
|
||||||
import { PENDING_STATE, REGISTER_PAGE } from '../../data/constants';
|
import { REGISTER_PAGE } from '../../data/constants';
|
||||||
import ThirdPartyAuthAlert from '../ThirdPartyAuthAlert';
|
import ThirdPartyAuthAlert from '../ThirdPartyAuthAlert';
|
||||||
|
|
||||||
describe('ThirdPartyAuthAlert', () => {
|
describe('ThirdPartyAuthAlert', () => {
|
||||||
@@ -36,19 +38,4 @@ describe('ThirdPartyAuthAlert', () => {
|
|||||||
).toJSON();
|
).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/* eslint-disable import/no-import-module-exports */
|
/* eslint-disable import/no-import-module-exports */
|
||||||
/* eslint-disable react/function-component-definition */
|
/* eslint-disable react/function-component-definition */
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|||||||
@@ -1,25 +1,5 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// 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`] = `
|
exports[`ThirdPartyAuthAlert should match login page third party auth alert message snapshot 1`] = `
|
||||||
<div
|
<div
|
||||||
className="fade alert-content alert-warning mt-n2 mb-5 alert show"
|
className="fade alert-content alert-warning mt-n2 mb-5 alert show"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const configuration = {
|
|||||||
MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '',
|
MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '',
|
||||||
SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false,
|
SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false,
|
||||||
SHOW_REGISTRATION_LINKS: process.env.SHOW_REGISTRATION_LINKS !== 'false',
|
SHOW_REGISTRATION_LINKS: process.env.SHOW_REGISTRATION_LINKS !== 'false',
|
||||||
|
ENABLE_HARD_CODE_OPTIONAL_FIELDS: process.env.ENABLE_HARD_CODE_OPTIONAL_FIELDS || false,
|
||||||
ENABLE_IMAGE_LAYOUT: process.env.ENABLE_IMAGE_LAYOUT || false,
|
ENABLE_IMAGE_LAYOUT: process.env.ENABLE_IMAGE_LAYOUT || false,
|
||||||
// Links
|
// Links
|
||||||
ACTIVATION_EMAIL_SUPPORT_LINK: process.env.ACTIVATION_EMAIL_SUPPORT_LINK || null,
|
ACTIVATION_EMAIL_SUPPORT_LINK: process.env.ACTIVATION_EMAIL_SUPPORT_LINK || null,
|
||||||
@@ -35,6 +36,8 @@ const configuration = {
|
|||||||
ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL,
|
ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL,
|
||||||
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '',
|
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '',
|
||||||
ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '',
|
ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '',
|
||||||
|
AUTO_GENERATED_USERNAME_EXPERIMENT_ID: process.env.AUTO_GENERATED_USERNAME_EXPERIMENT_ID || '',
|
||||||
|
RECAPTCHA_SITE_KEY_WEB: process.env.RECAPTCHA_SITE_KEY_WEB || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default configuration;
|
export default configuration;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const appId = 'org.openedx.frontend.app.authn';
|
|
||||||
33
src/data/configureStore.js
Normal file
33
src/data/configureStore.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -37,3 +37,4 @@ export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+
|
|||||||
// things like auto-enrollment upon login and registration.
|
// 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 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 REDIRECT = 'redirect';
|
||||||
|
export const APP_NAME = 'authn_mfe';
|
||||||
|
|||||||
38
src/data/reducers.js
Executable file
38
src/data/reducers.js
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
import { combineReducers } from 'redux';
|
||||||
|
|
||||||
|
import { reducer as cohesionReducer, storeName as cohesionStoreName } from '../cohesion/data/reducers';
|
||||||
|
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,
|
||||||
|
[cohesionStoreName]: cohesionReducer,
|
||||||
|
[authnProgressiveProfilingStoreName]: authnProgressiveProfilingReducers,
|
||||||
|
});
|
||||||
|
export default createRootReducer;
|
||||||
19
src/data/sagas.js
Normal file
19
src/data/sagas.js
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
37
src/data/segment/utils.js
Normal file
37
src/data/segment/utils.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
|
|
||||||
|
import { APP_NAME } from '../constants';
|
||||||
|
|
||||||
|
export const LINK_TIMEOUT = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an event tracker function that sends a tracking event with the given name and options.
|
||||||
|
*
|
||||||
|
* @param {string} name - The name of the event to be tracked.
|
||||||
|
* @param {object} [options={}] - Additional options to be included with the event.
|
||||||
|
* @returns {function} - A function that, when called, sends the tracking event.
|
||||||
|
*/
|
||||||
|
export const createEventTracker = (name, options = {}) => () => sendTrackEvent(
|
||||||
|
name,
|
||||||
|
{ ...options, app_name: APP_NAME },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an event tracker function that sends a tracking event with the given name and options.
|
||||||
|
*
|
||||||
|
* @param {string} name - The name of the event to be tracked.
|
||||||
|
* @param {object} [options={}] - Additional options to be included with the event.
|
||||||
|
* @returns {function} - A function that, when called, sends the tracking event.
|
||||||
|
*/
|
||||||
|
export const createPageEventTracker = (name, options = null) => () => sendPageEvent(
|
||||||
|
name,
|
||||||
|
options,
|
||||||
|
{ app_name: APP_NAME },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createLinkTracker = (tracker, href) => (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
tracker();
|
||||||
|
return setTimeout(() => { window.location.href = href; }, LINK_TIMEOUT);
|
||||||
|
};
|
||||||
14
src/data/tests/reduxUtils.test.js
Normal file
14
src/data/tests/reduxUtils.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,3 +11,11 @@ export default function setCookie(cookieName, cookieValue, cookieExpiry) {
|
|||||||
cookies.set(cookieName, cookieValue, options);
|
cookies.set(cookieName, cookieValue, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeCookie(cookieName) {
|
||||||
|
if (cookieName) {
|
||||||
|
const cookies = new Cookies();
|
||||||
|
const options = { domain: getConfig().SESSION_COOKIE_DOMAIN, path: '/' };
|
||||||
|
cookies.remove(cookieName, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,3 +81,9 @@ export const isHostAvailableInQueryParams = () => {
|
|||||||
const queryParams = getAllPossibleQueryParams();
|
const queryParams = getAllPossibleQueryParams();
|
||||||
return 'host' in queryParams;
|
return 'host' in queryParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const redirectWithDelay = (redirectUrl) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export {
|
|||||||
updatePathWithQueryParams,
|
updatePathWithQueryParams,
|
||||||
windowScrollTo,
|
windowScrollTo,
|
||||||
} from './dataUtils';
|
} from './dataUtils';
|
||||||
export { default as setCookie } from './cookies';
|
export { default as AsyncActionType } from './reduxUtils';
|
||||||
|
export { default as setCookie, removeCookie } from './cookies';
|
||||||
|
|||||||
34
src/data/utils/reduxUtils.js
Normal file
34
src/data/utils/reduxUtils.js
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { Form, Icon } from '@openedx/paragon';
|
import { Form, Icon } from '@openedx/paragon';
|
||||||
import { ExpandMore } from '@openedx/paragon/icons';
|
import { ExpandMore } from '@openedx/paragon/icons';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { fireEvent, render } from '@testing-library/react';
|
import { fireEvent, render } from '@testing-library/react';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert } from '@openedx/paragon';
|
import { Alert } from '@openedx/paragon';
|
||||||
@@ -41,7 +43,7 @@ const ForgotPasswordAlert = (props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case INTERNAL_SERVER_ERROR:
|
case INTERNAL_SERVER_ERROR:
|
||||||
message = formatMessage(messages['internal.server.error']);
|
message = formatMessage(messages['internal.server.error']);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -12,39 +12,46 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { ChevronLeft } from '@openedx/paragon/icons';
|
import { ChevronLeft } from '@openedx/paragon/icons';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
import { Helmet } from 'react-helmet';
|
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 ForgotPasswordAlert from './ForgotPasswordAlert';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import BaseContainer from '../base-container';
|
import BaseContainer from '../base-container';
|
||||||
import { FormGroup } from '../common-components';
|
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';
|
import { updatePathWithQueryParams, windowScrollTo } from '../data/utils';
|
||||||
|
import {
|
||||||
|
trackForgotPasswordPageEvent,
|
||||||
|
trackForgotPasswordPageViewed,
|
||||||
|
} from '../tracking/trackers/forgotpassword';
|
||||||
|
|
||||||
const ForgotPasswordPage = () => {
|
const ForgotPasswordPage = (props) => {
|
||||||
const platformName = getConfig().SITE_NAME;
|
const platformName = getConfig().SITE_NAME;
|
||||||
const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
|
const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
|
||||||
|
const {
|
||||||
|
status, submitState, emailValidationError,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const navigate = useNavigate();
|
const [email, setEmail] = useState(props.email);
|
||||||
const location = useLocation();
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [bannerEmail, setBannerEmail] = useState('');
|
const [bannerEmail, setBannerEmail] = useState('');
|
||||||
const [formErrors, setFormErrors] = useState('');
|
const [formErrors, setFormErrors] = useState('');
|
||||||
const [validationError, setValidationError] = useState('');
|
const [validationError, setValidationError] = useState(emailValidationError);
|
||||||
const [status, setStatus] = useState(location.state?.status || null);
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// React Query hook for forgot password
|
|
||||||
const { mutate: sendForgotPassword, isPending: isSending } = useForgotPassword();
|
|
||||||
|
|
||||||
const submitState = isSending ? 'pending' : 'default';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sendPageEvent('login_and_registration', 'reset');
|
trackForgotPasswordPageEvent();
|
||||||
sendTrackEvent('edx.bi.password_reset_form.viewed', { category: 'user-engagement' });
|
trackForgotPasswordPageViewed();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValidationError(emailValidationError);
|
||||||
|
}, [emailValidationError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'complete') {
|
if (status === 'complete') {
|
||||||
setEmail('');
|
setEmail('');
|
||||||
@@ -64,38 +71,22 @@ const ForgotPasswordPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
setValidationError(getValidationMessage(email));
|
props.setForgotPasswordFormData({ email, emailValidationError: getValidationMessage(email) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => props.setForgotPasswordFormData({ emailValidationError: '' });
|
||||||
setValidationError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBannerEmail(email);
|
setBannerEmail(email);
|
||||||
|
|
||||||
const validateError = getValidationMessage(email);
|
const error = getValidationMessage(email);
|
||||||
if (validateError) {
|
if (error) {
|
||||||
setFormErrors(validateError);
|
setFormErrors(error);
|
||||||
setValidationError(validateError);
|
props.setForgotPasswordFormData({ email, emailValidationError: error });
|
||||||
windowScrollTo({ left: 0, top: 0, behavior: 'smooth' });
|
windowScrollTo({ left: 0, top: 0, behavior: 'smooth' });
|
||||||
} else {
|
} else {
|
||||||
setFormErrors('');
|
props.forgotPassword(email);
|
||||||
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');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,7 +156,7 @@ const ForgotPasswordPage = () => {
|
|||||||
)}
|
)}
|
||||||
<p className="mt-5.5 small text-gray-700">
|
<p className="mt-5.5 small text-gray-700">
|
||||||
{formatMessage(messages['additional.help.text'], { platformName })}
|
{formatMessage(messages['additional.help.text'], { platformName })}
|
||||||
<span className="mx-1">
|
<span>
|
||||||
<Hyperlink isInline destination={`mailto:${getConfig().INFO_EMAIL}`}>{getConfig().INFO_EMAIL}</Hyperlink>
|
<Hyperlink isInline destination={`mailto:${getConfig().INFO_EMAIL}`}>{getConfig().INFO_EMAIL}</Hyperlink>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -176,4 +167,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);
|
||||||
|
|||||||
32
src/forgot-password/data/actions.js
Normal file
32
src/forgot-password/data/actions.js
Normal 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 },
|
||||||
|
});
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
58
src/forgot-password/data/reducers.js
Normal file
58
src/forgot-password/data/reducers.js
Normal 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;
|
||||||
35
src/forgot-password/data/sagas.js
Normal file
35
src/forgot-password/data/sagas.js
Normal 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);
|
||||||
|
}
|
||||||
10
src/forgot-password/data/selectors.js
Normal file
10
src/forgot-password/data/selectors.js
Normal 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,
|
||||||
|
);
|
||||||
@@ -2,7 +2,8 @@ import { getConfig } from '@edx/frontend-platform';
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import formurlencoded from 'form-urlencoded';
|
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 = {
|
const requestConfig = {
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
@@ -19,8 +20,4 @@ const forgotPassword = async (email: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
}
|
||||||
|
|
||||||
export {
|
|
||||||
forgotPassword,
|
|
||||||
};
|
|
||||||
34
src/forgot-password/data/tests/reducers.test.js
Normal file
34
src/forgot-password/data/tests/reducers.test.js
Normal 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',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/forgot-password/data/tests/sagas.test.js
Normal file
67
src/forgot-password/data/tests/sagas.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1,5 @@
|
|||||||
export { default as ForgotPasswordPage } from './ForgotPasswordPage';
|
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';
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
'additional.help.text': {
|
'additional.help.text': {
|
||||||
id: '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',
|
description: 'additional help text on forgot password page',
|
||||||
},
|
},
|
||||||
'sign.in.text': {
|
'sign.in.text': {
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { mergeConfig } from '@edx/frontend-platform';
|
import { mergeConfig } from '@edx/frontend-platform';
|
||||||
import { configure, IntlProvider } from '@edx/frontend-platform/i18n';
|
import { configure, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import {
|
import {
|
||||||
fireEvent, render, screen, waitFor,
|
fireEvent, render, screen,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import configureStore from 'redux-mock-store';
|
||||||
|
|
||||||
import {
|
import { INTERNAL_SERVER_ERROR, LOGIN_PAGE } from '../../data/constants';
|
||||||
FORBIDDEN_STATE, FORM_SUBMISSION_ERROR, INTERNAL_SERVER_ERROR, LOGIN_PAGE,
|
|
||||||
} from '../../data/constants';
|
|
||||||
import { PASSWORD_RESET } from '../../reset-password/data/constants';
|
import { PASSWORD_RESET } from '../../reset-password/data/constants';
|
||||||
import { useForgotPassword } from '../data/apiHook';
|
import { setForgotPasswordFormData } from '../data/actions';
|
||||||
import ForgotPasswordAlert from '../ForgotPasswordAlert';
|
|
||||||
import ForgotPasswordPage from '../ForgotPasswordPage';
|
import ForgotPasswordPage from '../ForgotPasswordPage';
|
||||||
|
|
||||||
const mockedNavigator = jest.fn();
|
const mockedNavigator = jest.fn();
|
||||||
@@ -26,9 +25,13 @@ jest.mock('react-router-dom', () => ({
|
|||||||
useNavigate: () => mockedNavigator,
|
useNavigate: () => mockedNavigator,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../data/apiHook', () => ({
|
const mockStore = configureStore();
|
||||||
useForgotPassword: jest.fn(),
|
|
||||||
}));
|
const initialState = {
|
||||||
|
forgotPassword: {
|
||||||
|
status: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe('ForgotPasswordPage', () => {
|
describe('ForgotPasswordPage', () => {
|
||||||
mergeConfig({
|
mergeConfig({
|
||||||
@@ -36,55 +39,19 @@ describe('ForgotPasswordPage', () => {
|
|||||||
INFO_EMAIL: '',
|
INFO_EMAIL: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
let queryClient;
|
let props = {};
|
||||||
let mockMutate;
|
let store = {};
|
||||||
let mockIsPending;
|
|
||||||
|
|
||||||
const renderWrapper = (component, options = {}) => {
|
const reduxWrapper = children => (
|
||||||
const {
|
<IntlProvider locale="en">
|
||||||
status = null,
|
<MemoryRouter>
|
||||||
isPending = false,
|
<Provider store={store}>{children}</Provider>
|
||||||
mutateImplementation = jest.fn(),
|
</MemoryRouter>
|
||||||
} = options;
|
</IntlProvider>
|
||||||
|
);
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Create a fresh QueryClient for each test
|
store = mockStore(initialState);
|
||||||
queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||||
getAuthenticatedUser: jest.fn(() => ({
|
getAuthenticatedUser: jest.fn(() => ({
|
||||||
userId: 3,
|
userId: 3,
|
||||||
@@ -99,13 +66,17 @@ describe('ForgotPasswordPage', () => {
|
|||||||
},
|
},
|
||||||
messages: { 'es-419': {}, de: {}, 'en-us': {} },
|
messages: { 'es-419': {}, de: {}, 'en-us': {} },
|
||||||
});
|
});
|
||||||
|
props = {
|
||||||
// Clear mock calls between tests
|
forgotPassword: jest.fn(),
|
||||||
jest.clearAllMocks();
|
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', () => {
|
it('not should display need other help signing in button', () => {
|
||||||
const { queryByTestId } = render(renderWrapper(<ForgotPasswordPage />));
|
const { queryByTestId } = render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
const forgotPasswordButton = queryByTestId('forgot-password');
|
const forgotPasswordButton = queryByTestId('forgot-password');
|
||||||
expect(forgotPasswordButton).toBeNull();
|
expect(forgotPasswordButton).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -114,14 +85,14 @@ describe('ForgotPasswordPage', () => {
|
|||||||
mergeConfig({
|
mergeConfig({
|
||||||
LOGIN_ISSUE_SUPPORT_LINK: '/support',
|
LOGIN_ISSUE_SUPPORT_LINK: '/support',
|
||||||
});
|
});
|
||||||
render(renderWrapper(<ForgotPasswordPage />));
|
render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
const forgotPasswordButton = screen.findByText('Need help signing in?');
|
const forgotPasswordButton = screen.findByText('Need help signing in?');
|
||||||
expect(forgotPasswordButton).toBeDefined();
|
expect(forgotPasswordButton).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display email validation error message', async () => {
|
it('should display email validation error message', async () => {
|
||||||
const validationMessage = 'We were unable to contact you.Enter a valid email address below.';
|
const validationMessage = 'We were unable to contact you.Enter a valid email address below.';
|
||||||
const { container } = render(renderWrapper(<ForgotPasswordPage />));
|
const { container } = render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText('Email');
|
const emailInput = screen.getByLabelText('Email');
|
||||||
|
|
||||||
@@ -135,28 +106,23 @@ describe('ForgotPasswordPage', () => {
|
|||||||
expect(validationErrors).toBe(validationMessage);
|
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.'
|
const expectedMessage = 'We were unable to contact you.'
|
||||||
+ 'An error has occurred. Try refreshing the page, or check your internet connection.';
|
+ '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(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
const { container } = render(renderWrapper(<ForgotPasswordPage />, {
|
|
||||||
status: 'server-error',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The ForgotPasswordAlert should render with server error status
|
const alertElements = container.querySelectorAll('.alert-danger');
|
||||||
await waitFor(() => {
|
const validationErrors = alertElements[0].textContent;
|
||||||
const alertElements = container.querySelectorAll('.alert-danger');
|
expect(validationErrors).toBe(expectedMessage);
|
||||||
if (alertElements.length > 0) {
|
|
||||||
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 validationMessage = 'We were unable to contact you.Enter your email below.';
|
||||||
const { container } = render(renderWrapper(<ForgotPasswordPage />));
|
const { container } = render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
|
|
||||||
const submitButton = screen.getByText('Submit');
|
const submitButton = screen.getByText('Submit');
|
||||||
fireEvent.click(submitButton);
|
fireEvent.click(submitButton);
|
||||||
@@ -167,25 +133,21 @@ describe('ForgotPasswordPage', () => {
|
|||||||
expect(validationErrors).toBe(validationMessage);
|
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.';
|
const rateLimitMessage = 'An error occurred.Your previous request is in progress, please try again in a few moments.';
|
||||||
|
store = mockStore({
|
||||||
// Create component with forbidden status to simulate rate limit error
|
forgotPassword: { status: 'forbidden' },
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { container } = render(reduxWrapper(<ForgotPasswordPage {...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', () => {
|
it('should not display any error message on change event', () => {
|
||||||
render(renderWrapper(<ForgotPasswordPage />));
|
render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText('Email');
|
const emailInput = screen.getByLabelText('Email');
|
||||||
|
|
||||||
@@ -195,248 +157,115 @@ describe('ForgotPasswordPage', () => {
|
|||||||
expect(errorElement).toBeNull();
|
expect(errorElement).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not cause errors when blur event occurs', () => {
|
it('should set error in redux store on onBlur', () => {
|
||||||
render(renderWrapper(<ForgotPasswordPage />));
|
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(<ForgotPasswordPage {...props} />));
|
||||||
const emailInput = screen.getByLabelText('Email');
|
const emailInput = screen.getByLabelText('Email');
|
||||||
|
|
||||||
// Simply test that blur event doesn't cause errors
|
|
||||||
fireEvent.blur(emailInput);
|
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 validationMessage = 'Enter your email';
|
||||||
const { container } = render(renderWrapper(<ForgotPasswordPage />));
|
props = {
|
||||||
const submitButton = screen.getByText('Submit');
|
...props,
|
||||||
fireEvent.click(submitButton);
|
emailValidationError: validationMessage,
|
||||||
|
email: '',
|
||||||
|
};
|
||||||
|
const { container } = render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
const validationElement = container.querySelector('.pgn__form-text-invalid');
|
const validationElement = container.querySelector('.pgn__form-text-invalid');
|
||||||
expect(validationElement.textContent).toEqual(validationMessage);
|
expect(validationElement.textContent).toEqual(validationMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not cause errors when focus event occurs', () => {
|
it('should clear error in redux store on onFocus', () => {
|
||||||
render(renderWrapper(<ForgotPasswordPage />));
|
const forgotPasswordFormData = {
|
||||||
|
emailValidationError: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
props = {
|
||||||
|
...props,
|
||||||
|
email: 'test@gmail',
|
||||||
|
emailValidationError: 'Enter a valid email address',
|
||||||
|
};
|
||||||
|
|
||||||
|
store.dispatch = jest.fn(store.dispatch);
|
||||||
|
|
||||||
|
render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
const emailInput = screen.getByLabelText('Email');
|
const emailInput = screen.getByLabelText('Email');
|
||||||
|
|
||||||
fireEvent.focus(emailInput);
|
fireEvent.focus(emailInput);
|
||||||
|
|
||||||
|
expect(store.dispatch).toHaveBeenCalledWith(setForgotPasswordFormData(forgotPasswordFormData));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not display error message initially', async () => {
|
it('should clear error message when cleared in props on focus', async () => {
|
||||||
render(renderWrapper(<ForgotPasswordPage />));
|
props = {
|
||||||
|
...props,
|
||||||
|
emailValidationError: '',
|
||||||
|
email: '',
|
||||||
|
};
|
||||||
|
render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
const errorElement = screen.queryByTestId('email-invalid-feedback');
|
const errorElement = screen.queryByTestId('email-invalid-feedback');
|
||||||
expect(errorElement).toBeNull();
|
expect(errorElement).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display success message after email is sent', async () => {
|
it('should display success message after email is sent', () => {
|
||||||
const testEmail = 'test@example.com';
|
store = mockStore({
|
||||||
const { container } = render(renderWrapper(<ForgotPasswordPage />, {
|
...initialState,
|
||||||
status: 'complete',
|
forgotPassword: {
|
||||||
}));
|
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(<ForgotPasswordPage {...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 () => {
|
it('should display invalid password reset link error', () => {
|
||||||
render(renderWrapper(<ForgotPasswordPage />));
|
store = mockStore({
|
||||||
|
...initialState,
|
||||||
const emailInput = screen.getByLabelText('Email');
|
forgotPassword: {
|
||||||
const submitButton = screen.getByText('Submit');
|
status: PASSWORD_RESET.INVALID_TOKEN,
|
||||||
|
},
|
||||||
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),
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
});
|
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 { container } = render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
const successMutation = (email, { onSuccess }) => {
|
const successElement = findByTextContent(container, successMessage);
|
||||||
onSuccess({}, email);
|
|
||||||
};
|
|
||||||
|
|
||||||
render(renderWrapper(<ForgotPasswordPage />, {
|
expect(successElement).toBeDefined();
|
||||||
mutateImplementation: successMutation,
|
expect(successElement.textContent).toEqual(successMessage);
|
||||||
}));
|
|
||||||
|
|
||||||
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),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should redirect onto login page', async () => {
|
it('should redirect onto login page', async () => {
|
||||||
const { container } = render(renderWrapper(<ForgotPasswordPage />));
|
const { container } = render(reduxWrapper(<ForgotPasswordPage {...props} />));
|
||||||
|
|
||||||
const navElement = container.querySelector('nav');
|
const navElement = container.querySelector('nav');
|
||||||
const anchorElement = navElement.querySelector('a');
|
const anchorElement = navElement.querySelector('a');
|
||||||
fireEvent.click(anchorElement);
|
fireEvent.click(anchorElement);
|
||||||
expect(mockedNavigator).toHaveBeenCalledWith(expect.stringContaining(LOGIN_PAGE));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display token validation rate limit error message', async () => {
|
expect(mockedNavigator).toHaveBeenCalledWith(LOGIN_PAGE);
|
||||||
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.');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'core-js/stable';
|
import 'core-js/stable';
|
||||||
import 'regenerator-runtime/runtime';
|
import 'regenerator-runtime/runtime';
|
||||||
|
|
||||||
import { StrictMode } from 'react';
|
import React, { StrictMode } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
APP_INIT_ERROR, APP_READY, initialize, mergeConfig, subscribe,
|
APP_INIT_ERROR, APP_READY, initialize, mergeConfig, subscribe,
|
||||||
|
|||||||
@@ -1,75 +1,2 @@
|
|||||||
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
|
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
|
||||||
@import "sass/style";
|
@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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert } from '@openedx/paragon';
|
import { Alert, Hyperlink } from '@openedx/paragon';
|
||||||
import { CheckCircle, Error } from '@openedx/paragon/icons';
|
import { CheckCircle, Error } from '@openedx/paragon/icons';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
@@ -34,19 +36,17 @@ const AccountActivationMessage = ({ messageType }) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ACCOUNT_ACTIVATION_MESSAGE.ERROR: {
|
case ACCOUNT_ACTIVATION_MESSAGE.ERROR: {
|
||||||
const supportLink = (
|
const supportEmail = (
|
||||||
<Alert.Link href={getConfig().ACTIVATION_EMAIL_SUPPORT_LINK}>
|
<Hyperlink isInline destination={`mailto:${getConfig().INFO_EMAIL}`}>{getConfig().INFO_EMAIL}</Hyperlink>
|
||||||
{formatMessage(messages['account.activation.support.link'])}
|
|
||||||
</Alert.Link>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
heading = formatMessage(messages[`account.${activationOrConfirmation}.error.message.title`]);
|
heading = formatMessage(messages[`account.${activationOrConfirmation}.error.message.title`]);
|
||||||
activationMessage = (
|
activationMessage = (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="account.activation.error.message"
|
id="account.activation.error.message"
|
||||||
defaultMessage="Something went wrong, please {supportLink} to resolve this issue."
|
defaultMessage="Something went wrong, please contact {supportEmail} to resolve this issue."
|
||||||
description="Account activation error message"
|
description="Account activation error message"
|
||||||
values={{ supportLink }}
|
values={{ supportEmail }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
@@ -10,23 +11,27 @@ import PropTypes from 'prop-types';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
import trackCohesionEvent from '../cohesion/trackers';
|
||||||
import { DEFAULT_REDIRECT_URL, RESET_PAGE } from '../data/constants';
|
import { DEFAULT_REDIRECT_URL, RESET_PAGE } from '../data/constants';
|
||||||
import { updatePathWithQueryParams } from '../data/utils';
|
import { updatePathWithQueryParams } from '../data/utils';
|
||||||
|
import { redirectWithDelay } from '../data/utils/dataUtils';
|
||||||
import useMobileResponsive from '../data/utils/useMobileResponsive';
|
import useMobileResponsive from '../data/utils/useMobileResponsive';
|
||||||
|
|
||||||
const ChangePasswordPrompt = ({ variant, redirectUrl }) => {
|
const ChangePasswordPrompt = ({ variant, redirectUrl }) => {
|
||||||
const isMobileView = useMobileResponsive();
|
const isMobileView = useMobileResponsive();
|
||||||
const [redirectToResetPasswordPage, setRedirectToResetPasswordPage] = useState(false);
|
const [redirectToResetPasswordPage, setRedirectToResetPasswordPage] = useState(false);
|
||||||
|
const cohesionEventData = useSelector(state => state.cohesion.eventData);
|
||||||
const handlers = {
|
const handlers = {
|
||||||
handleToggleOff: () => {
|
handleToggleOff: () => {
|
||||||
if (variant === 'block') {
|
if (variant === 'block') {
|
||||||
setRedirectToResetPasswordPage(true);
|
setRedirectToResetPasswordPage(true);
|
||||||
} else {
|
} else {
|
||||||
window.location.href = redirectUrl || getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
trackCohesionEvent(cohesionEventData);
|
||||||
|
redirectWithDelay(redirectUrl || getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// 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 [isOpen, open, close] = useToggle(true, handlers);
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthService } from '@edx/frontend-platform/auth';
|
import { getAuthService } from '@edx/frontend-platform/auth';
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { connect, useDispatch } from 'react-redux';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Form, StatefulButton } from '@openedx/paragon';
|
import {
|
||||||
|
Form, StatefulButton,
|
||||||
|
} from '@openedx/paragon';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import Skeleton from 'react-loading-skeleton';
|
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 {
|
||||||
|
ELEMENT_NAME, ELEMENT_TEXT, ELEMENT_TYPES, PAGE_TYPES,
|
||||||
|
} from '../cohesion/constants';
|
||||||
|
import { setCohesionEventStates } from '../cohesion/data/actions';
|
||||||
import {
|
import {
|
||||||
FormGroup,
|
FormGroup,
|
||||||
InstitutionLogistration,
|
InstitutionLogistration,
|
||||||
@@ -16,12 +31,11 @@ import {
|
|||||||
RedirectLogistration,
|
RedirectLogistration,
|
||||||
ThirdPartyAuthAlert,
|
ThirdPartyAuthAlert,
|
||||||
} from '../common-components';
|
} from '../common-components';
|
||||||
import AccountActivationMessage from './AccountActivationMessage';
|
import { getThirdPartyAuthContext } from '../common-components/data/actions';
|
||||||
import { useThirdPartyAuthContext } from '../common-components/components/ThirdPartyAuthContext';
|
import { thirdPartyAuthContextSelector } from '../common-components/data/selectors';
|
||||||
import { useThirdPartyAuthHook } from '../common-components/data/apiHook';
|
|
||||||
import EnterpriseSSO from '../common-components/EnterpriseSSO';
|
import EnterpriseSSO from '../common-components/EnterpriseSSO';
|
||||||
import ThirdPartyAuth from '../common-components/ThirdPartyAuth';
|
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 {
|
import {
|
||||||
getActivationStatus,
|
getActivationStatus,
|
||||||
getAllPossibleQueryParams,
|
getAllPossibleQueryParams,
|
||||||
@@ -29,94 +43,86 @@ import {
|
|||||||
getTpaProvider,
|
getTpaProvider,
|
||||||
updatePathWithQueryParams,
|
updatePathWithQueryParams,
|
||||||
} from '../data/utils';
|
} from '../data/utils';
|
||||||
|
import { removeCookie } from '../data/utils/cookies';
|
||||||
import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess';
|
import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess';
|
||||||
import { useLoginContext } from './components/LoginContext';
|
import {
|
||||||
import { useLogin } from './data/apiHook';
|
trackForgotPasswordLinkClick, trackLoginPageViewed, trackLoginSuccess,
|
||||||
import { INVALID_FORM, TPA_AUTHENTICATION_FAILURE } from './data/constants';
|
} from '../tracking/trackers/login';
|
||||||
import LoginFailureMessage from './LoginFailure';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
const LoginPage = ({
|
const LoginPage = (props) => {
|
||||||
institutionLogin,
|
|
||||||
handleInstitutionLogin,
|
|
||||||
}) => {
|
|
||||||
// Context for third-party auth
|
|
||||||
const {
|
const {
|
||||||
|
backedUpFormData,
|
||||||
|
loginErrorCode,
|
||||||
|
loginErrorContext,
|
||||||
|
loginResult,
|
||||||
|
shouldBackupState,
|
||||||
|
thirdPartyAuthContext: {
|
||||||
|
providers,
|
||||||
|
currentProvider,
|
||||||
|
secondaryProviders,
|
||||||
|
finishAuthUrl,
|
||||||
|
platformName,
|
||||||
|
errorMessage: thirdPartyErrorMessage,
|
||||||
|
},
|
||||||
thirdPartyAuthApiStatus,
|
thirdPartyAuthApiStatus,
|
||||||
thirdPartyAuthContext,
|
institutionLogin,
|
||||||
setThirdPartyAuthContextBegin,
|
showResetPasswordSuccessBanner,
|
||||||
setThirdPartyAuthContextSuccess,
|
submitState,
|
||||||
setThirdPartyAuthContextFailure,
|
// Actions
|
||||||
} = useThirdPartyAuthContext();
|
backupFormState,
|
||||||
const location = useLocation();
|
handleInstitutionLogin,
|
||||||
|
getTPADataFromBackend,
|
||||||
const {
|
} = props;
|
||||||
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;
|
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const dispatch = useDispatch();
|
||||||
const activationMsgType = getActivationStatus();
|
const activationMsgType = getActivationStatus();
|
||||||
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
|
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
|
||||||
|
|
||||||
const tpaHint = useMemo(() => getTpaHint(), []);
|
const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields });
|
||||||
const params = { ...queryParams };
|
const [errorCode, setErrorCode] = useState({ type: '', count: 0, context: {} });
|
||||||
if (tpaHint) {
|
const [errors, setErrors] = useState({ ...backedUpFormData.errors });
|
||||||
params.tpa_hint = tpaHint;
|
const tpaHint = getTpaHint();
|
||||||
}
|
|
||||||
const { data, isSuccess, error } = useThirdPartyAuthHook(LOGIN_PAGE, params);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sendPageEvent('login_and_registration', 'login');
|
trackLoginPageViewed();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch third-party auth context data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setThirdPartyAuthContextBegin();
|
if (loginResult.success) {
|
||||||
if (isSuccess && data) {
|
trackLoginSuccess();
|
||||||
setThirdPartyAuthContextSuccess(
|
// Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component
|
||||||
data.fieldDescriptions,
|
removeCookie('ssoPipelineRedirectionDone');
|
||||||
data.optionalFields,
|
|
||||||
data.thirdPartyAuthContext,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (error) {
|
}, [loginResult]);
|
||||||
setThirdPartyAuthContextFailure();
|
|
||||||
|
useEffect(() => {
|
||||||
|
const payload = { ...queryParams };
|
||||||
|
if (tpaHint) {
|
||||||
|
payload.tpa_hint = tpaHint;
|
||||||
}
|
}
|
||||||
}, [tpaHint, queryParams, isSuccess, data, error,
|
getTPADataFromBackend(payload);
|
||||||
setThirdPartyAuthContextBegin, setThirdPartyAuthContextSuccess, setThirdPartyAuthContextFailure]);
|
}, [getTPADataFromBackend, queryParams, tpaHint]);
|
||||||
|
/**
|
||||||
|
* Backup the login form in redux when login page is toggled.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldBackupState) {
|
||||||
|
backupFormState({
|
||||||
|
formFields: { ...formFields },
|
||||||
|
errors: { ...errors },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [shouldBackupState, formFields, errors, backupFormState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loginErrorCode) {
|
||||||
|
setErrorCode(prevState => ({
|
||||||
|
type: loginErrorCode,
|
||||||
|
count: prevState.count + 1,
|
||||||
|
context: { ...loginErrorContext },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [loginErrorCode, loginErrorContext]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (thirdPartyErrorMessage) {
|
if (thirdPartyErrorMessage) {
|
||||||
@@ -131,10 +137,7 @@ const LoginPage = ({
|
|||||||
}, [thirdPartyErrorMessage]);
|
}, [thirdPartyErrorMessage]);
|
||||||
|
|
||||||
const validateFormFields = (payload) => {
|
const validateFormFields = (payload) => {
|
||||||
const {
|
const { emailOrUsername, password } = payload;
|
||||||
emailOrUsername,
|
|
||||||
password,
|
|
||||||
} = payload;
|
|
||||||
const fieldErrors = { ...errors };
|
const fieldErrors = { ...errors };
|
||||||
|
|
||||||
if (emailOrUsername === '') {
|
if (emailOrUsername === '') {
|
||||||
@@ -151,19 +154,24 @@ const LoginPage = ({
|
|||||||
|
|
||||||
const handleSubmit = (event) => {
|
const handleSubmit = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const eventData = {
|
||||||
|
pageType: PAGE_TYPES.SIGN_IN,
|
||||||
|
elementType: ELEMENT_TYPES.BUTTON,
|
||||||
|
webElementText: ELEMENT_TEXT.SIGN_IN,
|
||||||
|
webElementName: ELEMENT_NAME.SIGN_IN,
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch(setCohesionEventStates(eventData));
|
||||||
|
|
||||||
if (showResetPasswordSuccessBanner) {
|
if (showResetPasswordSuccessBanner) {
|
||||||
setShowResetPasswordSuccessBanner(false);
|
props.dismissPasswordResetBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = { ...formFields };
|
const formData = { ...formFields };
|
||||||
const validationErrors = validateFormFields(formData);
|
const validationErrors = validateFormFields(formData);
|
||||||
if (validationErrors.emailOrUsername || validationErrors.password) {
|
if (validationErrors.emailOrUsername || validationErrors.password) {
|
||||||
setErrors(validationErrors);
|
setErrors({ ...validationErrors });
|
||||||
setErrorCode(prev => ({
|
setErrorCode(prevState => ({ type: INVALID_FORM, count: prevState.count + 1, context: {} }));
|
||||||
type: INVALID_FORM,
|
|
||||||
count: prev.count + 1,
|
|
||||||
context: {},
|
|
||||||
}));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,36 +181,20 @@ const LoginPage = ({
|
|||||||
password: formData.password,
|
password: formData.password,
|
||||||
...queryParams,
|
...queryParams,
|
||||||
};
|
};
|
||||||
loginUser(payload);
|
props.loginRequest(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnChange = (event) => {
|
const handleOnChange = (event) => {
|
||||||
const {
|
const { name, value } = event.target;
|
||||||
name,
|
setFormFields(prevState => ({ ...prevState, [name]: value }));
|
||||||
value,
|
|
||||||
} = event.target;
|
|
||||||
// Save to context for persistence across tab switches
|
|
||||||
setFormFields(prevState => ({
|
|
||||||
...prevState,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnFocus = (event) => {
|
const handleOnFocus = (event) => {
|
||||||
const { name } = event.target;
|
const { name } = event.target;
|
||||||
setErrors(prevErrors => ({
|
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
|
||||||
...prevErrors,
|
|
||||||
[name]: '',
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const trackForgotPasswordLinkClick = () => {
|
|
||||||
sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders);
|
||||||
provider,
|
|
||||||
skipHintedLogin,
|
|
||||||
} = getTpaProvider(tpaHint, providers, secondaryProviders);
|
|
||||||
|
|
||||||
if (tpaHint) {
|
if (tpaHint) {
|
||||||
if (thirdPartyAuthApiStatus === PENDING_STATE) {
|
if (thirdPartyAuthApiStatus === PENDING_STATE) {
|
||||||
@@ -227,7 +219,6 @@ const LoginPage = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -237,6 +228,7 @@ const LoginPage = ({
|
|||||||
success={loginResult.success}
|
success={loginResult.success}
|
||||||
redirectUrl={loginResult.redirectUrl}
|
redirectUrl={loginResult.redirectUrl}
|
||||||
finishAuthUrl={finishAuthUrl}
|
finishAuthUrl={finishAuthUrl}
|
||||||
|
currentProvider={currentProvider}
|
||||||
/>
|
/>
|
||||||
<div className="mw-xs mt-3 mb-2">
|
<div className="mw-xs mt-3 mb-2">
|
||||||
<LoginFailureMessage
|
<LoginFailureMessage
|
||||||
@@ -279,10 +271,10 @@ const LoginPage = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="brand"
|
variant="brand"
|
||||||
className="login-button-width"
|
className="login-button-width"
|
||||||
state={(isLoggingIn ? PENDING_STATE : 'default')}
|
state={submitState}
|
||||||
labels={{
|
labels={{
|
||||||
default: formatMessage(messages['sign.in.button']),
|
default: formatMessage(messages['sign.in.button']),
|
||||||
pending: 'pending',
|
pending: '',
|
||||||
}}
|
}}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
@@ -310,9 +302,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 = {
|
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,
|
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,
|
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,
|
||||||
|
},
|
||||||
|
)(LoginPage);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
39
src/login/data/actions.js
Normal 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,
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
76
src/login/data/reducers.js
Normal file
76
src/login/data/reducers.js
Normal 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;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user