Compare commits

..

19 Commits

Author SHA1 Message Date
Adolfo R. Brandes
967c52bde9 1.0.0-alpha.3 2025-06-29 13:20:08 +02:00
Adolfo R. Brandes
3252b593fd fix: bump frontend-base
Bump frontend-base to alpha.4 to fix a bug with the route role
redirection mechanism.
2025-06-29 13:19:33 +02:00
Adolfo R. Brandes
c98b9a1408 1.0.0-alpha.2 2025-06-29 13:11:02 +02:00
Adolfo R. Brandes
b6c659ccb8 feat: redirect to route role on successful login
Now we have a way to discover app URLs dynamically; do so for the
(default) redirect on login.
2025-06-29 13:09:17 +02:00
Adolfo R. Brandes
7efa279a29 1.0.0-alpha.1 2025-06-28 22:16:19 +02:00
Adolfo R. Brandes
f83f24af89 chore: bump frontend-base 2025-06-28 22:16:07 +02:00
Adolfo R. Brandes
b8116485b0 v1.0.0-alpha.0 2025-06-28 19:31:31 +02:00
Adolfo R. Brandes
762ff75fc4 Merge pull request #1515 from arbrandes/frontend-base-fixes
feat: Prepare for publication to NPM
2025-06-28 14:28:54 -03:00
Adolfo R. Brandes
095b156b95 chore: bump frontend-base 2025-06-28 19:27:26 +02:00
Adolfo R. Brandes
033e0fd7c5 feat: handle authentication roles 2025-06-28 19:18:34 +02:00
Adolfo R. Brandes
1ecdb0b6af chore: gitignore npm pack artifacts 2025-06-26 23:05:50 -03:00
Adolfo R. Brandes
18951cc4d0 chore: clean up npmignore 2025-06-26 23:05:50 -03:00
Adolfo R. Brandes
4dd5ddcc8b refactor: use appId from constants 2025-06-26 23:05:50 -03:00
Adolfo R. Brandes
6acbf64a71 chore: prepare for publication
Update package.json for publication as a "buildless" library.

(Also upgrade openedx-atlas.)
2025-06-26 19:52:56 -03:00
Adolfo R. Brandes
aea12a6a37 fix: remove catch-all route
Apps should avoid having a catch-all route, instead relying on the shell
to provide it.
2025-06-26 19:46:12 -03:00
Adolfo R. Brandes
3d778807f1 fix: update index.html
The only purpose of the included index.html is to serve the dev
environment, so modify it to make that clear.
2025-06-26 19:28:02 -03:00
Adolfo R. Brandes
014a990c22 docs: remove reference to devstack 2025-06-26 19:27:40 -03:00
Adolfo R. Brandes
7c8051a440 test: fix a couple of tests 2025-06-26 19:27:23 -03:00
Adolfo R. Brandes
8f8531a242 refactor: migrate to frontend-base
BREAKING CHANGE: refactors the MFE for frontend-base.
2025-06-24 15:30:07 -03:00
242 changed files with 11027 additions and 24932 deletions

45
.env
View File

@@ -1,45 +0,0 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=null
BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
MARKETING_SITE_BASE_URL=null
ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=''
SITE_NAME=null
INFO_EMAIL=''
# ***** Cookies *****
USER_RETENTION_COOKIE_NAME=null
# ***** Links *****
LOGIN_ISSUE_SUPPORT_LINK=''
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK=null
POST_REGISTRATION_REDIRECT_URL=''
SEARCH_CATALOG_URL=''
# ***** Features flags *****
DISABLE_ENTERPRISE_LOGIN=''
ENABLE_AUTO_GENERATED_USERNAME=''
ENABLE_DYNAMIC_REGISTRATION_FIELDS=''
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN=''
ENABLE_POST_REGISTRATION_RECOMMENDATIONS=''
MARKETING_EMAILS_OPT_IN=''
SHOW_CONFIGURABLE_EDX_FIELDS=''
ENABLE_IMAGE_LAYOUT=''
# ***** Zendesk related keys *****
ZENDESK_KEY=''
ZENDESK_LOGO_URL=''
# ***** Base Container Images *****
BANNER_IMAGE_LARGE=''
BANNER_IMAGE_MEDIUM=''
BANNER_IMAGE_SMALL=''
BANNER_IMAGE_EXTRA_SMALL=''
# ***** Miscellaneous *****
APP_ID=''
MFE_CONFIG_API_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

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

View File

@@ -1,4 +0,0 @@
# Copy these to the .env.private to enable edX specific functionality on local system
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN='true'
MARKETING_EMAILS_OPT_IN='true'
SHOW_CONFIGURABLE_EDX_FIELDS='true'

View File

@@ -1,21 +0,0 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:1995'
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:18000/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'
APP_ID=''
MFE_CONFIG_API_URL=''
PARAGON_THEME_URLS={}

View File

@@ -1,6 +0,0 @@
coverage/*
dist/
docs
node_modules/
__mocks__/
__snapshots__/

View File

@@ -1,52 +0,0 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('eslint', {
rules: {
// Temporarily update the 'indent', 'template-curly-spacing' and
// 'no-multiple-empty-lines' rules since they are causing eslint
// to fail for no apparent reason since upgrading
// @openedx/frontend-build from v3 to v5:
// - TypeError: Cannot read property 'range' of null
indent: [
'error',
2,
{ ignoredNodes: ['TemplateLiteral', 'SwitchCase'] },
],
'template-curly-spacing': 'off',
'jsx-a11y/label-has-associated-control': ['error', {
labelComponents: [],
labelAttributes: [],
controlComponents: [],
assert: 'htmlFor',
depth: 25,
}],
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: true }],
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
['sibling', 'parent'],
'index',
],
pathGroups: [
{
pattern: '@(react|react-dom|react-redux)',
group: 'external',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['react'],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'function-paren-newline': 'off',
},
});

16
.gitignore vendored
View File

@@ -1,21 +1,15 @@
.DS_Store
.eslintcache
.idea
node_modules
npm-debug.log
coverage
module.config.js
.env.private
dist/
/*.tgz
### i18n ###
src/i18n/transifex_input.json
temp/babel-plugin-react-intl
### pyenv ###
.python-version
### Emacs ###
### Editors ###
.DS_Store
*~
/temp
/.vscode
src/i18n/messages

View File

@@ -1,11 +1,6 @@
.eslintignore
.eslintrc.json
.gitignore
docker-compose.yml
Dockerfile
Makefile
npm-debug.log
coverage
__mocks__
node_modules
public
*.test.js
*.test.jsx
*.test.ts
*.test.tsx

2
.nvmrc
View File

@@ -1 +1 @@
24
20

2
CODEOWNERS Normal file
View File

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

View File

@@ -26,49 +26,14 @@ This is a micro-frontend application responsible for the login, registration and
Getting Started
***************
Prerequisites
=============
Installation
============
`Tutor`_ is currently recommended as a development environment for your new MFE. Please refer to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
Cloning and Startup
===================
1. Clone your new repo:
.. code-block:: bash
git clone https://github.com/edx/frontend-app-authn.git
2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes a ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
.. code-block:: bash
cd frontend-app-authn && npm install
4. Update the application port to use for local development:
The default port is 1999. If this does not work for you, update the line
``PORT=1999`` to your port in all ``.env.*`` files
5. Start the devserver. The app will be running at ``localhost:1999``, or whatever port you change it too.
.. code-block:: bash
npm run dev
**Note:** Follow `Enable social auth locally <docs/how_tos/enable_social_auth.rst>`_ for enabling Social Sign-on Buttons (SSO) locally
Environment Variables/Setup Notes
=================================
@@ -130,7 +95,7 @@ The authentication micro-frontend also requires the following additional variabl
* - ``MFE_CONFIG_API_URL``
- Link of the API to get runtime mfe configuration variables from the site configuration or django settings.
- ``/api/v1/mfe_config`` | ``''`` (empty strings are falsy)
- ``/api/v1/mfe_config`` | ``''`` (empty strings are falsy)
* - ``APP_ID``
- Name of MFE, this will be used by the API to get runtime configurations for the specific micro frontend. For a frontend repo `frontend-app-appName`, use `appName` as APP_ID.
@@ -158,10 +123,6 @@ Furthermore, there are several edX-specific environment variables that enable in
- Enables support for opting in marketing emails that helps us getting user consent for sending marketing emails.
- ``true`` | ``''`` (empty strings are falsy)
* - ``SHOW_CONFIGURABLE_EDX_FIELDS``
- For edX, country and honor code fields are required by default. This flag enables edX specific required fields.
- ``true`` | ``''`` (empty strings are falsy)
For more information see the document: `Micro-frontend applications in Open
edX <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`__.
@@ -240,4 +201,4 @@ Please see `LICENSE <https://github.com/openedx/frontend-app-authn/blob/master/L
:target: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml
:alt: Continuous Integration
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
:target: https://github.com/semantic-release/semantic-release
:target: https://github.com/semantic-release/semantic-release

5
app.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="@openedx/frontend-base" />
declare module 'site.config' {
export default SiteConfig;
}

3
babel.config.js Normal file
View File

@@ -0,0 +1,3 @@
const { createConfig } = require('@openedx/frontend-base/config');
module.exports = createConfig('babel');

22
eslint.config.js Normal file
View File

@@ -0,0 +1,22 @@
// @ts-check
const { createLintConfig } = require('@openedx/frontend-base/config');
module.exports = createLintConfig(
{
files: [
'src/**/*',
'site.config.*',
],
},
{
ignores: [
'coverage/*',
'dist/*',
'docs/*',
'node_modules/*',
'**/__mocks__/*',
'**/__snapshots__/*',
],
},
);

