Compare commits

..

13 Commits

Author SHA1 Message Date
lunyachek
779275f52c feat: Auth form visual enhancements - remove css changes, rename const 2024-04-19 10:56:25 +05:00
lunyachek
0b449ae285 feat: Auth form visual enhancements
- Hide preloaders for third party auth providers if they disabled
- Suggested usernames container enlargement for mathing to neighboring inputs
2024-04-19 10:56:25 +05:00
Dmytro
728154a577 fix: register page crashing after click on field "username" (#1027) 2023-08-24 10:57:14 +05:00
Sagirov Eugeniy
1cca916784 chore: update frontend-platform version to v4.2.0 2023-05-09 18:21:28 -03:00
Shahbaz Shabbir
c5ef676e65 feat: hide signup page on the bases of flag (#837)
Co-authored-by: attiyaishaque <atiya.ishaq@arbisoft.com>
2023-04-10 17:18:22 +05:00
Dmytro
ed4fbaaca5 feat: displaying a support link on the welcome page (#763)
This update adds a display dependency
support links on welcome page if variable
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK non-empty
in MFE configuration.
2023-03-08 10:08:59 +05:00
Stanislav
b676a058b1 fix: Remove horizontal scroll in windows (#759) 2023-03-02 16:15:55 +05:00
Dmytro
ce6cc34bde fix: link to the password reset page (#753)
When using two different deployment
approaches, with one of them we get an
incorrectly working link to the password
reset page.
2023-03-02 16:15:38 +05:00
Ghassan Maslamani
c5deeb7f99 chore(i18n): update translations (#761)
Co-authored-by: Jenkins <sre+jenkins@edx.org>
2023-03-01 12:25:31 +05:00
Dmytro
dabf556e2f fix: display or sign in with (#750) 2023-02-20 13:36:20 +05:00
Dmytro
fd4404ec28 feat: don't show support button (#746)
Do not show the button "Need help logging in?"
if there is no support page for sign-in issues.
2023-02-14 15:54:57 +05:00
Zainab Amir
8dbf20b3d2 fix: add policy banner settings to config (#708) 2022-12-19 18:07:57 +00:00
Zainab Amir
4ae9ead191 feat: make policy banner configurable (#707) 2022-12-19 18:07:57 +00:00
290 changed files with 56958 additions and 33152 deletions

32
.env Normal file
View File

@@ -0,0 +1,32 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=null
BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
MARKETING_SITE_BASE_URL=null
ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=''
SITE_NAME=null
USER_INFO_COOKIE_NAME=null
AUTHN_MINIMAL_HEADER=true
LOGIN_ISSUE_SUPPORT_LINK=''
USER_SURVEY_COOKIE_NAME=null
COOKIE_DOMAIN=null
WELCOME_PAGE_SUPPORT_LINK=null
INFO_EMAIL=''
DISABLE_ENTERPRISE_LOGIN=''
REGISTER_CONVERSION_COOKIE_NAME=null
ENABLE_PROGRESSIVE_PROFILING=''
MARKETING_EMAILS_OPT_IN=''
ENABLE_COPPA_COMPLIANCE=''
ZENDESK_KEY=''
ZENDESK_LOGO_URL=''
APP_ID=''
MFE_CONFIG_API_URL=''
ENABLE_COOKIE_POLICY_BANNER=''

View File

@@ -18,26 +18,21 @@ ORDER_HISTORY_URL='http://localhost:1996/orders'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME='Your Platform Name Here'
INFO_EMAIL='info@example.com'
# ***** Features *****
ENABLE_DYNAMIC_REGISTRATION_FIELDS='true'
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN='true'
# ***** Cookies *****
SESSION_COOKIE_DOMAIN='localhost'
USER_INFO_COOKIE_NAME='edx-user-info'
# ***** Links *****
LOGIN_ISSUE_SUPPORT_LINK='http://localhost:18000/login-issue-support-url'
AUTHN_MINIMAL_HEADER=true
LOGIN_ISSUE_SUPPORT_LINK='/login-issue-support-url'
TOS_AND_HONOR_CODE='http://localhost:18000/honor'
TOS_LINK='http://localhost:18000/tos'
PRIVACY_POLICY='http://localhost:18000/privacy'
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK='http://localhost:1999/welcome'
# ***** Base Container Images *****
BANNER_IMAGE_LARGE=''
BANNER_IMAGE_MEDIUM=''
BANNER_IMAGE_SMALL=''
BANNER_IMAGE_EXTRA_SMALL=''
# ***** Miscellaneous *****
APP_ID=''
MFE_CONFIG_API_URL=''
USER_SURVEY_COOKIE_NAME='openedx-user-survey-type'
COOKIE_DOMAIN='localhost'
WELCOME_PAGE_SUPPORT_LINK='http://localhost:1999/welcome'
INFO_EMAIL='info@edx.org'
DISABLE_ENTERPRISE_LOGIN=''
REGISTER_CONVERSION_COOKIE_NAME='openedx-user-register-conversion'
ENABLE_COPPA_COMPLIANCE=''
MARKETING_EMAILS_OPT_IN=''
ZENDESK_KEY=''
ZENDESK_LOGO_URL=''
APP_ID=''
MFE_CONFIG_API_URL=''

30
.env.test Normal file
View File

@@ -0,0 +1,30 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:1995'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME='Your Platform Name Here'
USER_INFO_COOKIE_NAME='edx-user-info'
LOGIN_ISSUE_SUPPORT_LINK='https://login-issue-support-url.com'
USER_SURVEY_COOKIE_NAME='openedx-user-survey-type'
WELCOME_PAGE_SUPPORT_LINK='http://localhost:1999/welcome'
DISABLE_ENTERPRISE_LOGIN=''
REGISTER_CONVERSION_COOKIE_NAME='openedx-user-register-conversion'
MARKETING_EMAILS_OPT_IN=''
ENABLE_COPPA_COMPLIANCE=''
ZENDESK_KEY=''
ZENDESK_LOGO_URL=''
APP_ID=''
MFE_CONFIG_API_URL=''

5
.eslintignore Executable file
View File

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

50
.eslintrc.js Normal file
View File

@@ -0,0 +1,50 @@
const { createConfig } = require('@edx/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:
// - TypeError: Cannot read property 'range' of null
'indent': [
'error',
2,
{ 'ignoredNodes': ['TemplateLiteral', 'SwitchCase'] }
],
'template-curly-spacing': 'off',
'jsx-a11y/label-has-associated-control': ['error', {
labelComponents: [],
labelAttributes: [],
controlComponents: [],
assert: 'htmlFor',
depth: 25
}],
'sort-imports': ['error', {ignoreCase: true, ignoreDeclarationSort: true}],
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
['sibling', 'parent'],
'index',
],
pathGroups: [
{
pattern: '@(react|react-dom|react-redux)',
group: 'external',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['react'],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
},
});

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @openedx/2U-infinity

View File

@@ -1,7 +0,0 @@
version: 2
updates:
# Adding new check for github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -2,16 +2,11 @@
Include a description of your changes here, along with a link to any relevant Jira tickets and/or Github issues.
#### JIRA
[XXX-XXXX](https://2u-internal.atlassian.net/browse/XXX-XXXX)
#### How Has This Been Tested?
Please describe in detail how you tested your changes.
#### Screenshots/sandbox (optional):
Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if its not applicable.**
|Before|After|
@@ -25,5 +20,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/2u-infinity** to do it.
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/vanguards** to do it.
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.

View File

@@ -16,4 +16,4 @@ jobs:
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,20 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

@@ -1,25 +0,0 @@
name: autoupdate
on:
push:
branches:
- master
pull_request:
types: [ labeled ]
branches:
- master
jobs:
autoupdate:
name: autoupdate
runs-on: ubuntu-latest
steps:
- uses: docker://chinthakagodawita/autoupdate-action:v1
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
DRY_RUN: "false"
PR_FILTER: "labelled"
PR_LABELS: "autoupdate"
EXCLUDED_LABELS: "dependencies,wontfix"
MERGE_MSG: "Branch was auto-updated."
RETRY_COUNT: "5"
RETRY_SLEEP: "300"
MERGE_CONFLICT_ACTION: "fail"

View File

@@ -10,15 +10,18 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
strategy:
matrix:
node: [16]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Setup Nodejs
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version-file: '.nvmrc'
node-version: ${{ matrix.node }}
- name: Install Dependencies
run: npm ci
@@ -39,7 +42,4 @@ jobs:
run: npm run build
- name: Run Code Coverage
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
uses: codecov/codecov-action@v3

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

View File

@@ -1,12 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

14
.gitignore vendored
View File

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

View File

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

1
.nvmrc
View File

@@ -1 +0,0 @@
24

9
.tx/config Normal file
View File

@@ -0,0 +1,9 @@
[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

View File

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

View File

@@ -1,30 +1,19 @@
intl_imports = ./node_modules/.bin/intl-imports.js
export TRANSIFEX_RESOURCE = frontend-app-authn
transifex_langs = "ar,fr,es_419,zh_CN,it_IT,pt_PT,de_DE,uk,ru,hi"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs
transifex_temp = ./temp/babel-plugin-react-intl
precommit:
npm run lint
npm audit
requirements:
npm ci
clean:
rm -rf dist
build: clean
tsc --project tsconfig.build.json
tsc-alias -p tsconfig.build.json
find src -type f -name '*.scss' -exec sh -c '\
for f in "$$@"; do \
d="dist/$${f#src/}"; \
mkdir -p "$$(dirname "$$d")"; \
cp "$$f" "$$d"; \
done' sh {} +
npm install
i18n.extract:
# Pulling display strings from .jsx files into .json files...
@@ -42,31 +31,22 @@ 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
# Pulls translations from Transifex.
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& 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
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
$(intl_imports) paragon frontend-platform frontend-app-authn
# This target is used by Travis.
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json
.PHONY: validate
validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run test
npm run build
.PHONY: validate.ci
validate.ci:
npm ci
make validate

View File

@@ -1,12 +1,12 @@
##################
frontend-app-authn
##################
|Build Status| |ci-badge| |Codecov| |semantic-release|
********
Purpose
********
frontend-app-authn
====================
Please tag **@openedx/vanguards** on any PRs or issues. Thanks!
Introduction
------------
This is a micro-frontend application responsible for the login, registration and password reset functionality.
@@ -22,22 +22,33 @@ This is a micro-frontend application responsible for the login, registration and
- Progressive profiling page
***************
Getting Started
***************
Installation
============
------------
`Tutor`_ is currently recommended as a development environment for your new MFE. Please refer to the `relevant tutor-mfe documentation`_ to get started using it.
This MFE is bundled with `Devstack <https://github.com/openedx/devstack>`_, see the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ section for setup instructions.
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
1. Install Devstack using the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ instructions.
2. Start up LMS, if it's not already started.
4. Within this project (frontend-app-authn), install requirements and start the development server:
.. code-block::
npm install
npm start # The server will run on port 1999
5. Once the dev server is up, visit http://localhost:1999 to access the MFE
.. image:: ./docs/images/frontend-app-authn-localhost-preview.png
**Note:** Follow `Enable social auth locally <docs/how_tos/enable_social_auth.rst>`_ for enabling Social Sign-on Buttons (SSO) locally
Environment Variables/Setup Notes
=================================
---------------------------------
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`__.
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
The authentication micro-frontend also requires the following additional variable:
@@ -61,8 +72,8 @@ The authentication micro-frontend also requires the following additional variabl
- The fully-qualified URL to the password reset support page in the target environment.
- ``https://support.example.com``
* - ``AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK``
- The fully-qualified URL to the progressive profiling support page in the target environment.
* - ``WELCOME_PAGE_SUPPORT_LINK``
- The fully-qualified URL to the welcome support page in the target environment.
- ``https://support.example.com``
* - ``TOS_AND_HONOR_CODE``
@@ -85,7 +96,7 @@ The authentication micro-frontend also requires the following additional variabl
- Enables support for configurable registration fields on the MFE. This flag must be enabled to show any required registration field besides the default fields (name, email, username, password).
- ``true`` | ``''`` (empty strings are falsy)
* - ``ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN``
* - ``ENABLE_PROGRESSIVE_PROFILING``
- Enables support for progressive profiling. If enabled, users are redirected to a second page where data for optional registration fields can be collected.
- ``true`` | ``''`` (empty strings are falsy)
@@ -93,21 +104,8 @@ The authentication micro-frontend also requires the following additional variabl
- Disables the enterprise login from Authn MFE.
- ``true`` | ``''`` (empty strings are falsy)
* - ``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)
* - ``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.
@@ -124,72 +122,13 @@ Furthermore, there are several edX-specific environment variables that enable in
- ``true`` | ``''`` (empty strings are falsy)
For more information see the document: `Micro-frontend applications in Open
edX <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`__.
How To Contribute
=================
Contributions are very welcome, and strongly encouraged! We've
put together `some documentation that describes our contribution process <https://docs.openedx.org/en/latest/developers/references/developer_guide/process/index.html>`_.
Even though they were written with edx-platform in mind, the guidelines should be followed for Open edX code in general.
PR description template should be automatically applied if you are sending PR from github interface; otherwise you
can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend-app-authn/blob/master/.github/pull_request_template.md>`_
This project is currently accepting all types of contributions, bug fixes and security fixes.
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/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@openedx.org.
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
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.
==============================
@@ -201,4 +140,4 @@ Please see `LICENSE <https://github.com/openedx/frontend-app-authn/blob/master/L
:target: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml
:alt: Continuous Integration
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
:target: https://github.com/semantic-release/semantic-release
:target: https://github.com/semantic-release/semantic-release

5
app.d.ts vendored
View File

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

View File

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

View File

@@ -1,19 +0,0 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'frontend-app-authn'
description: "Micro-frontend for authentication service. It contains views for login, registration and password reset functionality."
links:
- url: 'https://github.com/openedx/frontend-app-authn/blob/master/README.rst'
title: 'Documentation'
icon: 'Article'
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: group:2u-infinity
type: 'service'
lifecycle: 'production'

View File

@@ -91,7 +91,7 @@ In the data sub-directory, the file names describe what each piece of code does.
/ProfilePhotoUploader.jsx // supporting view
/data // Note: most files here are named with a plural, as they contain many of the things in question.
/actions.js
/mockedData.js
/constants.js
/reducers.js
/sagas.js
/selectors.js

View File

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

View File

@@ -1,39 +0,0 @@
========================================
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

View File

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

View File

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

8
openedx.yaml Normal file
View File

@@ -0,0 +1,8 @@
# 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: edx/vanguards
openedx-release:
ref: master

61952
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,28 @@
{
"name": "@openedx/frontend-app-authn",
"version": "1.0.0-alpha.6",
"description": "Frontend authentication",
"engines": {
"node": "^24.12"
},
"name": "@edx/frontend-app-authn",
"version": "0.1.0",
"description": "Frontend application template",
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-app-authn.git"
},
"exports": {
".": "./dist/index.js",
"./app.scss": "./dist/app.scss"
},
"files": [
"/dist"
],
"browserslist": [
"extends @edx/browserslist-config"
],
"sideEffects": [
"*.css",
"*.scss"
],
"scripts": {
"build": "make build",
"clean": "make clean",
"dev": "PORT=1999 PUBLIC_PATH=/authn openedx dev",
"i18n_extract": "openedx formatjs extract",
"lint": "openedx lint .",
"lint:fix": "openedx lint --fix .",
"prepack": "npm run build",
"snapshot": "openedx test --updateSnapshot",
"test": "openedx test --coverage --passWithNoTests"
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"author": "Open edX",
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-authn#readme",
"publishConfig": {
@@ -44,41 +32,57 @@
"url": "https://github.com/openedx/frontend-app-authn/issues"
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/openedx-atlas": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"classnames": "^2.5.1",
"fastest-levenshtein": "^1.0.16",
"form-urlencoded": "^6.1.5",
"i18n-iso-countries": "^7.13.0",
"prop-types": "^15.8.1",
"query-string": "^7.1.3",
"react-helmet": "^6.1.0",
"react-loading-skeleton": "^3.5.0",
"react-responsive": "^8.2.0",
"universal-cookie": "^8.0.1"
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-cookie-policy-banner": "2.2.0",
"@edx/frontend-platform": "4.2.0",
"@edx/paragon": "20.20.0",
"@fortawesome/fontawesome-svg-core": "6.2.1",
"@fortawesome/free-brands-svg-icons": "6.2.1",
"@fortawesome/free-regular-svg-icons": "6.2.1",
"@fortawesome/free-solid-svg-icons": "6.2.1",
"@fortawesome/react-fontawesome": "0.2.0",
"@redux-devtools/extension": "3.2.3",
"classnames": "2.3.2",
"core-js": "3.26.1",
"extract-react-intl-messages": "4.1.1",
"fastest-levenshtein": "1.0.16",
"form-urlencoded": "4.2.1",
"lodash.camelcase": "4.3.0",
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
"query-string": "5.1.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "6.1.0",
"react-loading-skeleton": "2.2.0",
"react-onclickoutside": "6.12.2",
"react-redux": "7.2.9",
"react-responsive": "8.2.0",
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"redux": "4.2.0",
"redux-logger": "3.0.6",
"redux-mock-store": "1.5.4",
"redux-saga": "1.2.1",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.7",
"sanitize-html": "2.7.3",
"semver-regex": "3.1.4",
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@edx/browserslist-config": "^1.5.0",
"@testing-library/react": "^16.3.0",
"@types/jest": "^29.5.14",
"babel-plugin-formatjs": "10.5.38",
"eslint-plugin-import": "2.31.0",
"jest": "^29.7.0",
"react-test-renderer": "^18.3.1",
"ts-jest": "^29.4.0",
"tsc-alias": "^1.8.16"
},
"peerDependencies": {
"@openedx/frontend-base": "^1.0.0-alpha.14",
"@openedx/paragon": "^23",
"@tanstack/react-query": "^5",
"react": "^18",
"react-dom": "^18",
"react-router": "^6",
"react-router-dom": "^6"
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-build": "11.0.2",
"@edx/reactifex": "1.1.0",
"babel-plugin-formatjs": "10.3.31",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.7",
"eslint-plugin-import": "2.26.0",
"glob": "7.2.3",
"history": "5.3.0",
"husky": "7.0.4",
"jest": "27.5.1",
"react-test-renderer": "16.14.0"
}
}

View File

@@ -1,9 +1,70 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Authentication Development Site></title>
<title>Authn | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<% if (process.env.OPTIMIZELY_URL) { %>
<script
src="<%= process.env.OPTIMIZELY_URL %>"
></script>
<% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %>
<script
src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"
></script>
<% } %>
<% if (process.env.ZENDESK_KEY) { %>
<script
id="ze-snippet"
src="https://static.zdassets.com/ekr/snippet.js?key=<%= process.env.ZENDESK_KEY %>"
>
</script>
<script type="text/javascript">
window.zESettings = {
cookies: true,
webWidget: {
contactOptions: {
enabled: false,
},
chat: {
suppress: false,
},
contactForm: {
ticketForms: [
{
id: 360003368814,
subject: false,
fields: [
{
id: 'description',
prefill: {
'*': '',
},
},
],
},
],
selectTicketForm: {
'*': 'Please choose your request type:',
},
attachments: true,
},
helpCenter: {
originalArticleButton: true,
},
answerBot: {
suppress: false,
contactOnlyAfterQuery: true,
title: { '*': 'edX Support' },
avatar: {
url: '<%= process.env.ZENDESK_LOGO_URL %>',
name: { '*': 'edX Support' },
},
},
},
};
</script>
<% } %>
</head>
<body>
<div id="root"></div>

View File

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

View File

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

View File

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

45
src/MainApp.jsx Executable file
View File

@@ -0,0 +1,45 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { Helmet } from 'react-helmet';
import { Redirect, Route, Switch } from 'react-router-dom';
import {
Logistration, NotFoundPage, registerIcons, UnAuthOnlyRoute,
} from './common-components';
import configureStore from './data/configureStore';
import {
LOGIN_PAGE, PAGE_NOT_FOUND, PASSWORD_RESET_CONFIRM, REGISTER_PAGE, RESET_PAGE, WELCOME_PAGE,
} from './data/constants';
import { updatePathWithQueryParams } from './data/utils';
import ForgotPasswordPage from './forgot-password';
import ResetPasswordPage from './reset-password';
import { ProgressiveProfiling } from './welcome';
import './index.scss';
registerIcons();
const MainApp = () => (
<AppProvider store={configureStore()}>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
<Switch>
<Route exact path="/">
<Redirect to={updatePathWithQueryParams(REGISTER_PAGE)} />
</Route>
<UnAuthOnlyRoute exact path={LOGIN_PAGE} render={() => <Logistration selectedPage={LOGIN_PAGE} />} />
<UnAuthOnlyRoute exact path={REGISTER_PAGE} component={Logistration} />
<UnAuthOnlyRoute exact path={RESET_PAGE} component={ForgotPasswordPage} />
<Route exact path={PASSWORD_RESET_CONFIRM} component={ResetPasswordPage} />
<Route exact path={WELCOME_PAGE} component={ProgressiveProfiling} />
<Route path={PAGE_NOT_FOUND} component={NotFoundPage} />
<Route path="*">
<Redirect to={PAGE_NOT_FOUND} />
</Route>
</Switch>
</AppProvider>
);
export default MainApp;

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
@use "@openedx/frontend-base/shell/app.scss";
@use "./sass/style";

View File

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

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
const AuthLargeLayout = ({ intl, username }) => (
<div className="w-50 d-flex">
<div className="col-md-10 bg-light-200 p-0">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
</Hyperlink>
<div className="min-vh-100 d-flex align-items-center">
<div className="large-screen-left-container mr-n4.5 large-yellow-line mt-5" />
<div>
<h1 className="welcome-to-platform data-hj-suppress">
{intl.formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, username })}
</h1>
<h2 className="complete-your-profile">
{intl.formatMessage(messages['complete.your.profile.1'])}
<div className="text-accent-a">
{intl.formatMessage(messages['complete.your.profile.2'])}
</div>
</h2>
</div>
</div>
</div>
<div className="col-md-2 bg-white p-0">
<svg className="m1-n1 w-100 h-100 large-screen-svg-light" preserveAspectRatio="xMaxYMin meet">
<g transform="skewX(171.6)">
<rect x="0" y="0" height="100%" width="100%" />
</g>
</svg>
</div>
</div>
);
AuthLargeLayout.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
export default injectIntl(AuthLargeLayout);

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
const AuthMediumLayout = ({ intl, username }) => (
<>
<div className="w-100 medium-screen-top-stripe" />
<div className="w-100 p-0 mb-3 d-flex">
<div className="col-md-10 bg-light-200">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
</Hyperlink>
<div className="d-flex align-items-center justify-content-center mb-4 ml-5">
<div className="medium-yellow-line mt-5 mr-n2" />
<div>
<h1 className="h3 data-hj-suppress mw-320">
{intl.formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, username })}
</h1>
<h2 className="display-1">
{intl.formatMessage(messages['complete.your.profile.1'])}
<div className="text-accent-a">
{intl.formatMessage(messages['complete.your.profile.2'])}
</div>
</h2>
</div>
</div>
</div>
<div className="col-md-2 bg-white p-0">
<svg className="w-100 h-100 medium-screen-svg-light" preserveAspectRatio="xMaxYMin meet">
<g transform="skewX(168)">
<rect x="0" y="0" height="100%" width="100%" />
</g>
</svg>
</div>
</div>
</>
);
AuthMediumLayout.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
export default injectIntl(AuthMediumLayout);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
const AuthSmallLayout = ({ intl, username }) => (
<div className="min-vw-100 bg-light-200">
<div className="col-md-12 small-screen-top-stripe" />
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
</Hyperlink>
<div className="d-flex align-items-center mb-3 mt-3 mr-3">
<div className="small-yellow-line mt-4.5" />
<div>
<h1 className="h5 data-hj-suppress">
{intl.formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, username })}
</h1>
<h2 className="h1">
{intl.formatMessage(messages['complete.your.profile.1'])}
<div className="text-accent-a">
{intl.formatMessage(messages['complete.your.profile.2'])}
</div>
</h2>
</div>
</div>
</div>
);
AuthSmallLayout.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
export default injectIntl(AuthSmallLayout);

View File

@@ -0,0 +1,55 @@
import React from 'react';
import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { getLocale } from '@edx/frontend-platform/i18n';
import { breakpoints } from '@edx/paragon';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import MediaQuery from 'react-responsive';
import AuthLargeLayout from './AuthLargeLayout';
import AuthMediumLayout from './AuthMediumLayout';
import AuthSmallLayout from './AuthSmallLayout';
import LargeLayout from './LargeLayout';
import MediumLayout from './MediumLayout';
import SmallLayout from './SmallLayout';
const BaseComponent = ({ children, showWelcomeBanner }) => {
const authenticatedUser = showWelcomeBanner ? getAuthenticatedUser() : null;
const username = authenticatedUser ? authenticatedUser.username : null;
return (
<>
{getConfig().ENABLE_COOKIE_POLICY_BANNER ? <CookiePolicyBanner languageCode={getLocale()} /> : null}
<div className="col-md-12 extra-large-screen-top-stripe" />
<div className="layout">
<MediaQuery maxWidth={breakpoints.small.maxWidth - 1}>
{authenticatedUser ? <AuthSmallLayout username={username} /> : <SmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
{authenticatedUser ? <AuthMediumLayout username={username} /> : <MediumLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.extraLarge.minWidth} maxWidth={breakpoints.extraExtraLarge.maxWidth}>
{authenticatedUser ? <AuthLargeLayout username={username} /> : <LargeLayout />}
</MediaQuery>
<div className={classNames('content', { 'align-items-center mt-0': authenticatedUser })}>
{children}
</div>
</div>
</>
);
};
BaseComponent.defaultProps = {
showWelcomeBanner: false,
};
BaseComponent.propTypes = {
children: PropTypes.node.isRequired,
showWelcomeBanner: PropTypes.bool,
};
export default BaseComponent;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@edx/paragon';
import classNames from 'classnames';
import messages from './messages';
const LargeLayout = ({ intl }) => (
<div className="w-50 d-flex">
<div className="col-md-9 bg-primary-400">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="min-vh-100 d-flex align-items-center">
<div className={classNames({ 'large-yellow-line mr-n4.5': getConfig().SITE_NAME === 'edX' })} />
<h1
className={classNames(
'display-2 text-white mw-xs',
{ 'ml-6': getConfig().SITE_NAME !== 'edX' },
)}
>
{intl.formatMessage(messages['start.learning'])}
<div className="text-accent-a">
{intl.formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
</div>
</h1>
</div>
</div>
<div className="col-md-3 bg-white p-0">
<svg className="ml-n1 w-100 h-100 large-screen-svg-primary" preserveAspectRatio="xMaxYMin meet">
<g transform="skewX(171.6)">
<rect x="0" y="0" height="100%" width="100%" />
</g>
</svg>
</div>
</div>
);
LargeLayout.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LargeLayout);

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@edx/paragon';
import classNames from 'classnames';
import messages from './messages';
const MediumLayout = ({ intl }) => (
<>
<div className="w-100 medium-screen-top-stripe" />
<div className="w-100 p-0 mb-3 d-flex">
<div className="col-md-10 bg-primary-400">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image alt={getConfig().SITE_NAME} className="logo" src={getConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="d-flex align-items-center justify-content-center mb-4 ">
<div className={classNames({ 'mt-1 medium-yellow-line': getConfig().SITE_NAME === 'edX' })} />
<div>
<h1
className={classNames(
'display-1 text-white mt-5 mb-5 mr-2',
{ 'ml-4.5': getConfig().SITE_NAME !== 'edX' },
)}
>
<span className="mr-2">{intl.formatMessage(messages['start.learning'])}</span>
<span className="text-accent-a d-inline-block">
{intl.formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
</span>
</h1>
</div>
</div>
</div>
<div className="col-md-2 bg-white p-0">
<svg className="w-100 h-100 medium-screen-svg-primary" preserveAspectRatio="xMaxYMin meet">
<g transform="skewX(168)">
<rect x="0" y="0" height="100%" width="100%" />
</g>
</svg>
</div>
</div>
</>
);
MediumLayout.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(MediumLayout);

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, Image } from '@edx/paragon';
import classNames from 'classnames';
import messages from './messages';
const SmallLayout = ({ intl }) => (
<span className="bg-primary-400 w-100">
<div className="col-md-12 small-screen-top-stripe" />
<div>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="d-flex align-items-center mb-3 mt-3 mr-3">
<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">{intl.formatMessage(messages['start.learning'])}</span>
<span className="text-accent-a d-inline-block">
{intl.formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
</span>
</h1>
</div>
</div>
</span>
);
SmallLayout.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(SmallLayout);

View File

@@ -0,0 +1 @@
export { default } from './BaseComponent';

View File

@@ -1,11 +1,17 @@
import { defineMessages } from '@openedx/frontend-base';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'welcome.to.platform': {
id: 'welcome.to.platform',
defaultMessage: 'Welcome to {siteName}, {fullName}!',
description: 'Welcome message that appears on progressive profile page',
'start.learning': {
id: 'start.learning',
defaultMessage: 'Start learning',
description: 'Header text for logistration MFE pages',
},
'with.site.name': {
id: 'with.site.name',
defaultMessage: 'with {siteName}',
description: 'Header text with site name for logistration MFE pages',
},
// authenticated user base component text
'complete.your.profile.1': {
id: 'complete.your.profile.1',
defaultMessage: 'Complete',
@@ -16,6 +22,11 @@ const messages = defineMessages({
defaultMessage: 'your profile',
description: 'part of text "complete your profile"',
},
'welcome.to.platform': {
id: 'welcome.to.platform',
defaultMessage: 'Welcome to {siteName}, {username}!',
description: 'Welcome message that appears on progressive profile page',
},
});
export default messages;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import LargeLayout from '../LargeLayout';
import MediumLayout from '../MediumLayout';
import SmallLayout from '../SmallLayout';
describe('ScreenLayout', () => {
it('should display the form, pass as a child in SmallScreenLayout', () => {
const smallScreen = mount(
<IntlProvider locale="en">
<div>
<SmallLayout />
<form>
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(smallScreen.find('form').exists()).toEqual(true);
});
it('should display the form, pass as a child in MediumScreenLayout', () => {
const mediumScreen = mount(
<IntlProvider locale="en">
<div>
<MediumLayout />
<form>
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(mediumScreen.find('form').exists()).toEqual(true);
});
it('should display the form, pass as a child in LargeScreenLayout', () => {
const largeScreen = mount(
<IntlProvider locale="en">
<div>
<LargeLayout />
<form>
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(largeScreen.find('form').exists()).toEqual(true);
});
});

View File

@@ -1,48 +0,0 @@
import { IntlProvider } from '@openedx/frontend-base';
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', () => {
render(
<IntlProvider locale="en">
<div>
<DefaultSmallLayout />
<form aria-label="form">
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(screen.getByRole('form')).toBeDefined();
});
it('should display the form passed as a child in MediumScreenLayout', () => {
render(
<IntlProvider locale="en">
<div>
<DefaultMediumLayout />
<form aria-label="form">
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(screen.getByRole('form')).toBeDefined();
});
it('should display the form passed as a child in LargeScreenLayout', () => {
render(
<IntlProvider locale="en">
<div>
<DefaultLargeLayout />
<form aria-label="form">
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(screen.getByRole('form')).toBeDefined();
});
});

View File

@@ -1,42 +0,0 @@
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import classNames from 'classnames';
import messages from './messages';
const LargeLayout = () => {
const { formatMessage } = useIntl();
return (
<div className="w-50 d-flex">
<div className="col-md-9 bg-primary-400">
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="min-vh-100 d-flex align-items-center">
<div className={classNames({ 'large-yellow-line mr-n4.5': getSiteConfig().siteName === 'edX' })} />
<h1
className={classNames(
'display-2 text-white mw-xs',
{ 'ml-6': getSiteConfig().siteName !== 'edX' },
)}
>
{formatMessage(messages['start.learning'])}
<div className="text-accent-a">
{formatMessage(messages['with.site.name'], { siteName: getSiteConfig().siteName })}
</div>
</h1>
</div>
</div>
<div className="col-md-3 bg-white p-0">
<svg className="ml-n1 w-100 h-100 large-screen-svg-primary" preserveAspectRatio="xMaxYMin meet">
<g transform="skewX(171.6)">
<rect x="0" y="0" height="100%" width="100%" />
</g>
</svg>
</div>
</div>
);
};
export default LargeLayout;

View File

@@ -1,49 +0,0 @@
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import classNames from 'classnames';
import messages from './messages';
const MediumLayout = () => {
const { formatMessage } = useIntl();
return (
<>
<div className="w-100 medium-screen-top-stripe" />
<div className="w-100 p-0 mb-3 d-flex">
<div className="col-md-10 bg-primary-400">
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image alt={getSiteConfig().siteName} className="logo" src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="d-flex align-items-center justify-content-center mb-4 ">
<div className={classNames({ 'mt-1 medium-yellow-line': getSiteConfig().siteName === 'edX' })} />
<div>
<h1
className={classNames(
'display-1 text-white mt-5 mb-5 mr-2 main-heading',
{ 'ml-4.5': getSiteConfig().siteName !== 'edX' },
)}
>
<span>
{formatMessage(messages['start.learning'])}{' '}
<span className="text-accent-a d-inline-block">
{formatMessage(messages['with.site.name'], { siteName: getSiteConfig().siteName })}
</span>
</span>
</h1>
</div>
</div>
</div>
<div className="col-md-2 bg-white p-0">
<svg className="w-100 h-100 medium-screen-svg-primary" preserveAspectRatio="xMaxYMin meet">
<g transform="skewX(168)">
<rect x="0" y="0" height="100%" width="100%" />
</g>
</svg>
</div>
</div>
</>
);
};
export default MediumLayout;

View File

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

View File

@@ -1,3 +0,0 @@
export { default as DefaultLargeLayout } from './LargeLayout';
export { default as DefaultMediumLayout } from './MediumLayout';
export { default as DefaultSmallLayout } from './SmallLayout';

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
'start.learning': {
id: 'start.learning',
defaultMessage: 'Start learning',
description: 'Header text for logistration MFE pages',
},
'with.site.name': {
id: 'with.site.name',
defaultMessage: 'with {siteName}',
description: 'Header text with site name for logistration MFE pages',
},
});
export default messages;

View File

@@ -1,31 +0,0 @@
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import messages from './messages';
const ExtraSmallLayout = () => {
const { formatMessage } = useIntl();
return (
<span
className="w-100 bg-primary-500 banner__image extra-small-layout"
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_EXTRA_SMALL})` }}
>
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="ml-4.5 mr-1 pb-3.5 pt-3.5">
<h1 className="banner__heading">
<span className="text-light-500">
{formatMessage(messages['your.career.turning.point'])}{' '}
</span>
<span className="text-warning-300">
{formatMessage(messages['is.here'])}
</span>
</h1>
</div>
</span>
);
};
export default ExtraSmallLayout;

View File

@@ -1,32 +0,0 @@
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import './index.scss';
import messages from './messages';
const LargeLayout = () => {
const { formatMessage } = useIntl();
return (
<div
className="w-50 bg-primary-500 banner__image large-layout"
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_LARGE})` }}
>
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo position-absolute" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="min-vh-100 p-5 d-flex align-items-end">
<h1 className="display-2 mw-sm mb-3 d-flex flex-column flex-shrink-0 justify-content-center">
<span className="text-light-500">
{formatMessage(messages['your.career.turning.point'])}
</span>
<span className="text-warning-300">
{formatMessage(messages['is.here'])}
</span>
</h1>
</div>
</div>
);
};
export default LargeLayout;

View File

@@ -1,32 +0,0 @@
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import './index.scss';
import messages from './messages';
const MediumLayout = () => {
const { formatMessage } = useIntl();
return (
<div
className="w-100 mb-3 bg-primary-500 banner__image medium-layout"
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_MEDIUM})` }}
>
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="ml-5 pb-4 pt-4">
<h1 className="display-2 banner__heading">
<span className="text-light-500">
{formatMessage(messages['your.career.turning.point'])}{' '}
</span>
<span className="text-warning-300 d-inline-block">
{formatMessage(messages['is.here'])}
</span>
</h1>
</div>
</div>
);
};
export default MediumLayout;

View File

@@ -1,31 +0,0 @@
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import messages from './messages';
const SmallLayout = () => {
const { formatMessage } = useIntl();
return (
<span
className="w-100 bg-primary-500 banner__image small-layout"
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_SMALL})` }}
>
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="ml-5 mr-1 pb-3.5 pt-3.5">
<h1 className="display-2">
<span className="text-light-500">
{formatMessage(messages['your.career.turning.point'])}{' '}
</span>
<span className="text-warning-300">
{formatMessage(messages['is.here'])}
</span>
</h1>
</div>
</span>
);
};
export default SmallLayout;

