Compare commits
350 Commits
bilalqamar
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
839c4cb59c | ||
|
|
604a785007 | ||
|
|
0d2e41244a | ||
|
|
93bd0f24fe | ||
|
|
0d709d1565 | ||
|
|
642853e001 | ||
|
|
4cb79223b2 | ||
|
|
bbccd79785 | ||
|
|
359c349d50 | ||
|
|
3942378177 | ||
|
|
4be8a2a452 | ||
|
|
f3a353245f | ||
|
|
063bf759d4 | ||
|
|
b54ad18da2 | ||
|
|
3ef7bb2dc7 | ||
|
|
dd3ba31529 | ||
|
|
9b7be2aade | ||
|
|
c3a14286d0 | ||
|
|
6f2e519b3c | ||
|
|
f6a06d7f86 | ||
|
|
b7decc3c73 | ||
|
|
0e2e802f9d | ||
|
|
882545be12 | ||
|
|
34a334fabc | ||
|
|
1fd6e94792 | ||
|
|
e9b0902f49 | ||
|
|
a6ab635ea6 | ||
|
|
a22e0c80ca | ||
|
|
cff8da5a5e | ||
|
|
70f11247d8 | ||
|
|
362a2962af | ||
|
|
80760103a2 | ||
|
|
bee0afd611 | ||
|
|
0418a04fff | ||
|
|
5061391122 | ||
|
|
deb7cef005 | ||
|
|
9bd9d31599 | ||
|
|
aec68e7c18 | ||
|
|
f8868b1e36 | ||
|
|
ffb8a2d434 | ||
|
|
a615cba2fa | ||
|
|
c09d7f4eec | ||
|
|
a67a08a5fb | ||
|
|
43ef53b703 | ||
|
|
1dc39fcce1 | ||
|
|
0a28ef2fb4 | ||
|
|
c2fa1fa2df | ||
|
|
44cf541b06 | ||
|
|
b5b12d0e87 | ||
|
|
b2862eeb42 | ||
|
|
92a333cc66 | ||
|
|
7a9d9bb300 | ||
|
|
fc4eb61ec9 | ||
|
|
b2972929c9 | ||
|
|
bc251a61b2 | ||
|
|
632e962161 | ||
|
|
5d913b720e | ||
|
|
f8a5cb50ed | ||
|
|
b97f777b6f | ||
|
|
b2f7579054 | ||
|
|
24742c1cf5 | ||
|
|
051383e68a | ||
|
|
5443ebd01b | ||
|
|
3aa2422735 | ||
|
|
90a7dfeb15 | ||
|
|
97c7bd744f | ||
|
|
55c5f705fb | ||
|
|
f4e2adc261 | ||
|
|
58ec90aca6 | ||
|
|
76e400f0ad | ||
|
|
5bd6926f2f | ||
|
|
43a584ebd1 | ||
|
|
4cf0a64d81 | ||
|
|
db3d007c51 | ||
|
|
55a930840f | ||
|
|
fad82b52ad | ||
|
|
41450686aa | ||
|
|
2fcda640f5 | ||
|
|
82252f9a7c | ||
|
|
818d0278a5 | ||
|
|
ff3fce99db | ||
|
|
157c302384 | ||
|
|
f2a905d373 | ||
|
|
e984a0b07b | ||
|
|
7150d4562a | ||
|
|
451056866f | ||
|
|
76f0cc54d9 | ||
|
|
fb70f7a1c2 | ||
|
|
b664150b4d | ||
|
|
da5a2e31b6 | ||
|
|
486d0bfd37 | ||
|
|
9332fc113a | ||
|
|
181e837ca4 | ||
|
|
735a9afc3c | ||
|
|
319c48f1c8 | ||
|
|
fbd73bfbfe | ||
|
|
27a63cf406 | ||
|
|
7ea351f6a0 | ||
|
|
61e8c254d7 | ||
|
|
3a08e790c3 | ||
|
|
b4c5171886 | ||
|
|
7b83c416f8 | ||
|
|
a3c261bb13 | ||
|
|
98d03aa29f | ||
|
|
f5d5e2fd02 | ||
|
|
490bf27ed1 | ||
|
|
780acac2fd | ||
|
|
2ea763701d | ||
|
|
e2d9ba5857 | ||
|
|
747d656f0a | ||
|
|
8638ed5cf4 | ||
|
|
ca2e7f554a | ||
|
|
0e94124d74 | ||
|
|
af7edd8a3f | ||
|
|
9323f119c8 | ||
|
|
38a1924c6a | ||
|
|
2d7303009f | ||
|
|
dfcb94a831 | ||
|
|
520dd6ed6b | ||
|
|
1b32dbfa19 | ||
|
|
f7d9bdb5b5 | ||
|
|
063ec80cde | ||
|
|
3cbb134c3a | ||
|
|
059a60d1c8 | ||
|
|
c88d701271 | ||
|
|
33b98b356b | ||
|
|
1f81699af4 | ||
|
|
13aa77fc70 | ||
|
|
66531831b7 | ||
|
|
846d3f0662 | ||
|
|
f78e84ee0a | ||
|
|
2d27da2391 | ||
|
|
78413be34a | ||
|
|
88866a39c1 | ||
|
|
dc9699c033 | ||
|
|
00a0e27062 | ||
|
|
6839afcf3c | ||
|
|
1cd9c58c1a | ||
|
|
5d481a93c7 | ||
|
|
438d1fcfa7 | ||
|
|
bc912ce139 | ||
|
|
ab1c2d5379 | ||
|
|
c109f6e771 | ||
|
|
8976647190 | ||
|
|
cb051a83ad | ||
|
|
1b8aec5709 | ||
|
|
9a68e95fcc | ||
|
|
c90980afb0 | ||
|
|
abb8ae5085 | ||
|
|
8bb7462098 | ||
|
|
b4a5397ba1 | ||
|
|
a43c620dc4 | ||
|
|
93d11b8485 | ||
|
|
68e13d4daf | ||
|
|
f6617935e3 | ||
|
|
5f4591c046 | ||
|
|
ae52a8cb65 | ||
|
|
b8df66ad23 | ||
|
|
923776ab96 | ||
|
|
f170f5e3f0 | ||
|
|
730875ceb2 | ||
|
|
812350d24a | ||
|
|
6879bacb89 | ||
|
|
9b2b0f2019 | ||
|
|
87884f2d91 | ||
|
|
3e20fcae57 | ||
|
|
173896811d | ||
|
|
7af4a08bd9 | ||
|
|
6c12b3b034 | ||
|
|
5304085cd8 | ||
|
|
3cd9ae130c | ||
|
|
28ad2c2cf6 | ||
|
|
3e889df109 | ||
|
|
52c6efc34d | ||
|
|
584a84a99c | ||
|
|
7e4ab1c74c | ||
|
|
13d89cb3a0 | ||
|
|
5a1e2e6c97 | ||
|
|
6f1cf29a60 | ||
|
|
159f1ae30e | ||
|
|
e2e626552f | ||
|
|
308d7c62e4 | ||
|
|
0bc78da55d | ||
|
|
6527caea54 | ||
|
|
a52912e35b | ||
|
|
6479382b90 | ||
|
|
4ce36bb12c | ||
|
|
4cc7723984 | ||
|
|
3c3d359d4e | ||
|
|
cccbf3a9d1 | ||
|
|
4a3fd2ee8e | ||
|
|
48d7cb386a | ||
|
|
bdf9cab869 | ||
|
|
be02dabf40 | ||
|
|
c535fb9d24 | ||
|
|
8ab8d09b97 | ||
|
|
286c70d50f | ||
|
|
8939e5b91f | ||
|
|
bc9f7b3bce | ||
|
|
fd0bcb9e5f | ||
|
|
98e0167ef1 | ||
|
|
8091085f45 | ||
|
|
cd5abd1d9c | ||
|
|
2a88f435b9 | ||
|
|
fe1e9c5629 | ||
|
|
0e363ca724 | ||
|
|
c874638bd1 | ||
|
|
e5c3b1ed41 | ||
|
|
8a27b8cc37 | ||
|
|
046fbeab01 | ||
|
|
27ea509989 | ||
|
|
27f0508e6e | ||
|
|
c53fedf7a1 | ||
|
|
0f1a5e9aef | ||
|
|
6cb4b799b7 | ||
|
|
439b9161b5 | ||
|
|
6ffa45f0c1 | ||
|
|
a03ba3e3b3 | ||
|
|
e2a206caa5 | ||
|
|
3a963da819 | ||
|
|
4a65f0a84c | ||
|
|
9688bd3699 | ||
|
|
c123815a55 | ||
|
|
182e669593 | ||
|
|
65533b8d58 | ||
|
|
45185dba70 | ||
|
|
444c4b4434 | ||
|
|
d629d66bf2 | ||
|
|
9d46d68150 | ||
|
|
a4ed6a362e | ||
|
|
a1a0d3cd96 | ||
|
|
950c401e88 | ||
|
|
ce056c9ad2 | ||
|
|
3bd6e454d0 | ||
|
|
f52129a11e | ||
|
|
ea01050163 | ||
|
|
c1ec9b6e99 | ||
|
|
2c509b00ac | ||
|
|
ef358fe741 | ||
|
|
56e0520d9c | ||
|
|
1f7b7f5c41 | ||
|
|
471fa75155 | ||
|
|
c89d16e529 | ||
|
|
fc02ab820a | ||
|
|
ac23cdcc7a | ||
|
|
02c4c5be29 | ||
|
|
3bd7d61e3a | ||
|
|
32ebc69c0e | ||
|
|
c98c3b16c5 | ||
|
|
287fe3adfe | ||
|
|
d4e7b7b371 | ||
|
|
ad78f068e0 | ||
|
|
d156de2e66 | ||
|
|
99bca1bd9b | ||
|
|
8efb22595c | ||
|
|
73e8913f90 | ||
|
|
3ddaf795f2 | ||
|
|
a18df02d37 | ||
|
|
5a3cd93a09 | ||
|
|
d989eba0e1 | ||
|
|
00f8ee9c85 | ||
|
|
01b14d6d30 | ||
|
|
35dbca7bd1 | ||
|
|
73579ec53d | ||
|
|
90ae870a93 | ||
|
|
e9af062ff1 | ||
|
|
60a6c97e22 | ||
|
|
cd8474465b | ||
|
|
2d37b8b0bf | ||
|
|
05c2caa4d9 | ||
|
|
535a8c543f | ||
|
|
dc90cf9ce5 | ||
|
|
36354761cc | ||
|
|
f53add81f3 | ||
|
|
bca59ebd40 | ||
|
|
dcb5da42b0 | ||
|
|
b346c22b57 | ||
|
|
8be350e35f | ||
|
|
6695fb6f61 | ||
|
|
be5b0bb461 | ||
|
|
5f93278326 | ||
|
|
488644f50d | ||
|
|
56bab26018 | ||
|
|
ca42f3851d | ||
|
|
e4bddc2db0 | ||
|
|
8aeacaa001 | ||
|
|
80435d3e5b | ||
|
|
d6c5415c9a | ||
|
|
0306763eeb | ||
|
|
e4ac1288a9 | ||
|
|
e1f489838c | ||
|
|
9b046146a0 | ||
|
|
e0d605582e | ||
|
|
21b5a01cab | ||
|
|
955ea6485f | ||
|
|
9f8a1af7e3 | ||
|
|
e617a3ba40 | ||
|
|
fb3f962039 | ||
|
|
64da54f17c | ||
|
|
74741a1be6 | ||
|
|
e9aaf7024a | ||
|
|
e3d96385ee | ||
|
|
dc266a613e | ||
|
|
1b5755664c | ||
|
|
02bd8abcd1 | ||
|
|
a6e96f5ed1 | ||
|
|
872aa48675 | ||
|
|
60efe3cbb7 | ||
|
|
167f86c283 | ||
|
|
02d14a6359 | ||
|
|
a6a473ee5c | ||
|
|
8be469680d | ||
|
|
45e84d3f9c | ||
|
|
d6d71587c7 | ||
|
|
6b70692dd4 | ||
|
|
a056f241b5 | ||
|
|
115ce8d7c6 | ||
|
|
6e58c13ef5 | ||
|
|
65e29a021b | ||
|
|
6c91f01226 | ||
|
|
36a9ebef8c | ||
|
|
5c921fb983 | ||
|
|
98699b08ad | ||
|
|
1f3d1d1aee | ||
|
|
fc60d9f7d1 | ||
|
|
ad7099ad38 | ||
|
|
2ea9301c5e | ||
|
|
b9b4492de9 | ||
|
|
d74b5c49d9 | ||
|
|
27545ea4b6 | ||
|
|
db3655c843 | ||
|
|
10a10c8ed9 | ||
|
|
800a5fc6be | ||
|
|
924488c29b | ||
|
|
dae050ecb3 | ||
|
|
3a31cf33e2 | ||
|
|
66a0d5d840 | ||
|
|
26caf857bf | ||
|
|
fa34eae800 | ||
|
|
644b16580d | ||
|
|
479dac8397 | ||
|
|
c097b5b831 | ||
|
|
78722f3e73 | ||
|
|
7f8a270770 | ||
|
|
4ff14c8731 | ||
|
|
a957973105 | ||
|
|
f0b855d87e | ||
|
|
446649735d | ||
|
|
397c237e30 | ||
|
|
e10d6b6384 |
4
.env
4
.env
@@ -23,11 +23,13 @@ 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=''
|
||||
@@ -39,3 +41,5 @@ BANNER_IMAGE_EXTRA_SMALL=''
|
||||
# ***** Miscellaneous *****
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -19,6 +19,9 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME='Your Platform Name Here'
|
||||
INFO_EMAIL='info@example.com'
|
||||
# ***** Features *****
|
||||
ENABLE_DYNAMIC_REGISTRATION_FIELDS='true'
|
||||
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN='true'
|
||||
# ***** Cookies *****
|
||||
SESSION_COOKIE_DOMAIN='localhost'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
@@ -38,3 +41,5 @@ APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
ZENDESK_KEY=''
|
||||
ZENDESK_LOGO_URL=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -18,3 +18,4 @@ SEGMENT_KEY=''
|
||||
SITE_NAME='Your Platform Name Here'
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
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
|
||||
// @edx/frontend-build from v3 to v5:
|
||||
// @openedx/frontend-build from v3 to v5:
|
||||
// - TypeError: Cannot read property 'range' of null
|
||||
indent: [
|
||||
'error',
|
||||
|
||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @openedx/2U-infinity
|
||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -25,5 +25,5 @@ Include a link to the sandbox for design changes or screenshot for before and af
|
||||
|
||||
#### Post-merge Checklist
|
||||
|
||||
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/vanguards** to do it.
|
||||
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/2u-infinity** to do it.
|
||||
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
autoupdate:
|
||||
name: autoupdate
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker://chinthakagodawita/autoupdate-action:v1
|
||||
env:
|
||||
|
||||
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -10,17 +10,15 @@ on:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
@@ -41,4 +39,7 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
- name: Run Code Coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ temp/babel-plugin-react-intl
|
||||
*~
|
||||
/temp
|
||||
/.vscode
|
||||
src/i18n/messages
|
||||
@@ -1,9 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-authn]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# The following users are the owners of all frontend-app-authn files
|
||||
* @openedx/vanguards
|
||||
26
Makefile
26
Makefile
@@ -1,6 +1,3 @@
|
||||
export TRANSIFEX_RESOURCE = frontend-app-authn
|
||||
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
@@ -32,33 +29,16 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Experimental: OEP-58 Pulls translations using atlas
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull --filter=$(transifex_langs) \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/frontend-app-authn/src/i18n/messages:frontend-app-authn
|
||||
|
||||
$(intl_imports) paragon frontend-app-authn
|
||||
endif
|
||||
$(intl_imports) paragon frontend-platform frontend-app-authn
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
|
||||
121
README.rst
121
README.rst
@@ -1,12 +1,12 @@
|
||||
##################
|
||||
frontend-app-authn
|
||||
##################
|
||||
|
||||
|Build Status| |ci-badge| |Codecov| |semantic-release|
|
||||
|
||||
frontend-app-authn
|
||||
====================
|
||||
|
||||
Please tag **@openedx/vanguards** on any PRs or issues. Thanks!
|
||||
|
||||
Introduction
|
||||
------------
|
||||
********
|
||||
Purpose
|
||||
********
|
||||
|
||||
This is a micro-frontend application responsible for the login, registration and password reset functionality.
|
||||
|
||||
@@ -22,33 +22,57 @@ This is a micro-frontend application responsible for the login, registration and
|
||||
|
||||
- Progressive profiling page
|
||||
|
||||
***************
|
||||
Getting Started
|
||||
***************
|
||||
|
||||
Installation
|
||||
------------
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
This MFE is bundled with `Devstack <https://github.com/openedx/devstack>`_, see the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ section for setup instructions.
|
||||
`Tutor`_ is currently recommended as a development environment for your new MFE. Please refer to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
1. Install Devstack using the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ instructions.
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
|
||||
|
||||
2. Start up LMS, if it's not already started.
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
4. Within this project (frontend-app-authn), install requirements and start the development server:
|
||||
1. Clone your new repo:
|
||||
|
||||
.. code-block::
|
||||
.. code-block:: bash
|
||||
|
||||
npm install
|
||||
npm start # The server will run on port 1999
|
||||
git clone https://github.com/edx/frontend-app-authn.git
|
||||
|
||||
5. Once the dev server is up, visit http://localhost:1999 to access the MFE
|
||||
2. Use the version of Node specified in the ``.nvmrc`` file.
|
||||
|
||||
.. image:: ./docs/images/frontend-app-authn-localhost-preview.png
|
||||
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
|
||||
---------------------------------
|
||||
=================================
|
||||
|
||||
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
|
||||
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`__.
|
||||
|
||||
The authentication micro-frontend also requires the following additional variable:
|
||||
|
||||
@@ -106,15 +130,19 @@ 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.
|
||||
- ``authn`` | ``''``
|
||||
|
||||
* - ``ENABLE_IMAGE_LAYOUT``
|
||||
- Enables the image layout feature within the authn. When set to True, this feature allows the inclusion of images in the base container layout. For more details on configuring this feature, please refer to the `Modifying base container <docs/how_tos/modifying_base_container.rst>`_.
|
||||
- ``true`` | ``''`` (empty strings are falsy)
|
||||
|
||||
|
||||
edX-specific Environment Variables
|
||||
**********************************
|
||||
==================================
|
||||
|
||||
Furthermore, there are several edX-specific environment variables that enable integrations with closed-source services private to the edX organization, and might be unsupported in Open edX.
|
||||
|
||||
@@ -132,15 +160,16 @@ Furthermore, there are several edX-specific environment variables that enable in
|
||||
|
||||
* - ``SHOW_CONFIGURABLE_EDX_FIELDS``
|
||||
- For edX, country and honor code fields are required by default. This flag enables edX specific required fields.
|
||||
- ``true`` | ``''`` (empty strings are falsy)
|
||||
- ``true`` | ``''`` (empty strings are falsy)
|
||||
|
||||
For more information see the document: `Micro-frontend applications in Open
|
||||
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
|
||||
edX <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`__.
|
||||
|
||||
How To Contribute
|
||||
------------
|
||||
=================
|
||||
|
||||
Contributions are very welcome, and strongly encouraged! We've
|
||||
put together `some documentation that describes our contribution process <https://edx.readthedocs.org/projects/edx-developer-guide/en/latest/process/index.html>`_.
|
||||
put together `some documentation that describes our contribution process <https://docs.openedx.org/en/latest/developers/references/developer_guide/process/index.html>`_.
|
||||
|
||||
Even though they were written with edx-platform in mind, the guidelines should be followed for Open edX code in general.
|
||||
|
||||
@@ -149,34 +178,58 @@ can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes and security fixes.
|
||||
|
||||
Open edX Code of Conduct
|
||||
------------------------
|
||||
Getting Help
|
||||
============
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||
channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-authn/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Slack invitation: https://openedx.org/slack
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
The Open edX Code of Conduct
|
||||
============================
|
||||
All community members are expected to follow the `Open edX Code of Conduct <https://openedx.org/code-of-conduct/>`_.
|
||||
|
||||
People
|
||||
------
|
||||
======
|
||||
The assigned maintainers for this component and other project details may be
|
||||
found in `Backstage <https://backstage.openedx.org/catalog/default/group/vanguards>`_. Backstage pulls this data from the ``catalog-info.yaml``
|
||||
found in `Backstage <https://backstage.openedx.org/catalog/default/group/2u-infinity>`_. Backstage pulls this data from the ``catalog-info.yaml``
|
||||
file in this repo.
|
||||
|
||||
Reporting Security Issues
|
||||
-------------------------
|
||||
=========================
|
||||
|
||||
Please do not report security issues in public. Please email security@edx.org.
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
|
||||
Known Issues
|
||||
------------
|
||||
============
|
||||
|
||||
None
|
||||
|
||||
License
|
||||
-------
|
||||
=======
|
||||
|
||||
The code in this repository is licensed under the GNU Affero General Public License v3.0, unless
|
||||
otherwise noted.
|
||||
|
||||
Please see `LICENSE <https://github.com/openedx/frontend-app-authn/blob/master/LICENSE>`_ for details.
|
||||
|
||||
|
||||
==============================
|
||||
|
||||
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-app-authn.svg?branch=master
|
||||
|
||||
@@ -12,7 +12,8 @@ metadata:
|
||||
icon: 'Article'
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:vanguards
|
||||
owner: group:2u-infinity
|
||||
type: 'service'
|
||||
lifecycle: 'production'
|
||||
|
||||
@@ -3,7 +3,7 @@ Enable Social Auth Locally
|
||||
|
||||
Please follow the steps below to enable social auth (SSO) locally.
|
||||
|
||||
1. Follow `Enabling Third Party Authentication <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/tpa/index.html>`_ for backend configuration.
|
||||
1. Follow `Enabling Third Party Authentication <https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/tpa/index.html>`_ for backend configuration.
|
||||
|
||||
2. Authn has a component for rendering Social Auth providers at frontend which goes through each provider.
|
||||
|
||||
|
||||
39
docs/how_tos/modifying_base_container.rst
Normal file
39
docs/how_tos/modifying_base_container.rst
Normal file
@@ -0,0 +1,39 @@
|
||||
========================================
|
||||
Modifying the Base Container in Authn
|
||||
========================================
|
||||
|
||||
The base container in Authn serves as the fundamental layout structure for rendering different components based on configurations. This document outlines the process for modifying the base container to accommodate changes or customize layouts as needed.
|
||||
|
||||
Understanding Base Container Versions
|
||||
--------------------------------------
|
||||
|
||||
The base container supports two main versions:
|
||||
|
||||
- **Default Layout:** The default layout is the standard layout used when specific configurations do not dictate otherwise.
|
||||
.. image:: ../images/default_layout.png
|
||||
- **Image Layout:** The image layout is an alternative layout option that can be enabled based on configurations.
|
||||
.. image:: ../images/image_layout.png
|
||||
|
||||
Enabling the Image Layout
|
||||
---------------------------
|
||||
|
||||
To activate the image layout feature, navigate to your .env file and update the configurations:
|
||||
|
||||
**Update Configuration**
|
||||
|
||||
Locate the ``ENABLE_IMAGE_LAYOUT`` parameter and set its value to ``true``. Additionally, ensure that the Image configuration settings are provided. Your overall configurations should resemble the following:
|
||||
|
||||
|
||||
.. code-block::
|
||||
|
||||
# ***** Image Layout Configuration *****
|
||||
ENABLE_IMAGE_LAYOUT = True # Set to True to enable image layout feature
|
||||
|
||||
# ***** Base Container Images *****
|
||||
BANNER_IMAGE_LARGE='' # Path to the large banner image
|
||||
BANNER_IMAGE_MEDIUM='' # Path to the medium-sized banner image
|
||||
BANNER_IMAGE_SMALL='' # Path to the small banner image
|
||||
BANNER_IMAGE_EXTRA_SMALL='' # Path to the extra-small banner image
|
||||
|
||||
|
||||
This allows for the customization and adaptation of the base container layout according to specific requirements.
|
||||
BIN
docs/images/default_layout.png
Normal file
BIN
docs/images/default_layout.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 252 KiB |
BIN
docs/images/image_layout.png
Normal file
BIN
docs/images/image_layout.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -1,4 +1,4 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFiles: [
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||
|
||||
nick: Authn MFE
|
||||
oeps: {}
|
||||
owner: openedx/vanguards
|
||||
openedx-release:
|
||||
ref: master
|
||||
28895
package-lock.json
generated
28895
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
72
package.json
72
package.json
@@ -13,15 +13,12 @@
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"dev": "PUBLIC_PATH=/authn/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-authn#readme",
|
||||
@@ -33,54 +30,47 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-platform": "^5.5.4",
|
||||
"@edx/paragon": "20.46.2",
|
||||
"@fortawesome/fontawesome-svg-core": "6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.4.2",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@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",
|
||||
"@redux-devtools/extension": "3.2.5",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@tanstack/react-query": "^5.90.19",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"algoliasearch": "^4.14.3",
|
||||
"algoliasearch-helper": "^3.14.0",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.32.0",
|
||||
"algoliasearch-helper": "^3.26.0",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.43.0",
|
||||
"fastest-levenshtein": "1.0.16",
|
||||
"form-urlencoded": "6.1.0",
|
||||
"form-urlencoded": "6.1.6",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "7.1.3",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-loading-skeleton": "3.3.1",
|
||||
"react-redux": "7.2.9",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"react-responsive": "8.2.0",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-router": "6.30.3",
|
||||
"react-router-dom": "6.30.3",
|
||||
"react-zendesk": "^0.1.13",
|
||||
"redux": "4.2.0",
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-mock-store": "1.5.4",
|
||||
"redux-saga": "1.2.3",
|
||||
"redux-thunk": "2.4.2",
|
||||
"regenerator-runtime": "0.14.0",
|
||||
"reselect": "4.1.8",
|
||||
"universal-cookie": "4.0.4"
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"universal-cookie": "7.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@edx/frontend-build": "13.0.1",
|
||||
"@edx/reactifex": "1.1.0",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.8.0",
|
||||
"babel-plugin-formatjs": "10.5.3",
|
||||
"enzyme": "3.11.0",
|
||||
"eslint-plugin-import": "2.28.0",
|
||||
"@edx/typescript-config": "^1.1.0",
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"babel-plugin-formatjs": "11.2.4",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"glob": "7.2.3",
|
||||
"history": "5.3.0",
|
||||
"husky": "7.0.4",
|
||||
"jest": "29.6.2",
|
||||
"react-test-renderer": "^17.0.2"
|
||||
"jest": "30.3.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"ts-jest": "^29.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Authn | <%= process.env.SITE_NAME %></title>
|
||||
<title><%= (process.env.SITE_NAME && process.env.SITE_NAME != 'null') ? 'Authentication | ' + process.env.SITE_NAME : 'Authentication' %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.6/iframeResizer.contentWindow.min.js"
|
||||
integrity="sha512-R7Piufj0/o6jG9ZKrAvS2dblFr2kkuG4XVQwStX+/4P+KwOLUXn2DXy0l1AJDxxqGhkM/FJllZHG2PKOAheYzg=="
|
||||
<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>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
EmbeddedRegistrationRoute, NotFoundPage, registerIcons, UnAuthOnlyRoute, Zendesk,
|
||||
} from './common-components';
|
||||
import configureStore from './data/configureStore';
|
||||
import {
|
||||
AUTHN_PROGRESSIVE_PROFILING,
|
||||
LOGIN_PAGE,
|
||||
@@ -31,33 +29,48 @@ import './index.scss';
|
||||
|
||||
registerIcons();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const MainApp = () => (
|
||||
<AppProvider store={configureStore()}>
|
||||
<Helmet>
|
||||
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
||||
</Helmet>
|
||||
{getConfig().ZENDESK_KEY && <Zendesk />}
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate replace to={updatePathWithQueryParams(REGISTER_PAGE)} />} />
|
||||
<Route
|
||||
path={REGISTER_EMBEDDED_PAGE}
|
||||
element={<EmbeddedRegistrationRoute><RegistrationPage /></EmbeddedRegistrationRoute>}
|
||||
/>
|
||||
<Route
|
||||
path={LOGIN_PAGE}
|
||||
element={
|
||||
<UnAuthOnlyRoute><Logistration selectedPage={LOGIN_PAGE} /></UnAuthOnlyRoute>
|
||||
}
|
||||
/>
|
||||
<Route path={REGISTER_PAGE} element={<UnAuthOnlyRoute><Logistration /></UnAuthOnlyRoute>} />
|
||||
<Route path={RESET_PAGE} element={<UnAuthOnlyRoute><ForgotPasswordPage /></UnAuthOnlyRoute>} />
|
||||
<Route path={PASSWORD_RESET_CONFIRM} element={<ResetPasswordPage />} />
|
||||
<Route path={AUTHN_PROGRESSIVE_PROFILING} element={<ProgressiveProfiling />} />
|
||||
<Route path={RECOMMENDATIONS} element={<RecommendationsPage />} />
|
||||
<Route path={PAGE_NOT_FOUND} element={<NotFoundPage />} />
|
||||
<Route path="*" element={<Navigate replace to={PAGE_NOT_FOUND} />} />
|
||||
</Routes>
|
||||
</AppProvider>
|
||||
<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,50 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { DefaultLargeLayout, DefaultMediumLayout, DefaultSmallLayout } from './index';
|
||||
|
||||
describe('Default Layout tests', () => {
|
||||
it('should display the form passed as a child in SmallScreenLayout', () => {
|
||||
const smallScreen = mount(
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<div>
|
||||
<DefaultSmallLayout />
|
||||
<form>
|
||||
<form aria-label="form">
|
||||
<input type="text" />
|
||||
</form>
|
||||
</div>
|
||||
</IntlProvider>,
|
||||
);
|
||||
expect(smallScreen.find('form').exists()).toEqual(true);
|
||||
expect(screen.getByRole('form')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should display the form passed as a child in MediumScreenLayout', () => {
|
||||
const mediumScreen = mount(
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<div>
|
||||
<DefaultMediumLayout />
|
||||
<form>
|
||||
<form aria-label="form">
|
||||
<input type="text" />
|
||||
</form>
|
||||
</div>
|
||||
</IntlProvider>,
|
||||
);
|
||||
expect(mediumScreen.find('form').exists()).toEqual(true);
|
||||
expect(screen.getByRole('form')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should display the form passed as a child in LargeScreenLayout', () => {
|
||||
const largeScreen = mount(
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<div>
|
||||
<DefaultLargeLayout />
|
||||
<form>
|
||||
<form aria-label="form">
|
||||
<input type="text" />
|
||||
</form>
|
||||
</div>
|
||||
</IntlProvider>,
|
||||
);
|
||||
expect(largeScreen.find('form').exists()).toEqual(true);
|
||||
expect(screen.getByRole('form')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -23,13 +21,15 @@ const MediumLayout = () => {
|
||||
<div>
|
||||
<h1
|
||||
className={classNames(
|
||||
'display-1 text-white mt-5 mb-5 mr-2',
|
||||
'display-1 text-white mt-5 mb-5 mr-2 main-heading',
|
||||
{ 'ml-4.5': getConfig().SITE_NAME !== 'edX' },
|
||||
)}
|
||||
>
|
||||
<span className="mr-2">{formatMessage(messages['start.learning'])}</span>
|
||||
<span className="text-accent-a d-inline-block">
|
||||
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
|
||||
<span>
|
||||
{formatMessage(messages['start.learning'])}{' '}
|
||||
<span className="text-accent-a d-inline-block">
|
||||
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -17,17 +15,18 @@ const SmallLayout = () => {
|
||||
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
|
||||
<Image className="logo-small" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
|
||||
</Hyperlink>
|
||||
<div className="d-flex align-items-center mb-3 mt-3 mr-3">
|
||||
<div className="d-flex align-items-center m-3.5">
|
||||
<div className={classNames({ 'small-yellow-line mr-n2.5': getConfig().SITE_NAME === 'edX' })} />
|
||||
<h1
|
||||
className={classNames(
|
||||
'text-white mt-3.5 mb-3.5',
|
||||
{ 'ml-4.5': getConfig().SITE_NAME !== 'edX' },
|
||||
)}
|
||||
>
|
||||
<span className="mr-1">{formatMessage(messages['start.learning'])}</span>
|
||||
<span className="text-accent-a d-inline-block">
|
||||
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
|
||||
<span>
|
||||
{formatMessage(messages['start.learning'])}{' '}
|
||||
<span className="text-accent-a d-inline-block">
|
||||
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
|
||||
import './index.scss';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
|
||||
import './index.scss';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const LargeLayout = ({ username }) => {
|
||||
const LargeLayout = ({ fullName }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
@@ -20,7 +18,7 @@ const LargeLayout = ({ username }) => {
|
||||
<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, username })}
|
||||
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
|
||||
</h1>
|
||||
<h2 className="complete-your-profile">
|
||||
{formatMessage(messages['complete.your.profile.1'])}
|
||||
@@ -43,7 +41,7 @@ const LargeLayout = ({ username }) => {
|
||||
};
|
||||
|
||||
LargeLayout.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
fullName: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default LargeLayout;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const MediumLayout = ({ username }) => {
|
||||
const MediumLayout = ({ fullName }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
@@ -22,7 +20,7 @@ const MediumLayout = ({ username }) => {
|
||||
<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, username })}
|
||||
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
|
||||
</h1>
|
||||
<h2 className="display-1">
|
||||
{formatMessage(messages['complete.your.profile.1'])}
|
||||
@@ -46,7 +44,7 @@ const MediumLayout = ({ username }) => {
|
||||
};
|
||||
|
||||
MediumLayout.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
fullName: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default MediumLayout;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
import { Hyperlink, Image } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const SmallLayout = ({ username }) => {
|
||||
const SmallLayout = ({ fullName }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
@@ -16,11 +14,11 @@ const SmallLayout = ({ username }) => {
|
||||
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
|
||||
<Image className="logo-small" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
|
||||
</Hyperlink>
|
||||
<div className="d-flex align-items-center mb-3 mt-3 mr-3">
|
||||
<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, username })}
|
||||
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
|
||||
</h1>
|
||||
<h2 className="h1">
|
||||
{formatMessage(messages['complete.your.profile.1'])}
|
||||
@@ -35,7 +33,7 @@ const SmallLayout = ({ username }) => {
|
||||
};
|
||||
|
||||
SmallLayout.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
fullName: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default SmallLayout;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
'welcome.to.platform': {
|
||||
id: 'welcome.to.platform',
|
||||
defaultMessage: 'Welcome to {siteName}, {username}!',
|
||||
defaultMessage: 'Welcome to {siteName}, {fullName}!',
|
||||
description: 'Welcome message that appears on progressive profile page',
|
||||
},
|
||||
'complete.your.profile.1': {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
const IMAGE_LAYOUT = 'image-layout';
|
||||
const DEFAULT_LAYOUT = 'default-layout';
|
||||
|
||||
export { DEFAULT_LAYOUT, IMAGE_LAYOUT };
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { breakpoints } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import MediaQuery from 'react-responsive';
|
||||
@@ -10,40 +9,24 @@ import {
|
||||
ImageExtraSmallLayout, ImageLargeLayout, ImageMediumLayout, ImageSmallLayout,
|
||||
} from './components/image-layout';
|
||||
import { AuthLargeLayout, AuthMediumLayout, AuthSmallLayout } from './components/welcome-page-layout';
|
||||
import { DEFAULT_LAYOUT, IMAGE_LAYOUT } from './data/constants';
|
||||
|
||||
const BaseContainer = ({ children, showWelcomeBanner, username }) => {
|
||||
const [baseContainerVersion, setBaseContainerVersion] = useState(DEFAULT_LAYOUT);
|
||||
const BaseContainer = ({ children, showWelcomeBanner, fullName }) => {
|
||||
const enableImageLayout = getConfig().ENABLE_IMAGE_LAYOUT;
|
||||
|
||||
useEffect(() => {
|
||||
const initRebrandExperiment = () => {
|
||||
if (window.experiments?.rebrandExperiment) {
|
||||
setBaseContainerVersion(window.experiments?.rebrandExperiment?.variation);
|
||||
} else {
|
||||
window.experiments = window.experiments || {};
|
||||
window.experiments.rebrandExperiment = {};
|
||||
window.experiments.rebrandExperiment.handleLoaded = () => {
|
||||
setBaseContainerVersion(window.experiments?.rebrandExperiment?.variation);
|
||||
};
|
||||
}
|
||||
};
|
||||
initRebrandExperiment();
|
||||
}, []);
|
||||
|
||||
if (baseContainerVersion === IMAGE_LAYOUT) {
|
||||
if (enableImageLayout) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<MediaQuery maxWidth={breakpoints.extraSmall.maxWidth - 1}>
|
||||
{showWelcomeBanner ? <AuthSmallLayout username={username} /> : <ImageExtraSmallLayout />}
|
||||
{showWelcomeBanner ? <AuthSmallLayout fullName={fullName} /> : <ImageExtraSmallLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.small.minWidth} maxWidth={breakpoints.small.maxWidth - 1}>
|
||||
{showWelcomeBanner ? <AuthSmallLayout username={username} /> : <ImageSmallLayout />}
|
||||
{showWelcomeBanner ? <AuthSmallLayout fullName={fullName} /> : <ImageSmallLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
|
||||
{showWelcomeBanner ? <AuthMediumLayout username={username} /> : <ImageMediumLayout />}
|
||||
{showWelcomeBanner ? <AuthMediumLayout fullName={fullName} /> : <ImageMediumLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
|
||||
{showWelcomeBanner ? <AuthLargeLayout username={username} /> : <ImageLargeLayout />}
|
||||
{showWelcomeBanner ? <AuthLargeLayout fullName={fullName} /> : <ImageLargeLayout />}
|
||||
</MediaQuery>
|
||||
<div className={classNames('content', { 'align-items-center mt-0': showWelcomeBanner })}>
|
||||
{children}
|
||||
@@ -57,13 +40,13 @@ const BaseContainer = ({ children, showWelcomeBanner, username }) => {
|
||||
<div className="col-md-12 extra-large-screen-top-stripe" />
|
||||
<div className="layout">
|
||||
<MediaQuery maxWidth={breakpoints.small.maxWidth - 1}>
|
||||
{showWelcomeBanner ? <AuthSmallLayout username={username} /> : <DefaultSmallLayout />}
|
||||
{showWelcomeBanner ? <AuthSmallLayout fullName={fullName} /> : <DefaultSmallLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
|
||||
{showWelcomeBanner ? <AuthMediumLayout username={username} /> : <DefaultMediumLayout />}
|
||||
{showWelcomeBanner ? <AuthMediumLayout fullName={fullName} /> : <DefaultMediumLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
|
||||
{showWelcomeBanner ? <AuthLargeLayout username={username} /> : <DefaultLargeLayout />}
|
||||
{showWelcomeBanner ? <AuthLargeLayout fullName={fullName} /> : <DefaultLargeLayout />}
|
||||
</MediaQuery>
|
||||
<div className={classNames('content', { 'align-items-center mt-0': showWelcomeBanner })}>
|
||||
{children}
|
||||
@@ -75,13 +58,13 @@ const BaseContainer = ({ children, showWelcomeBanner, username }) => {
|
||||
|
||||
BaseContainer.defaultProps = {
|
||||
showWelcomeBanner: false,
|
||||
username: null,
|
||||
fullName: null,
|
||||
};
|
||||
|
||||
BaseContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
showWelcomeBanner: PropTypes.bool,
|
||||
username: PropTypes.string,
|
||||
fullName: PropTypes.string,
|
||||
};
|
||||
|
||||
export default BaseContainer;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { mergeConfig } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
|
||||
import BaseContainer from '../index';
|
||||
@@ -12,32 +11,34 @@ const LargeScreen = {
|
||||
};
|
||||
|
||||
describe('Base component tests', () => {
|
||||
it('should should default layout', () => {
|
||||
const baseContainer = mount(
|
||||
it('should show default layout', () => {
|
||||
const { container } = render(
|
||||
<IntlProvider locale="en">
|
||||
<BaseContainer />
|
||||
<BaseContainer>
|
||||
<div>Test Content</div>
|
||||
</BaseContainer>
|
||||
</IntlProvider>,
|
||||
LargeScreen,
|
||||
);
|
||||
|
||||
expect(baseContainer.find('.banner__image').exists()).toBeFalsy();
|
||||
expect(baseContainer.find('.large-screen-svg-primary').exists()).toBeTruthy();
|
||||
expect(container.querySelector('.banner__image')).toBeNull();
|
||||
expect(container.querySelector('.large-screen-svg-primary')).toBeDefined();
|
||||
});
|
||||
|
||||
it('[experiment] should show image layout for treatment group', () => {
|
||||
window.experiments = {
|
||||
rebrandExperiment: {
|
||||
variation: 'image-layout',
|
||||
},
|
||||
};
|
||||
it('renders Image layout when ENABLE_IMAGE_LAYOUT configuration is enabled', () => {
|
||||
mergeConfig({
|
||||
ENABLE_IMAGE_LAYOUT: true,
|
||||
});
|
||||
|
||||
const baseContainer = mount(
|
||||
const { container } = render(
|
||||
<IntlProvider locale="en">
|
||||
<BaseContainer />
|
||||
<BaseContainer showWelcomeBanner={false}>
|
||||
<div>Test Content</div>
|
||||
</BaseContainer>
|
||||
</IntlProvider>,
|
||||
LargeScreen,
|
||||
);
|
||||
|
||||
expect(baseContainer.find('.banner__image').exists()).toBeTruthy();
|
||||
expect(container.querySelector('.banner__image')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
Button, Form,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import { Login } from '@edx/paragon/icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
} from '@openedx/paragon';
|
||||
import { Login } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -19,7 +17,8 @@ import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
|
||||
const EnterpriseSSO = (props) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const tpaProvider = props.provider;
|
||||
const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false;
|
||||
const hideRegistrationLink = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false
|
||||
|| getConfig().SHOW_REGISTRATION_LINKS === false;
|
||||
|
||||
const handleSubmit = (e, url) => {
|
||||
e.preventDefault();
|
||||
@@ -74,7 +73,7 @@ const EnterpriseSSO = (props) => {
|
||||
className="w-100"
|
||||
onClick={(e) => handleClick(e)}
|
||||
>
|
||||
{disablePublicAccountCreation
|
||||
{hideRegistrationLink
|
||||
? formatMessage(messages['enterprisetpa.login.button.text.public.account.creation.disabled'])
|
||||
: formatMessage(messages['enterprisetpa.login.button.text'])}
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Form, TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
} from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const FormGroup = (props) => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Hyperlink, Icon } from '@edx/paragon';
|
||||
import { Institution } from '@edx/paragon/icons';
|
||||
import { Button, Hyperlink, Icon } from '@openedx/paragon';
|
||||
import { Institution } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const NotFoundPage = () => (
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Form, Icon, IconButton, OverlayTrigger, Tooltip, useToggle,
|
||||
} from '@edx/paragon';
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Check, Remove, Visibility, VisibilityOff,
|
||||
} from '@edx/paragon/icons';
|
||||
} from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
import { LETTER_REGEX, NUMBER_REGEX } from '../data/constants';
|
||||
import { clearRegistrationBackendError, fetchRealtimeValidations } from '../register/data/actions';
|
||||
import { useRegisterContext } from '../register/components/RegisterContext';
|
||||
import { useFieldValidations } from '../register/data/apiHook';
|
||||
import { validatePasswordField } from '../register/data/utils';
|
||||
|
||||
const PasswordField = (props) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const validationApiRateLimited = useSelector(state => state.register.validationApiRateLimited);
|
||||
const [isPasswordHidden, setHiddenTrue, setHiddenFalse] = useToggle(true);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
const {
|
||||
setValidationsSuccess,
|
||||
setValidationsFailure,
|
||||
validationApiRateLimited,
|
||||
clearRegistrationBackendError,
|
||||
} = useRegisterContext();
|
||||
|
||||
const fieldValidationsMutation = useFieldValidations({
|
||||
onSuccess: (data) => {
|
||||
setValidationsSuccess(data);
|
||||
},
|
||||
onError: () => {
|
||||
setValidationsFailure();
|
||||
},
|
||||
});
|
||||
|
||||
const handleBlur = (e) => {
|
||||
const { name, value } = e.target;
|
||||
if (name === props.name && e.relatedTarget?.name === 'passwordIcon') {
|
||||
@@ -50,7 +63,7 @@ const PasswordField = (props) => {
|
||||
if (fieldError) {
|
||||
props.handleErrorChange('password', fieldError);
|
||||
} else if (!validationApiRateLimited) {
|
||||
dispatch(fetchRealtimeValidations({ password: passwordValue }));
|
||||
fieldValidationsMutation.mutate({ password: passwordValue });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -65,7 +78,7 @@ const PasswordField = (props) => {
|
||||
}
|
||||
if (props.handleErrorChange) {
|
||||
props.handleErrorChange('password', '');
|
||||
dispatch(clearRegistrationBackendError('password'));
|
||||
clearRegistrationBackendError('password');
|
||||
}
|
||||
setTimeout(() => setShowTooltip(props.showRequirements && true), 150);
|
||||
};
|
||||
@@ -138,7 +151,7 @@ const PasswordField = (props) => {
|
||||
{props.errorMessage !== '' && (
|
||||
<Form.Control.Feedback key="error" className="form-text-size" hasIcon={false} feedback-for={props.name} type="invalid">
|
||||
{props.errorMessage}
|
||||
<span className="sr-only">{formatMessage(messages['password.sr.only.helping.text'])}</span>
|
||||
{props.showScreenReaderText && <span className="sr-only">{formatMessage(messages['password.sr.only.helping.text'])}</span>}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
@@ -153,6 +166,7 @@ PasswordField.defaultProps = {
|
||||
handleChange: () => {},
|
||||
handleErrorChange: null,
|
||||
showRequirements: true,
|
||||
showScreenReaderText: true,
|
||||
autoComplete: null,
|
||||
};
|
||||
|
||||
@@ -168,6 +182,7 @@ PasswordField.propTypes = {
|
||||
showRequirements: PropTypes.bool,
|
||||
value: PropTypes.string.isRequired,
|
||||
autoComplete: PropTypes.string,
|
||||
showScreenReaderText: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default PasswordField;
|
||||
|
||||
@@ -22,7 +22,6 @@ const RedirectLogistration = (props) => {
|
||||
host,
|
||||
} = props;
|
||||
let finalRedirectUrl = '';
|
||||
|
||||
if (success) {
|
||||
// If we're in a third party auth pipeline, we must complete the pipeline
|
||||
// once user has successfully logged in. Otherwise, redirect to the specified redirect url.
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { Login } from '@edx/paragon/icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Login } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Hyperlink, Icon,
|
||||
} from '@openedx/paragon';
|
||||
import { Institution } from '@openedx/paragon/icons';
|
||||
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 {
|
||||
RenderInstitutionButton,
|
||||
SocialAuthProviders,
|
||||
} from '../../common-components';
|
||||
import {
|
||||
PENDING_STATE, REGISTER_PAGE,
|
||||
} from '../../data/constants';
|
||||
import messages from '../messages';
|
||||
} from './index';
|
||||
|
||||
/**
|
||||
* This component renders the Single sign-on (SSO) buttons for the providers passed.
|
||||
@@ -20,33 +24,60 @@ import messages from '../messages';
|
||||
const ThirdPartyAuth = (props) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
providers, secondaryProviders, currentProvider, handleInstitutionLogin, thirdPartyAuthApiStatus,
|
||||
providers,
|
||||
secondaryProviders,
|
||||
currentProvider,
|
||||
handleInstitutionLogin,
|
||||
thirdPartyAuthApiStatus,
|
||||
isLoginPage,
|
||||
} = 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 isThirdPartyAuthActive = isSocialAuthActive || (isEnterpriseLoginDisabled && isInstitutionAuthActive);
|
||||
|
||||
return (
|
||||
<>
|
||||
{((isEnterpriseLoginDisabled && isInstitutionAuthActive) || isSocialAuthActive) && (
|
||||
<div className="mt-4 mb-3 h4">
|
||||
{formatMessage(messages['registration.other.options.heading'])}
|
||||
{isLoginPage
|
||||
? formatMessage(messages['login.other.options.heading'])
|
||||
: formatMessage(messages['registration.other.options.heading'])}
|
||||
</div>
|
||||
)}
|
||||
{(isLoginPage && !isEnterpriseLoginDisabled && isSocialAuthActive) && (
|
||||
<Hyperlink
|
||||
className={classNames(
|
||||
'btn btn-link btn-sm text-body p-0',
|
||||
{ 'mb-0': thirdPartyAuthApiStatus === PENDING_STATE },
|
||||
{ 'mb-4': thirdPartyAuthApiStatus !== PENDING_STATE },
|
||||
)}
|
||||
destination={enterpriseLoginURL}
|
||||
>
|
||||
<Icon src={Institution} className="institute-icon" />
|
||||
{formatMessage(messages['enterprise.login.btn.text'])}
|
||||
</Hyperlink>
|
||||
)}
|
||||
|
||||
{thirdPartyAuthApiStatus === PENDING_STATE ? (
|
||||
<Skeleton className="tpa-skeleton" height={36} count={2} />
|
||||
{thirdPartyAuthApiStatus === PENDING_STATE && isThirdPartyAuthActive ? (
|
||||
<div className="mt-4">
|
||||
<Skeleton className="tpa-skeleton" height={36} count={2} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(isEnterpriseLoginDisabled && isInstitutionAuthActive) && (
|
||||
<RenderInstitutionButton
|
||||
onSubmitHandler={handleInstitutionLogin}
|
||||
buttonTitle={formatMessage(messages['register.institution.login.button'])}
|
||||
buttonTitle={formatMessage(messages['institution.login.button'])}
|
||||
/>
|
||||
)}
|
||||
{isSocialAuthActive && (
|
||||
<div className="row m-0">
|
||||
<SocialAuthProviders socialAuthProviders={providers} referrer={REGISTER_PAGE} />
|
||||
<SocialAuthProviders
|
||||
socialAuthProviders={providers}
|
||||
referrer={isLoginPage ? LOGIN_PAGE : REGISTER_PAGE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -59,7 +90,8 @@ ThirdPartyAuth.defaultProps = {
|
||||
currentProvider: null,
|
||||
providers: [],
|
||||
secondaryProviders: [],
|
||||
thirdPartyAuthApiStatus: 'pending',
|
||||
thirdPartyAuthApiStatus: PENDING_STATE,
|
||||
isLoginPage: false,
|
||||
};
|
||||
|
||||
ThirdPartyAuth.propTypes = {
|
||||
@@ -86,6 +118,7 @@ ThirdPartyAuth.propTypes = {
|
||||
}),
|
||||
),
|
||||
thirdPartyAuthApiStatus: PropTypes.string,
|
||||
isLoginPage: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ThirdPartyAuth;
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Alert } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import Zendesk from 'react-zendesk';
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
133
src/common-components/components/ThirdPartyAuthContext.tsx
Normal file
133
src/common-components/components/ThirdPartyAuthContext.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
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,
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function getThirdPartyAuthContext(urlParams) {
|
||||
const getThirdPartyAuthContext = async (urlParams : string) => {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
params: urlParams,
|
||||
@@ -13,13 +12,14 @@ export async function getThirdPartyAuthContext(urlParams) {
|
||||
.get(
|
||||
`${getConfig().LMS_BASE_URL}/api/mfe_context`,
|
||||
requestConfig,
|
||||
)
|
||||
.catch((e) => {
|
||||
throw (e);
|
||||
});
|
||||
);
|
||||
return {
|
||||
fieldDescriptions: data.registrationFields || {},
|
||||
optionalFields: data.optionalFields || {},
|
||||
thirdPartyAuthContext: data.contextData || {},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
getThirdPartyAuthContext,
|
||||
};
|
||||
17
src/common-components/data/apiHook.ts
Normal file
17
src/common-components/data/apiHook.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
};
|
||||
6
src/common-components/data/queryKeys.ts
Normal file
6
src/common-components/data/queryKeys.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { appId } from '../../constants';
|
||||
|
||||
export const ThirdPartyAuthQueryKeys = {
|
||||
all: [appId, 'ThirdPartyAuth'] as const,
|
||||
byPage: (pageId: string) => [appId, 'ThirdPartyAuth', pageId] as const,
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
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: {
|
||||
fields: {},
|
||||
},
|
||||
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;
|
||||
@@ -1,32 +0,0 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
import {
|
||||
getThirdPartyAuthContextBegin,
|
||||
getThirdPartyAuthContextFailure,
|
||||
getThirdPartyAuthContextSuccess,
|
||||
THIRD_PARTY_AUTH_CONTEXT,
|
||||
} from './actions';
|
||||
import {
|
||||
getThirdPartyAuthContext,
|
||||
} from './service';
|
||||
import { setCountryFromThirdPartyAuthContext } from '../../register/data/actions';
|
||||
|
||||
export function* fetchThirdPartyAuthContext(action) {
|
||||
try {
|
||||
yield put(getThirdPartyAuthContextBegin());
|
||||
const {
|
||||
fieldDescriptions, optionalFields, thirdPartyAuthContext,
|
||||
} = yield call(getThirdPartyAuthContext, action.payload.urlParams);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
);
|
||||
@@ -1,82 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { runSaga } from 'redux-saga';
|
||||
|
||||
import { setCountryFromThirdPartyAuthContext } from '../../../register/data/actions';
|
||||
import initializeMockLogging from '../../../setupTest';
|
||||
import * as actions from '../actions';
|
||||
import { fetchThirdPartyAuthContext } from '../sagas';
|
||||
import * as api from '../service';
|
||||
|
||||
const { loggingService } = initializeMockLogging();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,6 @@ 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';
|
||||
|
||||
@@ -112,6 +112,26 @@ const messages = defineMessages({
|
||||
description: 'Select ticket form',
|
||||
defaultMessage: 'Please choose your request type:',
|
||||
},
|
||||
'registration.other.options.heading': {
|
||||
id: 'registration.other.options.heading',
|
||||
defaultMessage: 'Or register with:',
|
||||
description: 'A message that appears above third party auth providers i.e saml, google, facebook etc',
|
||||
},
|
||||
'institution.login.button': {
|
||||
id: 'institution.login.button',
|
||||
defaultMessage: 'Institution/campus credentials',
|
||||
description: 'shows institutions list',
|
||||
},
|
||||
'login.other.options.heading': {
|
||||
id: 'login.other.options.heading',
|
||||
defaultMessage: 'Or sign in with:',
|
||||
description: 'Text that appears above other sign in options like social auth buttons',
|
||||
},
|
||||
'enterprise.login.btn.text': {
|
||||
id: 'enterprise.login.btn.text',
|
||||
defaultMessage: 'Company or school credentials',
|
||||
description: 'Company or school login link text.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { mount } from 'enzyme';
|
||||
import { render } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { REGISTER_EMBEDDED_PAGE } from '../../data/constants';
|
||||
import EmbeddedRegistrationRoute from '../EmbeddedRegistrationRoute';
|
||||
|
||||
import {
|
||||
MemoryRouter, Route, BrowserRouter as Router, Routes,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { PAGE_NOT_FOUND, REGISTER_EMBEDDED_PAGE } from '../../data/constants';
|
||||
import EmbeddedRegistrationRoute from '../EmbeddedRegistrationRoute';
|
||||
|
||||
const RRD = require('react-router-dom');
|
||||
// Just render plain div with its children
|
||||
// eslint-disable-next-line react/prop-types
|
||||
@@ -27,6 +26,10 @@ const TestApp = () => (
|
||||
path={REGISTER_EMBEDDED_PAGE}
|
||||
element={<EmbeddedRegistrationRoute><span>Embedded Register Page</span></EmbeddedRegistrationRoute>}
|
||||
/>
|
||||
<Route
|
||||
path={PAGE_NOT_FOUND}
|
||||
element={<span>Page not found</span>}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
@@ -45,12 +48,13 @@ describe('EmbeddedRegistrationRoute', () => {
|
||||
|
||||
it('should not render embedded register page if host query param is not available in the url', async () => {
|
||||
let embeddedRegistrationPage = null;
|
||||
|
||||
await act(async () => {
|
||||
embeddedRegistrationPage = await mount(routerWrapper());
|
||||
const { container } = await render(routerWrapper());
|
||||
embeddedRegistrationPage = container;
|
||||
});
|
||||
|
||||
expect(embeddedRegistrationPage.find('span').exists()).toBeFalsy();
|
||||
const renderedPage = embeddedRegistrationPage.querySelector('span');
|
||||
expect(renderedPage.textContent).toBe('Page not found');
|
||||
});
|
||||
|
||||
it('should render embedded register page if host query param is available in the url (embedded)', async () => {
|
||||
@@ -61,12 +65,13 @@ describe('EmbeddedRegistrationRoute', () => {
|
||||
};
|
||||
|
||||
let embeddedRegistrationPage = null;
|
||||
|
||||
await act(async () => {
|
||||
embeddedRegistrationPage = await mount(routerWrapper());
|
||||
const { container } = await render(routerWrapper());
|
||||
embeddedRegistrationPage = container;
|
||||
});
|
||||
|
||||
expect(embeddedRegistrationPage.find('span').exists()).toBeTruthy();
|
||||
expect(embeddedRegistrationPage.find('span').text()).toBe('Embedded Register Page');
|
||||
const renderedPage = embeddedRegistrationPage.querySelector('span');
|
||||
expect(renderedPage).toBeTruthy();
|
||||
expect(renderedPage.textContent).toBe('Embedded Register Page');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
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 { fetchRealtimeValidations } from '../../register/data/actions';
|
||||
import { RegisterProvider } from '../../register/components/RegisterContext';
|
||||
import { useFieldValidations } from '../../register/data/apiHook';
|
||||
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',
|
||||
@@ -21,64 +26,89 @@ describe('FormGroup', () => {
|
||||
};
|
||||
|
||||
it('should show help text on field focus', () => {
|
||||
const formGroup = mount(<FormGroup {...props} />);
|
||||
expect(formGroup.find('.pgn-transition-replace-group').find('div#email-1').exists()).toBeFalsy();
|
||||
const { queryByText, getByLabelText } = render(<FormGroup {...props} />);
|
||||
const emailInput = getByLabelText('Email');
|
||||
|
||||
formGroup.find('input#email').simulate('focus');
|
||||
expect(formGroup.find('.pgn-transition-replace-group').find('div#email-1').text()).toEqual('Email field help text');
|
||||
expect(queryByText('Email field help text')).toBeNull();
|
||||
|
||||
fireEvent.focus(emailInput);
|
||||
|
||||
const helpText = queryByText('Email field help text');
|
||||
|
||||
expect(helpText).toBeTruthy();
|
||||
expect(helpText.textContent).toEqual('Email field help text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordField', () => {
|
||||
const mockStore = configureStore();
|
||||
const IntlPasswordField = injectIntl(PasswordField);
|
||||
let props = {};
|
||||
let store = {};
|
||||
let queryClient;
|
||||
let mockMutate;
|
||||
|
||||
const reduxWrapper = children => (
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
const renderWrapper = (children) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter>
|
||||
<RegisterProvider>
|
||||
{children}
|
||||
</RegisterProvider>
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
register: {
|
||||
validationApiRateLimited: false,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore(initialState);
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockMutate = jest.fn();
|
||||
useFieldValidations.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
props = {
|
||||
floatingLabel: 'Password',
|
||||
name: 'password',
|
||||
value: 'password123',
|
||||
handleFocus: jest.fn(),
|
||||
};
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show/hide password on icon click', () => {
|
||||
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
||||
const passwordInput = getByLabelText('Password');
|
||||
|
||||
passwordField.find('button[aria-label="Show password"]').simulate('click');
|
||||
expect(passwordField.find('input').prop('type')).toEqual('text');
|
||||
const showPasswordButton = getByLabelText('Show password');
|
||||
fireEvent.click(showPasswordButton);
|
||||
expect(passwordInput.type).toBe('text');
|
||||
|
||||
passwordField.find('button[aria-label="Hide password"]').simulate('click');
|
||||
expect(passwordField.find('input').prop('type')).toEqual('password');
|
||||
const hidePasswordButton = getByLabelText('Hide password');
|
||||
fireEvent.click(hidePasswordButton);
|
||||
expect(passwordInput.type).toBe('password');
|
||||
});
|
||||
|
||||
it('should show password requirement tooltip on focus', async () => {
|
||||
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
||||
const passwordInput = getByLabelText('Password');
|
||||
jest.useFakeTimers();
|
||||
await act(async () => {
|
||||
passwordField.find('input').simulate('focus');
|
||||
fireEvent.focus(passwordInput);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
passwordField.update();
|
||||
const passwordRequirementTooltip = document.querySelector('#password-requirement-left');
|
||||
|
||||
expect(passwordField.find('#password-requirement-left').exists()).toBeTruthy();
|
||||
expect(passwordRequirementTooltip).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show all password requirement checks as failed', async () => {
|
||||
@@ -86,48 +116,66 @@ describe('PasswordField', () => {
|
||||
...props,
|
||||
value: '',
|
||||
};
|
||||
|
||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
||||
const passwordInput = getByLabelText('Password');
|
||||
jest.useFakeTimers();
|
||||
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
await act(async () => {
|
||||
passwordField.find('input').simulate('focus');
|
||||
fireEvent.focus(passwordInput);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
passwordField.update();
|
||||
|
||||
expect(passwordField.find('#letter-check span').prop('className')).toEqual('pgn__icon mr-1 text-light-700');
|
||||
expect(passwordField.find('#number-check span').prop('className')).toEqual('pgn__icon mr-1 text-light-700');
|
||||
expect(passwordField.find('#characters-check span').prop('className')).toEqual('pgn__icon mr-1 text-light-700');
|
||||
const letterCheckIcon = document.querySelector('#letter-check span');
|
||||
const numberCheckIcon = document.querySelector('#number-check span');
|
||||
const charactersCheckIcon = document.querySelector('#characters-check span');
|
||||
|
||||
expect(letterCheckIcon).toBeTruthy();
|
||||
expect(letterCheckIcon.className).toContain('pgn__icon mr-1 text-light-700');
|
||||
|
||||
expect(numberCheckIcon).toBeTruthy();
|
||||
expect(numberCheckIcon.className).toContain('pgn__icon mr-1 text-light-700');
|
||||
|
||||
expect(charactersCheckIcon).toBeTruthy();
|
||||
expect(charactersCheckIcon.className).toContain('pgn__icon mr-1 text-light-700');
|
||||
});
|
||||
|
||||
it('should update password requirement checks', async () => {
|
||||
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
||||
const passwordInput = getByLabelText('Password');
|
||||
jest.useFakeTimers();
|
||||
await act(async () => {
|
||||
passwordField.find('input').simulate('focus');
|
||||
fireEvent.focus(passwordInput);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
passwordField.update();
|
||||
|
||||
expect(passwordField.find('#letter-check span').prop('className')).toEqual('pgn__icon text-success mr-1');
|
||||
expect(passwordField.find('#number-check span').prop('className')).toEqual('pgn__icon text-success mr-1');
|
||||
expect(passwordField.find('#characters-check span').prop('className')).toEqual('pgn__icon text-success mr-1');
|
||||
const letterCheckIcon = document.querySelector('#letter-check span');
|
||||
const numberCheckIcon = document.querySelector('#number-check span');
|
||||
const charactersCheckIcon = document.querySelector('#characters-check span');
|
||||
|
||||
expect(letterCheckIcon).toBeTruthy();
|
||||
expect(letterCheckIcon.className).toContain('pgn__icon text-success mr-1');
|
||||
|
||||
expect(numberCheckIcon).toBeTruthy();
|
||||
expect(numberCheckIcon.className).toContain('pgn__icon text-success mr-1');
|
||||
|
||||
expect(charactersCheckIcon).toBeTruthy();
|
||||
expect(charactersCheckIcon.className).toContain('pgn__icon text-success mr-1');
|
||||
});
|
||||
|
||||
it('should not run validations when blur is fired on password icon click', () => {
|
||||
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
const { container, getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
|
||||
passwordField.find('button[aria-label="Show password"]').simulate('blur', {
|
||||
const passwordIcon = getByLabelText('Show password');
|
||||
|
||||
fireEvent.blur(passwordInput, {
|
||||
target: {
|
||||
name: 'password',
|
||||
value: 'invalid',
|
||||
},
|
||||
relatedTarget: {
|
||||
name: 'passwordIcon',
|
||||
},
|
||||
relatedTarget: passwordIcon,
|
||||
});
|
||||
|
||||
expect(passwordField.find('div[feedback-for="password"]').exists()).toBeFalsy();
|
||||
expect(container.querySelector('div[feedback-for="password"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should call props handle blur if available', () => {
|
||||
@@ -135,9 +183,10 @@ describe('PasswordField', () => {
|
||||
...props,
|
||||
handleBlur: jest.fn(),
|
||||
};
|
||||
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
const { container } = render(renderWrapper(<PasswordField {...props} />));
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
|
||||
passwordField.find('input#password').simulate('blur', {
|
||||
fireEvent.blur(passwordInput, {
|
||||
target: {
|
||||
name: 'password',
|
||||
value: '',
|
||||
@@ -152,9 +201,10 @@ describe('PasswordField', () => {
|
||||
...props,
|
||||
handleErrorChange: jest.fn(),
|
||||
};
|
||||
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
const { container } = render(renderWrapper(<PasswordField {...props} />));
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
|
||||
passwordField.find('input#password').simulate('blur', {
|
||||
fireEvent.blur(passwordInput, {
|
||||
target: {
|
||||
name: 'password',
|
||||
value: '',
|
||||
@@ -174,9 +224,11 @@ describe('PasswordField', () => {
|
||||
handleErrorChange: jest.fn(),
|
||||
};
|
||||
|
||||
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
||||
|
||||
passwordField.find('button[aria-label="Show password"]').simulate('focus', {
|
||||
const passwordIcon = getByLabelText('Show password');
|
||||
|
||||
fireEvent.focus(passwordIcon, {
|
||||
target: {
|
||||
name: 'passwordIcon',
|
||||
value: '',
|
||||
@@ -192,9 +244,11 @@ describe('PasswordField', () => {
|
||||
handleErrorChange: jest.fn(),
|
||||
};
|
||||
|
||||
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
||||
|
||||
passwordField.find('button[aria-label="Show password"]').simulate('focus', {
|
||||
const passwordIcon = getByLabelText('Show password');
|
||||
|
||||
fireEvent.focus(passwordIcon, {
|
||||
target: {
|
||||
name: 'password',
|
||||
value: 'invalid',
|
||||
@@ -209,34 +263,34 @@ 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 passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
|
||||
passwordField.find('button[aria-label="Show password"]').simulate('blur', {
|
||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
||||
const passwordField = getByLabelText('Password');
|
||||
fireEvent.blur(passwordField, {
|
||||
target: {
|
||||
name: 'password',
|
||||
value: 'password123',
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ password: 'password123' }));
|
||||
expect(mockMutate).toHaveBeenCalledWith({ 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 passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
|
||||
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
|
||||
|
||||
passwordField.find('button[aria-label="Show password"]').simulate('blur', {
|
||||
const passwordIcon = getByLabelText('Show password');
|
||||
|
||||
fireEvent.blur(passwordIcon, {
|
||||
target: {
|
||||
name: 'passwordIcon',
|
||||
value: undefined,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
import { REGISTER_PAGE } from '../../data/constants';
|
||||
import { PENDING_STATE, REGISTER_PAGE } from '../../data/constants';
|
||||
import ThirdPartyAuthAlert from '../ThirdPartyAuthAlert';
|
||||
|
||||
describe('ThirdPartyAuthAlert', () => {
|
||||
@@ -38,4 +36,19 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
/* eslint-disable import/no-import-module-exports */
|
||||
/* eslint-disable react/function-component-definition */
|
||||
import React from 'react';
|
||||
|
||||
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { mount } from 'enzyme';
|
||||
import { render } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { UnAuthOnlyRoute } from '..';
|
||||
import { REGISTER_PAGE } from '../../data/constants';
|
||||
|
||||
import {
|
||||
MemoryRouter, Route, BrowserRouter as Router, Routes,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { UnAuthOnlyRoute } from '..';
|
||||
import { REGISTER_PAGE } from '../../data/constants';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(),
|
||||
fetchAuthenticatedUser: jest.fn(),
|
||||
@@ -55,7 +52,7 @@ describe('UnAuthOnlyRoute', () => {
|
||||
fetchAuthenticatedUser.mockReturnValueOnce(Promise.resolve(user));
|
||||
|
||||
await act(async () => {
|
||||
await mount(routerWrapper());
|
||||
await render(routerWrapper());
|
||||
});
|
||||
|
||||
expect(fetchAuthenticatedUser).toBeCalledWith({ forceRefresh: true });
|
||||
@@ -66,7 +63,7 @@ describe('UnAuthOnlyRoute', () => {
|
||||
fetchAuthenticatedUser.mockReturnValueOnce(Promise.resolve(null));
|
||||
|
||||
await act(async () => {
|
||||
await mount(routerWrapper());
|
||||
await render(routerWrapper());
|
||||
});
|
||||
|
||||
expect(fetchAuthenticatedUser).toBeCalledWith({ forceRefresh: false });
|
||||
|
||||
@@ -66,14 +66,14 @@ exports[`SocialAuthProviders should match social auth provider with iconClass sn
|
||||
data-prefix="fab"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
style={{}}
|
||||
viewBox="0 0 488 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
style={{}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -93,7 +93,7 @@ exports[`SocialAuthProviders should match social auth provider with iconClass sn
|
||||
`;
|
||||
|
||||
exports[`SocialAuthProviders should match social auth provider with iconImage snapshot 1`] = `
|
||||
Array [
|
||||
[
|
||||
<button
|
||||
className="btn-social btn-oa2-apple-id mr-3"
|
||||
data-provider-url="/auth/login/apple-id/?auth_entry=login&next=/dashboard"
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
// 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"
|
||||
@@ -21,7 +41,7 @@ exports[`ThirdPartyAuthAlert should match login page third party auth alert mess
|
||||
`;
|
||||
|
||||
exports[`ThirdPartyAuthAlert should match register page third party auth alert message snapshot 1`] = `
|
||||
Array [
|
||||
[
|
||||
<div
|
||||
className="fade alert-content alert-success mt-n2 mb-5 alert show"
|
||||
id="tpa-alert"
|
||||
|
||||
@@ -5,23 +5,23 @@ exports[`Zendesk Help should match login page third party auth alert message sna
|
||||
cookies={true}
|
||||
defer={true}
|
||||
webWidget={
|
||||
Object {
|
||||
"answerBot": Object {
|
||||
"avatar": Object {
|
||||
"name": Object {
|
||||
{
|
||||
"answerBot": {
|
||||
"avatar": {
|
||||
"name": {
|
||||
"*": "edX Support",
|
||||
},
|
||||
"url": undefined,
|
||||
},
|
||||
"contactOnlyAfterQuery": true,
|
||||
"suppress": false,
|
||||
"title": Object {
|
||||
"title": {
|
||||
"*": "edX Support",
|
||||
},
|
||||
},
|
||||
"chat": Object {
|
||||
"departments": Object {
|
||||
"enabled": Array [
|
||||
"chat": {
|
||||
"departments": {
|
||||
"enabled": [
|
||||
"account settings",
|
||||
"billing and payments",
|
||||
"certificates",
|
||||
@@ -33,17 +33,17 @@ exports[`Zendesk Help should match login page third party auth alert message sna
|
||||
},
|
||||
"suppress": false,
|
||||
},
|
||||
"contactForm": Object {
|
||||
"contactForm": {
|
||||
"attachments": true,
|
||||
"selectTicketForm": Object {
|
||||
"selectTicketForm": {
|
||||
"*": "Please choose your request type:",
|
||||
},
|
||||
"ticketForms": Array [
|
||||
Object {
|
||||
"fields": Array [
|
||||
Object {
|
||||
"ticketForms": [
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"id": "description",
|
||||
"prefill": Object {
|
||||
"prefill": {
|
||||
"*": "",
|
||||
},
|
||||
},
|
||||
@@ -53,10 +53,10 @@ exports[`Zendesk Help should match login page third party auth alert message sna
|
||||
},
|
||||
],
|
||||
},
|
||||
"contactOptions": Object {
|
||||
"contactOptions": {
|
||||
"enabled": false,
|
||||
},
|
||||
"helpCenter": Object {
|
||||
"helpCenter": {
|
||||
"originalArticleButton": true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ const configuration = {
|
||||
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,
|
||||
|
||||
1
src/constants.ts
Normal file
1
src/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const appId = 'org.openedx.frontend.app.authn';
|
||||
@@ -1,33 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { composeWithDevTools } from '@redux-devtools/extension';
|
||||
import { applyMiddleware, compose, createStore } from 'redux';
|
||||
import { createLogger } from 'redux-logger';
|
||||
import createSagaMiddleware from 'redux-saga';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
|
||||
import createRootReducer from './reducers';
|
||||
import rootSaga from './sagas';
|
||||
|
||||
const sagaMiddleware = createSagaMiddleware();
|
||||
|
||||
function composeMiddleware() {
|
||||
if (getConfig().ENVIRONMENT === 'development') {
|
||||
const loggerMiddleware = createLogger({
|
||||
collapsed: true,
|
||||
});
|
||||
return composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware));
|
||||
}
|
||||
|
||||
return compose(applyMiddleware(thunkMiddleware, sagaMiddleware));
|
||||
}
|
||||
|
||||
export default function configureStore(initialState = {}) {
|
||||
const store = createStore(
|
||||
createRootReducer(),
|
||||
initialState,
|
||||
composeMiddleware(),
|
||||
);
|
||||
sagaMiddleware.run(rootSaga);
|
||||
|
||||
return store;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
@@ -1,19 +0,0 @@
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
52
src/data/tests/cookies.test.js
Normal file
52
src/data/tests/cookies.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
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');
|
||||
|
||||
describe('setCookie function', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should set a cookie with default options', () => {
|
||||
getConfig.mockReturnValue({ SESSION_COOKIE_DOMAIN: 'example.com' });
|
||||
|
||||
setCookie('testCookie', 'testValue');
|
||||
|
||||
expect(Cookies).toHaveBeenCalled();
|
||||
expect(Cookies).toHaveBeenCalledWith();
|
||||
expect(Cookies.prototype.set).toHaveBeenCalledWith('testCookie', 'testValue', {
|
||||
domain: 'example.com',
|
||||
path: '/',
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(Cookies).toHaveBeenCalled();
|
||||
expect(Cookies).toHaveBeenCalledWith();
|
||||
expect(Cookies.prototype.set).toHaveBeenCalledWith('testCookie', 'testValue', {
|
||||
domain: 'example.com',
|
||||
path: '/',
|
||||
expires: expiry,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set a cookie if cookieName is undefined', () => {
|
||||
setCookie(undefined, 'testValue');
|
||||
|
||||
expect(Cookies).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import Cookies from 'universal-cookie';
|
||||
|
||||
export default function setCookie(cookieName, cookieValue, cookieExpiry) {
|
||||
const cookies = new Cookies();
|
||||
const options = { domain: getConfig().SESSION_COOKIE_DOMAIN, path: '/' };
|
||||
if (cookieExpiry) {
|
||||
options.expires = 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: '/' };
|
||||
if (cookieExpiry) {
|
||||
options.expires = cookieExpiry;
|
||||
}
|
||||
cookies.set(cookieName, cookieValue, options);
|
||||
}
|
||||
cookies.set(cookieName, cookieValue, options);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,4 @@ export {
|
||||
updatePathWithQueryParams,
|
||||
windowScrollTo,
|
||||
} from './dataUtils';
|
||||
export { default as AsyncActionType } from './reduxUtils';
|
||||
export { default as setCookie } from './cookies';
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Helper class to save time when writing out action types for asynchronous methods. Also helps
|
||||
* ensure that actions are namespaced.
|
||||
*/
|
||||
export default class AsyncActionType {
|
||||
constructor(topic, name) {
|
||||
this.topic = topic;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
get BASE() {
|
||||
return `${this.topic}__${this.name}`;
|
||||
}
|
||||
|
||||
get BEGIN() {
|
||||
return `${this.topic}__${this.name}__BEGIN`;
|
||||
}
|
||||
|
||||
get SUCCESS() {
|
||||
return `${this.topic}__${this.name}__SUCCESS`;
|
||||
}
|
||||
|
||||
get FAILURE() {
|
||||
return `${this.topic}__${this.name}__FAILURE`;
|
||||
}
|
||||
|
||||
get RESET() {
|
||||
return `${this.topic}__${this.name}__RESET`;
|
||||
}
|
||||
|
||||
get FORBIDDEN() {
|
||||
return `${this.topic}__${this.name}__FORBIDDEN`;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
import { breakpoints } from '@openedx/paragon';
|
||||
|
||||
/**
|
||||
* A react hook used to determine if the current window is mobile or not.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Form, Icon } from '@edx/paragon';
|
||||
import { ExpandMore } from '@edx/paragon/icons';
|
||||
import { Form, Icon } from '@openedx/paragon';
|
||||
import { ExpandMore } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const FormFieldRenderer = (props) => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { mount } from 'enzyme';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
|
||||
import FieldRenderer from '../FieldRenderer';
|
||||
|
||||
@@ -28,13 +26,14 @@ describe('FieldRendererTests', () => {
|
||||
options: [['1997', '1997'], ['1998', '1998']],
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const field = fieldRenderer.find('select#yob-field');
|
||||
field.simulate('change', { target: { value: 1997 } });
|
||||
const { container } = render(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const input = container.querySelector('select#yob-field');
|
||||
const label = container.querySelector('label');
|
||||
fireEvent.change(input, { target: { value: 1997 } });
|
||||
|
||||
expect(field.type()).toEqual('select');
|
||||
expect(fieldRenderer.find('label').text()).toEqual('Year of Birth');
|
||||
expect(value).toEqual(1997);
|
||||
expect(input.type).toEqual('select-one');
|
||||
expect(label.textContent).toContain(fieldData.label);
|
||||
expect(value).toEqual('1997');
|
||||
});
|
||||
|
||||
it('should return null if no options are provided for select field', () => {
|
||||
@@ -44,8 +43,8 @@ describe('FieldRendererTests', () => {
|
||||
name: 'yob-field',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
|
||||
expect(fieldRenderer.html()).toBeNull();
|
||||
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
|
||||
expect(container.innerHTML).toEqual('');
|
||||
});
|
||||
|
||||
it('should render textarea field', () => {
|
||||
@@ -55,12 +54,13 @@ describe('FieldRendererTests', () => {
|
||||
name: 'goals-field',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const field = fieldRenderer.find('#goals-field').last();
|
||||
field.simulate('change', { target: { value: 'These are my goals.' } });
|
||||
const { container } = render(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const input = container.querySelector('#goals-field');
|
||||
const label = container.querySelector('label');
|
||||
fireEvent.change(input, { target: { value: 'These are my goals.' } });
|
||||
|
||||
expect(field.type()).toEqual('textarea');
|
||||
expect(fieldRenderer.find('label').text()).toEqual('Why do you want to join this platform?');
|
||||
expect(input.type).toEqual(fieldData.type);
|
||||
expect(label.textContent).toContain('Why do you want to join this platform?');
|
||||
expect(value).toEqual('These are my goals.');
|
||||
});
|
||||
|
||||
@@ -71,12 +71,13 @@ describe('FieldRendererTests', () => {
|
||||
name: 'company-field',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const field = fieldRenderer.find('#company-field').last();
|
||||
field.simulate('change', { target: { value: 'ABC' } });
|
||||
const { container } = render(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const input = container.querySelector('input#company-field');
|
||||
const label = container.querySelector('label');
|
||||
fireEvent.change(input, { target: { value: 'ABC' } });
|
||||
|
||||
expect(field.type()).toEqual('input');
|
||||
expect(fieldRenderer.find('label').text()).toEqual('Company');
|
||||
expect(input.type).toEqual(fieldData.type);
|
||||
expect(label.textContent).toContain(fieldData.label);
|
||||
expect(value).toEqual('ABC');
|
||||
});
|
||||
|
||||
@@ -87,12 +88,13 @@ describe('FieldRendererTests', () => {
|
||||
name: 'marketing-emails-opt-in-field',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const field = fieldRenderer.find('input#marketing-emails-opt-in-field');
|
||||
field.simulate('change', { target: { checked: true, type: 'checkbox' } });
|
||||
const { container } = render(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const input = container.querySelector('input#marketing-emails-opt-in-field');
|
||||
const label = container.querySelector('label');
|
||||
fireEvent.click(input);
|
||||
|
||||
expect(field.prop('type')).toEqual('checkbox');
|
||||
expect(fieldRenderer.find('label').text()).toEqual(fieldData.label);
|
||||
expect(input.type).toEqual(fieldData.type);
|
||||
expect(label.textContent).toContain(fieldData.label);
|
||||
expect(value).toEqual(true);
|
||||
});
|
||||
|
||||
@@ -101,8 +103,8 @@ describe('FieldRendererTests', () => {
|
||||
type: 'unknown',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
|
||||
expect(fieldRenderer.html()).toBeNull();
|
||||
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
|
||||
expect(container.innerHTML).toContain('');
|
||||
});
|
||||
|
||||
it('should run onBlur and onFocus functions for a field if given', () => {
|
||||
@@ -117,7 +119,7 @@ describe('FieldRendererTests', () => {
|
||||
functionValue = `${e.target.name} focussed`;
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(
|
||||
const { container } = render(
|
||||
<FieldRenderer
|
||||
handleFocus={onFocus}
|
||||
handleBlur={onBlur}
|
||||
@@ -126,19 +128,19 @@ describe('FieldRendererTests', () => {
|
||||
onChangeHandler={changeHandler}
|
||||
/>,
|
||||
);
|
||||
const field = fieldRenderer.find('#test-field').last();
|
||||
const input = container.querySelector('#test-field');
|
||||
|
||||
field.simulate('focus');
|
||||
fireEvent.focus(input);
|
||||
expect(functionValue).toEqual('test-field focussed');
|
||||
|
||||
field.simulate('blur');
|
||||
fireEvent.blur(input);
|
||||
expect(functionValue).toEqual('test-field blurred');
|
||||
});
|
||||
|
||||
it('should render error message for required text fields', () => {
|
||||
const fieldData = { type: 'text', label: 'First Name', name: 'first-name-field' };
|
||||
|
||||
const fieldRenderer = mount(
|
||||
const { container } = render(
|
||||
<FieldRenderer
|
||||
isRequired
|
||||
fieldData={fieldData}
|
||||
@@ -147,7 +149,7 @@ describe('FieldRendererTests', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Enter your first name');
|
||||
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('Enter your first name');
|
||||
});
|
||||
|
||||
it('should render error message for required select fields', () => {
|
||||
@@ -155,7 +157,7 @@ describe('FieldRendererTests', () => {
|
||||
type: 'select', label: 'Preference', name: 'preference-field', options: [['a', 'Opt 1'], ['b', 'Opt 2']],
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(
|
||||
const { container } = render(
|
||||
<FieldRenderer
|
||||
isRequired
|
||||
fieldData={fieldData}
|
||||
@@ -164,13 +166,13 @@ describe('FieldRendererTests', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Select your preference');
|
||||
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('Select your preference');
|
||||
});
|
||||
|
||||
it('should render error message for required textarea fields', () => {
|
||||
const fieldData = { type: 'textarea', label: 'Goals', name: 'goals-field' };
|
||||
|
||||
const fieldRenderer = mount(
|
||||
const { container } = render(
|
||||
<FieldRenderer
|
||||
isRequired
|
||||
fieldData={fieldData}
|
||||
@@ -179,13 +181,13 @@ describe('FieldRendererTests', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Tell us your goals');
|
||||
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('Tell us your goals');
|
||||
});
|
||||
|
||||
it('should render error message for required checkbox fields', () => {
|
||||
const fieldData = { type: 'checkbox', label: 'Honor Code', name: 'honor-code-field' };
|
||||
|
||||
const fieldRenderer = mount(
|
||||
const { container } = render(
|
||||
<FieldRenderer
|
||||
isRequired
|
||||
fieldData={fieldData}
|
||||
@@ -194,6 +196,6 @@ describe('FieldRendererTests', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('You must agree to our Honor Code');
|
||||
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('You must agree to our Honor Code');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { CheckCircle, Error } from '@edx/paragon/icons';
|
||||
import { Alert } from '@openedx/paragon';
|
||||
import { CheckCircle, Error } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -43,7 +41,7 @@ const ForgotPasswordAlert = (props) => {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
break;
|
||||
case INTERNAL_SERVER_ERROR:
|
||||
message = formatMessage(messages['internal.server.error']);
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
@@ -11,44 +10,41 @@ import {
|
||||
StatefulButton,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@edx/paragon';
|
||||
import { ChevronLeft } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
} from '@openedx/paragon';
|
||||
import { ChevronLeft } from '@openedx/paragon/icons';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { forgotPassword, setForgotPasswordFormData } from './data/actions';
|
||||
import { forgotPasswordResultSelector } from './data/selectors';
|
||||
import { useForgotPassword } from './data/apiHook';
|
||||
import ForgotPasswordAlert from './ForgotPasswordAlert';
|
||||
import messages from './messages';
|
||||
import BaseContainer from '../base-container';
|
||||
import { FormGroup } from '../common-components';
|
||||
import { DEFAULT_STATE, LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants';
|
||||
import { LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants';
|
||||
import { updatePathWithQueryParams, windowScrollTo } from '../data/utils';
|
||||
|
||||
const ForgotPasswordPage = (props) => {
|
||||
const ForgotPasswordPage = () => {
|
||||
const platformName = getConfig().SITE_NAME;
|
||||
const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
|
||||
const {
|
||||
status, submitState, emailValidationError,
|
||||
} = props;
|
||||
|
||||
const { formatMessage } = useIntl();
|
||||
const [email, setEmail] = useState(props.email);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [bannerEmail, setBannerEmail] = useState('');
|
||||
const [formErrors, setFormErrors] = useState('');
|
||||
const [validationError, setValidationError] = useState(emailValidationError);
|
||||
const navigate = useNavigate();
|
||||
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';
|
||||
|
||||
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('');
|
||||
@@ -68,22 +64,38 @@ const ForgotPasswordPage = (props) => {
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
props.setForgotPasswordFormData({ email, emailValidationError: getValidationMessage(email) });
|
||||
setValidationError(getValidationMessage(email));
|
||||
};
|
||||
|
||||
const handleFocus = () => props.setForgotPasswordFormData({ emailValidationError: '' });
|
||||
const handleFocus = () => {
|
||||
setValidationError('');
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
setBannerEmail(email);
|
||||
|
||||
const error = getValidationMessage(email);
|
||||
if (error) {
|
||||
setFormErrors(error);
|
||||
props.setForgotPasswordFormData({ email, emailValidationError: error });
|
||||
const validateError = getValidationMessage(email);
|
||||
if (validateError) {
|
||||
setFormErrors(validateError);
|
||||
setValidationError(validateError);
|
||||
windowScrollTo({ left: 0, top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
props.forgotPassword(email);
|
||||
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');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,7 +165,7 @@ const ForgotPasswordPage = (props) => {
|
||||
)}
|
||||
<p className="mt-5.5 small text-gray-700">
|
||||
{formatMessage(messages['additional.help.text'], { platformName })}
|
||||
<span>
|
||||
<span className="mx-1">
|
||||
<Hyperlink isInline destination={`mailto:${getConfig().INFO_EMAIL}`}>{getConfig().INFO_EMAIL}</Hyperlink>
|
||||
</span>
|
||||
</p>
|
||||
@@ -164,26 +176,4 @@ const ForgotPasswordPage = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
export default ForgotPasswordPage;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
144
src/forgot-password/data/api.test.ts
Normal file
144
src/forgot-password/data/api.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,7 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import formurlencoded from 'form-urlencoded';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function forgotPassword(email) {
|
||||
const forgotPassword = async (email: string) => {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
isPublic: true,
|
||||
@@ -20,4 +19,8 @@ export async function forgotPassword(email) {
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
forgotPassword,
|
||||
};
|
||||
175
src/forgot-password/data/apiHook.test.ts
Normal file
175
src/forgot-password/data/apiHook.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
47
src/forgot-password/data/apiHook.ts
Normal file
47
src/forgot-password/data/apiHook.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
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;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { logError, logInfo } from '@edx/frontend-platform/logging';
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
FORGOT_PASSWORD,
|
||||
forgotPasswordBegin,
|
||||
forgotPasswordForbidden,
|
||||
forgotPasswordServerError,
|
||||
forgotPasswordSuccess,
|
||||
} from './actions';
|
||||
import { forgotPassword } from './service';
|
||||
|
||||
// Services
|
||||
export function* handleForgotPassword(action) {
|
||||
try {
|
||||
yield put(forgotPasswordBegin());
|
||||
|
||||
yield call(forgotPassword, action.payload.email);
|
||||
|
||||
yield put(forgotPasswordSuccess(action.payload.email));
|
||||
} catch (e) {
|
||||
if (e.response && e.response.status === 403) {
|
||||
yield put(forgotPasswordForbidden());
|
||||
logInfo(e);
|
||||
} else {
|
||||
yield put(forgotPasswordServerError());
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(FORGOT_PASSWORD.BASE, handleForgotPassword);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
export const storeName = 'forgotPassword';
|
||||
|
||||
export const forgotPasswordSelector = state => ({ ...state[storeName] });
|
||||
|
||||
export const forgotPasswordResultSelector = createSelector(
|
||||
forgotPasswordSelector,
|
||||
forgotPassword => forgotPassword,
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { runSaga } from 'redux-saga';
|
||||
|
||||
import initializeMockLogging from '../../../setupTest';
|
||||
import * as actions from '../actions';
|
||||
import { handleForgotPassword } from '../sagas';
|
||||
import * as api from '../service';
|
||||
|
||||
const { loggingService } = initializeMockLogging();
|
||||
|
||||
describe('handleForgotPassword', () => {
|
||||
const params = {
|
||||
payload: {
|
||||
forgotPasswordFormData: {
|
||||
email: 'test@test.com',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
loggingService.logError.mockReset();
|
||||
loggingService.logInfo.mockReset();
|
||||
});
|
||||
|
||||
it('should handle 500 error code', async () => {
|
||||
const passwordErrorResponse = { response: { status: 500 } };
|
||||
|
||||
const forgotPasswordRequest = jest.spyOn(api, 'forgotPassword').mockImplementation(
|
||||
() => Promise.reject(passwordErrorResponse),
|
||||
);
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleForgotPassword,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(dispatched).toEqual([
|
||||
actions.forgotPasswordBegin(),
|
||||
actions.forgotPasswordServerError(),
|
||||
]);
|
||||
forgotPasswordRequest.mockClear();
|
||||
});
|
||||
|
||||
it('should handle rate limit error', async () => {
|
||||
const forbiddenErrorResponse = { response: { status: 403 } };
|
||||
|
||||
const forbiddenPasswordRequest = jest.spyOn(api, 'forgotPassword').mockImplementation(
|
||||
() => Promise.reject(forbiddenErrorResponse),
|
||||
);
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleForgotPassword,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(loggingService.logInfo).toHaveBeenCalled();
|
||||
expect(dispatched).toEqual([
|
||||
actions.forgotPasswordBegin(),
|
||||
actions.forgotPasswordForbidden(null),
|
||||
]);
|
||||
forbiddenPasswordRequest.mockClear();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1 @@
|
||||
export { default as ForgotPasswordPage } from './ForgotPasswordPage';
|
||||
export { default as reducer } from './data/reducers';
|
||||
export { FORGOT_PASSWORD } from './data/actions';
|
||||
export { default as saga } from './data/sagas';
|
||||
export { storeName, forgotPasswordResultSelector } from './data/selectors';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user