View File

@@ -1,14 +1,15 @@
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@openedx/frontend-base/config');
module.exports = createConfig('jest', {
setupFiles: [
module.exports = createConfig('test', {
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
coveragePathIgnorePatterns: [
'src/setupTest.js',
'src/i18n',
'src/index.jsx',
'MainApp.jsx',
],
testEnvironment: 'jsdom',
moduleNameMapper: {
'\\.svg$': '<rootDir>/src/__mocks__/svg.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/src/__mocks__/file.js',
},
});

18073
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,31 @@
{
"name": "@edx/frontend-app-authn",
"version": "0.1.0",
"description": "Frontend application template",
"name": "@openedx/frontend-app-authn",
"version": "1.0.0-alpha.3",
"description": "Frontend authentication",
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-app-authn.git"
},
"main": "src/index.ts",
"files": [
"/src"
],
"browserslist": [
"extends @edx/browserslist-config"
],
"sideEffects": [
"*.css",
"*.scss"
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/authn/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "fedx-scripts jest --coverage --passWithNoTests"
"dev": "PORT=1999 PUBLIC_PATH=/authn openedx dev",
"i18n_extract": "openedx formatjs extract",
"lint": "openedx lint .",
"lint:fix": "openedx lint --fix .",
"snapshot": "openedx test --updateSnapshot",
"test": "openedx test --coverage --passWithNoTests"
},
"author": "edX",
"author": "Open edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-authn#readme",
"publishConfig": {
@@ -29,48 +35,45 @@
"url": "https://github.com/openedx/frontend-app-authn/issues"
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-brands-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.6",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.2",
"@optimizely/react-sdk": "^2.9.1",
"@tanstack/react-query": "^5.90.19",
"@testing-library/react": "^16.2.0",
"algoliasearch": "^4.14.3",
"algoliasearch-helper": "^3.26.0",
"classnames": "2.5.1",
"core-js": "3.43.0",
"fastest-levenshtein": "1.0.16",
"form-urlencoded": "6.1.6",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "6.1.0",
"react-loading-skeleton": "3.5.0",
"react-responsive": "8.2.0",
"react-router": "6.30.3",
"react-router-dom": "6.30.3",
"react-zendesk": "^0.1.13",
"regenerator-runtime": "0.14.1",
"universal-cookie": "7.2.2"
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/openedx-atlas": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@redux-devtools/extension": "^3.3.0",
"classnames": "^2.5.1",
"fastest-levenshtein": "^1.0.16",
"form-urlencoded": "^6.1.5",
"i18n-iso-countries": "^7.13.0",
"prop-types": "^15.8.1",
"query-string": "^7.1.3",
"react-helmet": "^6.1.0",
"react-loading-skeleton": "^3.5.0",
"react-responsive": "^8.2.0",
"redux-logger": "^3.0.6",
"redux-mock-store": "^1.5.5",
"redux-saga": "^1.3.0",
"redux-thunk": "^2.4.2",
"reselect": "^5.1.1",
"universal-cookie": "^8.0.1"
},
"devDependencies": {
"@edx/browserslist-config": "^1.1.1",
"@edx/typescript-config": "^1.1.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "^6.9.1",
"babel-plugin-formatjs": "10.5.41",
"eslint-plugin-import": "2.32.0",
"glob": "7.2.3",
"history": "5.3.0",
"jest": "30.3.0",
"react-test-renderer": "^18.3.1",
"ts-jest": "^29.4.0"
"@edx/browserslist-config": "^1.5.0",
"@testing-library/react": "^16.3.0",
"babel-plugin-formatjs": "10.5.38",
"eslint-plugin-import": "2.31.0",
"jest": "^29.7.0",
"react-test-renderer": "^18.3.1"
},
"peerDependencies": {
"@openedx/frontend-base": "^1.0.0-alpha.4",
"@openedx/paragon": "^22",
"react": "^18",
"react-dom": "^18",
"react-redux": "^8",
"react-router": "^6",
"react-router-dom": "^6",
"redux": "^4"
}
}

View File

@@ -1,24 +1,9 @@
<!doctype html>
<html lang="en-us">
<head>
<title><%= (process.env.SITE_NAME && process.env.SITE_NAME != 'null') ? 'Authentication | ' + process.env.SITE_NAME : 'Authentication' %></title>
<title>Authentication Development Site></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.4.4/iframeResizer.contentWindow.min.js"
integrity="sha512-IWwZFBvHzN41wNI6etRLLuLrDDj/6AwJcPt7cmKJAzluYTIHHQ1PF8wh0rSy05jxEvvjflVvH2MxeV6riyEEXg=="
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>
<body>
<div id="root"></div>

20
site.config.dev.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { EnvironmentTypes, SiteConfig } from '@openedx/frontend-base';
import { authnApp } from './src';
import './src/app.scss';
const siteConfig: SiteConfig = {
siteId: 'authn-dev',
siteName: 'Authn Dev',
baseUrl: 'http://apps.local.openedx.io:8080',
lmsBaseUrl: 'http://local.openedx.io:8000',
loginUrl: 'http://local.openedx.io:8000/login',
logoutUrl: 'http://local.openedx.io:8000/logout',
environment: EnvironmentTypes.DEVELOPMENT,
basename: '/authn',
apps: [authnApp],
};
export default siteConfig;

50
site.config.test.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { EnvironmentTypes, SiteConfig } from '@openedx/frontend-base';
import { appId } from './src/constants';
const siteConfig: SiteConfig = {
siteId: 'test-site',
siteName: 'Test Site',
baseUrl: 'http://localhost:1996',
lmsBaseUrl: 'http://localhost:8000',
loginUrl: 'http://localhost:8000/login',
logoutUrl: 'http://localhost:8000/logout',
environment: EnvironmentTypes.TEST,
apps: [{
appId,
config: {
ACTIVATION_EMAIL_SUPPORT_LINK: null,
ALLOW_PUBLIC_ACCOUNT_CREATION: false,
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: null,
BANNER_IMAGE_EXTRA_SMALL: '',
BANNER_IMAGE_LARGE: '',
BANNER_IMAGE_MEDIUM: '',
BANNER_IMAGE_SMALL: '',
DISABLE_ENTERPRISE_LOGIN: true,
ENABLE_AUTO_GENERATED_USERNAME: false,
ENABLE_DYNAMIC_REGISTRATION_FIELDS: false,
ENABLE_IMAGE_LAYOUT: false,
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: false,
FAVICON_URL: 'https://edx-cdn.org/v3/default/favicon.ico',
INFO_EMAIL: '',
LOGIN_ISSUE_SUPPORT_LINK: null,
LOGO_TRADEMARK_URL: 'https://edx-cdn.org/v3/default/logo-trademark.svg',
LOGO_URL: 'https://edx-cdn.org/v3/default/logo.svg',
LOGO_WHITE_URL: 'https://edx-cdn.org/v3/default/logo-white.svg',
MARKETING_EMAILS_OPT_IN: '',
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
PASSWORD_RESET_SUPPORT_LINK: null,
POST_REGISTRATION_REDIRECT_URL: '',
PRIVACY_POLICY: null,
SEARCH_CATALOG_URL: null,
SESSION_COOKIE_DOMAIN: 'local.openedx.io',
SHOW_REGISTRATION_LINKS: false,
TOS_AND_HONOR_CODE: null,
TOS_LINK: null,
USER_RETENTION_COOKIE_NAME: '',
},
}],
};
export default siteConfig;

23
src/Main.tsx Executable file
View File

@@ -0,0 +1,23 @@
import { Provider as ReduxProvider } from 'react-redux';
import { Outlet } from 'react-router-dom';
import { CurrentAppProvider } from '@openedx/frontend-base';
import { appId } from './constants';
import {
registerIcons,
} from './common-components';
import configureStore from './data/configureStore';
import './sass/_style.scss';
registerIcons();
const Main = () => (
<CurrentAppProvider appId={appId}>
<ReduxProvider store={configureStore()}>
<Outlet />
</ReduxProvider>
</CurrentAppProvider>
);
export default Main;

View File

@@ -1,76 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Helmet } from 'react-helmet';
import { Navigate, Route, Routes } from 'react-router-dom';
import {
EmbeddedRegistrationRoute, NotFoundPage, registerIcons, UnAuthOnlyRoute, Zendesk,
} from './common-components';
import {
AUTHN_PROGRESSIVE_PROFILING,
LOGIN_PAGE,
PAGE_NOT_FOUND,
PASSWORD_RESET_CONFIRM,
RECOMMENDATIONS,
REGISTER_EMBEDDED_PAGE,
REGISTER_PAGE,
RESET_PAGE,
} from './data/constants';
import { updatePathWithQueryParams } from './data/utils';
import { ForgotPasswordPage } from './forgot-password';
import Logistration from './logistration/Logistration';
import { ProgressiveProfiling } from './progressive-profiling';
import { RecommendationsPage } from './recommendations';
import { RegistrationPage } from './register';
import { ResetPasswordPage } from './reset-password';
import './index.scss';
registerIcons();
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
},
});
const MainApp = () => (
<QueryClientProvider client={queryClient}>
<AppProvider>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
{getConfig().ZENDESK_KEY && <Zendesk />}
<Routes>
<Route path="/" element={<Navigate replace to={updatePathWithQueryParams(REGISTER_PAGE)} />} />
<Route
path={REGISTER_EMBEDDED_PAGE}
element={<EmbeddedRegistrationRoute><RegistrationPage /></EmbeddedRegistrationRoute>}
/>
<Route
path={LOGIN_PAGE}
element={
<UnAuthOnlyRoute><Logistration selectedPage={LOGIN_PAGE} /></UnAuthOnlyRoute>
}
/>
<Route
path={REGISTER_PAGE}
element={
<UnAuthOnlyRoute><Logistration selectedPage={REGISTER_PAGE} /></UnAuthOnlyRoute>
}
/>
<Route path={RESET_PAGE} element={<UnAuthOnlyRoute><ForgotPasswordPage /></UnAuthOnlyRoute>} />
<Route path={PASSWORD_RESET_CONFIRM} element={<ResetPasswordPage />} />
<Route path={AUTHN_PROGRESSIVE_PROFILING} element={<ProgressiveProfiling />} />
<Route path={RECOMMENDATIONS} element={<RecommendationsPage />} />
<Route path={PAGE_NOT_FOUND} element={<NotFoundPage />} />
<Route path="*" element={<Navigate replace to={PAGE_NOT_FOUND} />} />
</Routes>
</AppProvider>
</QueryClientProvider>
);
export default MainApp;