View File

@@ -1,4 +0,0 @@
export { default as ImageLargeLayout } from './LargeLayout';
export { default as ImageMediumLayout } from './MediumLayout';
export { default as ImageSmallLayout } from './SmallLayout';
export { default as ImageExtraSmallLayout } from './ExtraSmallLayout';

View File

@@ -1,37 +0,0 @@
.company-logo {
width: 71px;
margin-top: 2rem;
margin-left: 1.5rem;
}
@media (max-width: 576px) {
.company-logo {
width: 44.67px;
margin-top: 1.25rem;
margin-left: 1.5rem;
}
}
.banner__image {
background-size: cover;
background-repeat: no-repeat;
border:none;
}
@media (min-width: 464px) and (max-width: 575.98px) {
.banner__heading {
font-size: 60px;
font-weight: 700;
line-height: 60px;
letter-spacing: -1.2px;
}
}
@media (min-width: 768px) and (max-width: 800px) {
.banner__heading {
font-size: 60px !important;
font-weight: 700 !important;
line-height: 60px !important;
letter-spacing: -2px !important;
}
}

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
'your.career.turning.point': {
id: 'your.career.turning.point',
defaultMessage: 'Your career turning point',
description: 'Part of the heading "Your career turning point is here." shown on Authn MFE',
},
'is.here': {
id: 'is.here',
defaultMessage: 'is here.',
description: 'Part of the heading "Your career turning point is here." shown on Authn MFE',
},
});
export default messages;

View File

@@ -1,46 +0,0 @@
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
const LargeLayout = ({ fullName }) => {
const { formatMessage } = useIntl();
return (
<div className="w-50 d-flex">
<div className="col-md-10 bg-light-200 p-0">
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_URL} />
</Hyperlink>
<div className="min-vh-100 d-flex align-items-center">
<div className="large-screen-left-container mr-n4.5 large-yellow-line mt-5" />
<div>
<h1 className="welcome-to-platform data-hj-suppress">
{formatMessage(messages['welcome.to.platform'], { siteName: getSiteConfig().siteName, fullName })}
</h1>
<h2 className="complete-your-profile">
{formatMessage(messages['complete.your.profile.1'])}
<div className="text-accent-a">
{formatMessage(messages['complete.your.profile.2'])}
</div>
</h2>
</div>
</div>
</div>
<div className="col-md-2 bg-white p-0">
<svg className="m1-n1 w-100 h-100 large-screen-svg-light" preserveAspectRatio="xMaxYMin meet">
<g transform="skewX(171.6)">
<rect x="0" y="0" height="100%" width="100%" />
</g>
</svg>
</div>
</div>
);
};
LargeLayout.propTypes = {
fullName: PropTypes.string.isRequired,
};
export default LargeLayout;

View File

@@ -1,49 +0,0 @@
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
const MediumLayout = ({ fullName }) => {
const { formatMessage } = useIntl();
return (
<>
<div className="w-100 medium-screen-top-stripe" />
<div className="w-100 p-0 mb-3 d-flex">
<div className="col-md-10 bg-light-200">
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_URL} />
</Hyperlink>
<div className="d-flex align-items-center justify-content-center mb-4 ml-5">
<div className="medium-yellow-line mt-5 mr-n2" />
<div>
<h1 className="h3 data-hj-suppress mw-320">
{formatMessage(messages['welcome.to.platform'], { siteName: getSiteConfig().siteName, fullName })}
</h1>
<h2 className="display-1">
{formatMessage(messages['complete.your.profile.1'])}
<div className="text-accent-a">
{formatMessage(messages['complete.your.profile.2'])}
</div>
</h2>
</div>
</div>
</div>
<div className="col-md-2 bg-white p-0">
<svg className="w-100 h-100 medium-screen-svg-light" preserveAspectRatio="xMaxYMin meet">
<g transform="skewX(168)">
<rect x="0" y="0" height="100%" width="100%" />
</g>
</svg>
</div>
</div>
</>
);
};
MediumLayout.propTypes = {
fullName: PropTypes.string.isRequired,
};
export default MediumLayout;