1
src/__mocks__/file.js Normal file
View File

@@ -0,0 +1 @@
module.exports = 'FileMock';

1
src/__mocks__/svg.js Normal file
View File

@@ -0,0 +1 @@
module.exports = 'SvgURL';

6
src/app.scss Executable file
View File

@@ -0,0 +1,6 @@
@use "@edx/brand/paragon/fonts";
@use "@edx/brand/paragon/variables";
@use "@openedx/paragon/scss/core/core";
@use "@edx/brand/paragon/overrides";
@use "sass/style";

43
src/app.ts Normal file
View File

@@ -0,0 +1,43 @@
import { App } from '@openedx/frontend-base';
import { appId } from './constants';
import routes from './routes';
import messages from './i18n';
const app: App = {
appId,
routes,
messages,
config: {
ACTIVATION_EMAIL_SUPPORT_LINK: null,
ALLOW_PUBLIC_ACCOUNT_CREATION: true,
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: null,
BANNER_IMAGE_EXTRA_SMALL: '',
BANNER_IMAGE_LARGE: '',
BANNER_IMAGE_MEDIUM: '',
BANNER_IMAGE_SMALL: '',
DISABLE_ENTERPRISE_LOGIN: true,
ENABLE_AUTO_GENERATED_USERNAME: false,
ENABLE_DYNAMIC_REGISTRATION_FIELDS: false,
ENABLE_IMAGE_LAYOUT: false,
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: false,
FAVICON_URL: 'https://edx-cdn.org/v3/default/favicon.ico',
INFO_EMAIL: '',
LOGIN_ISSUE_SUPPORT_LINK: null,
LOGO_TRADEMARK_URL: 'https://edx-cdn.org/v3/default/logo-trademark.svg',
LOGO_URL: 'https://edx-cdn.org/v3/default/logo.svg',
LOGO_WHITE_URL: 'https://edx-cdn.org/v3/default/logo-white.svg',
MARKETING_EMAILS_OPT_IN: '',
MARKETING_SITE_BASE_URL: 'http://local.openedx.io',
PASSWORD_RESET_SUPPORT_LINK: null,
POST_REGISTRATION_REDIRECT_URL: '',
PRIVACY_POLICY: null,
SEARCH_CATALOG_URL: null,
SESSION_COOKIE_DOMAIN: 'local.openedx.io',
SHOW_REGISTRATION_LINKS: true,
TOS_AND_HONOR_CODE: null,
TOS_LINK: null,
USER_RETENTION_COOKIE_NAME: '',
},
};
export default app;

View File

@@ -1,4 +1,4 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@openedx/frontend-base';
import { render, screen } from '@testing-library/react';
import { DefaultLargeLayout, DefaultMediumLayout, DefaultSmallLayout } from './index';

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import classNames from 'classnames';
@@ -11,20 +10,20 @@ const LargeLayout = () => {
return (
<div className="w-50 d-flex">
<div className="col-md-9 bg-primary-400">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="min-vh-100 d-flex align-items-center">
<div className={classNames({ 'large-yellow-line mr-n4.5': getConfig().SITE_NAME === 'edX' })} />
<div className={classNames({ 'large-yellow-line mr-n4.5': getSiteConfig().siteName === 'edX' })} />
<h1
className={classNames(
'display-2 text-white mw-xs',
{ 'ml-6': getConfig().SITE_NAME !== 'edX' },
{ 'ml-6': getSiteConfig().siteName !== 'edX' },
)}
>
{formatMessage(messages['start.learning'])}
<div className="text-accent-a">
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
{formatMessage(messages['with.site.name'], { siteName: getSiteConfig().siteName })}
</div>
</h1>
</div>

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import classNames from 'classnames';
@@ -13,22 +12,22 @@ const MediumLayout = () => {
<div className="w-100 medium-screen-top-stripe" />
<div className="w-100 p-0 mb-3 d-flex">
<div className="col-md-10 bg-primary-400">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image alt={getConfig().SITE_NAME} className="logo" src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image alt={getSiteConfig().siteName} className="logo" src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="d-flex align-items-center justify-content-center mb-4 ">
<div className={classNames({ 'mt-1 medium-yellow-line': getConfig().SITE_NAME === 'edX' })} />
<div className={classNames({ 'mt-1 medium-yellow-line': getSiteConfig().siteName === 'edX' })} />
<div>
<h1
className={classNames(
'display-1 text-white mt-5 mb-5 mr-2 main-heading',
{ 'ml-4.5': getConfig().SITE_NAME !== 'edX' },
{ 'ml-4.5': getSiteConfig().siteName !== 'edX' },
)}
>
<span>
{formatMessage(messages['start.learning'])}{' '}
<span className="text-accent-a d-inline-block">
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
{formatMessage(messages['with.site.name'], { siteName: getSiteConfig().siteName })}
</span>
</span>
</h1>

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import classNames from 'classnames';
@@ -12,11 +11,11 @@ const SmallLayout = () => {
<span className="bg-primary-400 w-100">
<div className="col-md-12 small-screen-top-stripe" />
<div>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="d-flex align-items-center m-3.5">
<div className={classNames({ 'small-yellow-line mr-n2.5': getConfig().SITE_NAME === 'edX' })} />
<div className={classNames({ 'small-yellow-line mr-n2.5': getSiteConfig().siteName === 'edX' })} />
<h1
className={classNames(
'text-white mt-3.5 mb-3.5',
@@ -25,7 +24,7 @@ const SmallLayout = () => {
<span>
{formatMessage(messages['start.learning'])}{' '}
<span className="text-accent-a d-inline-block">
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
{formatMessage(messages['with.site.name'], { siteName: getSiteConfig().siteName })}
</span>
</span>
</h1>

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
'start.learning': {

View File

@@ -1,7 +1,4 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import messages from './messages';
@@ -12,10 +9,10 @@ const ExtraSmallLayout = () => {
return (
<span
className="w-100 bg-primary-500 banner__image extra-small-layout"
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_EXTRA_SMALL})` }}
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_EXTRA_SMALL})` }}
>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="ml-4.5 mr-1 pb-3.5 pt-3.5">
<h1 className="banner__heading">

View File

@@ -1,7 +1,4 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import './index.scss';
@@ -13,10 +10,10 @@ const LargeLayout = () => {
return (
<div
className="w-50 bg-primary-500 banner__image large-layout"
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_LARGE})` }}
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_LARGE})` }}
>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo position-absolute" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo position-absolute" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="min-vh-100 p-5 d-flex align-items-end">
<h1 className="display-2 mw-sm mb-3 d-flex flex-column flex-shrink-0 justify-content-center">

View File

@@ -1,7 +1,4 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import './index.scss';
@@ -13,10 +10,10 @@ const MediumLayout = () => {
return (
<div
className="w-100 mb-3 bg-primary-500 banner__image medium-layout"
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_MEDIUM})` }}
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_MEDIUM})` }}
>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="ml-5 pb-4 pt-4">
<h1 className="display-2 banner__heading">

View File

@@ -1,7 +1,4 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import messages from './messages';
@@ -12,10 +9,10 @@ const SmallLayout = () => {
return (
<span
className="w-100 bg-primary-500 banner__image small-layout"
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_SMALL})` }}
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_SMALL})` }}
>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="ml-5 mr-1 pb-3.5 pt-3.5">
<h1 className="display-2">

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
'your.career.turning.point': {

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import PropTypes from 'prop-types';
@@ -11,14 +10,14 @@ const LargeLayout = ({ fullName }) => {
return (
<div className="w-50 d-flex">
<div className="col-md-10 bg-light-200 p-0">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_URL} />
</Hyperlink>
<div className="min-vh-100 d-flex align-items-center">
<div className="large-screen-left-container mr-n4.5 large-yellow-line mt-5" />
<div>
<h1 className="welcome-to-platform data-hj-suppress">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
{formatMessage(messages['welcome.to.platform'], { siteName: getSiteConfig().siteName, fullName })}
</h1>
<h2 className="complete-your-profile">
{formatMessage(messages['complete.your.profile.1'])}

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import PropTypes from 'prop-types';
@@ -13,14 +12,14 @@ const MediumLayout = ({ fullName }) => {
<div className="w-100 medium-screen-top-stripe" />
<div className="w-100 p-0 mb-3 d-flex">
<div className="col-md-10 bg-light-200">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_URL} />
</Hyperlink>
<div className="d-flex align-items-center justify-content-center mb-4 ml-5">
<div className="medium-yellow-line mt-5 mr-n2" />
<div>
<h1 className="h3 data-hj-suppress mw-320">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
{formatMessage(messages['welcome.to.platform'], { siteName: getSiteConfig().siteName, fullName })}
</h1>
<h2 className="display-1">
{formatMessage(messages['complete.your.profile.1'])}

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import PropTypes from 'prop-types';
@@ -11,14 +10,14 @@ const SmallLayout = ({ fullName }) => {
return (
<div className="min-vw-100 bg-light-200">
<div className="col-md-12 small-screen-top-stripe" />
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_URL} />
</Hyperlink>
<div className="d-flex align-items-center m-3.5">
<div className="small-yellow-line mt-4.5" />
<div>
<h1 className="h5 data-hj-suppress">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
{formatMessage(messages['welcome.to.platform'], { siteName: getSiteConfig().siteName, fullName })}
</h1>
<h2 className="h1">
{formatMessage(messages['complete.your.profile.1'])}

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
'welcome.to.platform': {

View File

@@ -1,4 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useAppConfig } from '@openedx/frontend-base';
import { breakpoints } from '@openedx/paragon';
import classNames from 'classnames';
import PropTypes from 'prop-types';
@@ -11,7 +11,7 @@ import {
import { AuthLargeLayout, AuthMediumLayout, AuthSmallLayout } from './components/welcome-page-layout';
const BaseContainer = ({ children, showWelcomeBanner, fullName }) => {
const enableImageLayout = getConfig().ENABLE_IMAGE_LAYOUT;
const enableImageLayout = useAppConfig().ENABLE_IMAGE_LAYOUT;
if (enableImageLayout) {
return (

View File

@@ -1,9 +1,9 @@
import { mergeConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider, mergeAppConfig } from '@openedx/frontend-base';
import { render } from '@testing-library/react';
import { Context as ResponsiveContext } from 'react-responsive';
import BaseContainer from '../index';
import { appId } from '../../constants';
const LargeScreen = {
wrappingComponent: ResponsiveContext.Provider,
@@ -26,7 +26,7 @@ describe('Base component tests', () => {
});
it('renders Image layout when ENABLE_IMAGE_LAYOUT configuration is enabled', () => {
mergeConfig({
mergeAppConfig(appId, {
ENABLE_IMAGE_LAYOUT: true,
});

View File

@@ -1,6 +1,5 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import {
Button, Form,
Icon,
@@ -8,8 +7,8 @@ import {
import { Login } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
import messages from './messages';
/**
* This component renders the Single sign-on (SSO) button only for the tpa provider passed
@@ -17,12 +16,12 @@ import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
const EnterpriseSSO = (props) => {
const { formatMessage } = useIntl();
const tpaProvider = props.provider;
const hideRegistrationLink = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false
|| getConfig().SHOW_REGISTRATION_LINKS === false;
const hideRegistrationLink = useAppConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false
|| useAppConfig().SHOW_REGISTRATION_LINKS === false;
const handleSubmit = (e, url) => {
e.preventDefault();
window.location.href = getConfig().LMS_BASE_URL + url;
window.location.href = getSiteConfig().lmsBaseUrl + url;
};
const handleClick = (e) => {
@@ -48,7 +47,7 @@ const EnterpriseSSO = (props) => {
{tpaProvider.iconImage ? (
<div aria-hidden="true">
<img className="btn-tpa__image-icon" src={tpaProvider.iconImage} alt={`icon ${tpaProvider.name}`} />
<span className="pl-2" aria-hidden="true">{ tpaProvider.name }</span>
<span className="pl-2" aria-hidden="true">{tpaProvider.name}</span>
</div>
)
: (
@@ -60,7 +59,7 @@ const EnterpriseSSO = (props) => {
<Icon className="h-75" src={Login} />
)}
</div>
<span className="pl-2" aria-hidden="true">{ tpaProvider.name }</span>
<span className="pl-2" aria-hidden="true">{tpaProvider.name}</span>
</>
)}
</Button>

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Button, Hyperlink, Icon } from '@openedx/paragon';
import { Institution } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
@@ -29,7 +28,7 @@ export const RenderInstitutionButton = props => {
* This component renders the page list of available institutions for login
* */
const InstitutionLogistration = props => {
const lmsBaseUrl = getConfig().LMS_BASE_URL;
const lmsBaseUrl = getSiteConfig().lmsBaseUrl;
const { formatMessage } = useIntl();
const {
secondaryProviders,

View File

@@ -1,4 +1,4 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FormattedMessage } from '@openedx/frontend-base';
const NotFoundPage = () => (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">

View File

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

View File

@@ -1,9 +1,9 @@
import { getConfig } from '@edx/frontend-platform';
import { useAppConfig, getSiteConfig } from '@openedx/frontend-base';
import PropTypes from 'prop-types';
import { Navigate } from 'react-router-dom';
import {
AUTHN_PROGRESSIVE_PROFILING, RECOMMENDATIONS, REDIRECT,
AUTHN_PROGRESSIVE_PROFILING, REDIRECT,
} from '../data/constants';
import { setCookie } from '../data/utils';
@@ -15,20 +15,20 @@ const RedirectLogistration = (props) => {
redirectToProgressiveProfilingPage,
success,
optionalFields,
redirectToRecommendationsPage,
educationLevel,
userId,
registrationEmbedded,
host,
} = props;
let finalRedirectUrl = '';
if (success) {
// If we're in a third party auth pipeline, we must complete the pipeline
// once user has successfully logged in. Otherwise, redirect to the specified redirect url.
// Note: For multiple enterprise use case, we need to make sure that user first visits the
// enterprise selection page and then complete the auth workflow
if (finishAuthUrl && !redirectUrl.includes(finishAuthUrl)) {
finalRedirectUrl = getConfig().LMS_BASE_URL + finishAuthUrl;
finalRedirectUrl = getSiteConfig().lmsBaseUrl + finishAuthUrl;
} else {
finalRedirectUrl = redirectUrl;
}
@@ -36,12 +36,12 @@ const RedirectLogistration = (props) => {
// Redirect to Progressive Profiling after successful registration
if (redirectToProgressiveProfilingPage) {
// TODO: Do we still need this cookie?
setCookie('van-504-returning-user', true);
setCookie('van-504-returning-user', true, useAppConfig().SESSION_COOKIE_DOMAIN);
if (registrationEmbedded) {
window.parent.postMessage({
action: REDIRECT,
redirectUrl: getConfig().POST_REGISTRATION_REDIRECT_URL,
redirectUrl: useAppConfig().POST_REGISTRATION_REDIRECT_URL,
}, host);
return null;
}
@@ -59,22 +59,6 @@ const RedirectLogistration = (props) => {
);
}
// Redirect to Recommendation page
if (redirectToRecommendationsPage) {
const registrationResult = { redirectUrl: finalRedirectUrl, success };
return (
<Navigate
to={RECOMMENDATIONS}
state={{
registrationResult,
educationLevel,
userId,
}}
replace
/>
);
}
window.location.href = finalRedirectUrl;
}
@@ -89,7 +73,6 @@ RedirectLogistration.defaultProps = {
redirectUrl: '',
redirectToProgressiveProfilingPage: false,
optionalFields: {},
redirectToRecommendationsPage: false,
userId: null,
registrationEmbedded: false,
host: '',
@@ -103,7 +86,6 @@ RedirectLogistration.propTypes = {
redirectUrl: PropTypes.string,
redirectToProgressiveProfilingPage: PropTypes.bool,
optionalFields: PropTypes.shape({}),
redirectToRecommendationsPage: PropTypes.bool,
userId: PropTypes.number,
registrationEmbedded: PropTypes.bool,
host: PropTypes.string,

View File

@@ -1,12 +1,11 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Icon } from '@openedx/paragon';
import { Login } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
import messages from './messages';
const SocialAuthProviders = (props) => {
const { formatMessage } = useIntl();
@@ -16,7 +15,7 @@ const SocialAuthProviders = (props) => {
e.preventDefault();
const url = e.currentTarget.dataset.providerUrl;
window.location.href = getConfig().LMS_BASE_URL + url;
window.location.href = getSiteConfig().lmsBaseUrl + url;
}
const socialAuth = socialAuthProviders.map((provider, index) => (

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import {
Hyperlink, Icon,
} from '@openedx/paragon';
@@ -8,10 +7,10 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import Skeleton from 'react-loading-skeleton';
import messages from './messages';
import {
ENTERPRISE_LOGIN_URL, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE,
} from '../data/constants';
import messages from './messages';
import {
RenderInstitutionButton,
@@ -33,8 +32,8 @@ const ThirdPartyAuth = (props) => {
} = props;
const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider;
const isSocialAuthActive = !!providers.length && !currentProvider;
const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN;
const enterpriseLoginURL = getConfig().LMS_BASE_URL + ENTERPRISE_LOGIN_URL;
const isEnterpriseLoginDisabled = useAppConfig().DISABLE_ENTERPRISE_LOGIN;
const enterpriseLoginURL = getSiteConfig().lmsBaseUrl + ENTERPRISE_LOGIN_URL;
const isThirdPartyAuthActive = isSocialAuthActive || (isEnterpriseLoginDisabled && isInstitutionAuthActive);
return (

View File

@@ -1,15 +1,14 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Alert } from '@openedx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
import messages from './messages';
const ThirdPartyAuthAlert = (props) => {
const { formatMessage } = useIntl();
const { currentProvider, referrer } = props;
const platformName = getConfig().SITE_NAME;
const platformName = getSiteConfig().siteName;
let message;
if (referrer === LOGIN_PAGE) {
@@ -28,7 +27,7 @@ const ThirdPartyAuthAlert = (props) => {
{referrer === REGISTER_PAGE ? (
<Alert.Heading>{formatMessage(messages['tpa.alert.heading'])}</Alert.Heading>
) : null}
<p>{ message }</p>
<p>{message}</p>
</Alert>
{referrer === REGISTER_PAGE ? (
<h4 className="mt-4 mb-4">{formatMessage(messages['registration.using.tpa.form.heading'])}</h4>

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { fetchAuthenticatedUser, getAuthenticatedUser, getSiteConfig } from '@openedx/frontend-base';
import PropTypes from 'prop-types';
import {
@@ -25,7 +24,7 @@ const UnAuthOnlyRoute = ({ children }) => {
if (isReady) {
if (authUser && authUser.username) {
global.location.href = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
global.location.href = getSiteConfig().lmsBaseUrl.concat(DEFAULT_REDIRECT_URL);
return null;
}

View File

@@ -1,59 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import Zendesk from 'react-zendesk';
import messages from './messages';
import { REGISTER_EMBEDDED_PAGE } from '../data/constants';
const ZendeskHelp = () => {
const { formatMessage } = useIntl();
const setting = {
cookies: true,
webWidget: {
contactOptions: {
enabled: false,
},
chat: {
suppress: false,
departments: {
enabled: ['account settings', 'billing and payments', 'certificates', 'deadlines', 'errors and technical issues', 'other', 'proctoring'],
},
},
contactForm: {
ticketForms: [
{
id: 360003368814,
subject: false,
fields: [{ id: 'description', prefill: { '*': '' } }],
},
],
selectTicketForm: {
'*': formatMessage(messages.selectTicketForm),
},
attachments: true,
},
helpCenter: {
originalArticleButton: true,
},
answerBot: {
suppress: false,
contactOnlyAfterQuery: true,
title: { '*': formatMessage(messages.supportTitle) },
avatar: {
url: getConfig().ZENDESK_LOGO_URL,
name: { '*': formatMessage(messages.supportTitle) },
},
},
},
};
if (window.location.pathname === REGISTER_EMBEDDED_PAGE) {
return null;
}
return (
<Zendesk defer zendeskKey={getConfig().ZENDESK_KEY} {...setting} />
);
};
export default ZendeskHelp;

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
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) => ({
type: THIRD_PARTY_AUTH_CONTEXT.SUCCESS,
payload: { fieldDescriptions, optionalFields, thirdPartyAuthContext },
});
export const getThirdPartyAuthContextFailure = () => ({
type: THIRD_PARTY_AUTH_CONTEXT.FAILURE,
});
export const clearThirdPartyAuthContextErrorMessage = () => ({
type: THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG,
});

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
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,
};
}
case THIRD_PARTY_AUTH_CONTEXT.FAILURE:
return {
...state,
thirdPartyAuthApiStatus: FAILURE_STATE,
thirdPartyAuthContext: {
...state.thirdPartyAuthContext,
errorMessage: null,
},
};
case THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG:
return {
...state,
thirdPartyAuthApiStatus: PENDING_STATE,
thirdPartyAuthContext: {
...state.thirdPartyAuthContext,
errorMessage: null,
},
};
default:
return state;
}
};
export default reducer;

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getAuthenticatedHttpClient, getSiteConfig } from '@openedx/frontend-base';
const getThirdPartyAuthContext = async (urlParams : string) => {
export async function getThirdPartyAuthContext(urlParams) {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
params: urlParams,
@@ -10,16 +9,15 @@ const getThirdPartyAuthContext = async (urlParams : string) => {
const { data } = await getAuthenticatedHttpClient()
.get(
`${getConfig().LMS_BASE_URL}/api/mfe_context`,
`${getSiteConfig().lmsBaseUrl}/api/mfe_context`,
requestConfig,
);
)
.catch((e) => {
throw (e);
});
return {
fieldDescriptions: data.registrationFields || {},
optionalFields: data.optionalFields || {},
thirdPartyAuthContext: data.contextData || {},
};
};
export {
getThirdPartyAuthContext,
};
}

View File

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

View File

@@ -0,0 +1,71 @@
import { runSaga } from 'redux-saga';
import { setCountryFromThirdPartyAuthContext } from '../../../register/data/actions';
import { initializeMockServices } from '../../../setupTest';
import * as actions from '../actions';
import { fetchThirdPartyAuthContext } from '../sagas';
import * as api from '../service';
const { loggingService } = initializeMockServices();
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: {},
}));
const dispatched = [];
await runSaga(
{ dispatch: (action) => dispatched.push(action) },
fetchThirdPartyAuthContext,
params,
);
expect(getThirdPartyAuthContext).toHaveBeenCalledTimes(1);
expect(dispatched).toEqual([
actions.getThirdPartyAuthContextBegin(),
setCountryFromThirdPartyAuthContext(),
actions.getThirdPartyAuthContextSuccess({}, {}, data),
]);
getThirdPartyAuthContext.mockClear();
});
it('should call service and dispatch error action', async () => {
const getThirdPartyAuthContext = jest.spyOn(api, 'getThirdPartyAuthContext')
.mockImplementation(() => Promise.reject(new Error('something went wrong')));
const dispatched = [];
await runSaga(
{ dispatch: (action) => dispatched.push(action) },
fetchThirdPartyAuthContext,
params,
);
expect(getThirdPartyAuthContext).toHaveBeenCalledTimes(1);
expect(loggingService.logError).toHaveBeenCalled();
expect(dispatched).toEqual([
actions.getThirdPartyAuthContextBegin(),
actions.getThirdPartyAuthContextFailure(),
]);
getThirdPartyAuthContext.mockClear();
});
});

View File

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

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
// institution login strings
@@ -85,33 +85,23 @@ const messages = defineMessages({
'login.third.party.auth.account.not.linked': {
id: 'login.third.party.auth.account.not.linked',
defaultMessage: 'You have successfully signed into {currentProvider}, but your {currentProvider} '
+ 'account does not have a linked {platformName} account. To link your accounts, '
+ 'sign in now using your {platformName} password.',
+ 'account does not have a linked {platformName} account. To link your accounts, '
+ 'sign in now using your {platformName} password.',
description: 'Message that appears on login page if user has successfully authenticated with social '
+ 'auth but no associated platform account exists',
+ 'auth but no associated platform account exists',
},
'register.third.party.auth.account.not.linked': {
id: 'register.third.party.auth.account.not.linked',
defaultMessage: 'You\'ve successfully signed into {currentProvider}! We just need a little more information '
+ 'before you start learning with {platformName}.',
+ 'before you start learning with {platformName}.',
description: 'Message that appears on register page if user has successfully authenticated with TPA '
+ 'but no associated platform account exists',
+ 'but no associated platform account exists',
},
'registration.using.tpa.form.heading': {
id: 'registration.using.tpa.form.heading',
defaultMessage: 'Finish creating your account',
description: 'Heading that appears above form when user is trying to create account using social auth',
},
supportTitle: {
id: 'zendesk.supportTitle',
description: 'Title for the support button',
defaultMessage: 'edX Support',
},
selectTicketForm: {
id: 'zendesk.selectTicketForm',
description: 'Select ticket form',
defaultMessage: 'Please choose your request type:',
},
'registration.other.options.heading': {
id: 'registration.other.options.heading',
defaultMessage: 'Or register with:',

View File

@@ -1,8 +1,7 @@
/* eslint-disable import/no-import-module-exports */
/* eslint-disable react/function-component-definition */
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getSiteConfig } from '@openedx/frontend-base';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import {
@@ -15,7 +14,7 @@ import EmbeddedRegistrationRoute from '../EmbeddedRegistrationRoute';
const RRD = require('react-router-dom');
// Just render plain div with its children
// eslint-disable-next-line react/prop-types
RRD.BrowserRouter = ({ children }) => <div>{ children }</div>;
RRD.BrowserRouter = ({ children }) => <div>{children}</div>;
module.exports = RRD;
const TestApp = () => (
@@ -60,7 +59,7 @@ describe('EmbeddedRegistrationRoute', () => {
it('should render embedded register page if host query param is available in the url (embedded)', async () => {
delete window.location;
window.location = {
href: getConfig().BASE_URL.concat(REGISTER_EMBEDDED_PAGE),
href: getSiteConfig().baseUrl.concat(REGISTER_EMBEDDED_PAGE),
search: '?host=http://localhost/host-websit',
};

View File

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

View File

@@ -1,4 +1,4 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@openedx/frontend-base';
import renderer from 'react-test-renderer';
import registerIcons from '../RegisterFaIcons';

View File

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

View File

@@ -1,6 +1,7 @@
/* eslint-disable import/no-import-module-exports */
/* eslint-disable react/function-component-definition */
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@openedx/frontend-base';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import {
@@ -10,7 +11,8 @@ import {
import { UnAuthOnlyRoute } from '..';
import { REGISTER_PAGE } from '../../data/constants';
jest.mock('@edx/frontend-platform/auth', () => ({
jest.mock('@openedx/frontend-base', () => ({
...jest.requireActual('@openedx/frontend-base'),
getAuthenticatedUser: jest.fn(),
fetchAuthenticatedUser: jest.fn(),
}));

View File

@@ -1,17 +0,0 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import renderer from 'react-test-renderer';
import Zendesk from '../Zendesk';
jest.mock('react-zendesk', () => 'Zendesk');
describe('Zendesk Help', () => {
it('should match login page third party auth alert message snapshot', () => {
const tree = renderer.create(
<IntlProvider locale="en">
<Zendesk />
</IntlProvider>,
).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -1,25 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThirdPartyAuthAlert renders skeleton for pending third-party auth 1`] = `
<div
className="fade alert-content alert-warning mt-n2 mb-5 alert show"
id="tpa-alert"
role="alert"
>
<div
className="pgn__alert-message-wrapper"
>
<div
className="alert-message-content"
>
<p>
You have successfully signed into Google, but your Google account does not have a linked Your Platform Name Here account. To link your accounts, sign in now using your Your Platform Name Here password.
</p>
</div>
</div>
</div>
`;
exports[`ThirdPartyAuthAlert should match login page third party auth alert message snapshot 1`] = `
<div
className="fade alert-content alert-warning mt-n2 mb-5 alert show"
@@ -33,7 +13,7 @@ exports[`ThirdPartyAuthAlert should match login page third party auth alert mess
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.
You have successfully signed into Google, but your Google account does not have a linked Test Site account. To link your accounts, sign in now using your Test Site password.
</p>
</div>
</div>
@@ -59,7 +39,7 @@ exports[`ThirdPartyAuthAlert should match register page third party auth alert m
Almost done!
</div>
<p>
You've successfully signed into Google! We just need a little more information before you start learning with Your Platform Name Here.
You've successfully signed into Google! We just need a little more information before you start learning with Test Site.
</p>
</div>
</div>

View File

@@ -1,65 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Zendesk Help should match login page third party auth alert message snapshot 1`] = `
<Zendesk
cookies={true}
defer={true}
webWidget={
{
"answerBot": {
"avatar": {
"name": {
"*": "edX Support",
},
"url": undefined,
},
"contactOnlyAfterQuery": true,
"suppress": false,
"title": {
"*": "edX Support",
},
},
"chat": {
"departments": {
"enabled": [
"account settings",
"billing and payments",
"certificates",
"deadlines",
"errors and technical issues",
"other",
"proctoring",
],
},
"suppress": false,
},
"contactForm": {
"attachments": true,
"selectTicketForm": {
"*": "Please choose your request type:",
},
"ticketForms": [
{
"fields": [
{
"id": "description",
"prefill": {
"*": "",
},
},
],
"id": 360003368814,
"subject": false,
},
],
},
"contactOptions": {
"enabled": false,
},
"helpCenter": {
"originalArticleButton": true,
},
}
}
/>
`;

View File

@@ -1,40 +0,0 @@
const configuration = {
// Cookies related configs
SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN,
USER_RETENTION_COOKIE_NAME: process.env.USER_RETENTION_COOKIE_NAME || '',
// Features
DISABLE_ENTERPRISE_LOGIN: process.env.DISABLE_ENTERPRISE_LOGIN || '',
ENABLE_AUTO_GENERATED_USERNAME: process.env.ENABLE_AUTO_GENERATED_USERNAME || false,
ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false,
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: process.env.ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN || false,
ENABLE_POST_REGISTRATION_RECOMMENDATIONS: process.env.ENABLE_POST_REGISTRATION_RECOMMENDATIONS || false,
MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '',
SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false,
SHOW_REGISTRATION_LINKS: process.env.SHOW_REGISTRATION_LINKS !== 'false',
ENABLE_IMAGE_LAYOUT: process.env.ENABLE_IMAGE_LAYOUT || false,
// Links
ACTIVATION_EMAIL_SUPPORT_LINK: process.env.ACTIVATION_EMAIL_SUPPORT_LINK || null,
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: process.env.AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK || null,
LOGIN_ISSUE_SUPPORT_LINK: process.env.LOGIN_ISSUE_SUPPORT_LINK || null,
PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK || null,
POST_REGISTRATION_REDIRECT_URL: process.env.POST_REGISTRATION_REDIRECT_URL || '',
PRIVACY_POLICY: process.env.PRIVACY_POLICY || null,
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,
TOS_AND_HONOR_CODE: process.env.TOS_AND_HONOR_CODE || null,
TOS_LINK: process.env.TOS_LINK || null,
// Base container images
BANNER_IMAGE_LARGE: process.env.BANNER_IMAGE_LARGE || '',
BANNER_IMAGE_MEDIUM: process.env.BANNER_IMAGE_MEDIUM || '',
BANNER_IMAGE_SMALL: process.env.BANNER_IMAGE_SMALL || '',
BANNER_IMAGE_EXTRA_SMALL: process.env.BANNER_IMAGE_EXTRA_SMALL || '',
// Recommendation constants
GENERAL_RECOMMENDATIONS: process.env.GENERAL_RECOMMENDATIONS || '[]',
// Miscellaneous
INFO_EMAIL: process.env.INFO_EMAIL || '',
ZENDESK_KEY: process.env.ZENDESK_KEY,
ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL,
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '',
ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '',
};
export default configuration;

View File

@@ -1,20 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import algoliasearch from 'algoliasearch';
// initialize Algolia workers
const initializeSearchClient = () => algoliasearch(
getConfig().ALGOLIA_APP_ID,
getConfig().ALGOLIA_SEARCH_API_KEY,
);
const getLocationRestrictionFilter = (userCountry) => {
if (userCountry) {
return `NOT blocked_in:"${userCountry}" AND (allowed_in:"null" OR allowed_in:"${userCountry}")`;
}
return '';
};
export {
initializeSearchClient,
getLocationRestrictionFilter,
};

View File

@@ -0,0 +1,33 @@
import { getSiteConfig } from '@openedx/frontend-base';
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 (getSiteConfig().environment === 'development') {
const loggerMiddleware = createLogger({
collapsed: true,
});
return composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware));
}
return compose(applyMiddleware(thunkMiddleware, sagaMiddleware));
}
export default function configureStore(initialState = {}) {
const store = createStore(
createRootReducer(),
initialState,
composeMiddleware(),
);
sagaMiddleware.run(rootSaga);
return store;
}

View File

@@ -5,7 +5,6 @@ export const REGISTER_EMBEDDED_PAGE = '/register-embedded';
export const RESET_PAGE = '/reset';
export const AUTHN_PROGRESSIVE_PROFILING = '/welcome';
export const DEFAULT_REDIRECT_URL = '/dashboard';
export const RECOMMENDATIONS = '/recommendations';
export const PASSWORD_RESET_CONFIRM = '/password_reset_confirm/:token/';
export const PAGE_NOT_FOUND = '/notfound';
export const ENTERPRISE_LOGIN_URL = '/enterprise/login';
@@ -29,9 +28,9 @@ export const EMBEDDED = 'embedded';
export const LETTER_REGEX = /[a-zA-Z]/;
export const NUMBER_REGEX = /\d/;
export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+)*'
+ '|^"([\\001-\\010\\013\\014\\016-\\037!#-\\[\\]-\\177]|\\\\[\\001-\\011\\013\\014\\016-\\177])*"'
+ ')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+)(?:[A-Z0-9-]{2,63})'
+ '|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$';
+ '|^"([\\001-\\010\\013\\014\\016-\\037!#-\\[\\]-\\177]|\\\\[\\001-\\011\\013\\014\\016-\\177])*"'
+ ')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+)(?:[A-Z0-9-]{2,63})'
+ '|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$';
// Query string parameters that can be passed to LMS to manage
// things like auto-enrollment upon login and registration.

59
src/data/countries.ts Normal file
View File

@@ -0,0 +1,59 @@
import { getPrimaryLanguageSubtag } from '@openedx/frontend-base';
import COUNTRIES, { langs as countryLangs } from 'i18n-iso-countries';
import arLocale from 'i18n-iso-countries/langs/ar.json';
import caLocale from 'i18n-iso-countries/langs/ca.json';
import enLocale from 'i18n-iso-countries/langs/en.json';
import esLocale from 'i18n-iso-countries/langs/es.json';
import frLocale from 'i18n-iso-countries/langs/fr.json';
import heLocale from 'i18n-iso-countries/langs/he.json';
import idLocale from 'i18n-iso-countries/langs/id.json';
import koLocale from 'i18n-iso-countries/langs/ko.json';
import plLocale from 'i18n-iso-countries/langs/pl.json';
import ptLocale from 'i18n-iso-countries/langs/pt.json';
import ruLocale from 'i18n-iso-countries/langs/ru.json';
import ukLocale from 'i18n-iso-countries/langs/uk.json';
import zhLocale from 'i18n-iso-countries/langs/zh.json';
COUNTRIES.registerLocale(arLocale);
COUNTRIES.registerLocale(enLocale);
COUNTRIES.registerLocale(esLocale);
COUNTRIES.registerLocale(frLocale);
COUNTRIES.registerLocale(zhLocale);
COUNTRIES.registerLocale(caLocale);
COUNTRIES.registerLocale(heLocale);
COUNTRIES.registerLocale(idLocale);
COUNTRIES.registerLocale(koLocale);
COUNTRIES.registerLocale(plLocale);
COUNTRIES.registerLocale(ptLocale);
COUNTRIES.registerLocale(ruLocale);
COUNTRIES.registerLocale(ukLocale);
/**
* Provides a lookup table of country IDs to country names for the current locale.
*
* @memberof module:I18n
*/
export function getCountryMessages(locale) {
const primaryLanguageSubtag = getPrimaryLanguageSubtag(locale);
const languageCode = countryLangs().includes(primaryLanguageSubtag) ? primaryLanguageSubtag : 'en';
return COUNTRIES.getNames(languageCode);
}
/**
* Provides a list of countries represented as objects of the following shape:
*
* {
* key, // The ID of the country
* name // The localized name of the country
* }
*
* TODO: ARCH-878: The list should be sorted alphabetically in the current locale.
* This is useful for populating dropdowns.
*
* @memberof module:I18n
*/
export function getCountryList(locale) {
const countryMessages = getCountryMessages(locale);
return Object.entries(countryMessages).map(([code, name]) => ({ code, name }));
}

View File

@@ -1,17 +0,0 @@
import {
createInstance,
} from '@optimizely/react-sdk';
const OPTIMIZELY_SDK_KEY = process.env.OPTIMIZELY_FULL_STACK_SDK_KEY;
const getOptimizelyInstance = () => {
if (OPTIMIZELY_SDK_KEY) {
return createInstance({
sdkKey: OPTIMIZELY_SDK_KEY,
});
}
return null;
};
export default getOptimizelyInstance();

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

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

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

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

View File

@@ -1,16 +0,0 @@
import { getLocationRestrictionFilter } from '../algolia';
describe('algoliaUtilsTests', () => {
it('test getLocationRestrictionFilter returns filter if country is passed', () => {
const countryCode = 'PK';
const filter = getLocationRestrictionFilter(countryCode);
const expectedFilter = `NOT blocked_in:"${countryCode}" AND (allowed_in:"null" OR allowed_in:"${countryCode}")`;
expect(filter).toEqual(expectedFilter);
});
it('test getLocationRestrictionFilter returns empty string if country is not passed', () => {
const countryCode = '';
const filter = getLocationRestrictionFilter(countryCode);
const expectedFilter = '';
expect(filter).toEqual(expectedFilter);
});
});

View File

@@ -1,13 +1,7 @@
import { getConfig } from '@edx/frontend-platform';
import Cookies from 'universal-cookie';
import { setCookie } from '../utils';
// Mock getConfig function
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
// Mock Cookies class
jest.mock('universal-cookie');
@@ -17,9 +11,7 @@ describe('setCookie function', () => {
});
it('should set a cookie with default options', () => {
getConfig.mockReturnValue({ SESSION_COOKIE_DOMAIN: 'example.com' });
setCookie('testCookie', 'testValue');
setCookie('testCookie', 'testValue', 'example.com');
expect(Cookies).toHaveBeenCalled();
expect(Cookies).toHaveBeenCalledWith();
@@ -30,10 +22,8 @@ describe('setCookie function', () => {
});
it('should set a cookie with specified expiry', () => {
getConfig.mockReturnValue({ SESSION_COOKIE_DOMAIN: 'example.com' });
const expiry = new Date('2023-12-31');
setCookie('testCookie', 'testValue', expiry);
setCookie('testCookie', 'testValue', 'example.com', expiry);
expect(Cookies).toHaveBeenCalled();
expect(Cookies).toHaveBeenCalledWith();
@@ -45,7 +35,7 @@ describe('setCookie function', () => {
});
it('should not set a cookie if cookieName is undefined', () => {
setCookie(undefined, 'testValue');
setCookie(undefined, 'testValue', 'example.com');
expect(Cookies).not.toHaveBeenCalled();
});

View File

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

View File

@@ -1,10 +1,9 @@
import { getConfig } from '@edx/frontend-platform';
import Cookies from 'universal-cookie';
export default function setCookie(cookieName, cookieValue, cookieExpiry) {
export default function setCookie(cookieName, cookieValue, cookieDomain, cookieExpiry) {
if (cookieName) { // To avoid setting getting exception when setting cookie with undefined names.
const cookies = new Cookies();
const options = { domain: getConfig().SESSION_COOKIE_DOMAIN, path: '/' };
const options = { domain: cookieDomain, path: '/' };
if (cookieExpiry) {
options.expires = cookieExpiry;
}

View File

@@ -40,10 +40,8 @@ export const updatePathWithQueryParams = (path) => {
return path;
}
if (queryParams.indexOf('track=pwreset') > -1) {
queryParams = queryParams.replace(
'?track=pwreset&', '?',
).replace('?track=pwreset', '').replace('&track=pwreset', '').replace('?&', '?');
if (queryParams.includes('track=pwreset')) {
queryParams = queryParams.replace('?track=pwreset&', '?',).replace('?track=pwreset', '').replace('&track=pwreset', '').replace('?&', '?');
}
return `${path}${queryParams}`;
@@ -53,7 +51,7 @@ export const getAllPossibleQueryParams = (locationURl = null) => {
const urlParams = locationURl ? QueryString.parseUrl(locationURl).query : QueryString.parse(window.location.search);
const params = {};
Object.entries(urlParams).forEach(([key, value]) => {
if (AUTH_PARAMS.indexOf(key) > -1) {
if (AUTH_PARAMS.includes(key)) {
params[key] = value;
}
});

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { getSiteConfig } from '@openedx/frontend-base';
import { fireEvent, render } from '@testing-library/react';
import FieldRenderer from '../FieldRenderer';
@@ -43,7 +43,7 @@ describe('FieldRendererTests', () => {
name: 'yob-field',
};
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => { }} />);
expect(container.innerHTML).toEqual('');
});
@@ -84,7 +84,7 @@ describe('FieldRendererTests', () => {
it('should render checkbox field', () => {
const fieldData = {
type: 'checkbox',
label: `I agree that ${getConfig().SITE_NAME} may send me marketing messages.`,
label: `I agree that ${getSiteConfig().siteName} may send me marketing messages.`,
name: 'marketing-emails-opt-in-field',
};
@@ -103,7 +103,7 @@ describe('FieldRendererTests', () => {
type: 'unknown',
};
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => { }} />);
expect(container.innerHTML).toContain('');
});

View File

@@ -1,14 +1,13 @@
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useAppConfig, useIntl } from '@openedx/frontend-base';
import { Alert } from '@openedx/paragon';
import { CheckCircle, Error } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import {
COMPLETE_STATE, FORBIDDEN_STATE, FORM_SUBMISSION_ERROR, INTERNAL_SERVER_ERROR,
} from '../data/constants';
import { PASSWORD_RESET } from '../reset-password/data/constants';
import messages from './messages';
const ForgotPasswordAlert = (props) => {
const { formatMessage } = useIntl();
@@ -34,7 +33,7 @@ const ForgotPasswordAlert = (props) => {
values={{
email: <span className="data-hj-suppress">{email}</span>,
supportLink: (
<Alert.Link href={getConfig().PASSWORD_RESET_SUPPORT_LINK} target="_blank">
<Alert.Link href={useAppConfig().PASSWORD_RESET_SUPPORT_LINK} target="_blank">
{formatMessage(messages['confirmation.support.link'])}
</Alert.Link>
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getAuthenticatedHttpClient, getSiteConfig } from '@openedx/frontend-base';
import formurlencoded from 'form-urlencoded';
const forgotPassword = async (email: string) => {
export async function forgotPassword(email) {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
@@ -10,7 +9,7 @@ const forgotPassword = async (email: string) => {
const { data } = await getAuthenticatedHttpClient()
.post(
`${getConfig().LMS_BASE_URL}/account/password`,
`${getSiteConfig().lmsBaseUrl}/account/password`,
formurlencoded({ email }),
requestConfig,
)
@@ -19,8 +18,4 @@ const forgotPassword = async (email: string) => {
});
return data;
};
export {
forgotPassword,
};
}

View File

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

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