View File

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

View File

@@ -1,3 +0,0 @@
export { default as AuthLargeLayout } from './LargeLayout';
export { default as AuthMediumLayout } from './MediumLayout';
export { default as AuthSmallLayout } from './SmallLayout';

View File

@@ -1,70 +0,0 @@
import { useAppConfig } from '@openedx/frontend-base';
import { breakpoints } from '@openedx/paragon';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import MediaQuery from 'react-responsive';
import { DefaultLargeLayout, DefaultMediumLayout, DefaultSmallLayout } from './components/default-layout';
import {
ImageExtraSmallLayout, ImageLargeLayout, ImageMediumLayout, ImageSmallLayout,
} from './components/image-layout';
import { AuthLargeLayout, AuthMediumLayout, AuthSmallLayout } from './components/welcome-page-layout';
const BaseContainer = ({ children, showWelcomeBanner, fullName }) => {
const enableImageLayout = useAppConfig().ENABLE_IMAGE_LAYOUT;
if (enableImageLayout) {
return (
<div className="layout">
<MediaQuery maxWidth={breakpoints.extraSmall.maxWidth - 1}>
{showWelcomeBanner ? <AuthSmallLayout fullName={fullName} /> : <ImageExtraSmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.small.minWidth} maxWidth={breakpoints.small.maxWidth - 1}>
{showWelcomeBanner ? <AuthSmallLayout fullName={fullName} /> : <ImageSmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
{showWelcomeBanner ? <AuthMediumLayout fullName={fullName} /> : <ImageMediumLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
{showWelcomeBanner ? <AuthLargeLayout fullName={fullName} /> : <ImageLargeLayout />}
</MediaQuery>
<div className={classNames('content', { 'align-items-center mt-0': showWelcomeBanner })}>
{children}
</div>
</div>
);
}
return (
<>
<div className="col-md-12 extra-large-screen-top-stripe" />
<div className="layout">
<MediaQuery maxWidth={breakpoints.small.maxWidth - 1}>
{showWelcomeBanner ? <AuthSmallLayout fullName={fullName} /> : <DefaultSmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
{showWelcomeBanner ? <AuthMediumLayout fullName={fullName} /> : <DefaultMediumLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
{showWelcomeBanner ? <AuthLargeLayout fullName={fullName} /> : <DefaultLargeLayout />}
</MediaQuery>
<div className={classNames('content', { 'align-items-center mt-0': showWelcomeBanner })}>
{children}
</div>
</div>
</>
);
};
BaseContainer.defaultProps = {
showWelcomeBanner: false,
fullName: null,
};
BaseContainer.propTypes = {
children: PropTypes.node.isRequired,
showWelcomeBanner: PropTypes.bool,
fullName: PropTypes.string,
};
export default BaseContainer;

View File

@@ -1,44 +0,0 @@
import { IntlProvider, mergeAppConfig } from '@openedx/frontend-base';
import { render } from '@testing-library/react';
import { Context as ResponsiveContext } from 'react-responsive';
import BaseContainer from '../index';
import { appId } from '../../constants';
const LargeScreen = {
wrappingComponent: ResponsiveContext.Provider,
wrappingComponentProps: { value: { width: 1200 } },
};
describe('Base component tests', () => {
it('should show default layout', () => {
const { container } = render(
<IntlProvider locale="en">
<BaseContainer>
<div>Test Content</div>
</BaseContainer>
</IntlProvider>,
LargeScreen,
);
expect(container.querySelector('.banner__image')).toBeNull();
expect(container.querySelector('.large-screen-svg-primary')).toBeDefined();
});
it('renders Image layout when ENABLE_IMAGE_LAYOUT configuration is enabled', () => {
mergeAppConfig(appId, {
ENABLE_IMAGE_LAYOUT: true,
});
const { container } = render(
<IntlProvider locale="en">
<BaseContainer showWelcomeBanner={false}>
<div>Test Content</div>
</BaseContainer>
</IntlProvider>,
LargeScreen,
);
expect(container.querySelector('.banner__image')).toBeDefined();
});
});

View File

@@ -1,26 +0,0 @@
import PropTypes from 'prop-types';
import { Navigate } from 'react-router-dom';
import { PAGE_NOT_FOUND } from '../data/constants';
import { isHostAvailableInQueryParams } from '../data/utils';
/**
* This wrapper redirects the requester to embedded register page only if host
* query param is present.
*/
const EmbeddedRegistrationRoute = ({ children }) => {
const registrationEmbedded = isHostAvailableInQueryParams();
// Show registration page for embedded experience even if the user is authenticated
if (registrationEmbedded) {
return children;
}
return <Navigate to={PAGE_NOT_FOUND} replace />;
};
EmbeddedRegistrationRoute.propTypes = {
children: PropTypes.node.isRequired,
};
export default EmbeddedRegistrationRoute;

View File

@@ -1,27 +1,25 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Form,
Icon,
} from '@openedx/paragon';
import { Login } from '@openedx/paragon/icons';
} from '@edx/paragon';
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
import messages from './messages';
/**
* This component renders the Single sign-on (SSO) button only for the tpa provider passed
* */
const EnterpriseSSO = (props) => {
const { formatMessage } = useIntl();
const { intl } = props;
const tpaProvider = props.provider;
const hideRegistrationLink = useAppConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false
|| useAppConfig().SHOW_REGISTRATION_LINKS === false;
const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false;
const handleSubmit = (e, url) => {
e.preventDefault();
window.location.href = getSiteConfig().lmsBaseUrl + url;
window.location.href = getConfig().LMS_BASE_URL + url;
};
const handleClick = (e) => {
@@ -35,7 +33,7 @@ const EnterpriseSSO = (props) => {
<div className="d-flex flex-column">
<div className="mw-450">
<Form className="m-0">
<p>{formatMessage(messages['enterprisetpa.title.heading'], { providerName: tpaProvider.name })}</p>
<p>{intl.formatMessage(messages['enterprisetpa.title.heading'], { providerName: tpaProvider.name })}</p>
<Button
id={tpaProvider.id}
key={tpaProvider.id}
@@ -46,20 +44,18 @@ const EnterpriseSSO = (props) => {
>
{tpaProvider.iconImage ? (
<div aria-hidden="true">
<img className="btn-tpa__image-icon" src={tpaProvider.iconImage} alt={`icon ${tpaProvider.name}`} />
<span className="pl-2" aria-hidden="true">{tpaProvider.name}</span>
<img className="icon-image" src={tpaProvider.iconImage} alt={`icon ${tpaProvider.name}`} />
<span className="pl-2" aria-hidden="true">{ tpaProvider.name }</span>
</div>
)
: (
<>
<div className="btn-tpa__font-container" aria-hidden="true">
{SUPPORTED_ICON_CLASSES.includes(tpaProvider.iconClass) ? (
<FontAwesomeIcon icon={['fab', tpaProvider.iconClass]} />)
: (
<Icon className="h-75" src={Login} />
)}
<div className="font-container" aria-hidden="true">
<FontAwesomeIcon
icon={SUPPORTED_ICON_CLASSES.includes(tpaProvider.iconClass) ? ['fab', tpaProvider.iconClass] : faSignInAlt}
/>
</div>
<span className="pl-2" aria-hidden="true">{tpaProvider.name}</span>
<span className="pl-2" aria-hidden="true">{ tpaProvider.name }</span>
</>
)}
</Button>
@@ -72,9 +68,9 @@ const EnterpriseSSO = (props) => {
className="w-100"
onClick={(e) => handleClick(e)}
>
{hideRegistrationLink
? formatMessage(messages['enterprisetpa.login.button.text.public.account.creation.disabled'])
: formatMessage(messages['enterprisetpa.login.button.text'])}
{disablePublicAccountCreation
? intl.formatMessage(messages['enterprisetpa.login.button.text.public.account.creation.disabled'])
: intl.formatMessage(messages['enterprisetpa.login.button.text'])}
</Button>
</Form>
</div>
@@ -105,6 +101,7 @@ EnterpriseSSO.propTypes = {
loginUrl: PropTypes.string,
registerUrl: PropTypes.string,
}),
intl: intlShape.isRequired,
};
export default EnterpriseSSO;
export default injectIntl(EnterpriseSSO);

View File

@@ -1,8 +1,8 @@
import { useState } from 'react';
import React, { useState } from 'react';
import {
Form, TransitionReplace,
} from '@openedx/paragon';
} from '@edx/paragon';
import PropTypes from 'prop-types';
const FormGroup = (props) => {
@@ -27,7 +27,7 @@ const FormGroup = (props) => {
readOnly={props.readOnly}
type={props.type}
aria-invalid={props.errorMessage !== ''}
className="form-group__form-field"
className="form-field"
autoComplete={props.autoComplete}
spellCheck={props.spellCheck}
name={props.name}
@@ -37,6 +37,7 @@ const FormGroup = (props) => {
onClick={handleClick}
onChange={props.handleChange}
controlClassName={props.borderClass}
trailingElement={props.trailingElement}
floatingLabel={props.floatingLabel}
>

View File

@@ -1,13 +1,13 @@
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Button, Hyperlink, Icon } from '@openedx/paragon';
import { Institution } from '@openedx/paragon/icons';
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink, Icon } from '@edx/paragon';
import { Institution } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
/**
* This component renders the Institution login button
* */
export const RenderInstitutionButton = props => {
const { onSubmitHandler, buttonTitle } = props;
@@ -24,13 +24,10 @@ export const RenderInstitutionButton = props => {
);
};
/**
* This component renders the page list of available institutions for login
* */
const InstitutionLogistration = props => {
const lmsBaseUrl = getSiteConfig().lmsBaseUrl;
const { formatMessage } = useIntl();
const lmsBaseUrl = getConfig().LMS_BASE_URL;
const {
intl,
secondaryProviders,
headingTitle,
} = props;
@@ -39,11 +36,11 @@ const InstitutionLogistration = props => {
<>
<div className="d-flex justify-content-left mb-4 mt-2">
<div className="flex-column">
<h4 className="mb-2 font-weight-bold institutions__heading">
<h4 className="mb-2 font-weight-bold institute-heading">
{headingTitle}
</h4>
<p className="mb-2">
{formatMessage(messages['institution.login.page.sub.heading'])}
{intl.formatMessage(messages['institution.login.page.sub.heading'])}
</p>
</div>
</div>
@@ -54,7 +51,7 @@ const InstitutionLogistration = props => {
<tr key={provider} className="pgn__data-table-row">
<td>
<Hyperlink
className="btn nav-item p-0 mb-1 institutions--provider-link"
className="btn nav-item p-0 mb-1 secondary-provider-link"
destination={lmsBaseUrl + provider.loginUrl}
>
{provider.name}
@@ -92,6 +89,7 @@ RenderInstitutionButton.defaultProps = {
InstitutionLogistration.propTypes = {
...LogistrationProps,
intl: intlShape.isRequired,
headingTitle: PropTypes.string,
};
InstitutionLogistration.defaultProps = {
@@ -99,4 +97,4 @@ InstitutionLogistration.defaultProps = {
headingTitle: '',
};
export default InstitutionLogistration;
export default injectIntl(InstitutionLogistration);

View File

@@ -0,0 +1,131 @@
import React, { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthService } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Icon,
Tab,
Tabs,
} from '@edx/paragon';
import { ChevronLeft } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom';
import BaseComponent from '../base-component';
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
import { getTpaHint, updatePathWithQueryParams } from '../data/utils';
import { LoginPage } from '../login';
import { RegistrationPage } from '../register';
import messages from './messages';
const Logistration = (props) => {
const { intl, selectedPage } = props;
const tpa = getTpaHint();
const [institutionLogin, setInstitutionLogin] = useState(false);
const [key, setKey] = useState('');
const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false;
useEffect(() => {
const authService = getAuthService();
if (authService) {
authService.getCsrfTokenService().getCsrfToken(getConfig().LMS_BASE_URL);
}
});
const handleInstitutionLogin = (e) => {
sendTrackEvent('edx.bi.institution_login_form.toggled', { category: 'user-engagement' });
if (typeof e === 'string') {
sendPageEvent('login_and_registration', e === '/login' ? 'login' : 'register');
} else {
sendPageEvent('login_and_registration', e.target.dataset.eventName);
}
setInstitutionLogin(!institutionLogin);
};
const handleOnSelect = (tabKey) => {
sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement' });
setKey(tabKey);
};
const tabTitle = (
<div className="d-flex">
<Icon src={ChevronLeft} className="left-icon" />
<span className="ml-2">
{selectedPage === LOGIN_PAGE
? intl.formatMessage(messages['logistration.sign.in'])
: intl.formatMessage(messages['logistration.register'])}
</span>
</div>
);
return (
<BaseComponent>
<div>
{disablePublicAccountCreation
? (
<>
<Redirect to={updatePathWithQueryParams(LOGIN_PAGE)} />
{institutionLogin && (
<Tabs defaultActiveKey="" id="controlled-tab" onSelect={handleInstitutionLogin}>
<Tab title={tabTitle} eventKey={LOGIN_PAGE} />
</Tabs>
)}
<div id="main-content" className="main-content">
{!institutionLogin && (
<h3 className="mb-4.5">{intl.formatMessage(messages['logistration.sign.in'])}</h3>
)}
<LoginPage institutionLogin={institutionLogin} handleInstitutionLogin={handleInstitutionLogin} />
</div>
</>
)
: (
<div>
{institutionLogin
? (
<Tabs defaultActiveKey="" id="controlled-tab" onSelect={handleInstitutionLogin}>
<Tab title={tabTitle} eventKey={selectedPage === LOGIN_PAGE ? LOGIN_PAGE : REGISTER_PAGE} />
</Tabs>
)
: (
<>
{!tpa && (
<Tabs defaultActiveKey={selectedPage} id="controlled-tab" onSelect={handleOnSelect}>
<Tab title={intl.formatMessage(messages['logistration.register'])} eventKey={REGISTER_PAGE} />
<Tab title={intl.formatMessage(messages['logistration.sign.in'])} eventKey={LOGIN_PAGE} />
</Tabs>
)}
</>
)}
{ key && (
<Redirect to={updatePathWithQueryParams(key)} />
)}
<div id="main-content" className="main-content">
{selectedPage === LOGIN_PAGE
? <LoginPage institutionLogin={institutionLogin} handleInstitutionLogin={handleInstitutionLogin} />
: (
<RegistrationPage
institutionLogin={institutionLogin}
handleInstitutionLogin={handleInstitutionLogin}
/>
)}
</div>
</div>
)}
</div>
</BaseComponent>
);
};
Logistration.propTypes = {
intl: intlShape.isRequired,
selectedPage: PropTypes.string,
};
Logistration.defaultProps = {
selectedPage: REGISTER_PAGE,
};
export default injectIntl(Logistration);

View File

@@ -1,15 +1,17 @@
import { FormattedMessage } from '@openedx/frontend-base';
import React from 'react';
const NotFoundPage = () => (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted mw-32em">
<FormattedMessage
id="error.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
import { FormattedMessage } from '@edx/frontend-platform/i18n';
export default NotFoundPage;
export default function NotFoundPage() {
return (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted mw-32em">
<FormattedMessage
id="error.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
}

View File

@@ -1,106 +1,41 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { useIntl } from '@openedx/frontend-base';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Form, Icon, IconButton, OverlayTrigger, Tooltip, useToggle,
} from '@openedx/paragon';
} from '@edx/paragon';
import {
Check, Remove, Visibility, VisibilityOff,
} from '@openedx/paragon/icons';
} from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { LETTER_REGEX, NUMBER_REGEX } from '../data/constants';
import { validatePasswordField } from '../register/data/utils';
import messages from './messages';
const noopFn = () => {};
const PasswordField = (props) => {
const { formatMessage } = useIntl();
const { formatMessage } = props.intl;
const [isPasswordHidden, setHiddenTrue, setHiddenFalse] = useToggle(true);
const [showTooltip, setShowTooltip] = useState(false);
const {
validationApiRateLimited = false,
clearRegistrationBackendError = noopFn,
validateField = noopFn,
} = props;
const handleBlur = (e) => {
const { name, value } = e.target;
if (name === props.name && e.relatedTarget?.name === 'passwordIcon') {
return; // Do not run validations on password icon click
}
let passwordValue = value;
if (name === 'passwordIcon') {
// To validate actual password value when onBlur is triggered by focusing out the password icon
passwordValue = props.value;
}
if (props.handleBlur) {
props.handleBlur({
target: {
name: props.name,
value: passwordValue,
},
});
}
if (props.handleBlur) { props.handleBlur(e); }
setShowTooltip(props.showRequirements && false);
if (props.handleErrorChange) { // If rendering from register page
const fieldError = validatePasswordField(passwordValue, formatMessage);
if (fieldError) {
props.handleErrorChange('password', fieldError);
} else if (!validationApiRateLimited) {
validateField({ password: passwordValue });
}
}
};
const handleFocus = (e) => {
if (e.target?.name === 'passwordIcon') {
return; // Do not clear error on password icon focus
}
if (props.handleFocus) {
props.handleFocus(e);
}
if (props.handleErrorChange) {
props.handleErrorChange('password', '');
clearRegistrationBackendError('password');
}
setTimeout(() => setShowTooltip(props.showRequirements && true), 150);
};
const HideButton = (
<IconButton
onFocus={handleFocus}
onBlur={handleBlur}
name="passwordIcon"
src={VisibilityOff}
iconAs={Icon}
onClick={setHiddenTrue}
size="sm"
variant="secondary"
alt={formatMessage(messages['hide.password'])}
/>
<IconButton onFocus={handleFocus} onBlur={handleBlur} name="passwordValidation" src={VisibilityOff} iconAs={Icon} onClick={setHiddenTrue} size="sm" variant="secondary" alt={formatMessage(messages['hide.password'])} />
);
const ShowButton = (
<IconButton
onFocus={handleFocus}
onBlur={handleBlur}
name="passwordIcon"
src={Visibility}
iconAs={Icon}
onClick={setHiddenFalse}
size="sm"
variant="secondary"
alt={formatMessage(messages['show.password'])}
/>
<IconButton onFocus={handleFocus} onBlur={handleBlur} name="passwordValidation" src={Visibility} iconAs={Icon} onClick={setHiddenFalse} size="sm" variant="secondary" alt={formatMessage(messages['show.password'])} />
);
const placement = window.innerWidth < 768 ? 'top' : 'left';
const tooltip = (
<Tooltip id={`password-requirement-${placement}`}>
@@ -124,7 +59,7 @@ const PasswordField = (props) => {
<OverlayTrigger key="tooltip" placement={placement} overlay={tooltip} show={showTooltip}>
<Form.Control
as="input"
className="form-group__form-field"
className="form-field"
type={isPasswordHidden ? 'password' : 'text'}
name={props.name}
value={props.value}
@@ -141,7 +76,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}
{props.showScreenReaderText && <span className="sr-only">{formatMessage(messages['password.sr.only.helping.text'])}</span>}
<span className="sr-only">{formatMessage(messages['password.sr.only.helping.text'])}</span>
</Form.Control.Feedback>
)}
</Form.Group>
@@ -154,13 +89,8 @@ PasswordField.defaultProps = {
handleBlur: null,
handleFocus: null,
handleChange: () => {},
handleErrorChange: null,
showRequirements: true,
showScreenReaderText: true,
autoComplete: null,
clearRegistrationBackendError: noopFn,
validateField: noopFn,
validationApiRateLimited: false,
};
PasswordField.propTypes = {
@@ -170,15 +100,11 @@ PasswordField.propTypes = {
handleBlur: PropTypes.func,
handleFocus: PropTypes.func,
handleChange: PropTypes.func,
handleErrorChange: PropTypes.func,
intl: intlShape.isRequired,
name: PropTypes.string.isRequired,
showRequirements: PropTypes.bool,
value: PropTypes.string.isRequired,
autoComplete: PropTypes.string,
showScreenReaderText: PropTypes.bool,
clearRegistrationBackendError: PropTypes.func,
validateField: PropTypes.func,
validationApiRateLimited: PropTypes.bool,
};
export default PasswordField;
export default injectIntl(PasswordField);

View File

@@ -1,24 +1,15 @@
import { useAppConfig, getSiteConfig } from '@openedx/frontend-base';
import PropTypes from 'prop-types';
import { Navigate } from 'react-router-dom';
import React from 'react';
import {
AUTHN_PROGRESSIVE_PROFILING, REDIRECT,
} from '../data/constants';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom';
import { WELCOME_PAGE } from '../data/constants';
import { setCookie } from '../data/utils';
const RedirectLogistration = (props) => {
function RedirectLogistration(props) {
const {
authenticatedUser,
finishAuthUrl,
redirectUrl,
redirectToProgressiveProfilingPage,
success,
optionalFields,
educationLevel,
userId,
registrationEmbedded,
host,
finishAuthUrl, redirectUrl, redirectToWelcomePage, success, optionalFields,
} = props;
let finalRedirectUrl = '';
@@ -28,67 +19,47 @@ const RedirectLogistration = (props) => {
// Note: For multiple enterprise use case, we need to make sure that user first visits the
// enterprise selection page and then complete the auth workflow
if (finishAuthUrl && !redirectUrl.includes(finishAuthUrl)) {
finalRedirectUrl = getSiteConfig().lmsBaseUrl + finishAuthUrl;
finalRedirectUrl = getConfig().LMS_BASE_URL + finishAuthUrl;
} else {
finalRedirectUrl = redirectUrl;
}
// Redirect to Progressive Profiling after successful registration
if (redirectToProgressiveProfilingPage) {
// TODO: Do we still need this cookie?
setCookie('van-504-returning-user', true, useAppConfig().SESSION_COOKIE_DOMAIN);
if (registrationEmbedded) {
window.parent.postMessage({
action: REDIRECT,
redirectUrl: useAppConfig().POST_REGISTRATION_REDIRECT_URL,
}, host);
return null;
}
if (redirectToWelcomePage) {
setCookie('van-504-returning-user', true);
// use this component to redirect WelcomePage after successful registration
// return <Redirect to={WELCOME_PAGE} />;
const registrationResult = { redirectUrl: finalRedirectUrl, success };
return (
<Navigate
to={AUTHN_PROGRESSIVE_PROFILING}
state={{
<Redirect to={{
pathname: WELCOME_PAGE,
state: {
registrationResult,
optionalFields,
authenticatedUser,
}}
replace
},
}}
/>
);
}
window.location.href = finalRedirectUrl;
}
return null;
};
return <></>;
}
RedirectLogistration.defaultProps = {
authenticatedUser: {},
educationLevel: null,
finishAuthUrl: null,
success: false,
redirectUrl: '',
redirectToProgressiveProfilingPage: false,
redirectToWelcomePage: false,
optionalFields: {},
userId: null,
registrationEmbedded: false,
host: '',
};
RedirectLogistration.propTypes = {
authenticatedUser: PropTypes.shape({}),
educationLevel: PropTypes.string,
finishAuthUrl: PropTypes.string,
success: PropTypes.bool,
redirectUrl: PropTypes.string,
redirectToProgressiveProfilingPage: PropTypes.bool,
redirectToWelcomePage: PropTypes.bool,
optionalFields: PropTypes.shape({}),
userId: PropTypes.number,
registrationEmbedded: PropTypes.bool,
host: PropTypes.string,
};
export default RedirectLogistration;

View File

@@ -1,21 +1,22 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Icon } from '@openedx/paragon';
import { Login } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
import messages from './messages';
const SocialAuthProviders = (props) => {
const { formatMessage } = useIntl();
const { referrer, socialAuthProviders } = props;
function SocialAuthProviders(props) {
const { intl, referrer, socialAuthProviders } = props;
function handleSubmit(e) {
e.preventDefault();
const url = e.currentTarget.dataset.providerUrl;
window.location.href = getSiteConfig().lmsBaseUrl + url;
window.location.href = getConfig().LMS_BASE_URL + url;
}
const socialAuth = socialAuthProviders.map((provider, index) => (
@@ -29,30 +30,29 @@ const SocialAuthProviders = (props) => {
>
{provider.iconImage ? (
<div aria-hidden="true">
<img className="btn-tpa__image-icon" src={provider.iconImage} alt={`icon ${provider.name}`} />
<img className="icon-image" src={provider.iconImage} alt={`icon ${provider.name}`} />
</div>
)
: (
<div className="btn-tpa__font-container" aria-hidden="true">
{SUPPORTED_ICON_CLASSES.includes(provider.iconClass) ? (
<FontAwesomeIcon icon={['fab', provider.iconClass]} />)
: (
<Icon className="h-75" src={Login} />
)}
</div>
<>
<div className="font-container" aria-hidden="true">
<FontAwesomeIcon
icon={SUPPORTED_ICON_CLASSES.includes(provider.iconClass) ? ['fab', provider.iconClass] : faSignInAlt}
/>
</div>
</>
)}
<span id="provider-name" className="notranslate mr-auto pl-2" aria-hidden="true">{provider.name}</span>
<span className="sr-only">
{referrer === LOGIN_PAGE
? formatMessage(messages['sso.sign.in.with'], { providerName: provider.name })
: formatMessage(messages['sso.create.account.using'], { providerName: provider.name })}
? intl.formatMessage(messages['sso.sign.in.with'], { providerName: provider.name })
: intl.formatMessage(messages['sso.create.account.using'], { providerName: provider.name })}
</span>
</button>
));
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{socialAuth}</>;
};
}
SocialAuthProviders.defaultProps = {
referrer: LOGIN_PAGE,
@@ -60,6 +60,7 @@ SocialAuthProviders.defaultProps = {
};
SocialAuthProviders.propTypes = {
intl: intlShape.isRequired,
referrer: PropTypes.string,
socialAuthProviders: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
@@ -68,8 +69,7 @@ SocialAuthProviders.propTypes = {
iconImage: PropTypes.string,
loginUrl: PropTypes.string,
registerUrl: PropTypes.string,
skipRegistrationForm: PropTypes.bool,
})),
};
export default SocialAuthProviders;
export default injectIntl(SocialAuthProviders);

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { TransitionReplace } from '@edx/paragon';
import PropTypes from 'prop-types';
const onChildExit = (htmlNode) => {
// If the leaving child has focus, take control and redirect it
if (htmlNode.contains(document.activeElement)) {
// Get the newly entering sibling.
// It's the previousSibling, but not for any explicit reason. So checking for both.
const enteringChild = htmlNode.previousSibling || htmlNode.nextSibling;
// There's no replacement, do nothing.
if (!enteringChild) return; // eslint-disable-line curly
// Get all the focusable elements in the entering child and focus the first one
const focusableElements = enteringChild.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusableElements.length) {
focusableElements[0].focus();
}
}
};
function SwitchContent({ expression, cases, className }) {
const getContent = (caseKey) => {
if (cases[caseKey]) {
if (typeof cases[caseKey] === 'string') {
return getContent(cases[caseKey]);
}
return React.cloneElement(cases[caseKey], { key: caseKey });
} else if (cases.default) { // eslint-disable-line no-else-return
if (typeof cases.default === 'string') {
return getContent(cases.default);
}
React.cloneElement(cases.default, { key: 'default' });
}
return null;
};
return (
<TransitionReplace
className={className}
onChildExit={onChildExit}
>
{getContent(expression)}
</TransitionReplace>
);
}
SwitchContent.propTypes = {
expression: PropTypes.string,
cases: PropTypes.objectOf(PropTypes.node).isRequired,
className: PropTypes.string,
};
SwitchContent.defaultProps = {
expression: null,
className: null,
};
export default SwitchContent;

View File

@@ -1,123 +0,0 @@
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
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 {
ENTERPRISE_LOGIN_URL, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE,
} from '../data/constants';
import messages from './messages';
import {
RenderInstitutionButton,
SocialAuthProviders,
} from './index';
/**
* This component renders the Single sign-on (SSO) buttons for the providers passed.
* */
const ThirdPartyAuth = (props) => {
const { formatMessage } = useIntl();
const {
providers,
secondaryProviders,
currentProvider,
handleInstitutionLogin,
thirdPartyAuthApiStatus,
isLoginPage,
} = props;
const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider;
const isSocialAuthActive = !!providers.length && !currentProvider;
const isEnterpriseLoginDisabled = useAppConfig().DISABLE_ENTERPRISE_LOGIN;
const enterpriseLoginURL = getSiteConfig().lmsBaseUrl + ENTERPRISE_LOGIN_URL;
const isThirdPartyAuthActive = isSocialAuthActive || (isEnterpriseLoginDisabled && isInstitutionAuthActive);
return (
<>
{((isEnterpriseLoginDisabled && isInstitutionAuthActive) || isSocialAuthActive) && (
<div className="mt-4 mb-3 h4">
{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 && isThirdPartyAuthActive ? (
<div className="mt-4">
<Skeleton className="tpa-skeleton" height={36} count={2} />
</div>
) : (
<>
{(isEnterpriseLoginDisabled && isInstitutionAuthActive) && (
<RenderInstitutionButton
onSubmitHandler={handleInstitutionLogin}
buttonTitle={formatMessage(messages['institution.login.button'])}
/>
)}
{isSocialAuthActive && (
<div className="row m-0">
<SocialAuthProviders
socialAuthProviders={providers}
referrer={isLoginPage ? LOGIN_PAGE : REGISTER_PAGE}
/>
</div>
)}
</>
)}
</>
);
};
ThirdPartyAuth.defaultProps = {
currentProvider: null,
providers: [],
secondaryProviders: [],
thirdPartyAuthApiStatus: PENDING_STATE,
isLoginPage: false,
};
ThirdPartyAuth.propTypes = {
currentProvider: PropTypes.string,
handleInstitutionLogin: PropTypes.func.isRequired,
providers: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
iconClass: PropTypes.string,
iconImage: PropTypes.string,
loginUrl: PropTypes.string,
registerUrl: PropTypes.string,
}),
),
secondaryProviders: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
iconClass: PropTypes.string,
iconImage: PropTypes.string,
loginUrl: PropTypes.string,
registerUrl: PropTypes.string,
}),
),
thirdPartyAuthApiStatus: PropTypes.string,
isLoginPage: PropTypes.bool,
};
export default ThirdPartyAuth;

View File

@@ -1,49 +1,42 @@
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Alert } from '@openedx/paragon';
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import PropTypes from 'prop-types';
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
import messages from './messages';
const ThirdPartyAuthAlert = (props) => {
const { formatMessage } = useIntl();
const { currentProvider, referrer } = props;
const platformName = getSiteConfig().siteName;
const { currentProvider, intl, referrer } = props;
const platformName = getConfig().SITE_NAME;
let message;
if (referrer === LOGIN_PAGE) {
message = formatMessage(messages['login.third.party.auth.account.not.linked'], { currentProvider, platformName });
message = intl.formatMessage(messages['login.third.party.auth.account.not.linked'], { currentProvider, platformName });
} else {
message = formatMessage(messages['register.third.party.auth.account.not.linked'], { currentProvider, platformName });
}
if (!currentProvider) {
return null;
message = intl.formatMessage(messages['register.third.party.auth.account.not.linked'], { currentProvider, platformName });
}
return (
<>
<Alert id="tpa-alert" className={referrer === REGISTER_PAGE ? 'alert-success mt-n2 mb-5' : 'alert-warning mt-n2 mb-5'}>
{referrer === REGISTER_PAGE ? (
<Alert.Heading>{formatMessage(messages['tpa.alert.heading'])}</Alert.Heading>
) : null}
<p>{message}</p>
</Alert>
<Alert id="tpa-alert" className={referrer === REGISTER_PAGE ? 'alert-success mt-n2' : 'alert-warning mt-n2'}>
{referrer === REGISTER_PAGE ? (
<h4 className="mt-4 mb-4">{formatMessage(messages['registration.using.tpa.form.heading'])}</h4>
<Alert.Heading>{intl.formatMessage(messages['tpa.alert.heading'])}</Alert.Heading>
) : null}
</>
<p>{ message }</p>
</Alert>
);
};
ThirdPartyAuthAlert.defaultProps = {
currentProvider: '',
referrer: LOGIN_PAGE,
};
ThirdPartyAuthAlert.propTypes = {
currentProvider: PropTypes.string,
currentProvider: PropTypes.string.isRequired,
intl: intlShape.isRequired,
referrer: PropTypes.string,
};
export default ThirdPartyAuthAlert;
export default injectIntl(ThirdPartyAuthAlert);

View File

@@ -1,17 +1,16 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { fetchAuthenticatedUser, getAuthenticatedUser, getSiteConfig } from '@openedx/frontend-base';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { Route } from 'react-router-dom';
import {
DEFAULT_REDIRECT_URL,
} from '../data/constants';
import { DEFAULT_REDIRECT_URL } from '../data/constants';
/**
* This wrapper redirects the requester to our default redirect url if they are
* already authenticated.
*/
const UnAuthOnlyRoute = ({ children }) => {
const UnAuthOnlyRoute = (props) => {
const [authUser, setAuthUser] = useState({});
const [isReady, setIsReady] = useState(false);
@@ -24,18 +23,14 @@ const UnAuthOnlyRoute = ({ children }) => {
if (isReady) {
if (authUser && authUser.username) {
global.location.href = getSiteConfig().lmsBaseUrl.concat(DEFAULT_REDIRECT_URL);
global.location.href = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
return null;
}
return children;
return <Route {...props} />;
}
return null;
};
UnAuthOnlyRoute.propTypes = {
children: PropTypes.node.isRequired,
return <></>;
};
export default UnAuthOnlyRoute;

View File

@@ -1,60 +0,0 @@
import { render, screen } from '@testing-library/react';
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')).toBeTruthy();
});
it('should provide all context values to children', () => {
render(
<ThirdPartyAuthProvider>
<TestComponent />
</ThirdPartyAuthProvider>,
);
expect(screen.getByText('FieldDescriptions Available')).toBeTruthy();
expect(screen.getByText('OptionalFields Available')).toBeTruthy();
expect(screen.getByText('AuthApiStatus Not Available')).toBeTruthy(); // Initially null
expect(screen.getByText('AuthContext Available')).toBeTruthy();
});
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')).toBeTruthy();
expect(screen.getByText('Second Child')).toBeTruthy();
expect(screen.getByText('Third Child')).toBeTruthy();
});
});

View File

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

View File

@@ -0,0 +1,22 @@
import { AsyncActionType } from '../../data/utils';
export const THIRD_PARTY_AUTH_CONTEXT = new AsyncActionType('THIRD_PARTY_AUTH', 'GET_THIRD_PARTY_AUTH_CONTEXT');
// 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,
});

View File

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

View File

@@ -1,19 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { getThirdPartyAuthContext } from './api';
import { ThirdPartyAuthQueryKeys } from './queryKeys';
// Error constants
export const THIRD_PARTY_AUTH_ERROR = 'third-party-auth-error';
const useThirdPartyAuthHook = (pageId, payload, { enabled = true } = {}) => useQuery({
queryKey: ThirdPartyAuthQueryKeys.byPage(pageId, payload),
queryFn: () => getThirdPartyAuthContext(payload),
retry: false,
staleTime: 5 * 60 * 1000, // 5 minutes — TPA context is effectively static per session
enabled,
});
export {
useThirdPartyAuthHook,
};

View File

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

View File

@@ -0,0 +1,37 @@
import { COMPLETE_STATE, PENDING_STATE } from '../../data/constants';
import { THIRD_PARTY_AUTH_CONTEXT } from './actions';
export const defaultState = {
extendedProfile: [],
fieldDescriptions: {},
optionalFields: {},
thirdPartyAuthApiStatus: 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,
extendedProfile: action.payload.fieldDescriptions.extendedProfile,
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: COMPLETE_STATE,
};
default:
return state;
}
};
export default reducer;

View File

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

View File

@@ -0,0 +1,25 @@
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 extendedProfileSelector = createSelector(
commonComponentsSelector,
commonComponents => commonComponents.extendedProfile,
);
export const optionalFieldsSelector = createSelector(
commonComponentsSelector,
commonComponents => commonComponents.optionalFields,
);

View File

@@ -0,0 +1,29 @@
import { camelCaseObject, convertKeyNames, 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 requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
params: urlParams,
isPublic: true,
};
const { data } = await getAuthenticatedHttpClient()
.get(
`${getConfig().LMS_BASE_URL}/api/mfe_context`,
requestConfig,
)
.catch((e) => {
throw (e);
});
return {
fieldDescriptions: data.registration_fields || {},
optionalFields: data.optional_fields || {},
// For backward compatibility with the API, once https://github.com/openedx/edx-platform/pull/30198 is merged
// and deployed update it to use data.context_data
thirdPartyAuthContext: camelCaseObject(
convertKeyNames(data.context_data || data, { fullname: 'name' }),
),
};
}

View File

@@ -0,0 +1,71 @@
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();
});
});

View File

@@ -1,11 +1,14 @@
export { default as RedirectLogistration } from './RedirectLogistration';
export { default as registerIcons } from './RegisterFaIcons';
export { default as EmbeddedRegistrationRoute } from './EmbeddedRegistrationRoute';
export { default as UnAuthOnlyRoute } from './UnAuthOnlyRoute';
export { default as NotFoundPage } from './NotFoundPage';
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 Logistration } from './Logistration';

View File

@@ -1,13 +1,29 @@
import { defineMessages } from '@openedx/frontend-base';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
// institution login strings
'institution.login.page.sub.heading': {
id: 'institution.login.page.sub.heading',
defaultMessage: 'Choose your institution from the list below',
description: 'Heading of the institutions list',
},
// logistration strings
// Confirmation Alert Message
'forgot.password.confirmation.title': {
id: 'forgot.password.confirmation.title',
defaultMessage: 'Check your email',
description: 'Forgot password confirmation message title',
},
'forgot.password.confirmation.support.link': {
id: 'forgot.password.confirmation.support.link',
defaultMessage: 'contact technical support',
description: 'Technical support link text',
},
'forgot.password.confirmation.info': {
id: 'forgot.password.confirmation.info',
defaultMessage: 'If you do not receive a password reset message after 1 minute, verify that you entered the correct '
+ 'email address, or check your spam folder.',
description: 'Part of message that appears after user requests password change',
},
// Logistration strinsg
'logistration.sign.in': {
id: 'logistration.sign.in',
defaultMessage: 'Sign in',
@@ -18,12 +34,27 @@ const messages = defineMessages({
defaultMessage: 'Register',
description: 'Text that appears on the tab to switch between login and register',
},
'internal.server.error.message': {
id: 'internal.server.error.message',
defaultMessage: 'An error has occurred. Try refreshing the page, or check your internet connection.',
description: 'Error message that appears when server responds with 500 error code',
},
'server.ratelimit.error.message': {
id: 'server.ratelimit.error.message',
defaultMessage: 'An error has occurred because of too many requests. Please try again after some time.',
description: 'Error message that appears when server responds with 429 error code',
},
// enterprise sso strings
'enterprisetpa.title.heading': {
id: 'enterprisetpa.title.heading',
defaultMessage: 'Would you like to sign in using your {providerName} credentials?',
description: 'Header text used in enterprise third party authentication',
},
'enterprisetpa.sso.button.title': {
id: 'enterprisetpa.sso.button.title',
defaultMessage: 'Sign in using {providerName}',
description: 'Text for third party auth provider buttons',
},
'enterprisetpa.login.button.text': {
id: 'enterprisetpa.login.button.text',
defaultMessage: 'Show me other ways to sign in or register',
@@ -85,42 +116,17 @@ const messages = defineMessages({
'login.third.party.auth.account.not.linked': {
id: 'login.third.party.auth.account.not.linked',
defaultMessage: 'You have successfully signed into {currentProvider}, but your {currentProvider} '
+ 'account does not have a linked {platformName} account. To link your accounts, '
+ 'sign in now using your {platformName} password.',
+ 'account does not have a linked {platformName} account. To link your accounts, '
+ 'sign in now using your {platformName} password.',
description: 'Message that appears on login page if user has successfully authenticated with social '
+ 'auth but no associated platform account exists',
+ 'auth but no associated platform account exists',
},
'register.third.party.auth.account.not.linked': {
id: 'register.third.party.auth.account.not.linked',
defaultMessage: 'You\'ve successfully signed into {currentProvider}! We just need a little more information '
+ 'before you start learning with {platformName}.',
+ 'before you start learning with {platformName}.',
description: 'Message that appears on register page if user has successfully authenticated with TPA '
+ 'but no associated platform account exists',
},
'registration.using.tpa.form.heading': {
id: 'registration.using.tpa.form.heading',
defaultMessage: 'Finish creating your account',
description: 'Heading that appears above form when user is trying to create account using social auth',
},
'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.',
+ 'but no associated platform account exists',
},
});

View File

@@ -1,76 +0,0 @@
/* eslint-disable import/no-import-module-exports */
/* eslint-disable react/function-component-definition */
import { getSiteConfig } from '@openedx/frontend-base';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
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
RRD.BrowserRouter = ({ children }) => <div>{children}</div>;
module.exports = RRD;
const TestApp = () => (
<Router>
<div>
<Routes>
<Route
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>
);
describe('EmbeddedRegistrationRoute', () => {
const routerWrapper = () => (
<MemoryRouter initialEntries={[REGISTER_EMBEDDED_PAGE]}>
<TestApp />
</MemoryRouter>
);
afterEach(() => {
jest.clearAllMocks();
});
it('should not render embedded register page if host query param is not available in the url', async () => {
let embeddedRegistrationPage = null;
await act(async () => {
const { container } = await render(routerWrapper());
embeddedRegistrationPage = container;
});
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 () => {
delete window.location;
window.location = {
href: getSiteConfig().baseUrl.concat(REGISTER_EMBEDDED_PAGE),
search: '?host=http://localhost/host-websit',
};
let embeddedRegistrationPage = null;
await act(async () => {
const { container } = await render(routerWrapper());
embeddedRegistrationPage = container;
});
const renderedPage = embeddedRegistrationPage.querySelector('span');
expect(renderedPage).toBeTruthy();
expect(renderedPage.textContent).toBe('Embedded Register Page');
});
});

View File

@@ -1,11 +1,12 @@
import { IntlProvider } from '@openedx/frontend-base';
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import FormGroup from '../FormGroup';
import PasswordField from '../PasswordField';
describe('FormGroup', () => {
const props = {
floatingLabel: 'Email',
@@ -16,29 +17,18 @@ describe('FormGroup', () => {
};
it('should show help text on field focus', () => {
const { queryByText, getByLabelText } = render(<FormGroup {...props} />);
const emailInput = getByLabelText('Email');
const formGroup = mount(<FormGroup {...props} />);
expect(formGroup.find('.pgn-transition-replace-group').find('div#email-1').exists()).toBeFalsy();
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');
formGroup.find('input#email').simulate('focus');
expect(formGroup.find('.pgn-transition-replace-group').find('div#email-1').text()).toEqual('Email field help text');
});
});
describe('PasswordField', () => {
const IntlPasswordField = injectIntl(PasswordField);
let props = {};
const wrapper = children => (
<IntlProvider locale="en">
{children}
</IntlProvider>
);
beforeEach(() => {
props = {
floatingLabel: 'Password',
@@ -49,29 +39,25 @@ describe('PasswordField', () => {
});
it('should show/hide password on icon click', () => {
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = getByLabelText('Password');
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
const showPasswordButton = getByLabelText('Show password');
fireEvent.click(showPasswordButton);
expect(passwordInput.type).toBe('text');
passwordField.find('button[aria-label="Show password"]').simulate('click');
expect(passwordField.find('input').prop('type')).toEqual('text');
const hidePasswordButton = getByLabelText('Hide password');
fireEvent.click(hidePasswordButton);
expect(passwordInput.type).toBe('password');
passwordField.find('button[aria-label="Hide password"]').simulate('click');
expect(passwordField.find('input').prop('type')).toEqual('password');
});
it('should show password requirement tooltip on focus', async () => {
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = getByLabelText('Password');
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
jest.useFakeTimers();
await act(async () => {
fireEvent.focus(passwordInput);
passwordField.find('input').simulate('focus');
jest.runAllTimers();
});
const passwordRequirementTooltip = document.querySelector('#password-requirement-left');
passwordField.update();
expect(passwordRequirementTooltip).toBeTruthy();
expect(passwordField.find('#password-requirement-left').exists()).toBeTruthy();
});
it('should show all password requirement checks as failed', async () => {
@@ -79,195 +65,31 @@ describe('PasswordField', () => {
...props,
value: '',
};
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = getByLabelText('Password');
jest.useFakeTimers();
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
await act(async () => {
fireEvent.focus(passwordInput);
passwordField.find('input').simulate('focus');
jest.runAllTimers();
});
passwordField.update();
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');
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');
});
it('should update password requirement checks', async () => {
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = getByLabelText('Password');
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
jest.useFakeTimers();
await act(async () => {
fireEvent.focus(passwordInput);
passwordField.find('input').simulate('focus');
jest.runAllTimers();
});
passwordField.update();
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 { container, getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = container.querySelector('input[name="password"]');
const passwordIcon = getByLabelText('Show password');
fireEvent.blur(passwordInput, {
target: {
name: 'password',
value: 'invalid',
},
relatedTarget: passwordIcon,
});
expect(container.querySelector('div[feedback-for="password"]')).toBeNull();
});
it('should call props handle blur if available', () => {
props = {
...props,
handleBlur: jest.fn(),
};
const { container } = render(wrapper(<PasswordField {...props} />));
const passwordInput = container.querySelector('input[name="password"]');
fireEvent.blur(passwordInput, {
target: {
name: 'password',
value: '',
},
});
expect(props.handleBlur).toHaveBeenCalledTimes(1);
});
it('should run validations on blur event when rendered from register page', () => {
props = {
...props,
handleErrorChange: jest.fn(),
};
const { container } = render(wrapper(<PasswordField {...props} />));
const passwordInput = container.querySelector('input[name="password"]');
fireEvent.blur(passwordInput, {
target: {
name: 'password',
value: '',
},
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'password',
'Password criteria has not been met',
);
});
it('should not clear error when focus is fired on password icon click when rendered from register page', () => {
props = {
...props,
handleErrorChange: jest.fn(),
};
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordIcon = getByLabelText('Show password');
fireEvent.focus(passwordIcon, {
target: {
name: 'passwordIcon',
value: '',
},
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(0);
});
it('should clear error when focus is fired on password icon click when rendered from register page', () => {
props = {
...props,
handleErrorChange: jest.fn(),
};
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordIcon = getByLabelText('Show password');
fireEvent.focus(passwordIcon, {
target: {
name: 'password',
value: 'invalid',
},
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'password',
'',
);
});
it('should run backend validations when frontend validations pass on blur when rendered from register page', () => {
const mockValidateField = jest.fn();
props = {
...props,
handleErrorChange: jest.fn(),
validateField: mockValidateField,
};
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordField = getByLabelText('Password');
fireEvent.blur(passwordField, {
target: {
name: 'password',
value: 'password123',
},
});
expect(mockValidateField).toHaveBeenCalledWith({ password: 'password123' });
});
it('should use password value from prop when password icon is focused out (blur due to icon)', () => {
props = {
...props,
value: 'testPassword',
handleErrorChange: jest.fn(),
handleBlur: jest.fn(),
};
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordIcon = getByLabelText('Show password');
fireEvent.blur(passwordIcon, {
target: {
name: 'passwordIcon',
value: undefined,
},
});
expect(props.handleBlur).toHaveBeenCalledTimes(1);
expect(props.handleBlur).toHaveBeenCalledWith({
target: {
name: 'password',
value: 'testPassword',
},
});
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');
});
});

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