feat: added support for both legacy and new design (#349)
This commit is contained in:
669
src/redesign/_style.scss
Normal file
669
src/redesign/_style.scss
Normal file
@@ -0,0 +1,669 @@
|
||||
// ----------------------------
|
||||
// #COLORS
|
||||
// ----------------------------
|
||||
$font-blue: #126f9a;
|
||||
$white: #FFFFFF;
|
||||
|
||||
// social platforms
|
||||
$facebook-blue: #1877F2;
|
||||
$facebook-focus-blue: #29487d;
|
||||
$google-blue: #4285f4;
|
||||
$google-focus-blue: #287ae6;
|
||||
$microsoft-black: #2f2f2f;
|
||||
$microsoft-focus-black: #000;
|
||||
$apple-black: #000000;
|
||||
$apple-focus-black: $apple-black;
|
||||
$accent-a-light: #c9f2f5;
|
||||
|
||||
.main-content {
|
||||
@extend .pt-4;
|
||||
min-width: 464px !important;
|
||||
}
|
||||
|
||||
.stateful-button-width {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.login-button-width {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.tpa-skeleton {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
a {
|
||||
color: $primary !important;
|
||||
:hover {
|
||||
color: $primary !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
left: -10000px;
|
||||
top: auto;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.close {
|
||||
padding-bottom: 0px !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
|
||||
.focus-out {
|
||||
position: absolute;
|
||||
padding-left: 17px;
|
||||
opacity: 0.75;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.alert-link {
|
||||
font-weight: normal;
|
||||
text-decoration: underline;
|
||||
color: #0075b4 !important;
|
||||
|
||||
&:hover {
|
||||
color: #065683 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.authn-header {
|
||||
border-bottom: 1px solid #e7e7e7;
|
||||
height: 3.75rem;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.authn-header img {
|
||||
height: 1.75rem;
|
||||
margin-left: 2rem;
|
||||
padding: 1rem 0;
|
||||
display: block;
|
||||
position: relative;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.btn-social {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
margin-bottom: 1rem;
|
||||
font-size: 14px;
|
||||
|
||||
background-color: $white;
|
||||
border: 1px solid $font-blue;
|
||||
width: 224px;
|
||||
height: 36px;
|
||||
color: $font-blue;
|
||||
|
||||
.icon-image {
|
||||
background-color: transparent;
|
||||
max-height: 24px;
|
||||
max-width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-tpa {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding-left: 1rem;
|
||||
width: 14rem;
|
||||
|
||||
.icon-image {
|
||||
background-color: transparent;
|
||||
max-height: 24px;
|
||||
max-width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.font-container {
|
||||
background-color: $font-blue;
|
||||
color: $white;
|
||||
font-size: 11px;
|
||||
|
||||
margin-left: -6px;
|
||||
padding-top: 10px;
|
||||
min-width: 30px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.btn-oa2-facebook {
|
||||
color: $white;
|
||||
border-color: $facebook-blue;
|
||||
background-color: $facebook-blue;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: $facebook-focus-blue;
|
||||
border: 1px solid $facebook-focus-blue;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-oa2-google-oauth2 {
|
||||
color: $white;
|
||||
border-color: $google-blue;
|
||||
background-color: $google-blue;
|
||||
|
||||
.icon-image {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: $google-focus-blue;
|
||||
border: 1px solid $google-focus-blue;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-oa2-apple-id {
|
||||
color: $white;
|
||||
border-color: $apple-black;
|
||||
background-color: $apple-black;
|
||||
font-size: 16px;
|
||||
|
||||
.icon-image {
|
||||
max-height: 1.8em;
|
||||
max-width: 2.0em;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: $apple-focus-black;
|
||||
border: 1px solid $apple-focus-black;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-oa2-azuread-oauth2 {
|
||||
color: $white;
|
||||
border-color: $microsoft-black;
|
||||
background-color: $microsoft-black;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: $microsoft-focus-black;
|
||||
border: 1px solid $microsoft-focus-black;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.submit {
|
||||
display: inherit;
|
||||
margin: 0 auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-heading-line {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 20%;
|
||||
background-color: gray;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
width: 20%;
|
||||
background-color: gray;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.institute-icon {
|
||||
@extend .mr-1;
|
||||
@extend .text-gray;
|
||||
|
||||
display: inline-block;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
|
||||
svg {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.login-help {
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
.full-vertical-height {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.help-links {
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
#honor-code p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#honor-code a span {
|
||||
@extend .sr-only;
|
||||
}
|
||||
|
||||
.font-weight-500 {
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.mw-420 {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.mw-500 {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.mw-32em {
|
||||
max-width: 32em;
|
||||
}
|
||||
|
||||
.h-90 {
|
||||
height: 90%;
|
||||
}
|
||||
|
||||
.mt-10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mt-7 {
|
||||
margin-top: 7rem;
|
||||
}
|
||||
|
||||
.pt-10 {
|
||||
padding-top: 10px;
|
||||
}
|
||||
.tooltip-shadow {
|
||||
box-shadow: 0px 5px 15px 0px rgba(0,0,0,0.3) !important;
|
||||
}
|
||||
#password-requirement-left {
|
||||
opacity: 1;
|
||||
.tooltip-inner {
|
||||
@extend .tooltip-shadow;
|
||||
background: white;
|
||||
display: block;
|
||||
color: #707070;
|
||||
}
|
||||
.arrow::before {
|
||||
border-left-color: #fff;
|
||||
@extend .tooltip-shadow;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#password-requirement-top {
|
||||
opacity: 1;
|
||||
width: 90%;
|
||||
margin-bottom: 10px;
|
||||
.tooltip-inner {
|
||||
max-width: inherit;
|
||||
background: white;
|
||||
display: block;
|
||||
@extend .tooltip-shadow;
|
||||
color: #707070;
|
||||
}
|
||||
.arrow::before {
|
||||
border-top-color: #fff;
|
||||
@extend .tooltip-shadow;
|
||||
}
|
||||
|
||||
}
|
||||
#forgotpassword-success-alert {
|
||||
.alert-link {
|
||||
color: #454545 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mw-500 {
|
||||
width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 463px) {
|
||||
.reset-password-container {
|
||||
width: 420px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.tpa-skeleton {
|
||||
min-width: 464px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 464px) {
|
||||
.section-heading-line {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
width: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-social {
|
||||
min-width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
min-width: 100vw !important;
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.large-screen-container {
|
||||
background-color: $white;
|
||||
width: 50vw;
|
||||
}
|
||||
|
||||
.medium-screen-container {
|
||||
flex-wrap: nowrap;
|
||||
max-width: inherit;
|
||||
height: 282px;
|
||||
}
|
||||
|
||||
.screen-header {
|
||||
background-color: $primary-400;
|
||||
}
|
||||
|
||||
.screen-polygon {
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
.medium-screen-svg {
|
||||
fill: $primary-400;
|
||||
overflow: inherit;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.large-screen-svg {
|
||||
fill: $primary-400;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.small-screen-header {
|
||||
background-color: $primary-400;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.large-screen-top-stripe {
|
||||
height: 0.5rem;
|
||||
background-image: linear-gradient(
|
||||
102.02deg,
|
||||
$brand-700 10%,
|
||||
$brand 10%,
|
||||
$brand 45%,
|
||||
$primary-700 45%,
|
||||
$primary-700 55%,
|
||||
$accent-a 55%,
|
||||
$accent-a 75%,
|
||||
$accent-a-light 75%,
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.medium-screen-top-stripe {
|
||||
height: 0.5rem;
|
||||
background-image: linear-gradient(
|
||||
102.02deg,
|
||||
$brand-700,
|
||||
$brand-700 10%,
|
||||
$brand 10%,
|
||||
$brand 90%,
|
||||
$primary-700 90%,
|
||||
$primary-700 100%,
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.medium-large-screen-top-stripe {
|
||||
height: 0.5rem;
|
||||
background-image: linear-gradient(
|
||||
102.02deg,
|
||||
$brand-700 10%,
|
||||
$brand 10%,
|
||||
$brand 65%,
|
||||
$primary-700 65%,
|
||||
$primary-700 75%,
|
||||
$accent-a 75%,
|
||||
$accent-a 75%);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.small-screen-top-stripe {
|
||||
height: 0.5rem;
|
||||
background-image: linear-gradient(
|
||||
102.02deg,
|
||||
$brand-700,
|
||||
$brand-700 20%,
|
||||
$brand 20%,
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.large-screen-svg-line {
|
||||
padding-top: 0.5rem;
|
||||
stroke: $accent-b;
|
||||
stroke-width: 0.5rem;
|
||||
width: 5.5rem;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.medium-screen-svg-line {
|
||||
padding-top: 0.5rem;
|
||||
stroke: $accent-b;
|
||||
stroke-width: 0.5rem;
|
||||
width: 7em;
|
||||
height: 115px;
|
||||
}
|
||||
|
||||
.small-screen-svg-line {
|
||||
padding-top: 0.5rem;
|
||||
stroke: $accent-b;
|
||||
stroke-width: 0.25rem;
|
||||
width: 4em;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.large-heading {
|
||||
margin-left: 7px;
|
||||
color: $white;
|
||||
max-width: 24rem;
|
||||
line-height: 78px;
|
||||
font-size: 78px;
|
||||
}
|
||||
|
||||
.medium-heading {
|
||||
padding-left: 1rem;
|
||||
color: $white;
|
||||
max-width: 27rem;
|
||||
line-height: 60px;
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
.small-heading {
|
||||
padding-left: 0.5rem;
|
||||
color: $white;
|
||||
line-height: 40px;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 4.44rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.username-suggestion {
|
||||
padding: 0.15rem 0.5rem;
|
||||
margin: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.username-suggestion-label {
|
||||
font-size: 0.875rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.yellow-border {
|
||||
border: 2px solid #F0CC00;
|
||||
}
|
||||
|
||||
.one-rem-font {
|
||||
font-size: 0.99rem;
|
||||
color: #707070;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction:column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) and (min-width: 768px) {
|
||||
.layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction:column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.layout{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
}
|
||||
.content {
|
||||
width: 50vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.institute-heading {
|
||||
color: $primary-700;
|
||||
}
|
||||
|
||||
.logistration-button {
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.logistration-button:hover{
|
||||
color: $gray-700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
background-color: #F2F0EF;
|
||||
}
|
||||
|
||||
.dropdown-container {
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.15), 0px 2px 8px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
line-height: 1.25rem;
|
||||
overflow-y: scroll;
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
width: 464px;
|
||||
}
|
||||
|
||||
@media (max-width: 464px) {
|
||||
.dropdown-container {
|
||||
width: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.-mt-4 {
|
||||
margin-top: -1.5rem;
|
||||
}
|
||||
|
||||
.alert .alert-icon {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.alert-dismissible .close {
|
||||
top: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
:hover {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
.email-top-domain-suggestion-alert-link {
|
||||
color: $primary !important;
|
||||
}
|
||||
|
||||
#forgot-password {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
.password-visibility{
|
||||
.btn-icon__icon-container .btn-icon__icon {
|
||||
height: 1.5rem !important;
|
||||
width: 1.5rem !important;
|
||||
color: $gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow-back-icon {
|
||||
max-width: 1rem;
|
||||
max-height: 1rem;
|
||||
margin-top:6px;
|
||||
}
|
||||
50
src/redesign/common-components/APIFailureMessage.jsx
Normal file
50
src/redesign/common-components/APIFailureMessage.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import messages from './messages';
|
||||
import { API_RATELIMIT_ERROR, INTERNAL_SERVER_ERROR } from '../data/constants';
|
||||
|
||||
const APIFailureMessage = (props) => {
|
||||
const { intl, header, errorCode } = props;
|
||||
let errorMessage = null;
|
||||
let id = null;
|
||||
|
||||
switch (errorCode) {
|
||||
case INTERNAL_SERVER_ERROR:
|
||||
id = INTERNAL_SERVER_ERROR;
|
||||
errorMessage = intl.formatMessage(messages['internal.server.error.message']);
|
||||
break;
|
||||
case API_RATELIMIT_ERROR:
|
||||
id = API_RATELIMIT_ERROR;
|
||||
errorMessage = intl.formatMessage(messages['server.ratelimit.error.message']);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-center m-4">
|
||||
<div className="d-flex flex-column mw-500">
|
||||
<Alert id={id} variant="danger">
|
||||
<Alert.Heading>
|
||||
{header}
|
||||
</Alert.Heading>
|
||||
<ul>
|
||||
<li key={errorMessage}>
|
||||
{errorMessage}
|
||||
</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
APIFailureMessage.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
header: PropTypes.string.isRequired,
|
||||
errorCode: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(APIFailureMessage);
|
||||
45
src/redesign/common-components/BaseComponent.jsx
Normal file
45
src/redesign/common-components/BaseComponent.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Responsive from 'react-responsive';
|
||||
|
||||
import LargeScreenLayout from './LargeScreenLayout';
|
||||
import MediumScreenHeader from './MediumScreenHeader';
|
||||
import SmallScreenHeader from './SmallScreenHeader';
|
||||
|
||||
const BaseComponent = ({ children }) => (
|
||||
<>
|
||||
<Responsive maxWidth={767}>
|
||||
<div className="col-md-12 small-screen-top-stripe" />
|
||||
</Responsive>
|
||||
<Responsive minWidth={768} maxWidth={799}>
|
||||
<div className="col-md-12 medium-screen-top-stripe" />
|
||||
</Responsive>
|
||||
<Responsive minWidth={800} maxWidth={1199}>
|
||||
<div className="col-md-12 medium-large-screen-top-stripe" />
|
||||
</Responsive>
|
||||
<Responsive minWidth={1200}>
|
||||
<div className="col-md-12 large-screen-top-stripe" />
|
||||
</Responsive>
|
||||
<div className="layout">
|
||||
<Responsive maxWidth={767}>
|
||||
<SmallScreenHeader />
|
||||
</Responsive>
|
||||
<Responsive minWidth={768} maxWidth={1199}>
|
||||
<MediumScreenHeader />
|
||||
</Responsive>
|
||||
<Responsive minWidth={1200}>
|
||||
<LargeScreenLayout />
|
||||
</Responsive>
|
||||
<div className="content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
BaseComponent.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default BaseComponent;
|
||||
104
src/redesign/common-components/EnterpriseSSO.jsx
Normal file
104
src/redesign/common-components/EnterpriseSSO.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import {
|
||||
Form, Button,
|
||||
} from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const EnterpriseSSO = (props) => {
|
||||
const { intl } = props;
|
||||
const tpaProvider = props.provider;
|
||||
|
||||
const handleSubmit = (e, url) => {
|
||||
e.preventDefault();
|
||||
window.location.href = getConfig().LMS_BASE_URL + url;
|
||||
};
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = LOGIN_PAGE;
|
||||
};
|
||||
|
||||
if (tpaProvider) {
|
||||
return (
|
||||
<div className="d-flex justify-content-center m-4">
|
||||
<div className="d-flex flex-column">
|
||||
<div className="mw-450">
|
||||
<Form className="m-0">
|
||||
<p>{intl.formatMessage(messages['enterprisetpa.title.heading'], { providerName: tpaProvider.name })}</p>
|
||||
<Button
|
||||
id={tpaProvider.id}
|
||||
key={tpaProvider.id}
|
||||
type="submit"
|
||||
variant="link"
|
||||
className={`btn-tpa btn-${tpaProvider.id}`}
|
||||
onClick={(e) => handleSubmit(e, tpaProvider.loginUrl)}
|
||||
>
|
||||
{tpaProvider.iconImage ? (
|
||||
<div aria-hidden="true">
|
||||
<img className="icon-image" src={tpaProvider.iconImage} alt={`icon ${tpaProvider.name}`} />
|
||||
<span className="pl-2" aria-hidden="true">{ tpaProvider.name }</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="mb-4" />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline-primary"
|
||||
state="Complete"
|
||||
className="w-100"
|
||||
onClick={(e) => handleClick(e)}
|
||||
>
|
||||
{intl.formatMessage(messages['enterprisetpa.login.button.text'])}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
};
|
||||
|
||||
EnterpriseSSO.defaultProps = {
|
||||
provider: {
|
||||
id: '',
|
||||
name: '',
|
||||
iconClass: '',
|
||||
iconImage: '',
|
||||
loginUrl: '',
|
||||
registerUrl: '',
|
||||
},
|
||||
};
|
||||
|
||||
EnterpriseSSO.propTypes = {
|
||||
provider: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
iconClass: PropTypes.string,
|
||||
iconImage: PropTypes.string,
|
||||
loginUrl: PropTypes.string,
|
||||
registerUrl: PropTypes.string,
|
||||
}),
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(EnterpriseSSO);
|
||||
98
src/redesign/common-components/FormGroup.jsx
Normal file
98
src/redesign/common-components/FormGroup.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Form, TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const FormGroup = (props) => {
|
||||
const [hasFocus, setHasFocus] = useState(false);
|
||||
|
||||
const handleFocus = (e) => {
|
||||
setHasFocus(true);
|
||||
if (props.handleFocus) { props.handleFocus(e); }
|
||||
};
|
||||
const handleClick = (e) => {
|
||||
if (props.handleClick) { props.handleClick(e); }
|
||||
};
|
||||
const handleOnBlur = (e) => {
|
||||
setHasFocus(false);
|
||||
if (props.handleBlur) { props.handleBlur(e); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Group controlId={props.name} className="mb-4" isInvalid={props.errorMessage !== ''}>
|
||||
<Form.Control
|
||||
as={props.as}
|
||||
type={props.type}
|
||||
|
||||
autoComplete={props.autoComplete}
|
||||
name={props.name}
|
||||
value={props.value}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleOnBlur}
|
||||
onClick={handleClick}
|
||||
onChange={props.handleChange}
|
||||
controlClassName={props.borderClass}
|
||||
|
||||
trailingElement={props.trailingElement}
|
||||
floatingLabel={props.floatingLabel}
|
||||
>
|
||||
{props.options ? props.options() : null}
|
||||
</Form.Control>
|
||||
<TransitionReplace>
|
||||
{hasFocus && props.helpText ? (
|
||||
<Form.Control.Feedback type="default" key="help-text" className="d-block">
|
||||
{props.helpText.map((message, index) => (
|
||||
<span key={`help-text-${index.toString()}`}>
|
||||
{message}
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</Form.Control.Feedback>
|
||||
) : <div key="empty" />}
|
||||
</TransitionReplace>
|
||||
{props.errorMessage !== '' && (
|
||||
<Form.Control.Feedback key="error" hasIcon={false} feedback-for={props.name} type="invalid">{props.errorMessage}</Form.Control.Feedback>
|
||||
)}
|
||||
{props.children}
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
FormGroup.defaultProps = {
|
||||
as: 'input',
|
||||
errorMessage: '',
|
||||
borderClass: '',
|
||||
autoComplete: null,
|
||||
handleBlur: null,
|
||||
handleChange: () => {},
|
||||
handleFocus: null,
|
||||
handleClick: null,
|
||||
helpText: [],
|
||||
options: null,
|
||||
trailingElement: null,
|
||||
type: 'text',
|
||||
children: null,
|
||||
};
|
||||
|
||||
FormGroup.propTypes = {
|
||||
as: PropTypes.string,
|
||||
errorMessage: PropTypes.string,
|
||||
borderClass: PropTypes.string,
|
||||
autoComplete: PropTypes.string,
|
||||
floatingLabel: PropTypes.string.isRequired,
|
||||
handleBlur: PropTypes.func,
|
||||
handleChange: PropTypes.func,
|
||||
handleFocus: PropTypes.func,
|
||||
handleClick: PropTypes.func,
|
||||
helpText: PropTypes.arrayOf(PropTypes.string),
|
||||
name: PropTypes.string.isRequired,
|
||||
options: PropTypes.func,
|
||||
trailingElement: PropTypes.element,
|
||||
type: PropTypes.string,
|
||||
value: PropTypes.string.isRequired,
|
||||
children: PropTypes.element,
|
||||
};
|
||||
|
||||
export default FormGroup;
|
||||
98
src/redesign/common-components/InstitutionLogistration.jsx
Normal file
98
src/redesign/common-components/InstitutionLogistration.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Hyperlink, Icon } from '@edx/paragon';
|
||||
import { Institution } from '@edx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
export const RenderInstitutionButton = props => {
|
||||
const { onSubmitHandler, buttonTitle } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="btn btn-link btn-sm text-body p-0 mb-4"
|
||||
variant="link"
|
||||
data-event-name="institution_login"
|
||||
onClick={onSubmitHandler}
|
||||
>
|
||||
<Icon src={Institution} className="institute-icon" />
|
||||
{buttonTitle}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const InstitutionLogistration = props => {
|
||||
const lmsBaseUrl = getConfig().LMS_BASE_URL;
|
||||
const {
|
||||
intl,
|
||||
secondaryProviders,
|
||||
headingTitle,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex justify-content-left mb-4 mt-4">
|
||||
<div className="flex-column">
|
||||
<h4 className="mb-2 font-weight-bold institute-heading">
|
||||
{headingTitle}
|
||||
</h4>
|
||||
<p className="mb-2">
|
||||
{intl.formatMessage(messages['institution.login.page.sub.heading'])}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<table className="pgn__data-table table-striped table-borderless">
|
||||
<tbody>
|
||||
{secondaryProviders.map(provider => (
|
||||
<tr key={provider} className="pgn__data-table-row">
|
||||
<td>
|
||||
<Hyperlink
|
||||
className="btn nav-item p-0 mb-1"
|
||||
destination={lmsBaseUrl + provider.loginUrl}
|
||||
>
|
||||
{provider.name}
|
||||
</Hyperlink>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LogistrationDefaultProps = {
|
||||
secondaryProviders: [],
|
||||
buttonTitle: '',
|
||||
};
|
||||
const LogistrationProps = {
|
||||
secondaryProviders: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string.isRequried,
|
||||
loginUrl: PropTypes.string.isRequired,
|
||||
})),
|
||||
};
|
||||
|
||||
RenderInstitutionButton.propTypes = {
|
||||
...LogistrationProps,
|
||||
buttonTitle: PropTypes.string,
|
||||
onSubmitHandler: PropTypes.func.isRequired,
|
||||
|
||||
};
|
||||
RenderInstitutionButton.defaultProps = {
|
||||
...LogistrationDefaultProps,
|
||||
};
|
||||
|
||||
InstitutionLogistration.propTypes = {
|
||||
...LogistrationProps,
|
||||
intl: intlShape.isRequired,
|
||||
headingTitle: PropTypes.string,
|
||||
};
|
||||
InstitutionLogistration.defaultProps = {
|
||||
...LogistrationDefaultProps,
|
||||
headingTitle: '',
|
||||
};
|
||||
|
||||
export default injectIntl(InstitutionLogistration);
|
||||
23
src/redesign/common-components/LargeScreenLayout.jsx
Normal file
23
src/redesign/common-components/LargeScreenLayout.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import LargeScreenLeftLayout from './LargeScreenLeftLayout';
|
||||
|
||||
const LargeScreenLayout = () => (
|
||||
<div className="container row p-0 m-0 large-screen-container">
|
||||
<div className="col-md-9 p-0 screen-header">
|
||||
<img alt="edx" className="logo position-absolute" src={getConfig().LOGO_WHITE_URL} />
|
||||
<LargeScreenLeftLayout />
|
||||
</div>
|
||||
<div className="col-md-3 p-0 screen-polygon">
|
||||
<svg width="100%" height="100%" className="ml-n1 large-screen-svg" preserveAspectRatio="xMaxYMin meet">
|
||||
<g transform="skewX(171.6)">
|
||||
<rect x="0" y="0" height="100%" width="100%" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LargeScreenLayout;
|
||||
30
src/redesign/common-components/LargeScreenLeftLayout.jsx
Normal file
30
src/redesign/common-components/LargeScreenLeftLayout.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const LargeScreenLeftLayout = (props) => {
|
||||
const { intl } = props;
|
||||
|
||||
return (
|
||||
<div className="min-vh-100 pr-0 mt-lg-n2 d-flex align-items-center">
|
||||
<svg className="large-screen-svg-line ml-5">
|
||||
<line x1="50" y1="0" x2="10" y2="215" />
|
||||
</svg>
|
||||
<h1 className="large-heading">
|
||||
{intl.formatMessage(messages['start.learning'])}
|
||||
<span className="text-accent-a"><br />
|
||||
{intl.formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LargeScreenLeftLayout.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LargeScreenLeftLayout);
|
||||
84
src/redesign/common-components/Logistration.jsx
Normal file
84
src/redesign/common-components/Logistration.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Tabs, Tab } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
|
||||
import { updatePathWithQueryParams, getTpaHint } from '../data/utils';
|
||||
import { LoginPage } from '../login';
|
||||
import { RegistrationPage } from '../register';
|
||||
|
||||
const Logistration = (props) => {
|
||||
const { intl, selectedPage } = props;
|
||||
const tpa = getTpaHint();
|
||||
const [institutionLogin, setInstitutionLogin] = useState(false);
|
||||
const [key, setKey] = useState('');
|
||||
|
||||
const handleInstitutionLogin = (e) => {
|
||||
sendTrackEvent('edx.bi.institution_login_form.toggled', { category: 'user-engagement' });
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{institutionLogin
|
||||
? (
|
||||
<span className="nav nav-tabs mt-2 pb-2">
|
||||
<FontAwesomeIcon className="mr-2 mt-1 ml-3" icon={faChevronLeft} />
|
||||
<Button
|
||||
variant="link"
|
||||
className="nav-item p-0 mb-1 logistration-button"
|
||||
data-event-name={selectedPage === LOGIN_PAGE ? 'login' : 'register'}
|
||||
onClick={handleInstitutionLogin}
|
||||
>
|
||||
{selectedPage === LOGIN_PAGE
|
||||
? intl.formatMessage(messages['logistration.sign.in'])
|
||||
: intl.formatMessage(messages['logistration.register'])}
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{!tpa && (
|
||||
<Tabs defaultActiveKey={selectedPage} id="controlled-tab-example" 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>
|
||||
);
|
||||
};
|
||||
|
||||
Logistration.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
selectedPage: PropTypes.string,
|
||||
};
|
||||
|
||||
Logistration.defaultProps = {
|
||||
selectedPage: REGISTER_PAGE,
|
||||
};
|
||||
|
||||
export default injectIntl(Logistration);
|
||||
42
src/redesign/common-components/MediumScreenHeader.jsx
Normal file
42
src/redesign/common-components/MediumScreenHeader.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const MediumScreenHeader = (props) => {
|
||||
const { intl } = props;
|
||||
|
||||
return (
|
||||
<div className="container row p-0 mb-3 medium-screen-container">
|
||||
<div className="col-md-10 p-0 screen-header">
|
||||
<img alt="edx" className="logo" src={getConfig().LOGO_WHITE_URL} />
|
||||
<div className="row mt-4 justify-content-center">
|
||||
<svg className="medium-screen-svg-line pl-5">
|
||||
<line x1="50" y1="0" x2="10" y2="215" />
|
||||
</svg>
|
||||
<h1 className="medium-heading pb-4">
|
||||
{intl.formatMessage(messages['start.learning'])}
|
||||
<span className="text-accent-a"><br />
|
||||
{intl.formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2 p-0 screen-polygon">
|
||||
<svg width="100%" height="100%" className="medium-screen-svg" preserveAspectRatio="xMaxYMin meet">
|
||||
<g transform="skewX(168)">
|
||||
<rect x="0" y="0" height="100%" width="100%" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MediumScreenHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(MediumScreenHeader);
|
||||
16
src/redesign/common-components/NotFoundPage.jsx
Normal file
16
src/redesign/common-components/NotFoundPage.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
105
src/redesign/common-components/PasswordField.jsx
Normal file
105
src/redesign/common-components/PasswordField.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import {
|
||||
Form, IconButton, useToggle, Tooltip, OverlayTrigger, Icon,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Check, Remove, Visibility, VisibilityOff,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { LETTER_REGEX, NUMBER_REGEX } from '../data/constants';
|
||||
|
||||
const PasswordField = (props) => {
|
||||
const { formatMessage } = props.intl;
|
||||
const [isPasswordHidden, setHiddenTrue, setHiddenFalse] = useToggle(true);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
const handleBlur = (e) => {
|
||||
if (props.handleBlur) { props.handleBlur(e); }
|
||||
setShowTooltip(props.showRequirements && false);
|
||||
};
|
||||
|
||||
const handleFocus = (e) => {
|
||||
if (props.handleFocus) {
|
||||
props.handleFocus(e);
|
||||
}
|
||||
setTimeout(() => setShowTooltip(props.showRequirements && true), 150);
|
||||
};
|
||||
|
||||
const HideButton = (
|
||||
<IconButton className="password-visibility" src={VisibilityOff} iconAs={Icon} onClick={setHiddenTrue} size="sm" variant="secondary" alt={formatMessage(messages['hide.password'])} />
|
||||
);
|
||||
|
||||
const ShowButton = (
|
||||
<IconButton className="password-visibility" 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}`}>
|
||||
<span id="letter-check" className="d-flex position-relative align-content-start">
|
||||
{LETTER_REGEX.test(props.value) ? <Icon className="text-success mr-1" src={Check} /> : <Icon className="mr-1" src={Remove} />}
|
||||
{formatMessage(messages['one.letter'])}
|
||||
</span>
|
||||
<span id="number-check" className="d-flex position-relative align-content-start">
|
||||
{NUMBER_REGEX.test(props.value) ? <Icon className="text-success mr-1" src={Check} /> : <Icon className="mr-1" src={Remove} />}
|
||||
{formatMessage(messages['one.number'])}
|
||||
</span>
|
||||
<span id="characters-check" className="d-flex position-relative align-content-start">
|
||||
{props.value.length >= 8 ? <Icon className="text-success mr-1" src={Check} /> : <Icon className="mr-1" src={Remove} />}
|
||||
{formatMessage(messages['eight.characters'])}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form.Group controlId={props.name} className="mb-4" isInvalid={props.errorMessage !== ''}>
|
||||
<OverlayTrigger key="tooltip" placement={placement} overlay={tooltip} show={showTooltip}>
|
||||
<Form.Control
|
||||
as="input"
|
||||
type={isPasswordHidden ? 'password' : 'text'}
|
||||
name={props.name}
|
||||
value={props.value}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={props.handleChange}
|
||||
controlClassName={props.borderClass}
|
||||
trailingElement={isPasswordHidden ? ShowButton : HideButton}
|
||||
floatingLabel={props.floatingLabel}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
{props.errorMessage !== '' && (
|
||||
<Form.Control.Feedback key="error" hasIcon={false} feedback-for={props.name} type="invalid">
|
||||
{props.errorMessage}
|
||||
<span className="sr-only">{formatMessage(messages['password.sr.only.helping.text'])}</span>
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
PasswordField.defaultProps = {
|
||||
borderClass: '',
|
||||
errorMessage: '',
|
||||
handleBlur: null,
|
||||
handleFocus: null,
|
||||
handleChange: () => {},
|
||||
showRequirements: true,
|
||||
};
|
||||
|
||||
PasswordField.propTypes = {
|
||||
borderClass: PropTypes.string,
|
||||
errorMessage: PropTypes.string,
|
||||
floatingLabel: PropTypes.string.isRequired,
|
||||
handleBlur: PropTypes.func,
|
||||
handleFocus: PropTypes.func,
|
||||
handleChange: PropTypes.func,
|
||||
intl: intlShape.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
showRequirements: PropTypes.bool,
|
||||
value: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PasswordField);
|
||||
55
src/redesign/common-components/RedirectLogistration.jsx
Normal file
55
src/redesign/common-components/RedirectLogistration.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { WELCOME_PAGE } from '../data/constants';
|
||||
import { setCookie } from '../data/utils';
|
||||
|
||||
function RedirectLogistration(props) {
|
||||
const {
|
||||
finishAuthUrl, redirectUrl, redirectToWelcomePage, success,
|
||||
} = props;
|
||||
let finalRedirectUrl = '';
|
||||
|
||||
if (success) {
|
||||
// If we're in a third party auth pipeline, we must complete the pipeline
|
||||
// once user has successfully logged in. Otherwise, redirect to the specified redirect url.
|
||||
// Note: For multiple enterprise use case, we need to make sure that user first visits the
|
||||
// enterprise selection page and then complete the auth workflow
|
||||
if (finishAuthUrl && !redirectUrl.includes(finishAuthUrl)) {
|
||||
finalRedirectUrl = getConfig().LMS_BASE_URL + finishAuthUrl;
|
||||
} else {
|
||||
finalRedirectUrl = redirectUrl;
|
||||
}
|
||||
|
||||
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 <Redirect to={{ pathname: WELCOME_PAGE, state: { registrationResult } }} />;
|
||||
}
|
||||
|
||||
window.location.href = finalRedirectUrl;
|
||||
}
|
||||
return <></>;
|
||||
}
|
||||
|
||||
RedirectLogistration.defaultProps = {
|
||||
finishAuthUrl: null,
|
||||
success: false,
|
||||
redirectUrl: '',
|
||||
redirectToWelcomePage: false,
|
||||
};
|
||||
|
||||
RedirectLogistration.propTypes = {
|
||||
finishAuthUrl: PropTypes.string,
|
||||
success: PropTypes.bool,
|
||||
redirectUrl: PropTypes.string,
|
||||
redirectToWelcomePage: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default RedirectLogistration;
|
||||
8
src/redesign/common-components/RegisterFaIcons.jsx
Normal file
8
src/redesign/common-components/RegisterFaIcons.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import {
|
||||
faApple, faFacebook, faGoogle, faMicrosoft,
|
||||
} from '@fortawesome/free-brands-svg-icons';
|
||||
|
||||
export default function registerIcons() {
|
||||
library.add(faApple, faFacebook, faGoogle, faMicrosoft);
|
||||
}
|
||||
36
src/redesign/common-components/SmallScreenHeader.jsx
Normal file
36
src/redesign/common-components/SmallScreenHeader.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const SmallScreenHeader = (props) => {
|
||||
const { intl } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="small-screen-header">
|
||||
<img alt="edx" className="logo" src={getConfig().LOGO_WHITE_URL} />
|
||||
<div className="d-flex mt-3">
|
||||
<svg className="small-screen-svg-line">
|
||||
<line x1="55" y1="0" x2="40" y2="65" />
|
||||
</svg>
|
||||
<h1 className="small-heading pb-3">
|
||||
{intl.formatMessage(messages['start.learning'])}
|
||||
<br />
|
||||
<span className="text-accent-a">
|
||||
{intl.formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
SmallScreenHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SmallScreenHeader);
|
||||
75
src/redesign/common-components/SocialAuthProviders.jsx
Normal file
75
src/redesign/common-components/SocialAuthProviders.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
|
||||
|
||||
function SocialAuthProviders(props) {
|
||||
const { intl, referrer, socialAuthProviders } = props;
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const url = e.currentTarget.dataset.providerUrl;
|
||||
window.location.href = getConfig().LMS_BASE_URL + url;
|
||||
}
|
||||
|
||||
const socialAuth = socialAuthProviders.map((provider, index) => (
|
||||
<button
|
||||
id={provider.id}
|
||||
key={provider.id}
|
||||
type="button"
|
||||
className={`btn-social btn-${provider.id} ${index % 2 === 0 ? 'mr-3' : ''}`}
|
||||
data-provider-url={referrer === LOGIN_PAGE ? provider.loginUrl : provider.registerUrl}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{provider.iconImage ? (
|
||||
<div aria-hidden="true">
|
||||
<img className="icon-image" src={provider.iconImage} alt={`icon ${provider.name}`} />
|
||||
</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="mr-auto pl-2" aria-hidden="true">{provider.name}</span>
|
||||
<span className="sr-only">
|
||||
{referrer === LOGIN_PAGE
|
||||
? intl.formatMessage(messages['sso.sign.in.with'], { providerName: provider.name })
|
||||
: intl.formatMessage(messages['sso.create.account.using'], { providerName: provider.name })}
|
||||
</span>
|
||||
</button>
|
||||
));
|
||||
|
||||
return <>{socialAuth}</>;
|
||||
}
|
||||
|
||||
SocialAuthProviders.defaultProps = {
|
||||
referrer: LOGIN_PAGE,
|
||||
socialAuthProviders: [],
|
||||
};
|
||||
|
||||
SocialAuthProviders.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
referrer: PropTypes.string,
|
||||
socialAuthProviders: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
iconClass: PropTypes.string,
|
||||
iconImage: PropTypes.string,
|
||||
loginUrl: PropTypes.string,
|
||||
registerUrl: PropTypes.string,
|
||||
})),
|
||||
};
|
||||
|
||||
export default injectIntl(SocialAuthProviders);
|
||||
61
src/redesign/common-components/SwitchContent.jsx
Normal file
61
src/redesign/common-components/SwitchContent.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { TransitionReplace } from '@edx/paragon';
|
||||
|
||||
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;
|
||||
42
src/redesign/common-components/ThirdPartyAuthAlert.jsx
Normal file
42
src/redesign/common-components/ThirdPartyAuthAlert.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
|
||||
|
||||
const ThirdPartyAuthAlert = (props) => {
|
||||
const { currentProvider, intl, referrer } = props;
|
||||
const platformName = getConfig().SITE_NAME;
|
||||
let message;
|
||||
|
||||
if (referrer === LOGIN_PAGE) {
|
||||
message = intl.formatMessage(messages['login.third.party.auth.account.not.linked'], { currentProvider, platformName });
|
||||
} else {
|
||||
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' : 'alert-warning mt-n2'}>
|
||||
{referrer === REGISTER_PAGE ? (
|
||||
<Alert.Heading>{intl.formatMessage(messages['tpa.alert.heading'])}</Alert.Heading>
|
||||
) : null}
|
||||
<p>{ message }</p>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
ThirdPartyAuthAlert.defaultProps = {
|
||||
referrer: LOGIN_PAGE,
|
||||
};
|
||||
|
||||
ThirdPartyAuthAlert.propTypes = {
|
||||
currentProvider: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
referrer: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(ThirdPartyAuthAlert);
|
||||
22
src/redesign/common-components/UnAuthOnlyRoute.jsx
Normal file
22
src/redesign/common-components/UnAuthOnlyRoute.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { DEFAULT_REDIRECT_URL } from '../data/constants';
|
||||
|
||||
/**
|
||||
* This wrapper redirects the requester to our default redirect url if they are
|
||||
* already authenticated.
|
||||
*/
|
||||
const UnAuthOnlyRoute = (props) => {
|
||||
const { authenticatedUser, config } = React.useContext(AppContext);
|
||||
|
||||
if (authenticatedUser) {
|
||||
global.location.href = config.LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Route {...props} />;
|
||||
};
|
||||
|
||||
export default UnAuthOnlyRoute;
|
||||
22
src/redesign/common-components/data/actions.js
Normal file
22
src/redesign/common-components/data/actions.js
Normal 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 = (thirdPartyAuthContext) => ({
|
||||
type: THIRD_PARTY_AUTH_CONTEXT.SUCCESS,
|
||||
payload: { thirdPartyAuthContext },
|
||||
});
|
||||
|
||||
export const getThirdPartyAuthContextFailure = () => ({
|
||||
type: THIRD_PARTY_AUTH_CONTEXT.FAILURE,
|
||||
});
|
||||
32
src/redesign/common-components/data/reducers.js
Normal file
32
src/redesign/common-components/data/reducers.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { THIRD_PARTY_AUTH_CONTEXT } from './actions';
|
||||
|
||||
import { PENDING_STATE, COMPLETE_STATE } from '../../data/constants';
|
||||
|
||||
export const defaultState = {
|
||||
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,
|
||||
thirdPartyAuthContext: action.payload.thirdPartyAuthContext,
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
};
|
||||
case THIRD_PARTY_AUTH_CONTEXT.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default reducer;
|
||||
34
src/redesign/common-components/data/sagas.js
Normal file
34
src/redesign/common-components/data/sagas.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
THIRD_PARTY_AUTH_CONTEXT,
|
||||
getThirdPartyAuthContextBegin,
|
||||
getThirdPartyAuthContextSuccess,
|
||||
getThirdPartyAuthContextFailure,
|
||||
} from './actions';
|
||||
|
||||
// Services
|
||||
import {
|
||||
getThirdPartyAuthContext,
|
||||
} from './service';
|
||||
|
||||
export function* fetchThirdPartyAuthContext(action) {
|
||||
try {
|
||||
yield put(getThirdPartyAuthContextBegin());
|
||||
const { thirdPartyAuthContext } = yield call(getThirdPartyAuthContext, action.payload.urlParams);
|
||||
|
||||
yield put(getThirdPartyAuthContextSuccess(
|
||||
thirdPartyAuthContext,
|
||||
));
|
||||
} catch (e) {
|
||||
yield put(getThirdPartyAuthContextFailure());
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(THIRD_PARTY_AUTH_CONTEXT.BASE, fetchThirdPartyAuthContext);
|
||||
}
|
||||
10
src/redesign/common-components/data/selectors.js
Normal file
10
src/redesign/common-components/data/selectors.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
export const storeName = 'commonComponents';
|
||||
|
||||
export const commonComponentsSelector = state => ({ ...state[storeName] });
|
||||
|
||||
export const thirdPartyAuthContextSelector = createSelector(
|
||||
commonComponentsSelector,
|
||||
commonComponents => commonComponents.thirdPartyAuthContext,
|
||||
);
|
||||
23
src/redesign/common-components/data/service.js
Normal file
23
src/redesign/common-components/data/service.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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 {
|
||||
thirdPartyAuthContext: camelCaseObject(convertKeyNames(data, { fullname: 'name' })),
|
||||
};
|
||||
}
|
||||
65
src/redesign/common-components/data/tests/sagas.test.js
Normal file
65
src/redesign/common-components/data/tests/sagas.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { runSaga } from 'redux-saga';
|
||||
|
||||
import * as actions from '../actions';
|
||||
import { fetchThirdPartyAuthContext } from '../sagas';
|
||||
import * as api from '../service';
|
||||
import initializeMockLogging from '../../../../setupTest';
|
||||
|
||||
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 }));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
fetchThirdPartyAuthContext,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(getThirdPartyAuthContext).toHaveBeenCalledTimes(1);
|
||||
expect(dispatched).toEqual([
|
||||
actions.getThirdPartyAuthContextBegin(),
|
||||
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();
|
||||
});
|
||||
});
|
||||
16
src/redesign/common-components/index.jsx
Normal file
16
src/redesign/common-components/index.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export { default as BaseComponent } from './BaseComponent';
|
||||
export { default as RedirectLogistration } from './RedirectLogistration';
|
||||
export { default as registerIcons } from './RegisterFaIcons';
|
||||
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 APIFailureMessage } from './APIFailureMessage';
|
||||
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';
|
||||
138
src/redesign/common-components/messages.jsx
Normal file
138
src/redesign/common-components/messages.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'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',
|
||||
},
|
||||
// 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',
|
||||
description: 'Text that appears on the tab to switch between login and register',
|
||||
},
|
||||
'logistration.register': {
|
||||
id: 'logistration.register',
|
||||
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',
|
||||
description: 'Button text for login',
|
||||
},
|
||||
// social auth providers
|
||||
'sso.sign.in.with': {
|
||||
id: 'sso.sign.in.with',
|
||||
defaultMessage: 'Sign in with {providerName}',
|
||||
description: 'Screen reader text that appears before social auth provider name',
|
||||
},
|
||||
'sso.create.account.using': {
|
||||
id: 'sso.create.account.using',
|
||||
defaultMessage: 'Create account using {providerName}',
|
||||
description: 'Screen reader text that appears before social auth provider name',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
// password field strings
|
||||
'show.password': {
|
||||
id: 'show.password',
|
||||
defaultMessage: 'Show password',
|
||||
description: 'aria label for show password icon on password field',
|
||||
},
|
||||
'hide.password': {
|
||||
id: 'hide.password',
|
||||
defaultMessage: 'Hide password',
|
||||
description: 'aria label for hide password icon on password field',
|
||||
},
|
||||
'one.letter': {
|
||||
id: 'one.letter',
|
||||
defaultMessage: '1 Letter',
|
||||
description: 'password requirement to have 1 letter',
|
||||
},
|
||||
'one.number': {
|
||||
id: 'one.number',
|
||||
defaultMessage: '1 Number',
|
||||
description: 'password requirement to have 1 number',
|
||||
},
|
||||
'eight.characters': {
|
||||
id: 'eight.characters',
|
||||
defaultMessage: '8 Characters',
|
||||
description: 'password requirement to have a minimum of 8 characters',
|
||||
},
|
||||
'password.sr.only.helping.text': {
|
||||
id: 'password.sr.only.helping.text',
|
||||
defaultMessage: 'Password must contain at least 8 characters, at least one letter, and at least one number',
|
||||
description: 'Password helping text for the sr-only class',
|
||||
},
|
||||
// third party auth
|
||||
'tpa.alert.heading': {
|
||||
id: 'tpa.alert.heading',
|
||||
defaultMessage: 'Almost done!',
|
||||
description: 'Success alert heading after user has successfully signed in with social auth',
|
||||
},
|
||||
'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.',
|
||||
description: 'Message that appears on login page if user has successfully authenticated with social '
|
||||
+ '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}.',
|
||||
description: 'Message that appears on register page if user has successfully authenticated with TPA '
|
||||
+ 'but no associated platform account exists',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
51
src/redesign/common-components/tests/BaseComponent.test.jsx
Normal file
51
src/redesign/common-components/tests/BaseComponent.test.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import LargeScreenLayout from '../LargeScreenLayout';
|
||||
import MediumScreenHeader from '../MediumScreenHeader';
|
||||
import SmallScreenHeader from '../SmallScreenHeader';
|
||||
|
||||
describe('ScreenLayout', () => {
|
||||
it('should display the form, pass as a child in SmallScreenLayout', () => {
|
||||
const smallScreen = mount(
|
||||
<IntlProvider locale="en">
|
||||
<div>
|
||||
<SmallScreenHeader />
|
||||
<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>
|
||||
<MediumScreenHeader />
|
||||
<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>
|
||||
<LargeScreenLayout />
|
||||
<form>
|
||||
<input type="text" />
|
||||
</form>
|
||||
</div>
|
||||
</IntlProvider>,
|
||||
);
|
||||
expect(largeScreen.find('form').exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
95
src/redesign/common-components/tests/FormField.test.jsx
Normal file
95
src/redesign/common-components/tests/FormField.test.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import FormGroup from '../FormGroup';
|
||||
import PasswordField from '../PasswordField';
|
||||
|
||||
describe('FormGroup', () => {
|
||||
const props = {
|
||||
floatingLabel: 'Email',
|
||||
helpText: ['Email field help text'],
|
||||
name: 'email',
|
||||
value: '',
|
||||
handleFocus: jest.fn(),
|
||||
};
|
||||
|
||||
it('should show help text on field focus', () => {
|
||||
const formGroup = mount(<FormGroup {...props} />);
|
||||
expect(formGroup.find('.pgn-transition-replace-group').find('div#email-1').exists()).toBeFalsy();
|
||||
|
||||
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 = {};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
floatingLabel: 'Password',
|
||||
name: 'password',
|
||||
value: 'password123',
|
||||
handleFocus: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should show/hide password on icon click', () => {
|
||||
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
|
||||
|
||||
passwordField.find('button[aria-label="Show password"]').simulate('click');
|
||||
expect(passwordField.find('input').prop('type')).toEqual('text');
|
||||
|
||||
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 passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
|
||||
jest.useFakeTimers();
|
||||
await act(async () => {
|
||||
passwordField.find('input').simulate('focus');
|
||||
jest.runAllTimers();
|
||||
});
|
||||
passwordField.update();
|
||||
|
||||
expect(passwordField.find('#password-requirement-left').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show all password requirement checks as failed', async () => {
|
||||
props = {
|
||||
...props,
|
||||
value: '',
|
||||
};
|
||||
|
||||
jest.useFakeTimers();
|
||||
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
|
||||
await act(async () => {
|
||||
passwordField.find('input').simulate('focus');
|
||||
jest.runAllTimers();
|
||||
});
|
||||
passwordField.update();
|
||||
|
||||
expect(passwordField.find('#letter-check span').prop('className')).toEqual('pgn__icon mr-1');
|
||||
expect(passwordField.find('#number-check span').prop('className')).toEqual('pgn__icon mr-1');
|
||||
expect(passwordField.find('#characters-check span').prop('className')).toEqual('pgn__icon mr-1');
|
||||
});
|
||||
|
||||
it('should update password requirement checks', async () => {
|
||||
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
|
||||
jest.useFakeTimers();
|
||||
await act(async () => {
|
||||
passwordField.find('input').simulate('focus');
|
||||
jest.runAllTimers();
|
||||
});
|
||||
passwordField.update();
|
||||
|
||||
expect(passwordField.find('#letter-check span').prop('className')).toEqual('pgn__icon text-success mr-1');
|
||||
expect(passwordField.find('#number-check span').prop('className')).toEqual('pgn__icon text-success mr-1');
|
||||
expect(passwordField.find('#characters-check span').prop('className')).toEqual('pgn__icon text-success mr-1');
|
||||
});
|
||||
});
|
||||
176
src/redesign/common-components/tests/Logistration.test.jsx
Normal file
176
src/redesign/common-components/tests/Logistration.test.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { getConfig, mergeConfig } from '@edx/frontend-platform';
|
||||
import * as analytics from '@edx/frontend-platform/analytics';
|
||||
import { configure, injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import Logistration from '../Logistration';
|
||||
import { RenderInstitutionButton } from '../InstitutionLogistration';
|
||||
import { COMPLETE_STATE, LOGIN_PAGE } from '../../data/constants';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
analytics.sendPageEvent = jest.fn();
|
||||
|
||||
const mockStore = configureStore();
|
||||
const IntlLogistration = injectIntl(Logistration);
|
||||
|
||||
describe('Logistration', () => {
|
||||
let store = {};
|
||||
|
||||
const secondaryProviders = {
|
||||
id: 'saml-test',
|
||||
name: 'Test University',
|
||||
loginUrl: '/dummy-auth',
|
||||
registerUrl: '/dummy_auth',
|
||||
};
|
||||
|
||||
const reduxWrapper = children => (
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
it('should render registration page', () => {
|
||||
configure({
|
||||
loggingService: { logError: jest.fn() },
|
||||
config: {
|
||||
ENVIRONMENT: 'production',
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
|
||||
},
|
||||
messages: { 'es-419': {}, de: {}, 'en-us': {} },
|
||||
});
|
||||
|
||||
store = mockStore({
|
||||
register: {
|
||||
registrationResult: { success: false, redirectUrl: '' },
|
||||
registrationError: {},
|
||||
},
|
||||
commonComponents: {
|
||||
thirdPartyAuthApiStatus: null,
|
||||
},
|
||||
});
|
||||
const logistration = mount(reduxWrapper(<IntlLogistration />));
|
||||
|
||||
expect(logistration.find('#main-content').find('RegistrationPage').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render login page', () => {
|
||||
store = mockStore({
|
||||
login: {
|
||||
loginResult: { success: false, redirectUrl: '' },
|
||||
},
|
||||
commonComponents: {
|
||||
thirdPartyAuthApiStatus: null,
|
||||
},
|
||||
});
|
||||
|
||||
const props = { selectedPage: LOGIN_PAGE };
|
||||
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
|
||||
|
||||
expect(logistration.find('#main-content').find('LoginPage').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display institution login option when secondary providers are present', () => {
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: 'true',
|
||||
});
|
||||
|
||||
store = mockStore({
|
||||
login: {
|
||||
loginResult: { success: false, redirectUrl: '' },
|
||||
},
|
||||
commonComponents: {
|
||||
thirdPartyAuthContext: {
|
||||
currentProvider: null,
|
||||
finishAuthUrl: null,
|
||||
providers: [],
|
||||
secondaryProviders: [secondaryProviders],
|
||||
},
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
const props = { selectedPage: LOGIN_PAGE };
|
||||
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
|
||||
expect(logistration.text().includes('Institution/campus credentials')).toBe(true);
|
||||
|
||||
// on clicking "Institution/campus credentials" button, it should display institution login page
|
||||
logistration.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
|
||||
expect(logistration.text().includes('Test University')).toBe(true);
|
||||
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('send tracking and page events when institutional login button is clicked', () => {
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: 'true',
|
||||
});
|
||||
|
||||
store = mockStore({
|
||||
login: {
|
||||
loginResult: { success: false, redirectUrl: '' },
|
||||
},
|
||||
commonComponents: {
|
||||
thirdPartyAuthContext: {
|
||||
currentProvider: null,
|
||||
finishAuthUrl: null,
|
||||
providers: [],
|
||||
secondaryProviders: [secondaryProviders],
|
||||
},
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
const props = { selectedPage: LOGIN_PAGE };
|
||||
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
|
||||
logistration.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
|
||||
|
||||
expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement' });
|
||||
expect(analytics.sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login');
|
||||
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display institution register button', () => {
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: 'true',
|
||||
});
|
||||
|
||||
store = mockStore({
|
||||
register: {
|
||||
registrationResult: { success: false, redirectUrl: '' },
|
||||
registrationError: {},
|
||||
},
|
||||
commonComponents: {
|
||||
thirdPartyAuthContext: {
|
||||
currentProvider: null,
|
||||
finishAuthUrl: null,
|
||||
providers: [],
|
||||
secondaryProviders: [secondaryProviders],
|
||||
},
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL };
|
||||
|
||||
const root = mount(reduxWrapper(<IntlLogistration />));
|
||||
root.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
|
||||
expect(root.text().includes('Test University')).toBe(true);
|
||||
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import SocialAuthProviders from '../SocialAuthProviders';
|
||||
import registerIcons from '../RegisterFaIcons';
|
||||
|
||||
registerIcons();
|
||||
|
||||
describe('SocialAuthProviders', () => {
|
||||
let props = {};
|
||||
|
||||
const appleProvider = {
|
||||
id: 'oa2-apple-id',
|
||||
name: 'Apple',
|
||||
iconClass: null,
|
||||
iconImage: 'https://edx.devstack.lms/logo.png',
|
||||
loginUrl: '/auth/login/apple-id/?auth_entry=login&next=/dashboard',
|
||||
};
|
||||
|
||||
const facebookProvider = {
|
||||
id: 'oa2-facebook',
|
||||
name: 'Facebook',
|
||||
iconClass: null,
|
||||
iconImage: 'https://edx.devstack.lms/facebook-logo.png',
|
||||
loginUrl: '/auth/login/facebook/?auth_entry=login&next=/dashboard',
|
||||
};
|
||||
|
||||
it('should match social auth provider with iconImage snapshot', () => {
|
||||
props = { socialAuthProviders: [appleProvider, facebookProvider] };
|
||||
|
||||
const tree = renderer.create(
|
||||
<IntlProvider locale="en">
|
||||
<SocialAuthProviders {...props} />
|
||||
</IntlProvider>,
|
||||
).toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match social auth provider with iconClass snapshot', () => {
|
||||
props = {
|
||||
socialAuthProviders: [{
|
||||
...appleProvider,
|
||||
iconClass: 'google',
|
||||
iconImage: null,
|
||||
}],
|
||||
};
|
||||
|
||||
const tree = renderer.create(
|
||||
<IntlProvider locale="en">
|
||||
<SocialAuthProviders {...props} />
|
||||
</IntlProvider>,
|
||||
).toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match social auth provider with default icon snapshot', () => {
|
||||
props = {
|
||||
socialAuthProviders: [{
|
||||
...appleProvider,
|
||||
iconClass: 'default',
|
||||
iconImage: null,
|
||||
}],
|
||||
};
|
||||
|
||||
const tree = renderer.create(
|
||||
<IntlProvider locale="en">
|
||||
<SocialAuthProviders {...props} />
|
||||
</IntlProvider>,
|
||||
).toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ThirdPartyAuthAlert from '../ThirdPartyAuthAlert';
|
||||
import { REGISTER_PAGE } from '../../data/constants';
|
||||
|
||||
describe('ThirdPartyAuthAlert', () => {
|
||||
let props = {};
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
currentProvider: 'Google',
|
||||
platformName: 'edX',
|
||||
};
|
||||
});
|
||||
|
||||
it('should match login page third party auth alert message snapshot', () => {
|
||||
const tree = renderer.create(
|
||||
<IntlProvider locale="en">
|
||||
<ThirdPartyAuthAlert {...props} />
|
||||
</IntlProvider>,
|
||||
).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match register page third party auth alert message snapshot', () => {
|
||||
props = {
|
||||
...props,
|
||||
referrer: REGISTER_PAGE,
|
||||
};
|
||||
|
||||
const tree = renderer.create(
|
||||
<IntlProvider locale="en">
|
||||
<ThirdPartyAuthAlert {...props} />
|
||||
</IntlProvider>,
|
||||
).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { BrowserRouter as Router, MemoryRouter, Switch } from 'react-router-dom';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { UnAuthOnlyRoute } from '..';
|
||||
import { DEFAULT_REDIRECT_URL, LOGIN_PAGE } from '../../data/constants';
|
||||
|
||||
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>
|
||||
<Switch>
|
||||
<UnAuthOnlyRoute path={LOGIN_PAGE} render={() => (<span>Login Page</span>)} />
|
||||
</Switch>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
|
||||
describe('UnAuthOnlyRoute', () => {
|
||||
const routerWrapper = () => (
|
||||
<MemoryRouter initialEntries={[LOGIN_PAGE]}>
|
||||
<TestApp />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
it('should redirect to dashboard if already logged in', () => {
|
||||
const dashboardURL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
delete window.location;
|
||||
window.location = { href: '' };
|
||||
const user = {
|
||||
username: 'gonzo',
|
||||
other: 'data',
|
||||
};
|
||||
const mockUseContext = jest.fn().mockImplementation(() => ({
|
||||
authenticatedUser: user,
|
||||
config: getConfig(),
|
||||
}));
|
||||
|
||||
React.useContext = mockUseContext;
|
||||
mount(routerWrapper());
|
||||
|
||||
expect(window.location.href).toBe(dashboardURL);
|
||||
});
|
||||
|
||||
it('should render test login components', () => {
|
||||
const mockUseContext = jest.fn().mockImplementation(() => ({
|
||||
authenticatedUser: null,
|
||||
config: {},
|
||||
}));
|
||||
|
||||
React.useContext = mockUseContext;
|
||||
const wrapper = mount(routerWrapper());
|
||||
|
||||
expect(wrapper.find('span').text()).toBe('Login Page');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SocialAuthProviders should match social auth provider with default icon snapshot 1`] = `
|
||||
<button
|
||||
className="btn-social btn-oa2-apple-id mr-3"
|
||||
data-provider-url="/auth/login/apple-id/?auth_entry=login&next=/dashboard"
|
||||
id="oa2-apple-id"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="font-container"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-sign-in-alt fa-w-16 "
|
||||
data-icon="sign-in-alt"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M416 448h-84c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h84c17.7 0 32-14.3 32-32V160c0-17.7-14.3-32-32-32h-84c-6.6 0-12-5.4-12-12V76c0-6.6 5.4-12 12-12h84c53 0 96 43 96 96v192c0 53-43 96-96 96zm-47-201L201 79c-15-15-41-4.5-41 17v96H24c-13.3 0-24 10.7-24 24v96c0 13.3 10.7 24 24 24h136v96c0 21.5 26 32 41 17l168-168c9.3-9.4 9.3-24.6 0-34z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mr-auto pl-2"
|
||||
id="provider-name"
|
||||
>
|
||||
Apple
|
||||
</span>
|
||||
<span
|
||||
className="sr-only"
|
||||
>
|
||||
Sign in with Apple
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`SocialAuthProviders should match social auth provider with iconClass snapshot 1`] = `
|
||||
<button
|
||||
className="btn-social btn-oa2-apple-id mr-3"
|
||||
data-provider-url="/auth/login/apple-id/?auth_entry=login&next=/dashboard"
|
||||
id="oa2-apple-id"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="font-container"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-google fa-w-16 "
|
||||
data-icon="google"
|
||||
data-prefix="fab"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 488 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mr-auto pl-2"
|
||||
id="provider-name"
|
||||
>
|
||||
Apple
|
||||
</span>
|
||||
<span
|
||||
className="sr-only"
|
||||
>
|
||||
Sign in with Apple
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`SocialAuthProviders should match social auth provider with iconImage snapshot 1`] = `
|
||||
Array [
|
||||
<button
|
||||
className="btn-social btn-oa2-apple-id mr-3"
|
||||
data-provider-url="/auth/login/apple-id/?auth_entry=login&next=/dashboard"
|
||||
id="oa2-apple-id"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
alt="icon Apple"
|
||||
className="icon-image"
|
||||
src="https://edx.devstack.lms/logo.png"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mr-auto pl-2"
|
||||
id="provider-name"
|
||||
>
|
||||
Apple
|
||||
</span>
|
||||
<span
|
||||
className="sr-only"
|
||||
>
|
||||
Sign in with Apple
|
||||
</span>
|
||||
</button>,
|
||||
<button
|
||||
className="btn-social btn-oa2-facebook "
|
||||
data-provider-url="/auth/login/facebook/?auth_entry=login&next=/dashboard"
|
||||
id="oa2-facebook"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
alt="icon Facebook"
|
||||
className="icon-image"
|
||||
src="https://edx.devstack.lms/facebook-logo.png"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mr-auto pl-2"
|
||||
id="provider-name"
|
||||
>
|
||||
Facebook
|
||||
</span>
|
||||
<span
|
||||
className="sr-only"
|
||||
>
|
||||
Sign in with Facebook
|
||||
</span>
|
||||
</button>,
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,34 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ThirdPartyAuthAlert should match login page third party auth alert message snapshot 1`] = `
|
||||
<div
|
||||
className="fade alert-content alert-warning mt-n2 alert show"
|
||||
id="tpa-alert"
|
||||
role="alert"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
You have successfully signed into Google, but your Google account does not have a linked edX account. To link your accounts, sign in now using your edX password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ThirdPartyAuthAlert should match register page third party auth alert message snapshot 1`] = `
|
||||
<div
|
||||
className="fade alert-content alert-success mt-n2 alert show"
|
||||
id="tpa-alert"
|
||||
role="alert"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="alert-heading h4"
|
||||
>
|
||||
Almost done!
|
||||
</div>
|
||||
<p>
|
||||
You've successfully signed into Google! We just need a little more information before you start learning with edX.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
33
src/redesign/data/configureStore.js
Normal file
33
src/redesign/data/configureStore.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { applyMiddleware, createStore, compose } from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension';
|
||||
import { createLogger } from 'redux-logger';
|
||||
import createSagaMiddleware from 'redux-saga';
|
||||
|
||||
import createRootReducer from './reducers';
|
||||
import rootSaga from './sagas';
|
||||
|
||||
const sagaMiddleware = createSagaMiddleware();
|
||||
|
||||
function composeMiddleware() {
|
||||
if (getConfig().ENVIRONMENT === 'development') {
|
||||
const loggerMiddleware = createLogger({
|
||||
collapsed: true,
|
||||
});
|
||||
return composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware));
|
||||
}
|
||||
|
||||
return compose(applyMiddleware(thunkMiddleware, sagaMiddleware));
|
||||
}
|
||||
|
||||
export default function configureStore(initialState = {}) {
|
||||
const store = createStore(
|
||||
createRootReducer(),
|
||||
initialState,
|
||||
composeMiddleware(),
|
||||
);
|
||||
sagaMiddleware.run(rootSaga);
|
||||
|
||||
return store;
|
||||
}
|
||||
40
src/redesign/data/constants.js
Normal file
40
src/redesign/data/constants.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// URL Paths
|
||||
export const LOGIN_PAGE = '/login';
|
||||
export const REGISTER_PAGE = '/register';
|
||||
export const RESET_PAGE = '/reset';
|
||||
export const WELCOME_PAGE = '/welcome';
|
||||
export const DEFAULT_REDIRECT_URL = '/dashboard';
|
||||
export const PASSWORD_RESET_CONFIRM = '/password_reset_confirm/:token/';
|
||||
export const PAGE_NOT_FOUND = '/notfound';
|
||||
export const ENTERPRISE_LOGIN_URL = '/enterprise/login';
|
||||
|
||||
// Constants
|
||||
export const SUPPORTED_ICON_CLASSES = ['apple', 'facebook', 'google', 'microsoft'];
|
||||
|
||||
// Error Codes
|
||||
export const INTERNAL_SERVER_ERROR = 'internal-server-error';
|
||||
export const API_RATELIMIT_ERROR = 'api-ratelimit-error';
|
||||
|
||||
// States
|
||||
export const DEFAULT_STATE = 'default';
|
||||
export const PENDING_STATE = 'pending';
|
||||
export const COMPLETE_STATE = 'complete';
|
||||
|
||||
// Regex
|
||||
export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+)*'
|
||||
+ '|^"([\\001-\\010\\013\\014\\016-\\037!#-\\[\\]-\\177]|\\\\[\\001-\\011\\013\\014\\016-\\177])*"'
|
||||
+ ')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+)(?:[A-Z0-9-]{2,63})'
|
||||
+ '|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$';
|
||||
export const LETTER_REGEX = /[a-zA-Z]/;
|
||||
export const NUMBER_REGEX = /\d/;
|
||||
|
||||
// Query string parameters that can be passed to LMS to manage
|
||||
// things like auto-enrollment upon login and registration.
|
||||
export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow', 'next'];
|
||||
|
||||
export const DEFAULT_TOP_LEVEL_DOMAINS = ['com', 'com.au', 'com.tw', 'ca', 'co.nz', 'co.uk', 'de',
|
||||
'fr', 'it', 'ru', 'net', 'org', 'edu', 'gov', 'jp', 'nl', 'kr', 'se', 'eu',
|
||||
'ie', 'co.il', 'us', 'at', 'be', 'dk', 'hk', 'es', 'gr', 'ch', 'no', 'cz',
|
||||
'in', 'net', 'net.au', 'info', 'biz', 'mil', 'co.jp', 'sg', 'hu', 'uk'];
|
||||
|
||||
export const DEFAULT_SERVICE_PROVIDER_DOMAINS = ['yahoo', 'hotmail', 'live', 'outlook', 'gmx', 'gmail', 'edx'];
|
||||
31
src/redesign/data/reducers.js
Executable file
31
src/redesign/data/reducers.js
Executable file
@@ -0,0 +1,31 @@
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
import {
|
||||
reducer as loginReducer,
|
||||
storeName as loginStoreName,
|
||||
} from '../login';
|
||||
import {
|
||||
reducer as registerReducer,
|
||||
storeName as registerStoreName,
|
||||
} from '../register';
|
||||
import {
|
||||
reducer as commonComponentsReducer,
|
||||
storeName as commonComponentsStoreName,
|
||||
} from '../common-components';
|
||||
import {
|
||||
reducer as forgotPasswordReducer,
|
||||
storeName as forgotPasswordStoreName,
|
||||
} from '../forgot-password';
|
||||
import {
|
||||
reducer as resetPasswordReducer,
|
||||
storeName as resetPasswordStoreName,
|
||||
} from '../reset-password';
|
||||
|
||||
const createRootReducer = () => combineReducers({
|
||||
[loginStoreName]: loginReducer,
|
||||
[registerStoreName]: registerReducer,
|
||||
[commonComponentsStoreName]: commonComponentsReducer,
|
||||
[forgotPasswordStoreName]: forgotPasswordReducer,
|
||||
[resetPasswordStoreName]: resetPasswordReducer,
|
||||
});
|
||||
export default createRootReducer;
|
||||
17
src/redesign/data/sagas.js
Normal file
17
src/redesign/data/sagas.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { all } from 'redux-saga/effects';
|
||||
|
||||
import { saga as registrationSaga } from '../register';
|
||||
import { saga as loginSaga } from '../login';
|
||||
import { saga as commonComponentsSaga } from '../common-components';
|
||||
import { saga as forgotPasswordSaga } from '../forgot-password';
|
||||
import { saga as resetPasswordSaga } from '../reset-password';
|
||||
|
||||
export default function* rootSaga() {
|
||||
yield all([
|
||||
loginSaga(),
|
||||
registrationSaga(),
|
||||
commonComponentsSaga(),
|
||||
forgotPasswordSaga(),
|
||||
resetPasswordSaga(),
|
||||
]);
|
||||
}
|
||||
21
src/redesign/data/utils/cookies.js
Normal file
21
src/redesign/data/utils/cookies.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import Cookies from 'universal-cookie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export function setCookie(cookieName, cookieValue, cookieExpiry) {
|
||||
const cookies = new Cookies();
|
||||
const options = { domain: getConfig().COOKIE_DOMAIN, path: '/' };
|
||||
if (cookieExpiry) {
|
||||
options.expires = cookieExpiry;
|
||||
}
|
||||
cookies.set(cookieName, cookieValue, options);
|
||||
}
|
||||
|
||||
export default function setSurveyCookie(surveyType) {
|
||||
const cookieName = getConfig().USER_SURVEY_COOKIE_NAME;
|
||||
if (cookieName) {
|
||||
const signupTimestamp = (new Date()).getTime();
|
||||
// set expiry to exactly 24 hours from now
|
||||
const cookieExpiry = new Date(signupTimestamp + 1 * 864e5);
|
||||
setCookie(cookieName, surveyType, cookieExpiry);
|
||||
}
|
||||
}
|
||||
80
src/redesign/data/utils/dataUtils.js
Normal file
80
src/redesign/data/utils/dataUtils.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Utility functions
|
||||
|
||||
import * as QueryString from 'query-string';
|
||||
import { AUTH_PARAMS } from '../constants';
|
||||
|
||||
export default function processLink(link) {
|
||||
let matches;
|
||||
link.replace(/(.*?)<a href=["']([^"']*).*?>([^<]+)<\/a>(.*)/g, function () { // eslint-disable-line func-names
|
||||
matches = Array.prototype.slice.call(arguments, 1, 5); // eslint-disable-line prefer-rest-params
|
||||
});
|
||||
return matches;
|
||||
}
|
||||
|
||||
export const getTpaProvider = (tpaHintProvider, primaryProviders, secondaryProviders) => {
|
||||
let tpaProvider = null;
|
||||
let skipHintedLogin = false;
|
||||
[...primaryProviders, ...secondaryProviders].forEach((provider) => {
|
||||
if (provider.id === tpaHintProvider) {
|
||||
tpaProvider = provider;
|
||||
if (provider.skipHintedLogin) {
|
||||
skipHintedLogin = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return { provider: tpaProvider, skipHintedLogin };
|
||||
};
|
||||
|
||||
export const getTpaHint = () => {
|
||||
const params = QueryString.parse(window.location.search);
|
||||
let tpaHint = null;
|
||||
tpaHint = params.tpa_hint;
|
||||
if (!tpaHint) {
|
||||
const { next } = params;
|
||||
if (next) {
|
||||
const index = next.indexOf('tpa_hint=');
|
||||
if (index !== -1) {
|
||||
tpaHint = next.substring(index + 'tpa_hint='.length, next.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tpaHint;
|
||||
};
|
||||
|
||||
export const updatePathWithQueryParams = (path) => {
|
||||
const queryParams = window.location.search;
|
||||
|
||||
if (!queryParams) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `${path}${queryParams}`;
|
||||
};
|
||||
|
||||
export const getAllPossibleQueryParam = () => {
|
||||
const urlParams = QueryString.parse(window.location.search);
|
||||
const params = {};
|
||||
Object.entries(urlParams).forEach(([key, value]) => {
|
||||
if (AUTH_PARAMS.indexOf(key) > -1) {
|
||||
params[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
export const getActivationStatus = () => {
|
||||
const params = QueryString.parse(window.location.search);
|
||||
|
||||
return params.account_activation_status;
|
||||
};
|
||||
|
||||
export const isScrollBehaviorSupported = () => 'scrollBehavior' in document.documentElement.style;
|
||||
|
||||
export const windowScrollTo = (options) => {
|
||||
if (isScrollBehaviorSupported()) {
|
||||
return window.scrollTo(options);
|
||||
}
|
||||
|
||||
return window.scrollTo(options.top, options.left);
|
||||
};
|
||||
32
src/redesign/data/utils/dataUtils.test.js
Normal file
32
src/redesign/data/utils/dataUtils.test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { LOGIN_PAGE } from '../constants';
|
||||
import processLink, { updatePathWithQueryParams } from './dataUtils';
|
||||
|
||||
describe('processLink', () => {
|
||||
it('should use the provided processLink function to', () => {
|
||||
const expectedHref = 'http://test.server.com/';
|
||||
const expectedText = 'test link';
|
||||
const link = `<a href="${expectedHref}">${expectedText}</a>`;
|
||||
|
||||
const matches = processLink(link);
|
||||
|
||||
expect(matches[1]).toEqual(expectedHref);
|
||||
expect(matches[2]).toEqual(expectedText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePathWithQueryParams', () => {
|
||||
it('should append query params into the path', () => {
|
||||
const params = '?course_id=testCourseId';
|
||||
const expectedPath = `${LOGIN_PAGE}${params}`;
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
href: 'http://localhost/',
|
||||
search: params,
|
||||
},
|
||||
});
|
||||
const updatedPath = updatePathWithQueryParams(LOGIN_PAGE);
|
||||
|
||||
expect(updatedPath).toEqual(expectedPath);
|
||||
});
|
||||
});
|
||||
11
src/redesign/data/utils/index.js
Normal file
11
src/redesign/data/utils/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
default,
|
||||
getTpaProvider,
|
||||
getTpaHint,
|
||||
updatePathWithQueryParams,
|
||||
getAllPossibleQueryParam,
|
||||
getActivationStatus,
|
||||
windowScrollTo,
|
||||
} from './dataUtils';
|
||||
export { default as AsyncActionType } from './reduxUtils';
|
||||
export { default as setSurveyCookie, setCookie } from './cookies';
|
||||
34
src/redesign/data/utils/reduxUtils.js
Normal file
34
src/redesign/data/utils/reduxUtils.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Helper class to save time when writing out action types for asynchronous methods. Also helps
|
||||
* ensure that actions are namespaced.
|
||||
*/
|
||||
export default class AsyncActionType {
|
||||
constructor(topic, name) {
|
||||
this.topic = topic;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
get BASE() {
|
||||
return `${this.topic}__${this.name}`;
|
||||
}
|
||||
|
||||
get BEGIN() {
|
||||
return `${this.topic}__${this.name}__BEGIN`;
|
||||
}
|
||||
|
||||
get SUCCESS() {
|
||||
return `${this.topic}__${this.name}__SUCCESS`;
|
||||
}
|
||||
|
||||
get FAILURE() {
|
||||
return `${this.topic}__${this.name}__FAILURE`;
|
||||
}
|
||||
|
||||
get RESET() {
|
||||
return `${this.topic}__${this.name}__RESET`;
|
||||
}
|
||||
|
||||
get FORBIDDEN() {
|
||||
return `${this.topic}__${this.name}__FORBIDDEN`;
|
||||
}
|
||||
}
|
||||
14
src/redesign/data/utils/reduxUtils.test.js
Normal file
14
src/redesign/data/utils/reduxUtils.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import AsyncActionType from './reduxUtils';
|
||||
|
||||
describe('AsyncActionType', () => {
|
||||
it('should return well formatted action strings', () => {
|
||||
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
|
||||
|
||||
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
|
||||
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
|
||||
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
|
||||
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
|
||||
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
|
||||
expect(actionType.FORBIDDEN).toBe('HOUSE_CATS__START_THE_RACE__FORBIDDEN');
|
||||
});
|
||||
});
|
||||
106
src/redesign/forgot-password/ForgotPasswordAlert.jsx
Normal file
106
src/redesign/forgot-password/ForgotPasswordAlert.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { CheckCircle, Error } from '@edx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { INTERNAL_SERVER_ERROR } from '../data/constants';
|
||||
import { PASSWORD_RESET } from '../reset-password/data/constants';
|
||||
|
||||
const ForgotPasswordAlert = (props) => {
|
||||
const { email, emailError, intl } = props;
|
||||
let { status } = props;
|
||||
|
||||
if (emailError) {
|
||||
status = 'form-submission-error';
|
||||
}
|
||||
|
||||
let message = '';
|
||||
let heading = intl.formatMessage(messages['forgot.password.error.alert.title']);
|
||||
const supportUrl = getConfig().PASSWORD_RESET_SUPPORT_LINK;
|
||||
switch (status) {
|
||||
case 'complete':
|
||||
heading = intl.formatMessage(messages['confirmation.message.title']);
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id="forgot.password.confirmation.message"
|
||||
defaultMessage="We sent an email to {email} with instructions to reset your password.
|
||||
If you do not receive a password reset message after 1 minute, verify that you entered
|
||||
the correct email address, or check your spam folder. If you need further assistance, {supportLink}."
|
||||
description="Forgot password confirmation message"
|
||||
values={{
|
||||
email: <span className="data-hj-suppress">{email}</span>,
|
||||
supportLink: (
|
||||
<Alert.Link
|
||||
className="alert-link"
|
||||
href={supportUrl}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
window.open(supportUrl, '_blank');
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages['confirmation.support.link'])}
|
||||
</Alert.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case INTERNAL_SERVER_ERROR:
|
||||
message = intl.formatMessage(messages['internal.server.error']);
|
||||
break;
|
||||
case 'forbidden':
|
||||
heading = intl.formatMessage(messages['forgot.password.error.message.title']);
|
||||
message = intl.formatMessage(messages['forgot.password.request.in.progress.message']);
|
||||
break;
|
||||
case 'form-submission-error':
|
||||
message = intl.formatMessage(messages['extend.field.errors'], { emailError });
|
||||
break;
|
||||
case PASSWORD_RESET.INVALID_TOKEN:
|
||||
heading = intl.formatMessage(messages['invalid.token.heading']);
|
||||
message = intl.formatMessage(messages['invalid.token.error.message']);
|
||||
break;
|
||||
case PASSWORD_RESET.FORBIDDEN_REQUEST:
|
||||
heading = intl.formatMessage(messages['token.validation.rate.limit.error.heading']);
|
||||
message = intl.formatMessage(messages['token.validation.rate.limit.error']);
|
||||
break;
|
||||
case PASSWORD_RESET.INTERNAL_SERVER_ERROR:
|
||||
heading = intl.formatMessage(messages['token.validation.internal.sever.error.heading']);
|
||||
message = intl.formatMessage(messages['token.validation.internal.sever.error']);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
return (
|
||||
<Alert
|
||||
id="validation-errors"
|
||||
className="mb-5"
|
||||
variant={`${status === 'complete' ? 'success' : 'danger'}`}
|
||||
icon={status === 'complete' ? CheckCircle : Error}
|
||||
>
|
||||
<Alert.Heading>{heading}</Alert.Heading>
|
||||
<p>{message}</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
ForgotPasswordAlert.defaultProps = {
|
||||
email: '',
|
||||
emailError: '',
|
||||
};
|
||||
|
||||
ForgotPasswordAlert.propTypes = {
|
||||
status: PropTypes.string.isRequired,
|
||||
email: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
emailError: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(ForgotPasswordAlert);
|
||||
169
src/redesign/forgot-password/ForgotPasswordPage.jsx
Normal file
169
src/redesign/forgot-password/ForgotPasswordPage.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { Formik } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Form,
|
||||
StatefulButton,
|
||||
Hyperlink,
|
||||
Tabs,
|
||||
Tab,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBackIos } from '@edx/paragon/icons';
|
||||
|
||||
import { forgotPassword } from './data/actions';
|
||||
import { forgotPasswordResultSelector } from './data/selectors';
|
||||
|
||||
import messages from './messages';
|
||||
import { FormGroup } from '../common-components';
|
||||
import { DEFAULT_STATE, LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants';
|
||||
import { updatePathWithQueryParams, windowScrollTo } from '../data/utils';
|
||||
import ForgotPasswordAlert from './ForgotPasswordAlert';
|
||||
|
||||
const ForgotPasswordPage = (props) => {
|
||||
const { intl, status, submitState } = props;
|
||||
|
||||
const platformName = getConfig().SITE_NAME;
|
||||
const regex = new RegExp(VALID_EMAIL_REGEX, 'i');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [key, setKey] = useState('');
|
||||
const supportUrl = getConfig().LOGIN_ISSUE_SUPPORT_LINK;
|
||||
|
||||
useEffect(() => {
|
||||
sendPageEvent('login_and_registration', 'reset');
|
||||
sendTrackEvent('edx.bi.password_reset_form.viewed', { category: 'user-engagement' });
|
||||
}, []);
|
||||
|
||||
const getValidationMessage = (email) => {
|
||||
let error = '';
|
||||
|
||||
if (email === '') {
|
||||
error = intl.formatMessage(messages['forgot.password.empty.email.field.error']);
|
||||
} else if (!regex.test(email)) {
|
||||
error = intl.formatMessage(messages['forgot.password.page.invalid.email.message']);
|
||||
}
|
||||
|
||||
setValidationError(error);
|
||||
return error;
|
||||
};
|
||||
|
||||
const tabTitle = (
|
||||
<div className="d-flex">
|
||||
<Icon src={ArrowBackIos} className="arrow-back-icon" />
|
||||
<span className="ml-2">{intl.formatMessage(messages['sign.in.text'])}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs activeKey="" id="controlled-tab-example" onSelect={(k) => setKey(k)}>
|
||||
<Tab title={tabTitle} eventKey={LOGIN_PAGE} />
|
||||
</Tabs>
|
||||
{ key && (
|
||||
<Redirect to={updatePathWithQueryParams(key)} />
|
||||
)}
|
||||
<div id="main-content" className="main-content">
|
||||
<Formik
|
||||
initialValues={{ email: '' }}
|
||||
validateOnChange={false}
|
||||
validate={(values) => {
|
||||
const validationMessage = getValidationMessage(values.email);
|
||||
|
||||
if (validationMessage !== '') {
|
||||
windowScrollTo({ left: 0, top: 0, behavior: 'smooth' });
|
||||
return { email: validationMessage };
|
||||
}
|
||||
|
||||
return {};
|
||||
}}
|
||||
onSubmit={(values) => { props.forgotPassword(values.email); }}
|
||||
>
|
||||
{({
|
||||
errors, handleSubmit, setFieldValue, values,
|
||||
}) => (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages['forgot.password.page.title'],
|
||||
{ siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Form className="mw-xs">
|
||||
<ForgotPasswordAlert email={props.email} emailError={errors.email} status={status} />
|
||||
<h4>
|
||||
{intl.formatMessage(messages['forgot.password.page.heading'])}
|
||||
</h4>
|
||||
<p className="mb-4">
|
||||
{intl.formatMessage(messages['forgot.password.page.instructions'])}
|
||||
</p>
|
||||
<FormGroup
|
||||
floatingLabel={intl.formatMessage(messages['forgot.password.page.email.field.label'])}
|
||||
name="email"
|
||||
errorMessage={validationError}
|
||||
value={values.email}
|
||||
handleBlur={() => getValidationMessage(values.email)}
|
||||
handleChange={e => setFieldValue('email', e.target.value)}
|
||||
handleFocus={() => setValidationError('')}
|
||||
helpText={[intl.formatMessage(messages['forgot.password.email.help.text'], { platformName })]}
|
||||
/>
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
variant="brand"
|
||||
className="login-button-width"
|
||||
state={submitState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['forgot.password.page.submit.button']),
|
||||
pending: '',
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
<Hyperlink
|
||||
id="forgot-password"
|
||||
className="ml-4 font-weight-500 text-body"
|
||||
destination={supportUrl}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
window.open(supportUrl, '_blank');
|
||||
}}
|
||||
>{intl.formatMessage(messages['need.help.sign.in.text'])}
|
||||
</Hyperlink>
|
||||
<p className="mt-5 one-rem-font">{intl.formatMessage(messages['additional.help.text'])}
|
||||
<span><Hyperlink isInline destination={`mailto:${getConfig().INFO_EMAIL}`}>{getConfig().INFO_EMAIL}</Hyperlink></span>
|
||||
</p>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ForgotPasswordPage.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
email: PropTypes.string,
|
||||
forgotPassword: PropTypes.func.isRequired,
|
||||
status: PropTypes.string,
|
||||
submitState: PropTypes.string,
|
||||
};
|
||||
|
||||
ForgotPasswordPage.defaultProps = {
|
||||
email: '',
|
||||
status: null,
|
||||
submitState: DEFAULT_STATE,
|
||||
};
|
||||
|
||||
export default connect(
|
||||
forgotPasswordResultSelector,
|
||||
{
|
||||
forgotPassword,
|
||||
},
|
||||
)(injectIntl(ForgotPasswordPage));
|
||||
26
src/redesign/forgot-password/data/actions.js
Normal file
26
src/redesign/forgot-password/data/actions.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { AsyncActionType } from '../../data/utils';
|
||||
|
||||
export const FORGOT_PASSWORD = new AsyncActionType('FORGOT', 'PASSWORD');
|
||||
|
||||
// Forgot Password
|
||||
export const forgotPassword = email => ({
|
||||
type: FORGOT_PASSWORD.BASE,
|
||||
payload: { email },
|
||||
});
|
||||
|
||||
export const forgotPasswordBegin = () => ({
|
||||
type: FORGOT_PASSWORD.BEGIN,
|
||||
});
|
||||
|
||||
export const forgotPasswordSuccess = email => ({
|
||||
type: FORGOT_PASSWORD.SUCCESS,
|
||||
payload: { email },
|
||||
});
|
||||
|
||||
export const forgotPasswordForbidden = () => ({
|
||||
type: FORGOT_PASSWORD.FORBIDDEN,
|
||||
});
|
||||
|
||||
export const forgotPasswordServerError = () => ({
|
||||
type: FORGOT_PASSWORD.FAILURE,
|
||||
});
|
||||
43
src/redesign/forgot-password/data/reducers.js
Normal file
43
src/redesign/forgot-password/data/reducers.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { FORGOT_PASSWORD } from './actions';
|
||||
import { INTERNAL_SERVER_ERROR, PENDING_STATE } from '../../data/constants';
|
||||
import { PASSWORD_RESET_FAILURE } from '../../reset-password/data/actions';
|
||||
|
||||
export const defaultState = {
|
||||
status: '',
|
||||
submitState: '',
|
||||
email: '',
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action = null) => {
|
||||
if (action !== null) {
|
||||
switch (action.type) {
|
||||
case FORGOT_PASSWORD.BEGIN:
|
||||
return {
|
||||
status: 'pending',
|
||||
submitState: PENDING_STATE,
|
||||
};
|
||||
case FORGOT_PASSWORD.SUCCESS:
|
||||
return {
|
||||
...action.payload,
|
||||
status: 'complete',
|
||||
};
|
||||
case FORGOT_PASSWORD.FORBIDDEN:
|
||||
return {
|
||||
status: 'forbidden',
|
||||
};
|
||||
case FORGOT_PASSWORD.FAILURE:
|
||||
return {
|
||||
status: INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
case PASSWORD_RESET_FAILURE:
|
||||
return {
|
||||
status: action.payload.errorCode,
|
||||
};
|
||||
default:
|
||||
return defaultState;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default reducer;
|
||||
36
src/redesign/forgot-password/data/sagas.js
Normal file
36
src/redesign/forgot-password/data/sagas.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { logError, logInfo } from '@edx/frontend-platform/logging';
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
FORGOT_PASSWORD,
|
||||
forgotPasswordBegin,
|
||||
forgotPasswordSuccess,
|
||||
forgotPasswordForbidden,
|
||||
forgotPasswordServerError,
|
||||
} from './actions';
|
||||
|
||||
import { forgotPassword } from './service';
|
||||
|
||||
// Services
|
||||
export function* handleForgotPassword(action) {
|
||||
try {
|
||||
yield put(forgotPasswordBegin());
|
||||
|
||||
yield call(forgotPassword, action.payload.email);
|
||||
|
||||
yield put(forgotPasswordSuccess(action.payload.email));
|
||||
} catch (e) {
|
||||
if (e.response && e.response.status === 403) {
|
||||
yield put(forgotPasswordForbidden());
|
||||
logInfo(e);
|
||||
} else {
|
||||
yield put(forgotPasswordServerError());
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(FORGOT_PASSWORD.BASE, handleForgotPassword);
|
||||
}
|
||||
10
src/redesign/forgot-password/data/selectors.js
Normal file
10
src/redesign/forgot-password/data/selectors.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
export const storeName = 'forgotPassword';
|
||||
|
||||
export const forgotPasswordSelector = state => ({ ...state[storeName] });
|
||||
|
||||
export const forgotPasswordResultSelector = createSelector(
|
||||
forgotPasswordSelector,
|
||||
forgotPassword => forgotPassword,
|
||||
);
|
||||
23
src/redesign/forgot-password/data/service.js
Normal file
23
src/redesign/forgot-password/data/service.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import formurlencoded from 'form-urlencoded';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function forgotPassword(email) {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
isPublic: true,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
`${getConfig().LMS_BASE_URL}/account/password`,
|
||||
formurlencoded({ email }),
|
||||
requestConfig,
|
||||
)
|
||||
.catch((e) => {
|
||||
throw (e);
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
67
src/redesign/forgot-password/data/tests/sagas.test.js
Normal file
67
src/redesign/forgot-password/data/tests/sagas.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { runSaga } from 'redux-saga';
|
||||
|
||||
import * as actions from '../actions';
|
||||
import { handleForgotPassword } from '../sagas';
|
||||
import * as api from '../service';
|
||||
import initializeMockLogging from '../../../../setupTest';
|
||||
|
||||
const { loggingService } = initializeMockLogging();
|
||||
|
||||
describe('handleForgotPassword', () => {
|
||||
const params = {
|
||||
payload: {
|
||||
formData: {
|
||||
email: 'test@test.com',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
loggingService.logError.mockReset();
|
||||
loggingService.logInfo.mockReset();
|
||||
});
|
||||
|
||||
it('should handle 500 error code', async () => {
|
||||
const passwordErrorResponse = { response: { status: 500 } };
|
||||
|
||||
const forgotPasswordRequest = jest.spyOn(api, 'forgotPassword').mockImplementation(
|
||||
() => Promise.reject(passwordErrorResponse),
|
||||
);
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleForgotPassword,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(dispatched).toEqual([
|
||||
actions.forgotPasswordBegin(),
|
||||
actions.forgotPasswordServerError(),
|
||||
]);
|
||||
forgotPasswordRequest.mockClear();
|
||||
});
|
||||
|
||||
it('should handle rate limit error', async () => {
|
||||
const forbiddenErrorResponse = { response: { status: 403 } };
|
||||
|
||||
const forbiddenPasswordRequest = jest.spyOn(api, 'forgotPassword').mockImplementation(
|
||||
() => Promise.reject(forbiddenErrorResponse),
|
||||
);
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleForgotPassword,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(loggingService.logInfo).toHaveBeenCalled();
|
||||
expect(dispatched).toEqual([
|
||||
actions.forgotPasswordBegin(),
|
||||
actions.forgotPasswordForbidden(null),
|
||||
]);
|
||||
forbiddenPasswordRequest.mockClear();
|
||||
});
|
||||
});
|
||||
5
src/redesign/forgot-password/index.js
Normal file
5
src/redesign/forgot-password/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default } from './ForgotPasswordPage';
|
||||
export { default as reducer } from './data/reducers';
|
||||
export { FORGOT_PASSWORD } from './data/actions';
|
||||
export { default as saga } from './data/sagas';
|
||||
export { storeName, forgotPasswordResultSelector } from './data/selectors';
|
||||
143
src/redesign/forgot-password/messages.js
Normal file
143
src/redesign/forgot-password/messages.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'forgot.password.page.title': {
|
||||
id: 'forgot.password.page.title',
|
||||
defaultMessage: 'Forgot Password | {siteName}',
|
||||
description: 'forgot password page title',
|
||||
},
|
||||
'forgot.password.page.heading': {
|
||||
id: 'forgot.password.page.heading',
|
||||
defaultMessage: 'Reset password',
|
||||
description: 'The page heading for the forgot password page.',
|
||||
},
|
||||
'forgot.password.page.instructions': {
|
||||
id: 'forgot.password.page.instructions',
|
||||
defaultMessage: 'Please enter your email address below and we will send you an email with instructions on how to reset your password.',
|
||||
description: 'Instructions message for forgot password page.',
|
||||
},
|
||||
'forgot.password.page.invalid.email.message': {
|
||||
id: 'forgot.password.page.invalid.email.message',
|
||||
defaultMessage: 'Enter a valid email address',
|
||||
description: 'Invalid email address message for input field.',
|
||||
},
|
||||
'forgot.password.page.email.field.label': {
|
||||
id: 'forgot.password.page.email.field.label',
|
||||
defaultMessage: 'Email',
|
||||
description: 'Email field label for the forgot password page.',
|
||||
},
|
||||
'forgot.password.page.submit.button': {
|
||||
id: 'forgot.password.page.submit.button',
|
||||
defaultMessage: 'Submit',
|
||||
description: 'Submit button text for the forgot password page.',
|
||||
},
|
||||
'forgot.password.error.alert.title': {
|
||||
id: 'forgot.password.error.alert.title.',
|
||||
defaultMessage: 'We were unable to contact you.',
|
||||
description: 'Failed to send password recovery email.',
|
||||
},
|
||||
'forgot.password.error.message.title': {
|
||||
id: 'forgot.password.error.message.title',
|
||||
defaultMessage: 'An error occurred.',
|
||||
description: 'Title for message that appears when error occurs for password assistance page',
|
||||
},
|
||||
'forgot.password.request.in.progress.message': {
|
||||
id: 'forgot.password.request.in.progress.message',
|
||||
defaultMessage: 'Your previous request is in progress, please try again in a few moments.',
|
||||
description: 'Message displayed when previous password reset request is still in progress.',
|
||||
},
|
||||
'forgot.password.empty.email.field.error': {
|
||||
id: 'forgot.password.empty.email.field.error',
|
||||
defaultMessage: 'Enter your email',
|
||||
description: 'Error message that appears when user tries to submit empty email field',
|
||||
},
|
||||
'forgot.password.invalid.email.heading': {
|
||||
id: 'forgot.password.invalid.email',
|
||||
defaultMessage: 'An error occurred.',
|
||||
description: 'heading for invalid email',
|
||||
},
|
||||
'forgot.password.invalid.email.message': {
|
||||
id: 'forgot.password.invalid.email.message',
|
||||
defaultMessage: "The email address you've provided isn't formatted correctly.",
|
||||
description: 'message for invalid email',
|
||||
},
|
||||
'forgot.password.email.help.text': {
|
||||
id: 'forgot.password.email.help.text',
|
||||
defaultMessage: 'The email address you used to register with {platformName}',
|
||||
description: 'text help for the email',
|
||||
},
|
||||
// Confirmation Alert Message
|
||||
'confirmation.message.title': {
|
||||
id: 'confirmation.message.title',
|
||||
defaultMessage: 'Check your email',
|
||||
description: 'Forgot password confirmation message title',
|
||||
},
|
||||
'confirmation.support.link': {
|
||||
id: 'confirmation.support.link',
|
||||
defaultMessage: 'contact technical support',
|
||||
description: 'Technical support link text',
|
||||
},
|
||||
'need.help.sign.in.text': {
|
||||
id: 'need.help.sign.in.text',
|
||||
defaultMessage: 'Need help signing in?',
|
||||
description: 'Sign in help link on forgot password page',
|
||||
},
|
||||
'additional.help.text': {
|
||||
id: 'additional.help.text',
|
||||
defaultMessage: 'For additional help, contact edX support at ',
|
||||
description: 'additional help text on forgot password page',
|
||||
},
|
||||
'sign.in.text': {
|
||||
id: 'sign.in.text',
|
||||
defaultMessage: 'Sign in',
|
||||
description: 'login page link on password page',
|
||||
},
|
||||
'extend.field.errors': {
|
||||
id: 'extend.field.errors',
|
||||
defaultMessage: '{emailError} below.',
|
||||
description: 'extends the field error for alert message',
|
||||
},
|
||||
// Reset password token validation failure
|
||||
'invalid.token.heading': {
|
||||
id: 'invalid.token.heading',
|
||||
defaultMessage: 'Invalid password reset link',
|
||||
description: 'Alert heading when reset password link is invalid',
|
||||
},
|
||||
'invalid.token.error.message': {
|
||||
id: 'invalid.token.error.message',
|
||||
defaultMessage: 'This password reset link is invalid. It may have been used already. Enter your email below to receive a new link.',
|
||||
description: 'Alert message when reset password link has expired or is invalid',
|
||||
},
|
||||
'token.validation.rate.limit.error.heading': {
|
||||
id: 'token.validation.rate.limit.error.heading',
|
||||
defaultMessage: 'Too many requests',
|
||||
description: 'Too many request at server end point',
|
||||
},
|
||||
'token.validation.rate.limit.error': {
|
||||
id: 'token.validation.rate.limit.error',
|
||||
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',
|
||||
},
|
||||
'token.validation.internal.sever.error.heading': {
|
||||
id: 'token.validation.internal.sever.error.heading',
|
||||
defaultMessage: 'Token validation failure',
|
||||
description: 'Failed to validate reset password token error message.',
|
||||
},
|
||||
'token.validation.internal.sever.error': {
|
||||
id: 'token.validation.internal.sever.error',
|
||||
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',
|
||||
},
|
||||
// Error messages
|
||||
'internal.server.error': {
|
||||
id: 'internal.server.error',
|
||||
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',
|
||||
},
|
||||
'rate.limit.error': {
|
||||
id: 'rate.limit.error',
|
||||
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',
|
||||
},
|
||||
});
|
||||
export default messages;
|
||||
189
src/redesign/forgot-password/tests/ForgotPasswordPage.test.jsx
Normal file
189
src/redesign/forgot-password/tests/ForgotPasswordPage.test.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
|
||||
import { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter, Router } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { createMemoryHistory } from 'history';
|
||||
|
||||
import * as analytics from '@edx/frontend-platform/analytics';
|
||||
import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner';
|
||||
import { mergeConfig } from '@edx/frontend-platform';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ForgotPasswordPage from '../ForgotPasswordPage';
|
||||
import { INTERNAL_SERVER_ERROR, LOGIN_PAGE } from '../../data/constants';
|
||||
import { PASSWORD_RESET } from '../../reset-password/data/constants';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
analytics.sendPageEvent = jest.fn();
|
||||
|
||||
const IntlForgotPasswordPage = injectIntl(ForgotPasswordPage);
|
||||
const mockStore = configureStore();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
const initialState = {
|
||||
forgotPassword: {
|
||||
status: '',
|
||||
},
|
||||
};
|
||||
|
||||
describe('ForgotPasswordPage', () => {
|
||||
mergeConfig({
|
||||
LOGIN_ISSUE_SUPPORT_LINK: '',
|
||||
INFO_EMAIL: '',
|
||||
});
|
||||
|
||||
let props = {};
|
||||
let store = {};
|
||||
|
||||
const reduxWrapper = children => (
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore(initialState);
|
||||
props = {
|
||||
forgotPassword: jest.fn(),
|
||||
status: null,
|
||||
};
|
||||
});
|
||||
|
||||
it('should display need other help signing in button', () => {
|
||||
const wrapper = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
expect(wrapper.find('#forgot-password').first().text()).toEqual('Need help signing in?');
|
||||
});
|
||||
|
||||
it('should display email validation error message', async () => {
|
||||
const validationMessage = 'We were unable to contact you.Enter a valid email address below.';
|
||||
const wrapper = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
|
||||
wrapper.find('input#email').simulate(
|
||||
'change', { target: { value: 'invalid-email', name: 'email' } },
|
||||
);
|
||||
await act(async () => { await wrapper.find('button.btn-brand').simulate('click'); });
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('.alert-danger').text()).toEqual(validationMessage);
|
||||
});
|
||||
|
||||
it('should show alert on server error', () => {
|
||||
store = mockStore({
|
||||
forgotPassword: { status: INTERNAL_SERVER_ERROR },
|
||||
});
|
||||
const expectedMessage = 'We were unable to contact you.'
|
||||
+ 'An error has occurred. Try refreshing the page, or check your internet connection.';
|
||||
const wrapper = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
|
||||
expect(wrapper.find('#validation-errors').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should display empty email validation message', async () => {
|
||||
const validationMessage = 'We were unable to contact you.Enter your email below.';
|
||||
const forgotPasswordPage = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
|
||||
await act(async () => { await forgotPasswordPage.find('button.btn-brand').simulate('click'); });
|
||||
|
||||
forgotPasswordPage.update();
|
||||
expect(forgotPasswordPage.find('.alert-danger').text()).toEqual(validationMessage);
|
||||
});
|
||||
|
||||
it('should display request in progress error message', () => {
|
||||
const rateLimitMessage = 'An error occurred.Your previous request is in progress, please try again in a few moments.';
|
||||
store = mockStore({
|
||||
forgotPassword: { status: 'forbidden' },
|
||||
});
|
||||
|
||||
const forgotPasswordPage = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
expect(forgotPasswordPage.find('.alert-danger').text()).toEqual(rateLimitMessage);
|
||||
});
|
||||
|
||||
it('should not display any error message on change event', () => {
|
||||
const forgotPasswordPage = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
|
||||
const emailInput = forgotPasswordPage.find('input#email');
|
||||
emailInput.simulate('change', { target: { value: 'invalid-email', name: 'email' } });
|
||||
forgotPasswordPage.update();
|
||||
|
||||
expect(forgotPasswordPage.find('#email-invalid-feedback').exists()).toEqual(false);
|
||||
});
|
||||
|
||||
it('should display error message on blur event', async () => {
|
||||
const validationMessage = 'Enter your email';
|
||||
const forgotPasswordPage = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
const emailInput = forgotPasswordPage.find('input#email');
|
||||
|
||||
await act(async () => {
|
||||
await emailInput.simulate('blur', { target: { value: '', name: 'email' } });
|
||||
});
|
||||
|
||||
forgotPasswordPage.update();
|
||||
expect(forgotPasswordPage.find('.pgn__form-text-invalid').text()).toEqual(validationMessage);
|
||||
});
|
||||
|
||||
it('should clear error message on focus event', async () => {
|
||||
const validationMessage = 'Enter your email';
|
||||
const forgotPasswordPage = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
await act(async () => { await forgotPasswordPage.find('button.btn-brand').simulate('click'); });
|
||||
|
||||
forgotPasswordPage.update();
|
||||
expect(forgotPasswordPage.find('.pgn__form-text-invalid').text()).toEqual(validationMessage);
|
||||
|
||||
forgotPasswordPage.find('input#email').simulate('focus');
|
||||
expect(forgotPasswordPage.find('#email-invalid-feedback').exists()).toEqual(false);
|
||||
});
|
||||
|
||||
it('check cookie rendered', () => {
|
||||
const forgotPage = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
expect(forgotPage.find(<CookiePolicyBanner />)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display success message after email is sent', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
forgotPassword: {
|
||||
status: 'complete',
|
||||
},
|
||||
});
|
||||
const successMessage = 'Check your emailWe sent an email to with instructions to reset your password. If you do not '
|
||||
+ 'receive a password reset message after 1 minute, verify that you entered the correct email address,'
|
||||
+ ' or check your spam folder. If you need further assistance, contact technical support.';
|
||||
|
||||
const wrapper = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
expect(wrapper.find('.alert-success').text()).toEqual(successMessage);
|
||||
});
|
||||
|
||||
it('should display invalid password reset link error', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
forgotPassword: {
|
||||
status: PASSWORD_RESET.INVALID_TOKEN,
|
||||
},
|
||||
});
|
||||
const successMessage = 'Invalid password reset link'
|
||||
+ 'This password reset link is invalid. It may have been used already. '
|
||||
+ 'Enter your email below to receive a new link.';
|
||||
|
||||
const wrapper = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
expect(wrapper.find('.alert-danger').text()).toEqual(successMessage);
|
||||
});
|
||||
|
||||
it('should redirect onto login page', async () => {
|
||||
const forgotPasswordPage = mount(reduxWrapper(
|
||||
<Router history={history}>
|
||||
<IntlForgotPasswordPage {...props} />
|
||||
</Router>,
|
||||
));
|
||||
|
||||
await act(async () => { await forgotPasswordPage.find('nav').find('a').first().simulate('click'); });
|
||||
|
||||
forgotPasswordPage.update();
|
||||
expect(history.location.pathname).toEqual(LOGIN_PAGE);
|
||||
});
|
||||
});
|
||||
33
src/redesign/i18n/index.jsx
Normal file
33
src/redesign/i18n/index.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import arMessages from './messages/ar.json';
|
||||
import caMessages from './messages/ca.json';
|
||||
// no need to import en messages-- they are in the defaultMessage field
|
||||
import es419Messages from './messages/es_419.json';
|
||||
import frMessages from './messages/fr.json';
|
||||
import zhcnMessages from './messages/zh_CN.json';
|
||||
import heMessages from './messages/he.json';
|
||||
import idMessages from './messages/id.json';
|
||||
import kokrMessages from './messages/ko_kr.json';
|
||||
import plMessages from './messages/pl.json';
|
||||
import ptbrMessages from './messages/pt_br.json';
|
||||
import ruMessages from './messages/ru.json';
|
||||
import thMessages from './messages/th.json';
|
||||
import ukMessages from './messages/uk.json';
|
||||
|
||||
const messages = {
|
||||
ar: arMessages,
|
||||
es: es419Messages, // Prospectus uses es language code for spanish, added `es` option and pointed to es-419 strings.
|
||||
'es-419': es419Messages,
|
||||
fr: frMessages,
|
||||
'zh-cn': zhcnMessages,
|
||||
ca: caMessages,
|
||||
he: heMessages,
|
||||
id: idMessages,
|
||||
'ko-kr': kokrMessages,
|
||||
pl: plMessages,
|
||||
'pt-br': ptbrMessages,
|
||||
ru: ruMessages,
|
||||
th: thMessages,
|
||||
uk: ukMessages,
|
||||
};
|
||||
|
||||
export default messages;
|
||||
146
src/redesign/i18n/messages/ar.json
Normal file
146
src/redesign/i18n/messages/ar.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"forgot.password.confirmation.message": "You entered {strongEmail}. If this email address is associated with your\n edX account, we will send a message with password recovery instructions to this email address.",
|
||||
"forgot.password.technical.support.help.message": "If you need further assistance, {technicalSupportLink}.",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below:",
|
||||
"forgot.password.confirmation.title": "Check your email",
|
||||
"forgot.password.confirmation.support.link": "contact technical support",
|
||||
"forgot.password.confirmation.info": "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.",
|
||||
"internal.server.error.message": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"server.ratelimit.error.message": "An error has occurred because of too many requests. Please try again after some time.",
|
||||
"enterprisetpa.title.heading": "Would you like to sign in using your {providerName} credentials?",
|
||||
"enterprisetpa.sso.button.title": "Sign in using {providerName}",
|
||||
"enterprisetpa.login.button.text": "Show me other ways to sign in or register",
|
||||
"sso.sign.in.with": "Sign in with {providerName}",
|
||||
"sso.create.account.using": "Create account using {providerName}",
|
||||
"error.notfound.message": "الصفحة التي تبحث عنها غير متوفرة أو هناك خطأ في نص الرابط. الرجاء التحقق من الرابط والمحاولة مجددا.",
|
||||
"login.third.party.auth.account.not.linked.message": "لقد نجحت في تسجيل الدخول إلى {currentProvider}، ولكن حساب {currentProvider} لا يحتوي على حساب {platformName} مرتبط. لربط حساباتك، قم بتسجيل الدخول الآن باستخدام كلمة مرور {platformName}.",
|
||||
"register.third.party.auth.account.not.linked.message": "لقد نجحت في تسجيل الدخول إلى {currentProvider}. نحتاج إلى القليل من المعلومات قبل بدء التعلّم مع {platformName}.",
|
||||
"forgot.password.page.title": "Forgot Password | {siteName}",
|
||||
"forgot.password.page.heading": "Password assistance",
|
||||
"forgot.password.page.instructions": "Please enter your log-in or recovery email address below and we will send you an email with instructions.",
|
||||
"forgot.password.page.invalid.email.message": "The email address you've provided isn't formatted correctly.",
|
||||
"forgot.password.page.email.field.label": "Email",
|
||||
"forgot.password.page.submit.button": "Recover my password",
|
||||
"forgot.password.request.server.error": "We couldn’t send the password recovery email.",
|
||||
"forgot.password.error.message.title": "An error occurred.",
|
||||
"forgot.password.request.in.progress.message": "Your previous request is in progress, please try again in a few moments.",
|
||||
"forgot.password.empty.email.field.error": "Please enter your email.",
|
||||
"forgot.password.invalid.email": "An error occurred.",
|
||||
"forgot.password.invalid.email.message": "The email address you've provided isn't formatted correctly.",
|
||||
"forgot.password.email.help.text": "The email address you used to register with {platformName}",
|
||||
"account.activation.error.message": "Something went wrong, please {supportLink} to resolve this issue.",
|
||||
"non.compliant.password.error": "{passwordComplaintRequirements} {lineBreak}Your current password does not meet the new security\n requirements. We just sent a password-reset message to the email address associated with this account.\n Thank you for helping us keep your data safe.",
|
||||
"login.inactive.user.error": "أنت بحاجة لتنشيط حسابك من أجل تسجيل الدخول {lineBreak}\n{lineBreak}لقد أرسلنا للتو رابط التفعيل إلى البريد الإلكتروني {email}. تحقق من مجلدات الرسائل غير المرغوب فيها أو {supportLink} إذا لم تستلم بريدًا إلكترونيًا.",
|
||||
"login.reset.password.message.with.link": "If you've forgotten your password, click {resetLink} to reset.",
|
||||
"login.locked.reset.password.message.with.link": "To be on the safe side, you can reset your password {resetLink} before you try again.",
|
||||
"login.page.title": "Login | {siteName}",
|
||||
"sign.in.button": "Sign in",
|
||||
"need.help.signing.in.collapsible.menu": "Need help signing in?",
|
||||
"forgot.password.link": "Forgot my password",
|
||||
"other.sign.in.issues": "Other sign in issues",
|
||||
"need.other.help.signing.in.collapsible.menu": "Need other help signing in?",
|
||||
"institution.login.button": "Use my university info",
|
||||
"institution.login.page.title": "Sign in with institution/campus credentials",
|
||||
"institution.login.page.back.button": "Back to sign in",
|
||||
"create.an.account": "Create an account",
|
||||
"or.sign.in.with": "or sign in with",
|
||||
"non.compliant.password.title": "We recently changed our password requirements",
|
||||
"first.time.here": "First time here?",
|
||||
"email.label": "Email",
|
||||
"email.help.message": "The email address you used to register with edX.",
|
||||
"enterprise.login.link.text": "Sign in with your company or school",
|
||||
"email.format.validation.message": "The email address you've provided isn't formatted correctly.",
|
||||
"email.format.validation.less.chars.message": "Email must have at least 3 characters.",
|
||||
"email.validation.message": "Please enter your email.",
|
||||
"password.validation.message": "Please enter your password.",
|
||||
"password.label": "Password (required)",
|
||||
"register.link": "Create an account",
|
||||
"sign.in.heading": "Sign in",
|
||||
"account.activation.success.message.title": "Success! You have activated your account.",
|
||||
"account.activation.success.message": "You will now receive email updates and alerts from us related to the courses you are enrolled in. Sign in to continue.",
|
||||
"account.already.activated.message": "This account has already been activated.",
|
||||
"account.activation.error.message.title": "Your account could not be activated",
|
||||
"account.activation.support.link": "contact support",
|
||||
"login.rate.limit.reached.message": "Too many failed login attempts. Try again later.",
|
||||
"login.failure.header.title": "We couldn't sign you in.",
|
||||
"contact.support.link": "contact {platformName} support",
|
||||
"login.failed.link.text": "here",
|
||||
"login.incorrect.credentials.error": "Email or password is incorrect.",
|
||||
"login.failed.attempt.error": "You have {remainingAttempts} more sign in attempts before your account is temporarily locked.",
|
||||
"login.locked.out.error.message": "To protect your account, it’s been temporarily locked. Try again in {lockedOutPeriod} minutes.",
|
||||
"register.page.title": "Register | {siteName}",
|
||||
"create.account.button": "Create account",
|
||||
"already.have.an.edx.account": "Already have an edX account?",
|
||||
"sign.in.hyperlink": "Sign in.",
|
||||
"create.an.account.using": "or create an account using",
|
||||
"create.a.new.account": "Create a new account",
|
||||
"register.institution.login.button": "Use my institution/campus credentials",
|
||||
"register.institution.login.page.title": "Register with institution/campus credentials",
|
||||
"register.page.email.label": "Email (required)",
|
||||
"register.rate.limit.reached.message": "Too many failed registration attempts. Try again later.",
|
||||
"email.ratelimit.less.chars.validation.message": "Email must have 3 characters.",
|
||||
"email.ratelimit.incorrect.format.validation.message": "The email address you provided isn't formatted correctly.",
|
||||
"email.ratelimit.password.validation.message": "Your password must contain at least 8 characters",
|
||||
"register.page.password.validation.message": "Please enter your password.",
|
||||
"fullname.label": "Full name (required)",
|
||||
"fullname.validation.message": "Please enter your full name.",
|
||||
"username.label": "Public username (required)",
|
||||
"username.validation.message": "Please enter your public username.",
|
||||
"username.format.validation.message": "Usernames can only contain letters (A-Z, a-z), numerals (0-9), underscores (_), and hyphens (-).",
|
||||
"username.character.validation.message": "Your password must contain at least 1 letter.",
|
||||
"username.number.validation.message": "Your password must contain at least 1 number.",
|
||||
"username.ratelimit.less.chars.message": "Public username must have atleast 2 characters.",
|
||||
"country.validation.message": "Select your country or region of residence.",
|
||||
"support.education.research": "Support education research by providing additional information. (Optional)",
|
||||
"registration.request.server.error": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"registration.request.failure.header": "We couldn't create your account.",
|
||||
"helptext.name": "This name will be used by any certificates that you earn.",
|
||||
"helptext.username": "The name that will identify you in your courses. It cannot be changed later.",
|
||||
"helptext.password": "Your password must contain at least 8 characters, including 1 letter & 1 number.",
|
||||
"helptext.email": "This is what you will use to login.",
|
||||
"terms.of.service.and.honor.code": "Terms of Service and Honor Code",
|
||||
"privacy.policy": "Privacy Policy",
|
||||
"registration.year.of.birth.label": "Year of birth (optional)",
|
||||
"registration.country.label": "Country or region of residence (required)",
|
||||
"registration.field.gender.options.label": "Gender (optional)",
|
||||
"registration.goals.label": "Tell us why you're interested in edX (optional)",
|
||||
"registration.field.gender.options.f": "Female",
|
||||
"registration.field.gender.options.m": "Male",
|
||||
"registration.field.gender.options.o": "Other/Prefer not to say",
|
||||
"registration.field.education.levels.label": "Highest level of education completed (optional)",
|
||||
"registration.field.education.levels.p": "Doctorate",
|
||||
"registration.field.education.levels.m": "Master's or professional degree",
|
||||
"registration.field.education.levels.b": "Bachelor's degree",
|
||||
"registration.field.education.levels.a": "Associate's degree",
|
||||
"registration.field.education.levels.hs": "Secondary/high school",
|
||||
"registration.field.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"registration.field.education.levels.el": "Elementary/primary school",
|
||||
"registration.field.education.levels.none": "No formal education",
|
||||
"registration.field.education.levels.other": "Other education",
|
||||
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each\n Member process your personal data in accordance with the {privacyPolicy}.",
|
||||
"reset.password.request.invalid.token.description.message": "This password reset link is invalid. It may have been used already.\n To reset your password, go to the {loginPasswordLink} page and select {forgotPassword}",
|
||||
"reset.password.page.title": "Reset Password | {siteName}",
|
||||
"reset.password.page.heading": "Reset your password",
|
||||
"reset.password.page.instructions": "Enter and confirm your new password.",
|
||||
"reset.password.page.invalid.match.message": "Passwords do not match.",
|
||||
"forgot.password.page.new.field.label": "New password",
|
||||
"forgot.password.page.confirm.field.label": "Confirm password",
|
||||
"reset.password.page.submit.button": "Reset my password",
|
||||
"reset.password.request.success.header.message": "Password reset complete.",
|
||||
"forgot.password.confirmation.sign.in.link": "sign in",
|
||||
"reset.password.request.forgot.password.text": "Forgot password",
|
||||
"reset.password.request.invalid.token.header": "Invalid password reset link",
|
||||
"reset.password.empty.new.password.field.error": "Please enter your new password.",
|
||||
"forgot.password.empty.new.password.error.heading": "We couldn't reset your password.",
|
||||
"reset.password.request.server.error": "Failed to reset password",
|
||||
"reset.password.token.validation.sever.error": "Token validation failure",
|
||||
"reset.server.ratelimit.error": "Too many requests.",
|
||||
"reset.password.confirmation.support.link": "Sign in to your account.",
|
||||
"reset.password.request.success.header.description.message": "Your password has been reset. {loginPasswordLink}",
|
||||
"optional.fields.page.title": "Optional Fields | {siteName}",
|
||||
"optional.fields.page.heading": "Support education research by providing additional information.",
|
||||
"welcome.to.edx": "Welcome to edX, {username}!",
|
||||
"optional.fields.information.link": "Learn more about how we use this information.",
|
||||
"optional.fields.submit.button": "Submit",
|
||||
"optional.fields.skip.button": "Skip for now"
|
||||
}
|
||||
1
src/redesign/i18n/messages/ca.json
Normal file
1
src/redesign/i18n/messages/ca.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
146
src/redesign/i18n/messages/es_419.json
Normal file
146
src/redesign/i18n/messages/es_419.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"forgot.password.confirmation.message": "Introduciste {strongEmail}. Si esta dirección de correo electrónico está asociada con tu\n cuenta de edX, enviaremos un mensaje con instrucciones para recuperar tu contraseña a ese correo.",
|
||||
"forgot.password.technical.support.help.message": "Si necesitas ayuda adicional, {technicalSupportLink}.",
|
||||
"institution.login.page.sub.heading": "Elige tu institución en la siguiente lista:",
|
||||
"forgot.password.confirmation.title": "Verifica tu correo electrónico",
|
||||
"forgot.password.confirmation.support.link": "contacta con el equipo de soporte técnico",
|
||||
"forgot.password.confirmation.info": "Si no recibes un mensaje de recuperación de tu contraseña en un minuto, verifica que introduciste la dirección de correo electrónico correcta, o verifica tu carpeta de correo no deseado.",
|
||||
"internal.server.error.message": "Se ha producido un error. Intenta actualizar la página o comprueba tu conexión a Internet.",
|
||||
"server.ratelimit.error.message": "Se ha producido un error debido a demasiadas solicitudes. Por favor, inténtalo de nuevo después de algún tiempo.",
|
||||
"enterprisetpa.title.heading": "¿Deseas iniciar sesión con tus credenciales de {providerName}?",
|
||||
"enterprisetpa.sso.button.title": "Inicio de sesión con {providerName}",
|
||||
"enterprisetpa.login.button.text": "Mostrar otras formas de iniciar sesión o de registrarme",
|
||||
"sso.sign.in.with": "Inicio de sesión con {providerName}",
|
||||
"sso.create.account.using": "Crear una cuenta con {providerName}",
|
||||
"error.notfound.message": "La página que estas buscando no está disponible o hay un error en la URL. Por favor, verifica la URL y vuelve a intentarlo.",
|
||||
"login.third.party.auth.account.not.linked.message": "Te has registrado correctamente en {currentProvider}, pero tu cuenta de {currentProvider} no tiene una cuenta de {platformName} asociada. Para asociar tus cuentas, inicia sesión ahora usando tu contraseña de {platformName}.",
|
||||
"register.third.party.auth.account.not.linked.message": "Has iniciado sesión correctamente en {currentProvider}. Solo necesitamos un poco más de información para que puedas empezar a aprender con {platformName}.",
|
||||
"forgot.password.page.title": "Olvidé la contraseña | {siteName}",
|
||||
"forgot.password.page.heading": "Ayuda con la contraseña",
|
||||
"forgot.password.page.instructions": "Por favor introduce tu dirección de correo electrónico de inicio de sesión o de recuperación a continuación y te enviaremos un correo electrónico con instrucciones.",
|
||||
"forgot.password.page.invalid.email.message": "La dirección de correo que has introducido no está en el formato correcto.",
|
||||
"forgot.password.page.email.field.label": "Correo electrónico",
|
||||
"forgot.password.page.submit.button": "Recuperar mi contraseña",
|
||||
"forgot.password.request.server.error": "No hemos podido enviar el correo electrónico de recuperación de la contraseña.",
|
||||
"forgot.password.error.message.title": "Ha ocurrido un error.",
|
||||
"forgot.password.request.in.progress.message": "Su solicitud anterior está en progreso, por favor inténtalo de nuevo en unos minutos.",
|
||||
"forgot.password.empty.email.field.error": "Por favor, introduce tu correo electrónico.",
|
||||
"forgot.password.invalid.email": "Ha ocurrido un error.",
|
||||
"forgot.password.invalid.email.message": "La dirección de correo que has ingresado no está en el formato correcto.",
|
||||
"forgot.password.email.help.text": "El correo electrónico que utilizaste para registrarte en {platformName}",
|
||||
"account.activation.error.message": "Algo no funcionó correctamente, por favor {supportLink} para resolver este problema.",
|
||||
"non.compliant.password.error": "{passwordComplaintRequirements} {lineBreak}Tu contraseña actual no cumple con los nuevos requisitos\n de seguridad. Acabamos de enviar un mensaje de actualización de contraseña al correo electrónico asociado con esta cuenta.\n Gracias por ayudarnos a mantener tus datos seguros.",
|
||||
"login.inactive.user.error": "Para iniciar sesión, debes activar tu cuenta..{lineBreak}\n {lineBreak} Acabamos de enviar un enlace de activación a {email}. Si no recibes un correo electrónico,\n revisa tus carpetas de spam o {supportLink}.",
|
||||
"login.reset.password.message.with.link": "Si has olvidado tu contraseña, haz clic en {resetLink} para restablecerla.",
|
||||
"login.locked.reset.password.message.with.link": "Para estar seguro, puedes restablecer tu contraseña {resetLink} antes de volver a intentarlo.",
|
||||
"login.page.title": "Login | {siteName}",
|
||||
"sign.in.button": "Iniciar sesión",
|
||||
"need.help.signing.in.collapsible.menu": "¿Necesitas ayuda para iniciar sesión?",
|
||||
"forgot.password.link": "Olvidé mi contraseña",
|
||||
"other.sign.in.issues": "Otros problemas de inicio de sesión ",
|
||||
"need.other.help.signing.in.collapsible.menu": "¿Necesitas más ayuda para iniciar sesión?",
|
||||
"institution.login.button": "Usar información de mi universidad",
|
||||
"institution.login.page.title": "Iniciar sesión con las credenciales de la institución/campus",
|
||||
"institution.login.page.back.button": "Volver al inicio",
|
||||
"create.an.account": "Crear una cuenta",
|
||||
"or.sign.in.with": "o inicie sesión con",
|
||||
"non.compliant.password.title": "Recientemente hemos cambiado los requisitos de las contraseñas",
|
||||
"first.time.here": "Primera vez aquí?",
|
||||
"email.label": "Correo electrónico",
|
||||
"email.help.message": "La dirección de correo electrónico que usaste para registrarte en edX.",
|
||||
"enterprise.login.link.text": "Iniciar sesión con tu compañía o universidad",
|
||||
"email.format.validation.message": "La dirección de correo que has ingresado no está en el formato correcto.",
|
||||
"email.format.validation.less.chars.message": "El correo electrónico debe tener al menos 3 caracteres.",
|
||||
"email.validation.message": "Por favor, introduce tu correo electrónico.",
|
||||
"password.validation.message": "Por favor, introduzca tu contraseña.",
|
||||
"password.label": "Contraseña (obligatorio)",
|
||||
"register.link": "Crear una cuenta",
|
||||
"sign.in.heading": "Iniciar sesión",
|
||||
"account.activation.success.message.title": "Ha sido un éxito. Has activado tu cuenta.",
|
||||
"account.activation.success.message": "Ahora recibirás por correo electrónico actualizaciones y alertas relacionadas con los cursos en los que estás inscrito. Inicia sesión para continuar.",
|
||||
"account.already.activated.message": "La cuenta ya ha sido activada.",
|
||||
"account.activation.error.message.title": "Tu cuenta no ha podido ser activada",
|
||||
"account.activation.support.link": "contacta al equipo de soporte de edX",
|
||||
"login.rate.limit.reached.message": "Demasiados intentos fallidos de inicio de sesión. Inténtelo de nuevo más tarde.",
|
||||
"login.failure.header.title": "No se ha podido iniciar tu sesión.",
|
||||
"contact.support.link": "entrar en contacto con el soporte de {platformName}",
|
||||
"login.failed.link.text": "aquí",
|
||||
"login.incorrect.credentials.error": "Correo electrónico o contraseña incorrectos.",
|
||||
"login.failed.attempt.error": "Tienes {remainAttempts} más intentos de inicio de sesión antes de que tu cuenta se bloquee temporalmente.",
|
||||
"login.locked.out.error.message": "Para proteger tu cuenta, se ha bloqueado temporalmente. Inténtalo de nuevo en {lockedOutPeriod} minutos.",
|
||||
"register.page.title": "Register | {siteName}",
|
||||
"create.account.button": "Crear cuenta",
|
||||
"already.have.an.edx.account": "¿Ya tienes una cuenta en edX?",
|
||||
"sign.in.hyperlink": "Iniciar sesión ",
|
||||
"create.an.account.using": "o crea una cuenta con",
|
||||
"create.a.new.account": "Crear una nueva cuenta",
|
||||
"register.institution.login.button": "Usar mis credenciales de la institución o el Campus",
|
||||
"register.institution.login.page.title": "Registro con credenciales de la institución/campus",
|
||||
"register.page.email.label": "Correo electrónico (obligatorio)",
|
||||
"register.rate.limit.reached.message": "Demasiados intentos de registro fallidos. Vuelva a intentarlo más tarde.",
|
||||
"email.ratelimit.less.chars.validation.message": "El correo electrónico debe tener 3 caracteres.",
|
||||
"email.ratelimit.incorrect.format.validation.message": "La dirección de correo electrónico que has proporcionado no tiene el formato correcto.",
|
||||
"email.ratelimit.password.validation.message": "Tu contraseña debe contener al menos 8 caracteres",
|
||||
"register.page.password.validation.message": "Por favor, introduzca tu contraseña.",
|
||||
"fullname.label": "Nombre completo (obligatorio) ",
|
||||
"fullname.validation.message": "Por favor, introduce tu nombre completo.",
|
||||
"username.label": "Nombre de usuario público (obligatorio)",
|
||||
"username.validation.message": "Por favor, introduce tu nombre de usuario público.",
|
||||
"username.format.validation.message": "Los nombres de usuario únicamente pueden contener las letras (A-Z, a-z), números (0-9), guión bajo (_) y guiones (-).",
|
||||
"username.character.validation.message": "Tu contraseña debe contener al menos una letra.",
|
||||
"username.number.validation.message": "Tu contraseña debe contener al menos un número.",
|
||||
"username.ratelimit.less.chars.message": "El nombre de usuario público debe tener al menos 2 caracteres.",
|
||||
"country.validation.message": "Selecciona tu país o región de residencia. ",
|
||||
"support.education.research": "Apoya la investigación sobre educación proporcionando información adicional. (Opcional)",
|
||||
"registration.request.server.error": "Se ha producido un error. Intenta actualizar la página o comprueba tu conexión a Internet.",
|
||||
"registration.request.failure.header": "No pudimos crear tu cuenta.",
|
||||
"helptext.name": "Este nombre será utilizado por los certificados que obtengas.",
|
||||
"helptext.username": "El nombre bajo el cual te presentarás en tus cursos no puede ser cambiado después.",
|
||||
"helptext.password": "Tu contraseña debe contener al menos 8 caracteres, incluyendo 1 letra y 1 número.",
|
||||
"helptext.email": "Esto es lo que usará para iniciar sesión.",
|
||||
"terms.of.service.and.honor.code": "Condiciones de servicio y código de honor",
|
||||
"privacy.policy": "Política de privacidad ",
|
||||
"registration.year.of.birth.label": "Año de nacimiento (opcional)",
|
||||
"registration.country.label": "País o región de residencia (obligatorio)",
|
||||
"registration.field.gender.options.label": "Género (opcional)",
|
||||
"registration.goals.label": "Díganos por qué estás interesado en edX (opcional)",
|
||||
"registration.field.gender.options.f": "Femenino ",
|
||||
"registration.field.gender.options.m": "Masculino",
|
||||
"registration.field.gender.options.o": "Otro/Prefiero no decir",
|
||||
"registration.field.education.levels.label": "Nivel más alto de educación completado (opcional)",
|
||||
"registration.field.education.levels.p": "Doctorado",
|
||||
"registration.field.education.levels.m": "Maestría o magíster",
|
||||
"registration.field.education.levels.b": "Pregrado o Licenciatura",
|
||||
"registration.field.education.levels.a": "Grado técnico - tecnológico",
|
||||
"registration.field.education.levels.hs": "Enseñanza secundaria",
|
||||
"registration.field.education.levels.jhs": "Formación media",
|
||||
"registration.field.education.levels.el": "Enseñanza primaria",
|
||||
"registration.field.education.levels.none": "Ninguna educación formal",
|
||||
"registration.field.education.levels.other": "Otra educación",
|
||||
"register.page.terms.of.service.and.honor.code": "Al crear una cuenta, aceptas el {tosAndHonorCode} y reconoces que {platformName} y cada\n Miembro procesa tus datos personales de acuerdo con la {privacyPolicy}.",
|
||||
"reset.password.request.invalid.token.description.message": "Este enlace para restablecer la contraseña no es válido. Es posible que ya haya sido utilizado.\n Para restablecer tu contraseña, ve a la página {loginPasswordLink} y selecciona {forgotPassword}",
|
||||
"reset.password.page.title": "Restablecer contraseña | {siteName}",
|
||||
"reset.password.page.heading": "Restablece tu contraseña",
|
||||
"reset.password.page.instructions": "Ingresa y confirma tu nueva contraseña.",
|
||||
"reset.password.page.invalid.match.message": "Las contraseñas no son iguales.",
|
||||
"forgot.password.page.new.field.label": "Nueva contraseña",
|
||||
"forgot.password.page.confirm.field.label": "Confirmar contraseña",
|
||||
"reset.password.page.submit.button": "Restablecer mi contraseña",
|
||||
"reset.password.request.success.header.message": "Restablecimiento de la contraseña completado.",
|
||||
"forgot.password.confirmation.sign.in.link": "iniciar sesión",
|
||||
"reset.password.request.forgot.password.text": "Olvidé mi contraseña",
|
||||
"reset.password.request.invalid.token.header": "Enlace de restablecimiento de contraseña inválido",
|
||||
"reset.password.empty.new.password.field.error": "Por favor, introduce tu nueva contraseña.",
|
||||
"forgot.password.empty.new.password.error.heading": "No hemos podido restablecer tu contraseña.",
|
||||
"reset.password.request.server.error": "No se ha podido restablecer la contraseña",
|
||||
"reset.password.token.validation.sever.error": "Fallo de validación del token",
|
||||
"reset.server.ratelimit.error": "Demasiadas solicitudes.",
|
||||
"reset.password.confirmation.support.link": "Inicia sesión en tu cuenta.",
|
||||
"reset.password.request.success.header.description.message": "Su contraseña ha sido restablecida. {loginPasswordLink}",
|
||||
"optional.fields.page.title": "Optional Fields | {siteName}",
|
||||
"optional.fields.page.heading": "Support education research by providing additional information.",
|
||||
"welcome.to.edx": "Welcome to edX, {username}!",
|
||||
"optional.fields.information.link": "Learn more about how we use this information.",
|
||||
"optional.fields.submit.button": "Submit",
|
||||
"optional.fields.skip.button": "Skip for now"
|
||||
}
|
||||
146
src/redesign/i18n/messages/fr.json
Normal file
146
src/redesign/i18n/messages/fr.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"forgot.password.confirmation.message": "You entered {strongEmail}. If this email address is associated with your\n edX account, we will send a message with password recovery instructions to this email address.",
|
||||
"forgot.password.technical.support.help.message": "If you need further assistance, {technicalSupportLink}.",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below:",
|
||||
"forgot.password.confirmation.title": "Check your email",
|
||||
"forgot.password.confirmation.support.link": "contact technical support",
|
||||
"forgot.password.confirmation.info": "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.",
|
||||
"internal.server.error.message": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"server.ratelimit.error.message": "An error has occurred because of too many requests. Please try again after some time.",
|
||||
"enterprisetpa.title.heading": "Would you like to sign in using your {providerName} credentials?",
|
||||
"enterprisetpa.sso.button.title": "Sign in using {providerName}",
|
||||
"enterprisetpa.login.button.text": "Show me other ways to sign in or register",
|
||||
"sso.sign.in.with": "Sign in with {providerName}",
|
||||
"sso.create.account.using": "Create account using {providerName}",
|
||||
"error.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"login.third.party.auth.account.not.linked.message": "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.",
|
||||
"register.third.party.auth.account.not.linked.message": "You've successfully signed into {currentProvider}. We just need a little more information before you start learning with {platformName}.",
|
||||
"forgot.password.page.title": "Forgot Password | {siteName}",
|
||||
"forgot.password.page.heading": "Password assistance",
|
||||
"forgot.password.page.instructions": "Please enter your log-in or recovery email address below and we will send you an email with instructions.",
|
||||
"forgot.password.page.invalid.email.message": "The email address you've provided isn't formatted correctly.",
|
||||
"forgot.password.page.email.field.label": "Email",
|
||||
"forgot.password.page.submit.button": "Recover my password",
|
||||
"forgot.password.request.server.error": "We couldn’t send the password recovery email.",
|
||||
"forgot.password.error.message.title": "An error occurred.",
|
||||
"forgot.password.request.in.progress.message": "Your previous request is in progress, please try again in a few moments.",
|
||||
"forgot.password.empty.email.field.error": "Please enter your email.",
|
||||
"forgot.password.invalid.email": "An error occurred.",
|
||||
"forgot.password.invalid.email.message": "The email address you've provided isn't formatted correctly.",
|
||||
"forgot.password.email.help.text": "The email address you used to register with {platformName}",
|
||||
"account.activation.error.message": "Something went wrong, please {supportLink} to resolve this issue.",
|
||||
"non.compliant.password.error": "{passwordComplaintRequirements} {lineBreak}Your current password does not meet the new security\n requirements. We just sent a password-reset message to the email address associated with this account.\n Thank you for helping us keep your data safe.",
|
||||
"login.inactive.user.error": "In order to sign in, you need to activate your account.{lineBreak}\n {lineBreak}We just sent an activation link to {email}. If you do not receive an email,\n check your spam folders or {supportLink}.",
|
||||
"login.reset.password.message.with.link": "If you've forgotten your password, click {resetLink} to reset.",
|
||||
"login.locked.reset.password.message.with.link": "To be on the safe side, you can reset your password {resetLink} before you try again.",
|
||||
"login.page.title": "Login | {siteName}",
|
||||
"sign.in.button": "Sign in",
|
||||
"need.help.signing.in.collapsible.menu": "Need help signing in?",
|
||||
"forgot.password.link": "Forgot my password",
|
||||
"other.sign.in.issues": "Other sign in issues",
|
||||
"need.other.help.signing.in.collapsible.menu": "Need other help signing in?",
|
||||
"institution.login.button": "Use my university info",
|
||||
"institution.login.page.title": "Sign in with institution/campus credentials",
|
||||
"institution.login.page.back.button": "Back to sign in",
|
||||
"create.an.account": "Create an account",
|
||||
"or.sign.in.with": "or sign in with",
|
||||
"non.compliant.password.title": "We recently changed our password requirements",
|
||||
"first.time.here": "First time here?",
|
||||
"email.label": "Email",
|
||||
"email.help.message": "The email address you used to register with edX.",
|
||||
"enterprise.login.link.text": "Sign in with your company or school",
|
||||
"email.format.validation.message": "The email address you've provided isn't formatted correctly.",
|
||||
"email.format.validation.less.chars.message": "Email must have at least 3 characters.",
|
||||
"email.validation.message": "Please enter your email.",
|
||||
"password.validation.message": "Please enter your password.",
|
||||
"password.label": "Password (required)",
|
||||
"register.link": "Create an account",
|
||||
"sign.in.heading": "Sign in",
|
||||
"account.activation.success.message.title": "Success! You have activated your account.",
|
||||
"account.activation.success.message": "You will now receive email updates and alerts from us related to the courses you are enrolled in. Sign in to continue.",
|
||||
"account.already.activated.message": "This account has already been activated.",
|
||||
"account.activation.error.message.title": "Your account could not be activated",
|
||||
"account.activation.support.link": "contact support",
|
||||
"login.rate.limit.reached.message": "Too many failed login attempts. Try again later.",
|
||||
"login.failure.header.title": "We couldn't sign you in.",
|
||||
"contact.support.link": "contact {platformName} support",
|
||||
"login.failed.link.text": "here",
|
||||
"login.incorrect.credentials.error": "Email or password is incorrect.",
|
||||
"login.failed.attempt.error": "You have {remainingAttempts} more sign in attempts before your account is temporarily locked.",
|
||||
"login.locked.out.error.message": "To protect your account, it’s been temporarily locked. Try again in {lockedOutPeriod} minutes.",
|
||||
"register.page.title": "Register | {siteName}",
|
||||
"create.account.button": "Create account",
|
||||
"already.have.an.edx.account": "Already have an edX account?",
|
||||
"sign.in.hyperlink": "Sign in.",
|
||||
"create.an.account.using": "or create an account using",
|
||||
"create.a.new.account": "Create a new account",
|
||||
"register.institution.login.button": "Use my institution/campus credentials",
|
||||
"register.institution.login.page.title": "Register with institution/campus credentials",
|
||||
"register.page.email.label": "Email (required)",
|
||||
"register.rate.limit.reached.message": "Too many failed registration attempts. Try again later.",
|
||||
"email.ratelimit.less.chars.validation.message": "Email must have 3 characters.",
|
||||
"email.ratelimit.incorrect.format.validation.message": "The email address you provided isn't formatted correctly.",
|
||||
"email.ratelimit.password.validation.message": "Your password must contain at least 8 characters",
|
||||
"register.page.password.validation.message": "Please enter your password.",
|
||||
"fullname.label": "Full name (required)",
|
||||
"fullname.validation.message": "Please enter your full name.",
|
||||
"username.label": "Public username (required)",
|
||||
"username.validation.message": "Please enter your public username.",
|
||||
"username.format.validation.message": "Usernames can only contain letters (A-Z, a-z), numerals (0-9), underscores (_), and hyphens (-).",
|
||||
"username.character.validation.message": "Your password must contain at least 1 letter.",
|
||||
"username.number.validation.message": "Your password must contain at least 1 number.",
|
||||
"username.ratelimit.less.chars.message": "Public username must have atleast 2 characters.",
|
||||
"country.validation.message": "Select your country or region of residence.",
|
||||
"support.education.research": "Support education research by providing additional information. (Optional)",
|
||||
"registration.request.server.error": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"registration.request.failure.header": "We couldn't create your account.",
|
||||
"helptext.name": "This name will be used by any certificates that you earn.",
|
||||
"helptext.username": "The name that will identify you in your courses. It cannot be changed later.",
|
||||
"helptext.password": "Your password must contain at least 8 characters, including 1 letter & 1 number.",
|
||||
"helptext.email": "This is what you will use to login.",
|
||||
"terms.of.service.and.honor.code": "Terms of Service and Honor Code",
|
||||
"privacy.policy": "Privacy Policy",
|
||||
"registration.year.of.birth.label": "Year of birth (optional)",
|
||||
"registration.country.label": "Country or region of residence (required)",
|
||||
"registration.field.gender.options.label": "Gender (optional)",
|
||||
"registration.goals.label": "Tell us why you're interested in edX (optional)",
|
||||
"registration.field.gender.options.f": "Female",
|
||||
"registration.field.gender.options.m": "Male",
|
||||
"registration.field.gender.options.o": "Other/Prefer not to say",
|
||||
"registration.field.education.levels.label": "Highest level of education completed (optional)",
|
||||
"registration.field.education.levels.p": "Doctorate",
|
||||
"registration.field.education.levels.m": "Master's or professional degree",
|
||||
"registration.field.education.levels.b": "Bachelor's degree",
|
||||
"registration.field.education.levels.a": "Associate's degree",
|
||||
"registration.field.education.levels.hs": "Secondary/high school",
|
||||
"registration.field.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"registration.field.education.levels.el": "Elementary/primary school",
|
||||
"registration.field.education.levels.none": "No formal education",
|
||||
"registration.field.education.levels.other": "Other education",
|
||||
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each\n Member process your personal data in accordance with the {privacyPolicy}.",
|
||||
"reset.password.request.invalid.token.description.message": "This password reset link is invalid. It may have been used already.\n To reset your password, go to the {loginPasswordLink} page and select {forgotPassword}",
|
||||
"reset.password.page.title": "Reset Password | {siteName}",
|
||||
"reset.password.page.heading": "Reset your password",
|
||||
"reset.password.page.instructions": "Enter and confirm your new password.",
|
||||
"reset.password.page.invalid.match.message": "Passwords do not match.",
|
||||
"forgot.password.page.new.field.label": "New password",
|
||||
"forgot.password.page.confirm.field.label": "Confirm password",
|
||||
"reset.password.page.submit.button": "Reset my password",
|
||||
"reset.password.request.success.header.message": "Password reset complete.",
|
||||
"forgot.password.confirmation.sign.in.link": "sign in",
|
||||
"reset.password.request.forgot.password.text": "Forgot password",
|
||||
"reset.password.request.invalid.token.header": "Invalid password reset link",
|
||||
"reset.password.empty.new.password.field.error": "Please enter your new password.",
|
||||
"forgot.password.empty.new.password.error.heading": "We couldn't reset your password.",
|
||||
"reset.password.request.server.error": "Failed to reset password",
|
||||
"reset.password.token.validation.sever.error": "Token validation failure",
|
||||
"reset.server.ratelimit.error": "Too many requests.",
|
||||
"reset.password.confirmation.support.link": "Sign in to your account.",
|
||||
"reset.password.request.success.header.description.message": "Your password has been reset. {loginPasswordLink}",
|
||||
"optional.fields.page.title": "Optional Fields | {siteName}",
|
||||
"optional.fields.page.heading": "Support education research by providing additional information.",
|
||||
"welcome.to.edx": "Welcome to edX, {username}!",
|
||||
"optional.fields.information.link": "Learn more about how we use this information.",
|
||||
"optional.fields.submit.button": "Submit",
|
||||
"optional.fields.skip.button": "Skip for now"
|
||||
}
|
||||
1
src/redesign/i18n/messages/he.json
Normal file
1
src/redesign/i18n/messages/he.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
src/redesign/i18n/messages/id.json
Normal file
1
src/redesign/i18n/messages/id.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
src/redesign/i18n/messages/ko_kr.json
Normal file
1
src/redesign/i18n/messages/ko_kr.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
src/redesign/i18n/messages/pl.json
Normal file
1
src/redesign/i18n/messages/pl.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
src/redesign/i18n/messages/pt_br.json
Normal file
1
src/redesign/i18n/messages/pt_br.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
src/redesign/i18n/messages/ru.json
Normal file
1
src/redesign/i18n/messages/ru.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
src/redesign/i18n/messages/th.json
Normal file
1
src/redesign/i18n/messages/th.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
src/redesign/i18n/messages/uk.json
Normal file
1
src/redesign/i18n/messages/uk.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
146
src/redesign/i18n/messages/zh_CN.json
Normal file
146
src/redesign/i18n/messages/zh_CN.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"forgot.password.confirmation.message": "You entered {strongEmail}. If this email address is associated with your\n edX account, we will send a message with password recovery instructions to this email address.",
|
||||
"forgot.password.technical.support.help.message": "If you need further assistance, {technicalSupportLink}.",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below:",
|
||||
"forgot.password.confirmation.title": "Check your email",
|
||||
"forgot.password.confirmation.support.link": "contact technical support",
|
||||
"forgot.password.confirmation.info": "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.",
|
||||
"internal.server.error.message": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"server.ratelimit.error.message": "An error has occurred because of too many requests. Please try again after some time.",
|
||||
"enterprisetpa.title.heading": "Would you like to sign in using your {providerName} credentials?",
|
||||
"enterprisetpa.sso.button.title": "Sign in using {providerName}",
|
||||
"enterprisetpa.login.button.text": "Show me other ways to sign in or register",
|
||||
"sso.sign.in.with": "Sign in with {providerName}",
|
||||
"sso.create.account.using": "Create account using {providerName}",
|
||||
"error.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"login.third.party.auth.account.not.linked.message": "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.",
|
||||
"register.third.party.auth.account.not.linked.message": "You've successfully signed into {currentProvider}. We just need a little more information before you start learning with {platformName}.",
|
||||
"forgot.password.page.title": "Forgot Password | {siteName}",
|
||||
"forgot.password.page.heading": "Password assistance",
|
||||
"forgot.password.page.instructions": "Please enter your log-in or recovery email address below and we will send you an email with instructions.",
|
||||
"forgot.password.page.invalid.email.message": "The email address you've provided isn't formatted correctly.",
|
||||
"forgot.password.page.email.field.label": "Email",
|
||||
"forgot.password.page.submit.button": "Recover my password",
|
||||
"forgot.password.request.server.error": "We couldn’t send the password recovery email.",
|
||||
"forgot.password.error.message.title": "An error occurred.",
|
||||
"forgot.password.request.in.progress.message": "Your previous request is in progress, please try again in a few moments.",
|
||||
"forgot.password.empty.email.field.error": "Please enter your email.",
|
||||
"forgot.password.invalid.email": "An error occurred.",
|
||||
"forgot.password.invalid.email.message": "The email address you've provided isn't formatted correctly.",
|
||||
"forgot.password.email.help.text": "The email address you used to register with {platformName}",
|
||||
"account.activation.error.message": "Something went wrong, please {supportLink} to resolve this issue.",
|
||||
"non.compliant.password.error": "{passwordComplaintRequirements} {lineBreak}Your current password does not meet the new security\n requirements. We just sent a password-reset message to the email address associated with this account.\n Thank you for helping us keep your data safe.",
|
||||
"login.inactive.user.error": "In order to sign in, you need to activate your account.{lineBreak}\n {lineBreak}We just sent an activation link to {email}. If you do not receive an email,\n check your spam folders or {supportLink}.",
|
||||
"login.reset.password.message.with.link": "If you've forgotten your password, click {resetLink} to reset.",
|
||||
"login.locked.reset.password.message.with.link": "To be on the safe side, you can reset your password {resetLink} before you try again.",
|
||||
"login.page.title": "Login | {siteName}",
|
||||
"sign.in.button": "Sign in",
|
||||
"need.help.signing.in.collapsible.menu": "Need help signing in?",
|
||||
"forgot.password.link": "Forgot my password",
|
||||
"other.sign.in.issues": "Other sign in issues",
|
||||
"need.other.help.signing.in.collapsible.menu": "Need other help signing in?",
|
||||
"institution.login.button": "Use my university info",
|
||||
"institution.login.page.title": "Sign in with institution/campus credentials",
|
||||
"institution.login.page.back.button": "Back to sign in",
|
||||
"create.an.account": "Create an account",
|
||||
"or.sign.in.with": "or sign in with",
|
||||
"non.compliant.password.title": "We recently changed our password requirements",
|
||||
"first.time.here": "First time here?",
|
||||
"email.label": "Email",
|
||||
"email.help.message": "The email address you used to register with edX.",
|
||||
"enterprise.login.link.text": "Sign in with your company or school",
|
||||
"email.format.validation.message": "The email address you've provided isn't formatted correctly.",
|
||||
"email.format.validation.less.chars.message": "Email must have at least 3 characters.",
|
||||
"email.validation.message": "Please enter your email.",
|
||||
"password.validation.message": "Please enter your password.",
|
||||
"password.label": "Password (required)",
|
||||
"register.link": "Create an account",
|
||||
"sign.in.heading": "Sign in",
|
||||
"account.activation.success.message.title": "Success! You have activated your account.",
|
||||
"account.activation.success.message": "You will now receive email updates and alerts from us related to the courses you are enrolled in. Sign in to continue.",
|
||||
"account.already.activated.message": "This account has already been activated.",
|
||||
"account.activation.error.message.title": "Your account could not be activated",
|
||||
"account.activation.support.link": "contact support",
|
||||
"login.rate.limit.reached.message": "Too many failed login attempts. Try again later.",
|
||||
"login.failure.header.title": "We couldn't sign you in.",
|
||||
"contact.support.link": "contact {platformName} support",
|
||||
"login.failed.link.text": "here",
|
||||
"login.incorrect.credentials.error": "Email or password is incorrect.",
|
||||
"login.failed.attempt.error": "You have {remainingAttempts} more sign in attempts before your account is temporarily locked.",
|
||||
"login.locked.out.error.message": "To protect your account, it’s been temporarily locked. Try again in {lockedOutPeriod} minutes.",
|
||||
"register.page.title": "Register | {siteName}",
|
||||
"create.account.button": "Create account",
|
||||
"already.have.an.edx.account": "Already have an edX account?",
|
||||
"sign.in.hyperlink": "Sign in.",
|
||||
"create.an.account.using": "or create an account using",
|
||||
"create.a.new.account": "Create a new account",
|
||||
"register.institution.login.button": "Use my institution/campus credentials",
|
||||
"register.institution.login.page.title": "Register with institution/campus credentials",
|
||||
"register.page.email.label": "Email (required)",
|
||||
"register.rate.limit.reached.message": "Too many failed registration attempts. Try again later.",
|
||||
"email.ratelimit.less.chars.validation.message": "Email must have 3 characters.",
|
||||
"email.ratelimit.incorrect.format.validation.message": "The email address you provided isn't formatted correctly.",
|
||||
"email.ratelimit.password.validation.message": "Your password must contain at least 8 characters",
|
||||
"register.page.password.validation.message": "Please enter your password.",
|
||||
"fullname.label": "Full name (required)",
|
||||
"fullname.validation.message": "Please enter your full name.",
|
||||
"username.label": "Public username (required)",
|
||||
"username.validation.message": "Please enter your public username.",
|
||||
"username.format.validation.message": "Usernames can only contain letters (A-Z, a-z), numerals (0-9), underscores (_), and hyphens (-).",
|
||||
"username.character.validation.message": "Your password must contain at least 1 letter.",
|
||||
"username.number.validation.message": "Your password must contain at least 1 number.",
|
||||
"username.ratelimit.less.chars.message": "Public username must have atleast 2 characters.",
|
||||
"country.validation.message": "Select your country or region of residence.",
|
||||
"support.education.research": "Support education research by providing additional information. (Optional)",
|
||||
"registration.request.server.error": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"registration.request.failure.header": "We couldn't create your account.",
|
||||
"helptext.name": "This name will be used by any certificates that you earn.",
|
||||
"helptext.username": "The name that will identify you in your courses. It cannot be changed later.",
|
||||
"helptext.password": "Your password must contain at least 8 characters, including 1 letter & 1 number.",
|
||||
"helptext.email": "This is what you will use to login.",
|
||||
"terms.of.service.and.honor.code": "Terms of Service and Honor Code",
|
||||
"privacy.policy": "Privacy Policy",
|
||||
"registration.year.of.birth.label": "Year of birth (optional)",
|
||||
"registration.country.label": "Country or region of residence (required)",
|
||||
"registration.field.gender.options.label": "Gender (optional)",
|
||||
"registration.goals.label": "Tell us why you're interested in edX (optional)",
|
||||
"registration.field.gender.options.f": "Female",
|
||||
"registration.field.gender.options.m": "Male",
|
||||
"registration.field.gender.options.o": "Other/Prefer not to say",
|
||||
"registration.field.education.levels.label": "Highest level of education completed (optional)",
|
||||
"registration.field.education.levels.p": "Doctorate",
|
||||
"registration.field.education.levels.m": "Master's or professional degree",
|
||||
"registration.field.education.levels.b": "Bachelor's degree",
|
||||
"registration.field.education.levels.a": "Associate's degree",
|
||||
"registration.field.education.levels.hs": "Secondary/high school",
|
||||
"registration.field.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"registration.field.education.levels.el": "Elementary/primary school",
|
||||
"registration.field.education.levels.none": "No formal education",
|
||||
"registration.field.education.levels.other": "Other education",
|
||||
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each\n Member process your personal data in accordance with the {privacyPolicy}.",
|
||||
"reset.password.request.invalid.token.description.message": "This password reset link is invalid. It may have been used already.\n To reset your password, go to the {loginPasswordLink} page and select {forgotPassword}",
|
||||
"reset.password.page.title": "Reset Password | {siteName}",
|
||||
"reset.password.page.heading": "Reset your password",
|
||||
"reset.password.page.instructions": "Enter and confirm your new password.",
|
||||
"reset.password.page.invalid.match.message": "Passwords do not match.",
|
||||
"forgot.password.page.new.field.label": "New password",
|
||||
"forgot.password.page.confirm.field.label": "Confirm password",
|
||||
"reset.password.page.submit.button": "Reset my password",
|
||||
"reset.password.request.success.header.message": "Password reset complete.",
|
||||
"forgot.password.confirmation.sign.in.link": "sign in",
|
||||
"reset.password.request.forgot.password.text": "Forgot password",
|
||||
"reset.password.request.invalid.token.header": "Invalid password reset link",
|
||||
"reset.password.empty.new.password.field.error": "Please enter your new password.",
|
||||
"forgot.password.empty.new.password.error.heading": "We couldn't reset your password.",
|
||||
"reset.password.request.server.error": "Failed to reset password",
|
||||
"reset.password.token.validation.sever.error": "Token validation failure",
|
||||
"reset.server.ratelimit.error": "Too many requests.",
|
||||
"reset.password.confirmation.support.link": "Sign in to your account.",
|
||||
"reset.password.request.success.header.description.message": "Your password has been reset. {loginPasswordLink}",
|
||||
"optional.fields.page.title": "Optional Fields | {siteName}",
|
||||
"optional.fields.page.heading": "Support education research by providing additional information.",
|
||||
"welcome.to.edx": "Welcome to edX, {username}!",
|
||||
"optional.fields.information.link": "Learn more about how we use this information.",
|
||||
"optional.fields.submit.button": "Submit",
|
||||
"optional.fields.skip.button": "Skip for now"
|
||||
}
|
||||
42
src/redesign/index.jsx
Executable file
42
src/redesign/index.jsx
Executable file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import {
|
||||
BaseComponent, UnAuthOnlyRoute, registerIcons, NotFoundPage, Logistration,
|
||||
} from './common-components';
|
||||
import {
|
||||
LOGIN_PAGE, PAGE_NOT_FOUND, REGISTER_PAGE, RESET_PAGE, PASSWORD_RESET_CONFIRM, WELCOME_PAGE,
|
||||
} from './data/constants';
|
||||
import configureStore from './data/configureStore';
|
||||
|
||||
import ForgotPasswordPage from './forgot-password';
|
||||
import ResetPasswordPage from './reset-password';
|
||||
import WelcomePage from './welcome';
|
||||
import './index.scss';
|
||||
|
||||
registerIcons();
|
||||
|
||||
const RedesignApp = () => (
|
||||
<AppProvider store={configureStore()}>
|
||||
<BaseComponent>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Redirect to={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={WelcomePage} />
|
||||
<Route path={PAGE_NOT_FOUND} component={NotFoundPage} />
|
||||
<Route path="*">
|
||||
<Redirect to={PAGE_NOT_FOUND} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</BaseComponent>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
export default RedesignApp;
|
||||
10
src/redesign/index.scss
Executable file
10
src/redesign/index.scss
Executable file
@@ -0,0 +1,10 @@
|
||||
@import "~@edx/brand/paragon/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@edx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
|
||||
@import '@edx/frontend-component-cookie-policy-banner/build/_cookie-policy-banner';
|
||||
|
||||
@import "./style";
|
||||
74
src/redesign/login/AccountActivationMessage.jsx
Normal file
74
src/redesign/login/AccountActivationMessage.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { CheckCircle, Error } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { ACCOUNT_ACTIVATION_MESSAGE } from './data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const AccountActivationMessage = (props) => {
|
||||
const { intl, messageType } = props;
|
||||
const variant = messageType === ACCOUNT_ACTIVATION_MESSAGE.ERROR ? 'danger' : messageType;
|
||||
|
||||
let activationMessage;
|
||||
let heading;
|
||||
|
||||
const iconMapping = {
|
||||
[ACCOUNT_ACTIVATION_MESSAGE.SUCCESS]: CheckCircle,
|
||||
[ACCOUNT_ACTIVATION_MESSAGE.ERROR]: Error,
|
||||
};
|
||||
|
||||
switch (messageType) {
|
||||
case ACCOUNT_ACTIVATION_MESSAGE.SUCCESS: {
|
||||
heading = intl.formatMessage(messages['account.activation.success.message.title']);
|
||||
activationMessage = intl.formatMessage(messages['account.activation.success.message']);
|
||||
break;
|
||||
}
|
||||
case ACCOUNT_ACTIVATION_MESSAGE.INFO: {
|
||||
activationMessage = intl.formatMessage(messages['account.already.activated.message']);
|
||||
break;
|
||||
}
|
||||
case ACCOUNT_ACTIVATION_MESSAGE.ERROR: {
|
||||
const supportLink = (
|
||||
<Alert.Link href={getConfig().ACTIVATION_EMAIL_SUPPORT_LINK}>
|
||||
{intl.formatMessage(messages['account.activation.support.link'])}
|
||||
</Alert.Link>
|
||||
);
|
||||
|
||||
heading = intl.formatMessage(messages['account.activation.error.message.title']);
|
||||
activationMessage = (
|
||||
<FormattedMessage
|
||||
id="account.activation.error.message"
|
||||
defaultMessage="Something went wrong, please {supportLink} to resolve this issue."
|
||||
description="Account activation error message"
|
||||
values={{ supportLink }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return activationMessage ? (
|
||||
<Alert
|
||||
id="account-activation-message"
|
||||
className="mb-4"
|
||||
variant={variant}
|
||||
icon={iconMapping[messageType]}
|
||||
>
|
||||
{heading && <Alert.Heading>{heading}</Alert.Heading>}
|
||||
{activationMessage}
|
||||
</Alert>
|
||||
) : null;
|
||||
};
|
||||
|
||||
AccountActivationMessage.propTypes = {
|
||||
messageType: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccountActivationMessage);
|
||||
179
src/redesign/login/LoginFailure.jsx
Normal file
179
src/redesign/login/LoginFailure.jsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import processLink from '../data/utils';
|
||||
import {
|
||||
ACCOUNT_LOCKED_OUT,
|
||||
FAILED_LOGIN_ATTEMPT,
|
||||
FORBIDDEN_REQUEST,
|
||||
INACTIVE_USER,
|
||||
INCORRECT_EMAIL_PASSWORD,
|
||||
INTERNAL_SERVER_ERROR,
|
||||
INVALID_FORM,
|
||||
NON_COMPLIANT_PASSWORD_EXCEPTION,
|
||||
} from './data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const LoginFailureMessage = (props) => {
|
||||
const { intl } = props;
|
||||
const { context, errorCode, value } = props.loginError;
|
||||
let errorList;
|
||||
let link;
|
||||
let resetLink = (
|
||||
<>
|
||||
<a href="/reset">
|
||||
{intl.formatMessage(messages['login.incorrect.credentials.error.reset.link.text'])}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
|
||||
switch (errorCode) {
|
||||
case NON_COMPLIANT_PASSWORD_EXCEPTION: {
|
||||
errorList = (
|
||||
<FormattedMessage
|
||||
id="non.compliant.password.error"
|
||||
defaultMessage="{passwordComplaintRequirements} {lineBreak}Your current password does not meet the new security
|
||||
requirements. We just sent a password-reset message to the email address associated with this account.
|
||||
Thank you for helping us keep your data safe."
|
||||
values={{
|
||||
passwordComplaintRequirements: <strong>{intl.formatMessage(messages['non.compliant.password.title'])}</strong>,
|
||||
lineBreak: <br />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case FORBIDDEN_REQUEST:
|
||||
errorList = intl.formatMessage(messages['login.rate.limit.reached.message']);
|
||||
break;
|
||||
case INACTIVE_USER: {
|
||||
const supportLink = (
|
||||
<a href={context.supportLink}>
|
||||
{intl.formatMessage(messages['contact.support.link'], { platformName: context.platformName })}
|
||||
</a>
|
||||
);
|
||||
errorList = (
|
||||
<FormattedMessage
|
||||
id="login.inactive.user.error"
|
||||
defaultMessage="In order to sign in, you need to activate your account.{lineBreak}
|
||||
{lineBreak}We just sent an activation link to {email}. If you do not receive an email,
|
||||
check your spam folders or {supportLink}."
|
||||
values={{
|
||||
lineBreak: <br />,
|
||||
email: <strong className="data-hj-suppress">{props.loginError.email}</strong>,
|
||||
supportLink,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case INTERNAL_SERVER_ERROR:
|
||||
errorList = intl.formatMessage(messages['internal.server.error.message']);
|
||||
break;
|
||||
case INVALID_FORM:
|
||||
errorList = intl.formatMessage(messages['login.form.invalid.error.message']);
|
||||
break;
|
||||
case FAILED_LOGIN_ATTEMPT: {
|
||||
resetLink = (
|
||||
<a href="/reset">
|
||||
{intl.formatMessage(messages['login.incorrect.credentials.error.before.account.blocked.text'])}
|
||||
</a>
|
||||
);
|
||||
errorList = (
|
||||
<FormattedMessage
|
||||
id="login.incorrect.credentials.error.attempts.text"
|
||||
description="Error message for incorrect email or password including attempts"
|
||||
defaultMessage="The username, email or password you entered is incorrect. You have {remainingAttempts} more sign in
|
||||
attempts before your account is temporarily locked.{lineBreak}
|
||||
{lineBreak}If you've forgotten your password, {resetLink}"
|
||||
values={{
|
||||
lineBreak: <br />,
|
||||
remainingAttempts: context.remainingAttempts,
|
||||
resetLink,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case ACCOUNT_LOCKED_OUT: {
|
||||
errorList = (
|
||||
<FormattedMessage
|
||||
id="login.locked.out.error.message"
|
||||
description="Account locked out user message"
|
||||
defaultMessage="To protect your account, its been temporarily locked. Try again in 30 minutes. {lineBreak}
|
||||
{lineBreak} To be on the safe side, you can {resetLink} before try again."
|
||||
values={{
|
||||
lineBreak: <br />,
|
||||
resetLink,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case INCORRECT_EMAIL_PASSWORD:
|
||||
if (context.failureCount <= 1) {
|
||||
errorList = intl.formatMessage(messages['login.incorrect.credentials.error']);
|
||||
} else if (context.failureCount === 2) {
|
||||
errorList = (
|
||||
<FormattedMessage
|
||||
id="login.incorrect.credentials.error.with.reset.link"
|
||||
defaultMessage="The username, email or password you entered is incorrect. Please try again or {resetLink}."
|
||||
values={{ resetLink }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// TODO: use errorCode instead of processing error messages on frontend
|
||||
errorList = value.trim().split('\n');
|
||||
errorList = errorList.map((error) => {
|
||||
let matches;
|
||||
if (error.includes('a href')) {
|
||||
matches = processLink(error);
|
||||
const [beforeLink, href, linkText, afterLink] = matches;
|
||||
link = href;
|
||||
if (href.indexOf('/dashboard?tpa_hint') === 0) {
|
||||
link = `/login?next=${href}`;
|
||||
}
|
||||
return (
|
||||
<li key={error}>
|
||||
{beforeLink}
|
||||
<a href={link}>{linkText}</a>
|
||||
{afterLink}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return <li key={error}>{error}</li>;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert id="login-failure-alert" className="mb-5" variant="danger" icon={Error}>
|
||||
<Alert.Heading>{intl.formatMessage(messages['login.failure.header.title'])}</Alert.Heading>
|
||||
<p>{ errorList }</p>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
LoginFailureMessage.defaultProps = {
|
||||
loginError: {
|
||||
errorCode: null,
|
||||
value: '',
|
||||
},
|
||||
};
|
||||
|
||||
LoginFailureMessage.propTypes = {
|
||||
loginError: PropTypes.shape({
|
||||
context: PropTypes.object,
|
||||
email: PropTypes.string,
|
||||
errorCode: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
}),
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LoginFailureMessage);
|
||||
369
src/redesign/login/LoginPage.jsx
Normal file
369
src/redesign/login/LoginPage.jsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import React from 'react';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Form, Hyperlink, Icon, StatefulButton,
|
||||
} from '@edx/paragon';
|
||||
import { Institution } from '@edx/paragon/icons';
|
||||
|
||||
import AccountActivationMessage from './AccountActivationMessage';
|
||||
import { loginRequest, loginRequestFailure, loginRequestReset } from './data/actions';
|
||||
import { INVALID_FORM } from './data/constants';
|
||||
import { loginErrorSelector, loginRequestSelector } from './data/selectors';
|
||||
import LoginFailureMessage from './LoginFailure';
|
||||
import messages from './messages';
|
||||
|
||||
import {
|
||||
RedirectLogistration, SocialAuthProviders, ThirdPartyAuthAlert, RenderInstitutionButton,
|
||||
InstitutionLogistration, FormGroup, PasswordField,
|
||||
} from '../common-components';
|
||||
import { getThirdPartyAuthContext } from '../common-components/data/actions';
|
||||
import { thirdPartyAuthContextSelector } from '../common-components/data/selectors';
|
||||
import EnterpriseSSO from '../common-components/EnterpriseSSO';
|
||||
import {
|
||||
DEFAULT_STATE, ENTERPRISE_LOGIN_URL, PENDING_STATE, RESET_PAGE,
|
||||
} from '../data/constants';
|
||||
import {
|
||||
getTpaHint,
|
||||
getTpaProvider,
|
||||
windowScrollTo,
|
||||
setSurveyCookie,
|
||||
getActivationStatus,
|
||||
getAllPossibleQueryParam,
|
||||
} from '../data/utils';
|
||||
import { forgotPasswordResultSelector } from '../forgot-password';
|
||||
import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess';
|
||||
|
||||
class LoginPage extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
sendPageEvent('login_and_registration', 'login');
|
||||
this.state = {
|
||||
password: '',
|
||||
emailOrUsername: '',
|
||||
errors: {
|
||||
emailOrUsername: '',
|
||||
password: '',
|
||||
},
|
||||
isSubmitted: false,
|
||||
};
|
||||
this.queryParams = getAllPossibleQueryParam();
|
||||
this.tpaHint = getTpaHint();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const payload = { ...this.queryParams };
|
||||
|
||||
if (this.tpaHint) {
|
||||
payload.tpa_hint = this.tpaHint;
|
||||
}
|
||||
this.props.getThirdPartyAuthContext(payload);
|
||||
this.props.loginRequestReset();
|
||||
}
|
||||
|
||||
getEnterPriseLoginURL() {
|
||||
return getConfig().LMS_BASE_URL + ENTERPRISE_LOGIN_URL;
|
||||
}
|
||||
|
||||
handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
this.setState({ isSubmitted: true });
|
||||
|
||||
const { emailOrUsername, password } = this.state;
|
||||
const emailValidationError = this.validateEmail(emailOrUsername);
|
||||
const passwordValidationError = this.validatePassword(password);
|
||||
|
||||
if (emailValidationError !== '' || passwordValidationError !== '') {
|
||||
this.props.loginRequestFailure({
|
||||
errorCode: INVALID_FORM,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
email_or_username: emailOrUsername, password, ...this.queryParams,
|
||||
};
|
||||
this.props.loginRequest(payload);
|
||||
}
|
||||
|
||||
handleOnFocus = (e) => {
|
||||
const { errors } = this.state;
|
||||
errors[e.target.name] = '';
|
||||
this.setState({ errors });
|
||||
}
|
||||
|
||||
handleForgotPasswordLinkClickEvent = () => {
|
||||
sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement' });
|
||||
};
|
||||
|
||||
validateEmail(email) {
|
||||
const { errors } = this.state;
|
||||
|
||||
if (email === '') {
|
||||
errors.emailOrUsername = this.props.intl.formatMessage(messages['email.validation.message']);
|
||||
} else if (email.length < 3) {
|
||||
errors.emailOrUsername = this.props.intl.formatMessage(messages['email.format.validation.less.chars.message']);
|
||||
} else {
|
||||
errors.emailOrUsername = '';
|
||||
}
|
||||
this.setState({ errors });
|
||||
return errors.emailOrUsername;
|
||||
}
|
||||
|
||||
validatePassword(password) {
|
||||
const { errors } = this.state;
|
||||
errors.password = password.length > 0 ? '' : this.props.intl.formatMessage(messages['password.validation.message']);
|
||||
|
||||
this.setState({ errors });
|
||||
return errors.password;
|
||||
}
|
||||
|
||||
renderThirdPartyAuth(providers, secondaryProviders, currentProvider, thirdPartyAuthApiStatus, intl) {
|
||||
const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider;
|
||||
const isSocialAuthActive = !!providers.length && !currentProvider;
|
||||
const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(!isEnterpriseLoginDisabled || ((isEnterpriseLoginDisabled && isInstitutionAuthActive) || isSocialAuthActive))
|
||||
&& (
|
||||
<div className="mt-4 mb-3 h4">
|
||||
{intl.formatMessage(messages['login.other.options.heading'])}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEnterpriseLoginDisabled && (
|
||||
<Hyperlink className="btn btn-link btn-sm text-body p-0 mb-4" destination={this.getEnterPriseLoginURL()}>
|
||||
<Icon src={Institution} className="institute-icon" />
|
||||
{intl.formatMessage(messages['enterprise.login.btn.text'])}
|
||||
</Hyperlink>
|
||||
)}
|
||||
|
||||
{thirdPartyAuthApiStatus === PENDING_STATE ? (
|
||||
<Skeleton className="tpa-skeleton mb-3" height={30} count={2} />
|
||||
) : (
|
||||
<>
|
||||
{(isEnterpriseLoginDisabled && isInstitutionAuthActive) && (
|
||||
<RenderInstitutionButton
|
||||
onSubmitHandler={this.props.handleInstitutionLogin}
|
||||
buttonTitle={intl.formatMessage(messages['institution.login.button'])}
|
||||
/>
|
||||
)}
|
||||
{isSocialAuthActive && (
|
||||
<div className="row m-0">
|
||||
<SocialAuthProviders socialAuthProviders={providers} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderForm(
|
||||
currentProvider,
|
||||
providers,
|
||||
secondaryProviders,
|
||||
thirdPartyAuthContext,
|
||||
thirdPartyAuthApiStatus,
|
||||
submitState,
|
||||
intl,
|
||||
) {
|
||||
const activationMsgType = getActivationStatus();
|
||||
if (this.props.institutionLogin) {
|
||||
return (
|
||||
<InstitutionLogistration
|
||||
secondaryProviders={thirdPartyAuthContext.secondaryProviders}
|
||||
headingTitle={intl.formatMessage(messages['institution.login.page.title'])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.loginResult.success) {
|
||||
setSurveyCookie('login');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages['login.page.title'],
|
||||
{ siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
</Helmet>
|
||||
<RedirectLogistration
|
||||
success={this.props.loginResult.success}
|
||||
redirectUrl={this.props.loginResult.redirectUrl}
|
||||
finishAuthUrl={thirdPartyAuthContext.finishAuthUrl}
|
||||
/>
|
||||
<div className="mw-xs mt-3">
|
||||
{thirdPartyAuthContext.currentProvider
|
||||
&& (
|
||||
<ThirdPartyAuthAlert
|
||||
currentProvider={thirdPartyAuthContext.currentProvider}
|
||||
platformName={thirdPartyAuthContext.platformName}
|
||||
/>
|
||||
)}
|
||||
{this.props.loginError ? <LoginFailureMessage loginError={this.props.loginError} /> : null}
|
||||
{submitState === DEFAULT_STATE && this.state.isSubmitted ? windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }) : null}
|
||||
{activationMsgType && <AccountActivationMessage messageType={activationMsgType} />}
|
||||
{this.props.resetPassword && !this.props.loginError ? <ResetPasswordSuccess /> : null}
|
||||
<Form>
|
||||
<FormGroup
|
||||
name="emailOrUsername"
|
||||
value={this.state.emailOrUsername}
|
||||
handleChange={(e) => this.setState({ emailOrUsername: e.target.value, isSubmitted: false })}
|
||||
handleFocus={this.handleOnFocus}
|
||||
errorMessage={this.state.errors.emailOrUsername}
|
||||
floatingLabel={intl.formatMessage(messages['login.user.identity.label'])}
|
||||
/>
|
||||
<PasswordField
|
||||
name="password"
|
||||
value={this.state.password}
|
||||
showRequirements={false}
|
||||
handleChange={(e) => this.setState({ password: e.target.value, isSubmitted: false })}
|
||||
handleFocus={this.handleOnFocus}
|
||||
errorMessage={this.state.errors.password}
|
||||
floatingLabel={intl.formatMessage(messages['login.password.label'])}
|
||||
/>
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
variant="brand"
|
||||
className="login-button-width"
|
||||
state={submitState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['sign.in.button']),
|
||||
pending: '',
|
||||
}}
|
||||
onClick={this.handleSubmit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
<Link
|
||||
id="forgot-password"
|
||||
className="btn btn-link font-weight-500 text-body"
|
||||
to={RESET_PAGE}
|
||||
onClick={this.handleForgotPasswordLinkClickEvent}
|
||||
>
|
||||
{intl.formatMessage(messages['forgot.password'])}
|
||||
</Link>
|
||||
{this.renderThirdPartyAuth(providers, secondaryProviders, currentProvider, thirdPartyAuthApiStatus, intl)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
intl, submitState, thirdPartyAuthContext, thirdPartyAuthApiStatus,
|
||||
} = this.props;
|
||||
const { currentProvider, providers, secondaryProviders } = this.props.thirdPartyAuthContext;
|
||||
|
||||
if (this.tpaHint) {
|
||||
if (thirdPartyAuthApiStatus === PENDING_STATE) {
|
||||
return <Skeleton height={36} />;
|
||||
}
|
||||
const { provider, skipHintedLogin } = getTpaProvider(this.tpaHint, providers, secondaryProviders);
|
||||
if (skipHintedLogin) {
|
||||
window.location.href = getConfig().LMS_BASE_URL + provider.loginUrl;
|
||||
return null;
|
||||
}
|
||||
return provider ? (<EnterpriseSSO provider={provider} intl={intl} />) : this.renderForm(
|
||||
currentProvider,
|
||||
providers,
|
||||
secondaryProviders,
|
||||
thirdPartyAuthContext,
|
||||
thirdPartyAuthApiStatus,
|
||||
submitState,
|
||||
intl,
|
||||
);
|
||||
}
|
||||
return this.renderForm(
|
||||
currentProvider,
|
||||
providers,
|
||||
secondaryProviders,
|
||||
thirdPartyAuthContext,
|
||||
thirdPartyAuthApiStatus,
|
||||
submitState,
|
||||
intl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LoginPage.defaultProps = {
|
||||
forgotPassword: null,
|
||||
loginResult: null,
|
||||
loginError: null,
|
||||
resetPassword: false,
|
||||
submitState: DEFAULT_STATE,
|
||||
thirdPartyAuthApiStatus: 'pending',
|
||||
thirdPartyAuthContext: {
|
||||
currentProvider: null,
|
||||
finishAuthUrl: null,
|
||||
providers: [],
|
||||
secondaryProviders: [],
|
||||
},
|
||||
};
|
||||
|
||||
LoginPage.propTypes = {
|
||||
forgotPassword: PropTypes.shape({
|
||||
email: PropTypes.string,
|
||||
status: PropTypes.string,
|
||||
}),
|
||||
getThirdPartyAuthContext: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
loginError: PropTypes.objectOf(PropTypes.any),
|
||||
loginRequest: PropTypes.func.isRequired,
|
||||
loginRequestFailure: PropTypes.func.isRequired,
|
||||
loginRequestReset: PropTypes.func.isRequired,
|
||||
loginResult: PropTypes.shape({
|
||||
redirectUrl: PropTypes.string,
|
||||
success: PropTypes.bool,
|
||||
}),
|
||||
resetPassword: PropTypes.bool,
|
||||
submitState: PropTypes.string,
|
||||
thirdPartyAuthApiStatus: PropTypes.string,
|
||||
thirdPartyAuthContext: PropTypes.shape({
|
||||
currentProvider: PropTypes.string,
|
||||
platformName: PropTypes.string,
|
||||
providers: PropTypes.array,
|
||||
secondaryProviders: PropTypes.array,
|
||||
finishAuthUrl: PropTypes.string,
|
||||
}),
|
||||
institutionLogin: PropTypes.bool.isRequired,
|
||||
handleInstitutionLogin: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const forgotPassword = forgotPasswordResultSelector(state);
|
||||
const loginResult = loginRequestSelector(state);
|
||||
const thirdPartyAuthContext = thirdPartyAuthContextSelector(state);
|
||||
const loginError = loginErrorSelector(state);
|
||||
return {
|
||||
submitState: state.login.submitState,
|
||||
thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus,
|
||||
forgotPassword,
|
||||
loginError,
|
||||
loginResult,
|
||||
thirdPartyAuthContext,
|
||||
resetPassword: state.login.resetPassword,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
getThirdPartyAuthContext,
|
||||
loginRequest,
|
||||
loginRequestFailure,
|
||||
loginRequestReset,
|
||||
},
|
||||
)(injectIntl(LoginPage));
|
||||
27
src/redesign/login/data/actions.js
Normal file
27
src/redesign/login/data/actions.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { AsyncActionType } from '../../data/utils';
|
||||
|
||||
export const LOGIN_REQUEST = new AsyncActionType('LOGIN', 'REQUEST');
|
||||
|
||||
// Login
|
||||
export const loginRequest = creds => ({
|
||||
type: LOGIN_REQUEST.BASE,
|
||||
payload: { creds },
|
||||
});
|
||||
|
||||
export const loginRequestBegin = () => ({
|
||||
type: LOGIN_REQUEST.BEGIN,
|
||||
});
|
||||
|
||||
export const loginRequestSuccess = (redirectUrl, success) => ({
|
||||
type: LOGIN_REQUEST.SUCCESS,
|
||||
payload: { redirectUrl, success },
|
||||
});
|
||||
|
||||
export const loginRequestFailure = (loginError) => ({
|
||||
type: LOGIN_REQUEST.FAILURE,
|
||||
payload: { loginError },
|
||||
});
|
||||
|
||||
export const loginRequestReset = () => ({
|
||||
type: LOGIN_REQUEST.RESET,
|
||||
});
|
||||
16
src/redesign/login/data/constants.js
Normal file
16
src/redesign/login/data/constants.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Login Error Codes
|
||||
export const INACTIVE_USER = 'inactive-user';
|
||||
export const INTERNAL_SERVER_ERROR = 'internal-server-error';
|
||||
export const INVALID_FORM = 'invalid-form';
|
||||
export const NON_COMPLIANT_PASSWORD_EXCEPTION = 'NonCompliantPasswordException';
|
||||
export const FORBIDDEN_REQUEST = 'forbidden-request';
|
||||
export const FAILED_LOGIN_ATTEMPT = 'failed-login-attempt';
|
||||
export const ACCOUNT_LOCKED_OUT = 'account-locked-out';
|
||||
export const INCORRECT_EMAIL_PASSWORD = 'incorrect-email-or-password';
|
||||
|
||||
// Account Activation Message
|
||||
export const ACCOUNT_ACTIVATION_MESSAGE = {
|
||||
INFO: 'info',
|
||||
SUCCESS: 'success',
|
||||
ERROR: 'error',
|
||||
};
|
||||
48
src/redesign/login/data/reducers.js
Normal file
48
src/redesign/login/data/reducers.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { LOGIN_REQUEST } from './actions';
|
||||
|
||||
import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants';
|
||||
import { RESET_PASSWORD } from '../../reset-password';
|
||||
|
||||
export const defaultState = {
|
||||
loginError: null,
|
||||
loginResult: {},
|
||||
resetPassword: false,
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action) => {
|
||||
switch (action.type) {
|
||||
case LOGIN_REQUEST.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
submitState: PENDING_STATE,
|
||||
resetPassword: false,
|
||||
};
|
||||
case LOGIN_REQUEST.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
loginResult: action.payload,
|
||||
};
|
||||
case LOGIN_REQUEST.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
loginError: action.payload.loginError,
|
||||
submitState: DEFAULT_STATE,
|
||||
};
|
||||
case LOGIN_REQUEST.RESET:
|
||||
return {
|
||||
...state,
|
||||
loginError: null,
|
||||
};
|
||||
case RESET_PASSWORD.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
resetPassword: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default reducer;
|
||||
50
src/redesign/login/data/sagas.js
Normal file
50
src/redesign/login/data/sagas.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError, logInfo } from '@edx/frontend-platform/logging';
|
||||
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from './constants';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
LOGIN_REQUEST,
|
||||
loginRequestBegin,
|
||||
loginRequestFailure,
|
||||
loginRequestSuccess,
|
||||
} from './actions';
|
||||
|
||||
// Services
|
||||
import {
|
||||
loginRequest,
|
||||
} from './service';
|
||||
|
||||
export function* handleLoginRequest(action) {
|
||||
try {
|
||||
yield put(loginRequestBegin());
|
||||
|
||||
const { redirectUrl, success } = yield call(loginRequest, action.payload.creds);
|
||||
|
||||
yield put(loginRequestSuccess(
|
||||
redirectUrl,
|
||||
success,
|
||||
));
|
||||
} catch (e) {
|
||||
const statusCodes = [400];
|
||||
if (e.response) {
|
||||
const { status } = e.response;
|
||||
if (statusCodes.includes(status)) {
|
||||
yield put(loginRequestFailure(camelCaseObject(e.response.data)));
|
||||
logInfo(e);
|
||||
} else if (status === 403) {
|
||||
yield put(loginRequestFailure({ errorCode: FORBIDDEN_REQUEST }));
|
||||
logInfo(e);
|
||||
} else {
|
||||
yield put(loginRequestFailure({ errorCode: INTERNAL_SERVER_ERROR }));
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(LOGIN_REQUEST.BASE, handleLoginRequest);
|
||||
}
|
||||
15
src/redesign/login/data/selectors.js
Normal file
15
src/redesign/login/data/selectors.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
export const storeName = 'login';
|
||||
|
||||
export const loginSelector = state => ({ ...state[storeName] });
|
||||
|
||||
export const loginRequestSelector = createSelector(
|
||||
loginSelector,
|
||||
login => login.loginResult,
|
||||
);
|
||||
|
||||
export const loginErrorSelector = createSelector(
|
||||
loginSelector,
|
||||
login => login.loginError,
|
||||
);
|
||||
26
src/redesign/login/data/service.js
Normal file
26
src/redesign/login/data/service.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import querystring from 'querystring';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function loginRequest(creds) {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
isPublic: true,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
`${getConfig().LMS_BASE_URL}/api/user/v2/account/login_session/`,
|
||||
querystring.stringify(creds),
|
||||
requestConfig,
|
||||
)
|
||||
.catch((e) => {
|
||||
throw (e);
|
||||
});
|
||||
|
||||
return {
|
||||
redirectUrl: data.redirect_url || `${getConfig().LMS_BASE_URL}/dashboard`,
|
||||
success: data.success || false,
|
||||
};
|
||||
}
|
||||
111
src/redesign/login/data/tests/sagas.test.js
Normal file
111
src/redesign/login/data/tests/sagas.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { runSaga } from 'redux-saga';
|
||||
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
|
||||
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from '../constants';
|
||||
import * as actions from '../actions';
|
||||
import { handleLoginRequest } from '../sagas';
|
||||
import * as api from '../service';
|
||||
import initializeMockLogging from '../../../../setupTest';
|
||||
|
||||
const { loggingService } = initializeMockLogging();
|
||||
|
||||
describe('handleLoginRequest', () => {
|
||||
const params = {
|
||||
payload: {
|
||||
formData: {
|
||||
email: 'test@test.com',
|
||||
password: 'test-password',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const testErrorResponse = async (loginErrorResponse, expectedLogFunc, expectedDispatchers) => {
|
||||
const loginRequest = jest.spyOn(api, 'loginRequest').mockImplementation(() => Promise.reject(loginErrorResponse));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleLoginRequest,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(loginRequest).toHaveBeenCalledTimes(1);
|
||||
expect(expectedLogFunc).toHaveBeenCalled();
|
||||
expect(dispatched).toEqual(expectedDispatchers);
|
||||
loginRequest.mockClear();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
loggingService.logError.mockReset();
|
||||
loggingService.logInfo.mockReset();
|
||||
});
|
||||
|
||||
it('should call service and dispatch success action', async () => {
|
||||
const data = { redirectUrl: '/dashboard', success: true };
|
||||
const loginRequest = jest.spyOn(api, 'loginRequest')
|
||||
.mockImplementation(() => Promise.resolve(data));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleLoginRequest,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(loginRequest).toHaveBeenCalledTimes(1);
|
||||
expect(dispatched).toEqual([
|
||||
actions.loginRequestBegin(),
|
||||
actions.loginRequestSuccess(data.redirectUrl, data.success),
|
||||
]);
|
||||
loginRequest.mockClear();
|
||||
});
|
||||
|
||||
it('should call service and dispatch error action', async () => {
|
||||
const loginErrorResponse = {
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
login_error: 'something went wrong',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await testErrorResponse(loginErrorResponse, loggingService.logInfo, [
|
||||
actions.loginRequestBegin(),
|
||||
actions.loginRequestFailure(camelCaseObject(loginErrorResponse.response.data)),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle rate limit error code', async () => {
|
||||
const loginErrorResponse = {
|
||||
response: {
|
||||
status: 403,
|
||||
data: {
|
||||
errorCode: FORBIDDEN_REQUEST,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await testErrorResponse(loginErrorResponse, loggingService.logInfo, [
|
||||
actions.loginRequestBegin(),
|
||||
actions.loginRequestFailure(loginErrorResponse.response.data),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle 500 error code', async () => {
|
||||
const loginErrorResponse = {
|
||||
response: {
|
||||
status: 500,
|
||||
data: {
|
||||
errorCode: INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await testErrorResponse(loginErrorResponse, loggingService.logError, [
|
||||
actions.loginRequestBegin(),
|
||||
actions.loginRequestFailure(loginErrorResponse.response.data),
|
||||
]);
|
||||
});
|
||||
});
|
||||
4
src/redesign/login/index.js
Normal file
4
src/redesign/login/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as LoginPage } from './LoginPage';
|
||||
export { default as reducer } from './data/reducers';
|
||||
export { default as saga } from './data/sagas';
|
||||
export { storeName } from './data/selectors';
|
||||
218
src/redesign/login/messages.jsx
Normal file
218
src/redesign/login/messages.jsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'login.page.title': {
|
||||
id: 'login.page.title',
|
||||
defaultMessage: 'Login | {siteName}',
|
||||
description: 'login page title',
|
||||
},
|
||||
// Login labels
|
||||
'login.user.identity.label': {
|
||||
id: 'login.user.identity.label',
|
||||
defaultMessage: 'Username or email',
|
||||
description: 'Label for user identity field to enter either username or email to login',
|
||||
},
|
||||
'login.password.label': {
|
||||
id: 'login.password.label',
|
||||
defaultMessage: 'Password',
|
||||
description: 'Label for password field',
|
||||
},
|
||||
'sign.in.button': {
|
||||
id: 'sign.in.button',
|
||||
defaultMessage: 'Sign in',
|
||||
description: 'Button label that appears on login page',
|
||||
},
|
||||
'sign.in.btn.pending.state': {
|
||||
id: 'sign.in.btn.pending.state',
|
||||
defaultMessage: 'Loading',
|
||||
description: 'Title of icon that appears when button is in pending state',
|
||||
},
|
||||
'need.help.signing.in.collapsible.menu': {
|
||||
id: 'need.help.signing.in.collapsible.menu',
|
||||
defaultMessage: 'Need help signing in?',
|
||||
description: 'A button for collapsible need help signing in menu on login page',
|
||||
},
|
||||
'forgot.password.link': {
|
||||
id: 'forgot.password.link',
|
||||
defaultMessage: 'Forgot my password',
|
||||
description: 'Forgot password link',
|
||||
},
|
||||
'forgot.password': {
|
||||
id: 'forgot.password',
|
||||
defaultMessage: 'Forgot password',
|
||||
description: 'Button text for forgot password',
|
||||
},
|
||||
'other.sign.in.issues': {
|
||||
id: 'other.sign.in.issues',
|
||||
defaultMessage: 'Other sign in issues',
|
||||
description: 'A link that redirects to sign-in issues help',
|
||||
},
|
||||
'need.other.help.signing.in.collapsible.menu': {
|
||||
id: 'need.other.help.signing.in.collapsible.menu',
|
||||
defaultMessage: 'Need other help signing in?',
|
||||
description: 'A button for collapsible need other help signing in menu on forgot password page',
|
||||
},
|
||||
'institution.login.button': {
|
||||
id: 'institution.login.button',
|
||||
defaultMessage: 'Institution/campus credentials',
|
||||
description: 'shows institutions list',
|
||||
},
|
||||
'institution.login.page.title': {
|
||||
id: 'institution.login.page.title',
|
||||
defaultMessage: 'Sign in with institution/campus credentials',
|
||||
description: 'Heading of institution page',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'institution.login.page.back.button': {
|
||||
id: 'institution.login.page.back.button',
|
||||
defaultMessage: 'Back to sign in',
|
||||
description: 'return to login page',
|
||||
},
|
||||
'create.an.account': {
|
||||
id: 'create.an.account',
|
||||
defaultMessage: 'Create an account',
|
||||
description: 'Message on button to return to register page',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'non.compliant.password.title': {
|
||||
id: 'non.compliant.password.title',
|
||||
defaultMessage: 'We recently changed our password requirements',
|
||||
description: 'A title that appears in bold before error message for non-compliant password',
|
||||
},
|
||||
'first.time.here': {
|
||||
id: 'first.time.here',
|
||||
defaultMessage: 'First time here?',
|
||||
description: 'A question that appears before sign up link',
|
||||
},
|
||||
'email.help.message': {
|
||||
id: 'email.help.message',
|
||||
defaultMessage: 'The email address you used to register with edX.',
|
||||
description: 'Message that appears below email field on login page',
|
||||
},
|
||||
'enterprise.login.btn.text': {
|
||||
id: 'enterprise.login.btn.text',
|
||||
defaultMessage: 'Company or school credentials',
|
||||
description: 'Company or school login link text.',
|
||||
},
|
||||
'email.format.validation.message': {
|
||||
id: 'email.format.validation.message',
|
||||
defaultMessage: 'The email address you\'ve provided isn\'t formatted correctly.',
|
||||
description: 'Validation message that appears when email address format is incorrect',
|
||||
},
|
||||
'email.format.validation.less.chars.message': {
|
||||
id: 'email.format.validation.less.chars.message',
|
||||
defaultMessage: 'Email must have at least 3 characters.',
|
||||
description: 'Validation message that appears when email address is less than 3 characters',
|
||||
},
|
||||
'email.validation.message': {
|
||||
id: 'email.validation.message',
|
||||
defaultMessage: 'Enter your username or email',
|
||||
description: 'Validation message that appears when email is empty',
|
||||
},
|
||||
'password.validation.message': {
|
||||
id: 'password.validation.message',
|
||||
defaultMessage: 'Enter your password',
|
||||
description: 'Validation message that appears when password is empty',
|
||||
},
|
||||
'register.link': {
|
||||
id: 'register.link',
|
||||
defaultMessage: 'Create an account',
|
||||
description: 'Register page link',
|
||||
},
|
||||
'sign.in.heading': {
|
||||
id: 'sign.in.heading',
|
||||
defaultMessage: 'Sign in',
|
||||
description: 'Sign in text',
|
||||
},
|
||||
// Account Activation Strings
|
||||
'account.activation.success.message.title': {
|
||||
id: 'account.activation.success.message.title',
|
||||
defaultMessage: 'Success! You have activated your account.',
|
||||
description: 'Account Activation success message title',
|
||||
},
|
||||
'account.activation.success.message': {
|
||||
id: 'account.activation.success.message',
|
||||
defaultMessage: 'You will now receive email updates and alerts from us related to the courses you are enrolled in. Sign in to continue.',
|
||||
description: 'Message show to learners when their account has been activated successfully',
|
||||
},
|
||||
'account.already.activated.message': {
|
||||
id: 'account.already.activated.message',
|
||||
defaultMessage: 'This account has already been activated.',
|
||||
description: 'Message shown when learner account has already been activated',
|
||||
},
|
||||
'account.activation.error.message.title': {
|
||||
id: 'account.activation.error.message.title',
|
||||
defaultMessage: 'Your account could not be activated',
|
||||
description: 'Account Activation error message title',
|
||||
},
|
||||
'account.activation.support.link': {
|
||||
id: 'account.activation.support.link',
|
||||
defaultMessage: 'contact support',
|
||||
description: 'Link text used in account activation error message to go to learner help center',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'login.rate.limit.reached.message': {
|
||||
id: 'login.rate.limit.reached.message',
|
||||
defaultMessage: 'Too many failed login attempts. Try again later.',
|
||||
description: 'Error message that appears when an anonymous user has made too many failed login attempts',
|
||||
},
|
||||
'login.failure.header.title': {
|
||||
id: 'login.failure.header.title',
|
||||
defaultMessage: 'We couldn\'t sign you in.',
|
||||
description: 'Login failure header message.',
|
||||
},
|
||||
'contact.support.link': {
|
||||
id: 'contact.support.link',
|
||||
defaultMessage: 'contact {platformName} support',
|
||||
description: 'Link text used in inactive user error message to go to learner help center',
|
||||
},
|
||||
'login.incorrect.credentials.error': {
|
||||
id: 'login.incorrect.credentials.error',
|
||||
defaultMessage: 'The username, email or password you entered is incorrect. Please try again.',
|
||||
description: 'Error message for incorrect email or password',
|
||||
},
|
||||
'login.incorrect.credentials.error.with.reset.link': {
|
||||
id: 'login.incorrect.credentials.error.with.reset.link',
|
||||
defaultMessage: 'The username, email or password you entered is incorrect. Please try again or {resetLink}.',
|
||||
description: 'Error message for incorrect email or password with reset link',
|
||||
},
|
||||
'login.failed.attempt.error': {
|
||||
id: 'login.failed.attempt.error',
|
||||
defaultMessage: 'You have {remainingAttempts} more sign in attempts before your account is temporarily locked.',
|
||||
description: 'Failed login attempts error message',
|
||||
},
|
||||
'login.locked.out.error.message': {
|
||||
id: 'login.locked.out.error.message',
|
||||
defaultMessage: 'To protect your account, it’s been temporarily locked. Try again in {lockedOutPeriod} minutes.',
|
||||
description: 'Account locked out user message',
|
||||
},
|
||||
'login.form.invalid.error.message': {
|
||||
id: 'login.form.invalid.error.message',
|
||||
defaultMessage: 'Please fill in the fields below.',
|
||||
description: 'Login form empty input user message',
|
||||
},
|
||||
'login.incorrect.credentials.error.reset.link.text': {
|
||||
id: 'login.incorrect.credentials.error.reset.link.text',
|
||||
defaultMessage: 'reset your password',
|
||||
description: 'Reset password link text for incorrect email or password credentials',
|
||||
},
|
||||
'login.incorrect.credentials.error.before.account.blocked.text': {
|
||||
id: 'login.incorrect.credentials.error.before.account.blocked.text',
|
||||
defaultMessage: 'click here to reset it.',
|
||||
description: 'Reset password link text for incorrect email or password credentials before blocking account',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
57
src/redesign/login/tests/AccountActivationMessage.test.jsx
Normal file
57
src/redesign/login/tests/AccountActivationMessage.test.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AccountActivationMessage from '../AccountActivationMessage';
|
||||
import { ACCOUNT_ACTIVATION_MESSAGE } from '../data/constants';
|
||||
|
||||
const IntlAccountActivationMessage = injectIntl(AccountActivationMessage);
|
||||
|
||||
describe('AccountActivationMessage', () => {
|
||||
it('should match account already activated message', () => {
|
||||
const accountActivationMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.INFO} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'This account has already been activated.';
|
||||
expect(accountActivationMessage.find('#account-activation-message').find('div').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should match account activated success message', () => {
|
||||
const accountActivationMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.SUCCESS} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'Success! You have activated your account.'
|
||||
+ 'You will now receive email updates and alerts from us related to '
|
||||
+ 'the courses you are enrolled in. Sign in to continue.';
|
||||
expect(accountActivationMessage.find('#account-activation-message').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should match account activation error message', () => {
|
||||
const accountActivationMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.ERROR} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'Your account could not be activated'
|
||||
+ 'Something went wrong, please contact support to resolve this issue.';
|
||||
expect(accountActivationMessage.find('#account-activation-message').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should not display anything for invalid message type', () => {
|
||||
const accountActivationMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlAccountActivationMessage messageType="invalid-message" />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
expect(accountActivationMessage).toEqual({});
|
||||
});
|
||||
});
|
||||
224
src/redesign/login/tests/LoginFailure.test.jsx
Normal file
224
src/redesign/login/tests/LoginFailure.test.jsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import LoginFailureMessage from '../LoginFailure';
|
||||
import {
|
||||
FORBIDDEN_REQUEST,
|
||||
INACTIVE_USER,
|
||||
INTERNAL_SERVER_ERROR,
|
||||
INVALID_FORM,
|
||||
NON_COMPLIANT_PASSWORD_EXCEPTION,
|
||||
FAILED_LOGIN_ATTEMPT,
|
||||
INCORRECT_EMAIL_PASSWORD,
|
||||
} from '../data/constants';
|
||||
|
||||
const IntlLoginFailureMessage = injectIntl(LoginFailureMessage);
|
||||
|
||||
describe('LoginFailureMessage', () => {
|
||||
let props = {};
|
||||
|
||||
it('should match non compliant password error message', () => {
|
||||
props = {
|
||||
loginError: {
|
||||
errorCode: NON_COMPLIANT_PASSWORD_EXCEPTION,
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'We couldn\'t sign you in.We recently changed our password requirements '
|
||||
+ 'Your current password does not meet the new security requirements. We just sent a '
|
||||
+ 'password-reset message to the email address associated with this account. '
|
||||
+ 'Thank you for helping us keep your data safe.';
|
||||
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should match inactive user error message', () => {
|
||||
props = {
|
||||
loginError: {
|
||||
email: 'text@example.com',
|
||||
errorCode: INACTIVE_USER,
|
||||
context: {
|
||||
platformName: 'openedX',
|
||||
supportLink: 'https://support.edx.org/',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'We couldn\'t sign you in.In order to sign in, you need to activate your account. '
|
||||
+ 'We just sent an activation link to text@example.com. If you do not receive an email, '
|
||||
+ 'check your spam folders or contact openedX support.';
|
||||
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
expect(loginFailureMessage.find('#login-failure-alert').find('a').props().href).toEqual('https://support.edx.org/');
|
||||
});
|
||||
|
||||
it('test match failed login attempt error', () => {
|
||||
props = {
|
||||
loginError: {
|
||||
email: 'text@example.com',
|
||||
errorCode: FAILED_LOGIN_ATTEMPT,
|
||||
context: {
|
||||
remainingAttempts: 3,
|
||||
allowedFailureAttempts: 6,
|
||||
resetLink: '/reset',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
const expectedMessage = 'We couldn\'t sign you in.The username, email or password you entered is incorrect. '
|
||||
+ 'You have 3 more sign in attempts before your account is temporarily locked. If you\'ve forgotten your password, click here to reset it.';
|
||||
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('test match failed login error first attempt', () => {
|
||||
props = {
|
||||
loginError: {
|
||||
email: 'text@example.com',
|
||||
errorCode: INCORRECT_EMAIL_PASSWORD,
|
||||
context: {
|
||||
failureCount: 1,
|
||||
resetLink: '/reset',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
const expectedMessage = 'We couldn\'t sign you in.The username, email or password you entered is incorrect. Please try again.';
|
||||
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('test match failed login error second attempt', () => {
|
||||
props = {
|
||||
loginError: {
|
||||
email: 'text@example.com',
|
||||
errorCode: INCORRECT_EMAIL_PASSWORD,
|
||||
context: {
|
||||
failureCount: 2,
|
||||
resetLink: '/reset',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
const expectedMessage = 'We couldn\'t sign you in.The username, email or password you entered is incorrect. Please try again or reset your password.';
|
||||
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should match rate limit error message', () => {
|
||||
props = {
|
||||
loginError: {
|
||||
errorCode: FORBIDDEN_REQUEST,
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'We couldn\'t sign you in.Too many failed login attempts. Try again later.';
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should match internal server error message', () => {
|
||||
props = {
|
||||
loginError: {
|
||||
errorCode: INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'We couldn\'t sign you in.An error has occurred. Try refreshing the page, or check your internet connection.';
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should match invalid form error message', () => {
|
||||
props = {
|
||||
loginError: {
|
||||
errorCode: INVALID_FORM,
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'We couldn\'t sign you in.Please fill in the fields below.';
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should match direct render of error message', () => {
|
||||
const errorMessage = 'Email or password is incorrect.';
|
||||
props = {
|
||||
loginError: {
|
||||
value: errorMessage,
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'We couldn\'t sign you in.'.concat(errorMessage);
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should match error message containing link snapshot', () => {
|
||||
props = {
|
||||
loginError: {
|
||||
value: 'To be on the safe side, you can reset your password <a href="/reset">here</a> before you try again.\n',
|
||||
},
|
||||
};
|
||||
|
||||
const loginFailureMessage = mount(
|
||||
<IntlProvider locale="en">
|
||||
<IntlLoginFailureMessage {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const expectedMessage = 'We couldn\'t sign you in.To be on the safe side, you can reset your password here before you try again.';
|
||||
|
||||
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
|
||||
expect(loginFailureMessage.find('#login-failure-alert').find('a').props().href).toEqual('/reset');
|
||||
});
|
||||
});
|
||||
464
src/redesign/login/tests/LoginPage.test.jsx
Normal file
464
src/redesign/login/tests/LoginPage.test.jsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import React from 'react';
|
||||
|
||||
import { mount } from 'enzyme';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner';
|
||||
import { getConfig, mergeConfig } from '@edx/frontend-platform';
|
||||
import * as analytics from '@edx/frontend-platform/analytics';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { loginRequest, loginRequestFailure, loginRequestReset } from '../data/actions';
|
||||
import LoginFailureMessage from '../LoginFailure';
|
||||
import LoginPage from '../LoginPage';
|
||||
|
||||
import { COMPLETE_STATE, PENDING_STATE } from '../../data/constants';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
analytics.sendTrackEvent = jest.fn();
|
||||
analytics.sendPageEvent = jest.fn();
|
||||
|
||||
const IntlLoginFailureMessage = injectIntl(LoginFailureMessage);
|
||||
const IntlLoginPage = injectIntl(LoginPage);
|
||||
const mockStore = configureStore();
|
||||
|
||||
describe('LoginPage', () => {
|
||||
mergeConfig({
|
||||
USER_SURVEY_COOKIE_NAME: process.env.USER_SURVEY_COOKIE_NAME,
|
||||
});
|
||||
let props = {};
|
||||
let store = {};
|
||||
|
||||
const reduxWrapper = children => (
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
login: {
|
||||
loginResult: { success: false, redirectUrl: '' },
|
||||
},
|
||||
commonComponents: {
|
||||
thirdPartyAuthApiStatus: null,
|
||||
thirdPartyAuthContext: {
|
||||
currentProvider: null,
|
||||
finishAuthUrl: null,
|
||||
providers: [],
|
||||
secondaryProviders: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const secondaryProviders = {
|
||||
id: 'saml-test',
|
||||
name: 'Test University',
|
||||
loginUrl: '/dummy-auth',
|
||||
registerUrl: '/dummy_auth',
|
||||
skipHintedLogin: false,
|
||||
};
|
||||
|
||||
const ssoProvider = {
|
||||
id: 'oa2-apple-id',
|
||||
name: 'Apple',
|
||||
iconClass: null,
|
||||
iconImage: 'https://edx.devstack.lms/logo.png',
|
||||
loginUrl: '/auth/login/apple-id/?auth_entry=login&next=/dashboard',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore(initialState);
|
||||
props = {
|
||||
loginRequest: jest.fn(),
|
||||
handleInstitutionLogin: jest.fn(),
|
||||
institutionLogin: false,
|
||||
};
|
||||
});
|
||||
|
||||
// ******** test login form submission ********
|
||||
|
||||
it('should submit form for valid input', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
|
||||
loginPage.find('input#emailOrUsername').simulate('change', { target: { value: 'test@example.com' } });
|
||||
loginPage.find('input#password').simulate('change', { target: { value: 'password' } });
|
||||
loginPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(loginRequest({ email_or_username: 'test@example.com', password: 'password' }));
|
||||
});
|
||||
|
||||
it('should not dispatch loginRequest on empty form submission', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
|
||||
loginPage.find('button.btn-brand').simulate('click');
|
||||
expect(store.dispatch).not.toHaveBeenCalledWith(loginRequest({}));
|
||||
});
|
||||
|
||||
// ******** test login form validations ********
|
||||
|
||||
it('should match state on empty form submission', () => {
|
||||
const errorState = { emailOrUsername: 'Enter your username or email', password: 'Enter your password' };
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
|
||||
loginPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
// Check that loginRequestFailure was dispatched and state is updated
|
||||
expect(loginPage.state('errors')).toEqual(errorState);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(loginRequestFailure({ errorCode: 'invalid-form' }));
|
||||
});
|
||||
|
||||
it('should match state for invalid email (less than 3 characters), on form submission', () => {
|
||||
const errorState = { emailOrUsername: 'Email must have at least 3 characters.', password: '' };
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
|
||||
|
||||
loginPage.find('input#password').simulate('change', { target: { value: 'test', name: 'password' } });
|
||||
loginPage.find('input#emailOrUsername').simulate('change', { target: { value: 'te', name: 'email' } });
|
||||
loginPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
expect(loginPage.state('errors')).toEqual(errorState);
|
||||
});
|
||||
|
||||
it('should reset field related error messages on onFocus event', () => {
|
||||
const errorState = { emailOrUsername: '', password: '' };
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
|
||||
loginPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
loginPage.find('input#emailOrUsername').simulate('focus');
|
||||
loginPage.find('input#password').simulate('focus');
|
||||
expect(loginPage.state('errors')).toEqual(errorState);
|
||||
});
|
||||
|
||||
// ******** test form buttons and links ********
|
||||
|
||||
it('should match default button state', () => {
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find('button[type="submit"] span').first().text()).toEqual('Sign in');
|
||||
});
|
||||
|
||||
it('should match pending button state', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
login: {
|
||||
...initialState.login,
|
||||
submitState: PENDING_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
const button = loginPage.find('button[type="submit"] span').first();
|
||||
|
||||
expect(button.find('.sr-only').text()).toEqual('pending');
|
||||
});
|
||||
|
||||
it('should show forgot password link', () => {
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find('a#forgot-password').text()).toEqual('Forgot password');
|
||||
});
|
||||
|
||||
it('should show single sign on provider button', () => {
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: 'true',
|
||||
});
|
||||
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthContext: {
|
||||
...initialState.commonComponents.thirdPartyAuthContext,
|
||||
providers: [ssoProvider],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find(`button#${ssoProvider.id}`).length).toEqual(1);
|
||||
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display institution login option when no secondary providers are present', () => {
|
||||
const root = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(root.text().includes('Use my university info')).toBe(false);
|
||||
});
|
||||
|
||||
// ******** test alert messages ********
|
||||
|
||||
it('should match login error message', () => {
|
||||
const errorMessage = 'Email or password is incorrect.';
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
login: {
|
||||
...initialState.login,
|
||||
loginError: { value: errorMessage },
|
||||
},
|
||||
});
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find('#login-failure-alert').first().text()).toEqual(`We couldn't sign you in.${errorMessage}`);
|
||||
});
|
||||
|
||||
it('should match account activation message', () => {
|
||||
const activationMessage = 'Success! You have activated your account.'
|
||||
+ 'You will now receive email updates and alerts from us related '
|
||||
+ 'to the courses you are enrolled in. Sign in to continue.';
|
||||
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL.concat('/login'), search: '?account_activation_status=success' };
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find('div#account-activation-message').text()).toEqual(activationMessage);
|
||||
});
|
||||
|
||||
it('should match third party auth alert', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthContext: {
|
||||
...initialState.commonComponents.thirdPartyAuthContext,
|
||||
currentProvider: 'Apple',
|
||||
platformName: 'edX',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const expectedMessage = 'You have successfully signed into Apple, but your Apple account does not have a '
|
||||
+ 'linked edX account. To link your accounts, sign in now using your edX password.';
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find('#tpa-alert').find('p').text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
it('should match invalid login form error message', () => {
|
||||
const errorMessage = 'Please fill in the fields below.';
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
login: {
|
||||
...initialState.login,
|
||||
loginError: { errorCode: 'invalid-form' },
|
||||
},
|
||||
});
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find('#login-failure-alert p').first().text()).toEqual(errorMessage);
|
||||
});
|
||||
|
||||
// ******** test redirection ********
|
||||
|
||||
it('should redirect to url returned by login endpoint', () => {
|
||||
const dasboardUrl = 'http://localhost:18000/enterprise/select/active/?success_url=/dashboard';
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
login: {
|
||||
...initialState.login,
|
||||
loginResult: {
|
||||
success: true,
|
||||
redirectUrl: dasboardUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL };
|
||||
renderer.create(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(window.location.href).toBe(dasboardUrl);
|
||||
});
|
||||
|
||||
it('should redirect to finishAuthUrl upon successful login via SSO', () => {
|
||||
const authCompleteUrl = '/auth/complete/google-oauth2/';
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
login: {
|
||||
...initialState.login,
|
||||
loginResult: {
|
||||
success: true,
|
||||
redirectUrl: '',
|
||||
},
|
||||
},
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthContext: {
|
||||
...initialState.commonComponents.thirdPartyAuthContext,
|
||||
finishAuthUrl: authCompleteUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL };
|
||||
|
||||
renderer.create(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl);
|
||||
});
|
||||
|
||||
it('should redirect to social auth provider url on SSO button click', () => {
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: 'true',
|
||||
});
|
||||
|
||||
const loginUrl = '/auth/login/apple-id/?auth_entry=login&next=/dashboard';
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthContext: {
|
||||
...initialState.commonComponents.thirdPartyAuthContext,
|
||||
providers: [{
|
||||
...ssoProvider,
|
||||
loginUrl,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL };
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
|
||||
loginPage.find('button#oa2-apple-id').simulate('click');
|
||||
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + loginUrl);
|
||||
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: '',
|
||||
});
|
||||
});
|
||||
|
||||
// ******** test hinted third party auth ********
|
||||
|
||||
it('should render tpa button for tpa_hint id matching one of the primary providers', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthContext: {
|
||||
...initialState.commonComponents.thirdPartyAuthContext,
|
||||
providers: [ssoProvider],
|
||||
},
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL.concat('/login'), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
|
||||
ssoProvider.iconImage = null;
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find(`button#${ssoProvider.id}`).find('span').text()).toEqual(ssoProvider.name);
|
||||
expect(loginPage.find(`button#${ssoProvider.id}`).hasClass(`btn-tpa btn-${ssoProvider.id}`)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should render tpa button for tpa_hint id matching one of the secondary providers', () => {
|
||||
secondaryProviders.skipHintedLogin = true;
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthContext: {
|
||||
...initialState.commonComponents.thirdPartyAuthContext,
|
||||
secondaryProviders: [secondaryProviders],
|
||||
},
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL.concat('/login'), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` };
|
||||
secondaryProviders.iconImage = null;
|
||||
|
||||
mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(window.location.href).toEqual(getConfig().LMS_BASE_URL + secondaryProviders.loginUrl);
|
||||
});
|
||||
|
||||
it('should render regular tpa button for invalid tpa_hint value', () => {
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: 'true',
|
||||
});
|
||||
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthContext: {
|
||||
...initialState.commonComponents.thirdPartyAuthContext,
|
||||
providers: [ssoProvider],
|
||||
},
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL.concat('/login'), search: '?next=/dashboard&tpa_hint=invalid' };
|
||||
ssoProvider.iconImage = null;
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find(`button#${ssoProvider.id}`).find('span#provider-name').text()).toEqual(`${ssoProvider.name}`);
|
||||
|
||||
mergeConfig({
|
||||
DISABLE_ENTERPRISE_LOGIN: '',
|
||||
});
|
||||
});
|
||||
|
||||
// ******** miscellaneous tests ********
|
||||
|
||||
it('should render cookie banner', () => {
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(loginPage.find(<CookiePolicyBanner />)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should send page event when login page is rendered', () => {
|
||||
mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(analytics.sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'login');
|
||||
});
|
||||
|
||||
it('tests that form is only scrollable on form submission', () => {
|
||||
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
|
||||
loginPage.find('input#password').simulate('change', { target: { value: 'test', name: 'password' } });
|
||||
loginPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
expect(loginPage.find(<IntlLoginFailureMessage />)).toBeTruthy();
|
||||
expect(loginPage.find('LoginPage').state('isSubmitted')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should set login survey cookie', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
login: {
|
||||
...initialState.login,
|
||||
loginResult: {
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderer.create(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(document.cookie).toMatch(`${getConfig().USER_SURVEY_COOKIE_NAME}=login`);
|
||||
});
|
||||
|
||||
it('should reset login form errors', () => {
|
||||
const errorState = { emailOrUsername: '', password: '' };
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(loginRequestReset());
|
||||
expect(loginPage.state('errors')).toEqual(errorState);
|
||||
});
|
||||
});
|
||||
188
src/redesign/register/CountryDropdown.jsx
Normal file
188
src/redesign/register/CountryDropdown.jsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { ExpandMore, ExpandLess } from '@edx/paragon/icons';
|
||||
import onClickOutside from 'react-onclickoutside';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormGroup } from '../common-components';
|
||||
import { FORM_SUBMISSION_ERROR } from './data/constants';
|
||||
|
||||
class CountryDropdown extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
displayValue: '',
|
||||
icon: ExpandMore,
|
||||
errorMessage: '',
|
||||
showFieldError: true,
|
||||
};
|
||||
|
||||
this.handleFocus = this.handleFocus.bind(this);
|
||||
this.handleOnBlur = this.handleOnBlur.bind(this);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if (this.props.value !== nextProps.value && nextProps.value !== null) {
|
||||
const opt = this.props.options.find((o) => o[this.props.valueKey] === nextProps.value);
|
||||
if (opt && opt[this.props.displayValueKey] !== this.state.displayValue) {
|
||||
this.setState({ displayValue: opt[this.props.displayValueKey], showFieldError: false });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (props.errorCode === FORM_SUBMISSION_ERROR && state.showFieldError) {
|
||||
return { errorMessage: props.errorMessage };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getItems(strToFind = '') {
|
||||
let { options } = this.props;
|
||||
if (strToFind.length > 0) {
|
||||
options = options.filter((option) => (option.name.toLowerCase().includes(strToFind.toLowerCase())));
|
||||
}
|
||||
|
||||
return options.map((opt) => {
|
||||
const value = opt[this.props.valueKey];
|
||||
let displayValue = opt[this.props.displayValueKey];
|
||||
if (displayValue.length > 30) {
|
||||
displayValue = displayValue.substring(0, 30).concat('...');
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="dropdown-item" value={value} key={value} onClick={(e) => { this.handleItemClick(e); }}>
|
||||
{displayValue}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
setValue(value) {
|
||||
if (this.props.value === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.handleChange) {
|
||||
this.props.handleChange(value);
|
||||
}
|
||||
|
||||
const opt = this.props.options.find((o) => o[this.props.valueKey] === value);
|
||||
if (opt && opt[this.props.displayValueKey] !== this.state.displayValue) {
|
||||
this.setState({ displayValue: opt[this.props.displayValueKey], showFieldError: false });
|
||||
}
|
||||
}
|
||||
|
||||
setDisplayValue(value) {
|
||||
const normalized = value.toLowerCase();
|
||||
const opt = this.props.options.find((o) => o[this.props.displayValueKey].toLowerCase() === normalized);
|
||||
if (opt) {
|
||||
this.setValue(opt[this.props.valueKey]);
|
||||
this.setState({ displayValue: opt[this.props.displayValueKey], showFieldError: false });
|
||||
} else {
|
||||
this.setValue(null);
|
||||
this.setState({ displayValue: value, showFieldError: !value });
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
if (!this.props.value) {
|
||||
const dropDownItems = this.getItems(e.target.value);
|
||||
this.setState({
|
||||
dropDownItems, icon: ExpandLess, errorMessage: '', showFieldError: false,
|
||||
});
|
||||
}
|
||||
if (this.state.dropDownItems?.length > 0) {
|
||||
this.setState({ dropDownItems: '', icon: ExpandMore });
|
||||
}
|
||||
}
|
||||
|
||||
handleOnChange = (e) => {
|
||||
const findstr = e.target.value;
|
||||
|
||||
if (findstr.length > 0) {
|
||||
const filteredItems = this.getItems(findstr);
|
||||
this.setState({ dropDownItems: filteredItems, icon: ExpandLess, errorMessage: '' });
|
||||
} else {
|
||||
this.setState({ dropDownItems: '', icon: ExpandMore, errorMessage: this.props.errorMessage });
|
||||
}
|
||||
|
||||
this.setDisplayValue(e.target.value);
|
||||
}
|
||||
|
||||
handleClickOutside = () => {
|
||||
if (this.state.dropDownItems?.length > 0) {
|
||||
const msg = this.state.displayValue === '' ? this.props.errorMessage : '';
|
||||
this.setState(() => ({
|
||||
icon: ExpandMore,
|
||||
dropDownItems: '',
|
||||
errorMessage: msg,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
handleFocus(e) {
|
||||
if (this.props.handleFocus) { this.props.handleFocus(e); }
|
||||
}
|
||||
|
||||
handleOnBlur(e) {
|
||||
if (this.props.handleBlur) { this.props.handleBlur(e); }
|
||||
}
|
||||
|
||||
handleItemClick(e) {
|
||||
this.setValue(e.target.value);
|
||||
this.setState({ dropDownItems: '', icon: ExpandMore });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<FormGroup
|
||||
as="input"
|
||||
name={this.props.name}
|
||||
autoComplete="off"
|
||||
floatingLabel={this.props.floatingLabel}
|
||||
trailingElement={<Icon src={this.state.icon} />}
|
||||
handleChange={this.handleOnChange}
|
||||
handleClick={this.handleClick}
|
||||
handleBlur={this.handleOnBlur}
|
||||
handleFocus={this.handleFocus}
|
||||
value={this.state.displayValue}
|
||||
errorMessage={this.state.errorMessage}
|
||||
/>
|
||||
<div className="dropdown-container -mt-4">
|
||||
{ this.state.dropDownItems?.length > 0 ? this.state.dropDownItems : null }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CountryDropdown.defaultProps = {
|
||||
options: null,
|
||||
floatingLabel: null,
|
||||
handleFocus: null,
|
||||
handleChange: null,
|
||||
handleBlur: null,
|
||||
value: null,
|
||||
errorMessage: null,
|
||||
errorCode: null,
|
||||
};
|
||||
|
||||
CountryDropdown.propTypes = {
|
||||
options: PropTypes.arrayOf(PropTypes.object),
|
||||
floatingLabel: PropTypes.string,
|
||||
valueKey: PropTypes.string.isRequired,
|
||||
displayValueKey: PropTypes.string.isRequired,
|
||||
handleFocus: PropTypes.func,
|
||||
handleChange: PropTypes.func,
|
||||
handleBlur: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
errorMessage: PropTypes.string,
|
||||
errorCode: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default onClickOutside(CountryDropdown);
|
||||
104
src/redesign/register/OptionalFields.jsx
Normal file
104
src/redesign/register/OptionalFields.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form, Icon } from '@edx/paragon';
|
||||
import { ExpandMore } from '@edx/paragon/icons';
|
||||
|
||||
import { EDUCATION_LEVELS, GENDER_OPTIONS, YEAR_OF_BIRTH_OPTIONS } from './data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const OptionalFields = (props) => {
|
||||
const {
|
||||
intl, optionalFields, onChangeHandler, values,
|
||||
} = props;
|
||||
|
||||
const getOptions = () => ({
|
||||
yearOfBirthOptions: YEAR_OF_BIRTH_OPTIONS.map(({ value, label }) => (
|
||||
<option className="data-hj-suppress" key={value} value={value}>{label}</option>
|
||||
)),
|
||||
educationLevelOptions: EDUCATION_LEVELS.map(key => (
|
||||
<option className="data-hj-suppress" key={key} value={key}>
|
||||
{intl.formatMessage(messages[`registration.field.education.levels.${key || 'label'}`])}
|
||||
</option>
|
||||
)),
|
||||
genderOptions: GENDER_OPTIONS.map(key => (
|
||||
<option className="data-hj-suppress" key={key} value={key}>
|
||||
{intl.formatMessage(messages[`registration.field.gender.options.${key || 'label'}`])}
|
||||
</option>
|
||||
)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{optionalFields.includes('gender') && (
|
||||
<Form.Group controlId="gender">
|
||||
<Form.Control
|
||||
as="select"
|
||||
name="gender"
|
||||
value={values.gender}
|
||||
onChange={(e) => onChangeHandler('gender', e.target.value)}
|
||||
trailingElement={<Icon src={ExpandMore} />}
|
||||
floatingLabel={intl.formatMessage(messages['registration.field.gender.options.label'])}
|
||||
>
|
||||
{getOptions().genderOptions}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
)}
|
||||
{optionalFields.includes('yearOfBirth') && (
|
||||
<Form.Group controlId="yearOfBirth">
|
||||
<Form.Control
|
||||
as="select"
|
||||
name="yearOfBirth"
|
||||
value={values.yearOfBirth}
|
||||
onChange={(e) => onChangeHandler('yearOfBirth', e.target.value)}
|
||||
trailingElement={<Icon src={ExpandMore} />}
|
||||
floatingLabel={intl.formatMessage(messages['registration.year.of.birth.label'])}
|
||||
>
|
||||
<option value="">{intl.formatMessage(messages['registration.year.of.birth.label'])}</option>
|
||||
{getOptions().yearOfBirthOptions}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
)}
|
||||
{optionalFields.includes('levelOfEducation') && (
|
||||
<Form.Group controlId="levelOfEducation">
|
||||
<Form.Control
|
||||
as="select"
|
||||
name="levelOfEducation"
|
||||
value={values.levelOfEducation}
|
||||
onChange={(e) => onChangeHandler('levelOfEducation', e.target.value)}
|
||||
trailingElement={<Icon src={ExpandMore} />}
|
||||
floatingLabel={intl.formatMessage(messages['registration.field.education.levels.label'])}
|
||||
>
|
||||
{getOptions().educationLevelOptions}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
)}
|
||||
{optionalFields.includes('goals') && (
|
||||
<Form.Group controlId="goals">
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
name="goals"
|
||||
value={values.goals}
|
||||
onChange={(e) => onChangeHandler('goals', e.target.value)}
|
||||
floatingLabel={intl.formatMessage(messages['registration.goals.label'])}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
OptionalFields.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
optionalFields: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onChangeHandler: PropTypes.func.isRequired,
|
||||
values: PropTypes.shape({
|
||||
gender: PropTypes.string,
|
||||
goals: PropTypes.string,
|
||||
levelOfEducation: PropTypes.string,
|
||||
yearOfBirth: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(OptionalFields);
|
||||
58
src/redesign/register/RegistrationFailure.jsx
Normal file
58
src/redesign/register/RegistrationFailure.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
|
||||
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_SESSION_EXPIRED } from './data/constants';
|
||||
import messages from './messages';
|
||||
import { windowScrollTo } from '../data/utils';
|
||||
|
||||
const RegistrationFailureMessage = (props) => {
|
||||
const {
|
||||
context, errorCode, failureCount, intl,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
windowScrollTo({ left: 0, top: 0, behavior: 'smooth' });
|
||||
}, [errorCode, failureCount]);
|
||||
|
||||
let errorMessage;
|
||||
switch (errorCode) {
|
||||
case INTERNAL_SERVER_ERROR:
|
||||
errorMessage = intl.formatMessage(messages['registration.request.server.error']);
|
||||
break;
|
||||
case FORBIDDEN_REQUEST:
|
||||
errorMessage = intl.formatMessage(messages['registration.rate.limit.error']);
|
||||
break;
|
||||
case TPA_SESSION_EXPIRED:
|
||||
errorMessage = intl.formatMessage(messages['registration.tpa.session.expired'], { provider: context.provider });
|
||||
break;
|
||||
default:
|
||||
errorMessage = intl.formatMessage(messages['registration.empty.form.submission.error']);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert id="validation-errors" className="mb-5" variant="danger" icon={Error}>
|
||||
<Alert.Heading>{props.intl.formatMessage(messages['registration.request.failure.header'])}</Alert.Heading>
|
||||
<p>{errorMessage}</p>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
RegistrationFailureMessage.defaultProps = {
|
||||
context: {},
|
||||
};
|
||||
|
||||
RegistrationFailureMessage.propTypes = {
|
||||
context: PropTypes.shape({
|
||||
provider: PropTypes.string,
|
||||
}),
|
||||
errorCode: PropTypes.string.isRequired,
|
||||
failureCount: PropTypes.number.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(RegistrationFailureMessage);
|
||||
789
src/redesign/register/RegistrationPage.jsx
Normal file
789
src/redesign/register/RegistrationPage.jsx
Normal file
@@ -0,0 +1,789 @@
|
||||
import React from 'react';
|
||||
|
||||
import snakeCase from 'lodash.snakecase';
|
||||
import { connect } from 'react-redux';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import PropTypes, { string } from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
injectIntl, intlShape, getCountryList, getLocale, FormattedMessage,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert, Form, Hyperlink, StatefulButton,
|
||||
} from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import { closest } from 'fastest-levenshtein';
|
||||
|
||||
import {
|
||||
clearUsernameSuggestions, registerNewUser, resetRegistrationForm, fetchRealtimeValidations,
|
||||
} from './data/actions';
|
||||
import { FORM_SUBMISSION_ERROR } from './data/constants';
|
||||
import {
|
||||
registrationErrorSelector, registrationRequestSelector, validationsSelector, usernameSuggestionsSelector,
|
||||
} from './data/selectors';
|
||||
import messages from './messages';
|
||||
import OptionalFields from './OptionalFields';
|
||||
import RegistrationFailure from './RegistrationFailure';
|
||||
import UsernameField from './UsernameField';
|
||||
|
||||
import {
|
||||
RedirectLogistration, SocialAuthProviders, ThirdPartyAuthAlert, RenderInstitutionButton,
|
||||
InstitutionLogistration, FormGroup, PasswordField,
|
||||
} from '../common-components';
|
||||
import { getThirdPartyAuthContext } from '../common-components/data/actions';
|
||||
import { thirdPartyAuthContextSelector } from '../common-components/data/selectors';
|
||||
import EnterpriseSSO from '../common-components/EnterpriseSSO';
|
||||
import {
|
||||
DEFAULT_STATE, PENDING_STATE, REGISTER_PAGE, DEFAULT_TOP_LEVEL_DOMAINS,
|
||||
DEFAULT_SERVICE_PROVIDER_DOMAINS, VALID_EMAIL_REGEX, LETTER_REGEX, NUMBER_REGEX,
|
||||
} from '../data/constants';
|
||||
import {
|
||||
getTpaProvider, getTpaHint, getAllPossibleQueryParam, setSurveyCookie,
|
||||
} from '../data/utils';
|
||||
import CountryDropdown from './CountryDropdown';
|
||||
|
||||
class RegistrationPage extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
sendPageEvent('login_and_registration', 'register');
|
||||
const optionalFields = getConfig().REGISTRATION_OPTIONAL_FIELDS ? getConfig().REGISTRATION_OPTIONAL_FIELDS.split(',') : [];
|
||||
this.handleOnClose = this.handleOnClose.bind(this);
|
||||
|
||||
this.queryParams = getAllPossibleQueryParam();
|
||||
this.tpaHint = getTpaHint();
|
||||
this.state = {
|
||||
country: '',
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
username: '',
|
||||
errors: {
|
||||
email: '',
|
||||
name: '',
|
||||
username: '',
|
||||
password: '',
|
||||
country: '',
|
||||
},
|
||||
errorCode: null,
|
||||
failureCount: 0,
|
||||
optionalFields,
|
||||
optionalFieldsState: {},
|
||||
showOptionalField: false,
|
||||
startTime: Date.now(),
|
||||
optimizelyExperimentName: '',
|
||||
skipEmailValidation: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const payload = { ...this.queryParams };
|
||||
|
||||
if (this.tpaHint) {
|
||||
payload.tpa_hint = this.tpaHint;
|
||||
}
|
||||
this.props.resetRegistrationForm();
|
||||
this.props.getThirdPartyAuthContext(payload);
|
||||
// this.getExperiments();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if (this.props.validationDecisions !== nextProps.validationDecisions) {
|
||||
const state = { errors: { ...this.state.errors, ...nextProps.validationDecisions } };
|
||||
|
||||
if (nextProps.registrationErrorCode) {
|
||||
state.errorCode = nextProps.registrationErrorCode;
|
||||
}
|
||||
let {
|
||||
suggestedTldMessage,
|
||||
suggestedTopLevelDomain,
|
||||
suggestedSldMessage,
|
||||
suggestedServiceLevelDomain,
|
||||
|
||||
} = this.state;
|
||||
if (state.errors.email) {
|
||||
suggestedTldMessage = '';
|
||||
suggestedTopLevelDomain = '';
|
||||
suggestedSldMessage = '';
|
||||
suggestedServiceLevelDomain = '';
|
||||
}
|
||||
this.setState({
|
||||
...state,
|
||||
suggestedTldMessage,
|
||||
suggestedTopLevelDomain,
|
||||
suggestedSldMessage,
|
||||
suggestedServiceLevelDomain,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.props.registrationErrorCode !== nextProps.registrationErrorCode) {
|
||||
this.setState({ errorCode: nextProps.registrationErrorCode });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.props.thirdPartyAuthContext.pipelineUserDetails !== nextProps.thirdPartyAuthContext.pipelineUserDetails) {
|
||||
this.setState({
|
||||
...nextProps.thirdPartyAuthContext.pipelineUserDetails,
|
||||
country: nextProps.thirdPartyAuthContext.countryCode || '',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.props.thirdPartyAuthContext.countryCode !== nextProps.thirdPartyAuthContext.countryCode) {
|
||||
this.setState({
|
||||
country: nextProps.thirdPartyAuthContext.countryCode || '',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getExperiments = () => {
|
||||
const { optimizelyExperimentName } = window;
|
||||
|
||||
if (optimizelyExperimentName) {
|
||||
this.setState({ optimizelyExperimentName });
|
||||
}
|
||||
};
|
||||
|
||||
getOptionalFields() {
|
||||
return (
|
||||
<OptionalFields
|
||||
optionalFields={this.state.optionalFields}
|
||||
values={this.state.optionalFieldsState}
|
||||
onChangeHandler={
|
||||
(fieldName, value) => {
|
||||
this.setState(prevState => ({
|
||||
optionalFieldsState: { ...prevState.optionalFieldsState, [fieldName]: value },
|
||||
}));
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const totalRegistrationTime = (Date.now() - this.state.startTime) / 1000;
|
||||
let payload = {
|
||||
name: this.state.name,
|
||||
username: this.state.username,
|
||||
email: this.state.email,
|
||||
country: this.state.country,
|
||||
honor_code: true,
|
||||
is_authn_mfe: true,
|
||||
};
|
||||
|
||||
if (this.props.thirdPartyAuthContext.currentProvider) {
|
||||
payload.social_auth_provider = this.props.thirdPartyAuthContext.currentProvider;
|
||||
} else {
|
||||
payload.password = this.state.password;
|
||||
}
|
||||
|
||||
let errors = {};
|
||||
Object.keys(payload).forEach(key => {
|
||||
errors = this.validateInput(key, payload[key], { ...payload, form_field_key: key });
|
||||
});
|
||||
|
||||
if (!this.isFormValid(errors)) {
|
||||
this.setState(prevState => ({
|
||||
errorCode: FORM_SUBMISSION_ERROR,
|
||||
failureCount: prevState.failureCount + 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Since optional fields and query params are not validated we can add it to payload after
|
||||
// required fields have been validated. This will save us unwanted calls to validateInput()
|
||||
payload = { ...payload, ...this.queryParams };
|
||||
this.state.optionalFields.forEach((key) => {
|
||||
if (this.state.optionalFieldsState[key]) {
|
||||
payload[snakeCase(key)] = this.state.optionalFieldsState[key];
|
||||
}
|
||||
});
|
||||
|
||||
payload.totalRegistrationTime = totalRegistrationTime;
|
||||
this.props.registerNewUser(payload);
|
||||
}
|
||||
|
||||
handleOnBlur = (e) => {
|
||||
const { name, value } = e.target;
|
||||
const payload = {
|
||||
is_authn_mfe: true,
|
||||
form_field_key: name,
|
||||
email: this.state.email,
|
||||
username: this.state.username,
|
||||
password: this.state.password,
|
||||
name: this.state.name,
|
||||
honor_code: true,
|
||||
country: this.state.country,
|
||||
};
|
||||
this.validateInput(name, value, payload);
|
||||
}
|
||||
|
||||
handleOnChange = (e) => {
|
||||
if (e.target.name === 'optionalFields') {
|
||||
sendTrackEvent('edx.bi.user.register.optional_fields_selected', {});
|
||||
this.setState({
|
||||
showOptionalField: e.target.checked,
|
||||
});
|
||||
} else if (!(e.target.name === 'username' && e.target.value.length > 30)) {
|
||||
this.setState({
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleOnFocus = (e) => {
|
||||
const { errors } = this.state;
|
||||
errors[e.target.name] = '';
|
||||
const state = { errors };
|
||||
if (e.target.name === 'email') {
|
||||
state.skipEmailValidation = false;
|
||||
} else if (e.target.name === 'username') {
|
||||
this.props.clearUsernameSuggestions();
|
||||
}
|
||||
this.setState({ ...state });
|
||||
}
|
||||
|
||||
handleSuggestionClick = (e, suggestion) => {
|
||||
const { errors } = this.state;
|
||||
if (e.target.name === 'username') {
|
||||
errors.username = '';
|
||||
this.setState({
|
||||
username: suggestion,
|
||||
errors,
|
||||
});
|
||||
this.props.clearUsernameSuggestions();
|
||||
} else if (e.target.name === 'email') {
|
||||
errors.email = '';
|
||||
this.setState({
|
||||
email: suggestion,
|
||||
suggestedServiceLevelDomain: '',
|
||||
suggestedSldMessage: '',
|
||||
suggestedTopLevelDomain: '',
|
||||
suggestedTldMessage: '',
|
||||
borderClass: '',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isFormValid(validations) {
|
||||
const keyValidList = Object.entries(validations).map(([key]) => !validations[key]);
|
||||
return keyValidList.every((current) => current === true);
|
||||
}
|
||||
|
||||
validateInput(fieldName, value, payload) {
|
||||
const { errors } = this.state;
|
||||
const { intl, statusCode } = this.props;
|
||||
|
||||
const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
|
||||
switch (fieldName) {
|
||||
case 'email':
|
||||
if (!value) {
|
||||
errors.email = intl.formatMessage(messages['empty.email.field.error']);
|
||||
} else if (value.length <= 2 || !emailRegex.test(value)) {
|
||||
errors.email = intl.formatMessage(messages['email.invalid.format.error']);
|
||||
this.setState({
|
||||
suggestedServiceLevelDomain: '',
|
||||
suggestedSldMessage: '',
|
||||
borderClass: '',
|
||||
suggestedTopLevelDomain: '',
|
||||
suggestedTldMessage: '',
|
||||
});
|
||||
} else if (emailRegex.test(value) && !this.state.skipEmailValidation) {
|
||||
errors.email = '';
|
||||
let emailLexemes = value.split('@');
|
||||
let domainLexemes = emailLexemes[1].split('.');
|
||||
const serviceProvider = domainLexemes.slice(-2)[0];
|
||||
const topLevelDomain = domainLexemes.slice(-1)[0];
|
||||
|
||||
if (DEFAULT_TOP_LEVEL_DOMAINS.indexOf(topLevelDomain) < 0) {
|
||||
let suggestedTld = closest(topLevelDomain, DEFAULT_TOP_LEVEL_DOMAINS);
|
||||
suggestedTld = `${emailLexemes[0]}@${domainLexemes[0]}.${suggestedTld}`;
|
||||
errors.email = intl.formatMessage(messages['email.invalid.format.error']);
|
||||
|
||||
this.setState({
|
||||
suggestedTopLevelDomain: suggestedTld,
|
||||
suggestedTldMessage: intl.formatMessage(messages['did.you.mean.alert.text']),
|
||||
suggestedServiceLevelDomain: '',
|
||||
suggestedSldMessage: '',
|
||||
borderClass: '',
|
||||
skipEmailValidation: false,
|
||||
});
|
||||
break;
|
||||
} else {
|
||||
this.setState({ suggestedTopLevelDomain: '', suggestedTldMessage: '' });
|
||||
}
|
||||
|
||||
if (DEFAULT_SERVICE_PROVIDER_DOMAINS.indexOf(serviceProvider) < 0) {
|
||||
let suggestedSld = closest(serviceProvider, DEFAULT_SERVICE_PROVIDER_DOMAINS);
|
||||
suggestedSld = `${emailLexemes[0]}@${suggestedSld}.${domainLexemes[1]}`;
|
||||
errors.email = '';
|
||||
|
||||
this.setState({
|
||||
suggestedServiceLevelDomain: suggestedSld,
|
||||
suggestedSldMessage: intl.formatMessage(messages['did.you.mean.alert.text']),
|
||||
borderClass: 'yellow-border',
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
suggestedServiceLevelDomain: '',
|
||||
suggestedSldMessage: '',
|
||||
borderClass: '',
|
||||
});
|
||||
}
|
||||
emailLexemes = '';
|
||||
domainLexemes = '';
|
||||
if (payload && statusCode !== 403) {
|
||||
this.props.fetchRealtimeValidations(payload);
|
||||
}
|
||||
} else if (payload && statusCode !== 403) {
|
||||
this.props.fetchRealtimeValidations(payload);
|
||||
} else {
|
||||
errors.email = '';
|
||||
}
|
||||
break;
|
||||
case 'name':
|
||||
if (!value) {
|
||||
errors.name = intl.formatMessage(messages['empty.name.field.error']);
|
||||
} else {
|
||||
errors.name = '';
|
||||
}
|
||||
break;
|
||||
case 'username':
|
||||
if (!value || value.length <= 1 || value.length > 30) {
|
||||
errors.username = intl.formatMessage(messages['username.validation.message']);
|
||||
} else if (!value.match(/^[a-zA-Z0-9_-]*$/i)) {
|
||||
errors.username = intl.formatMessage(messages['username.format.validation.message']);
|
||||
} else if (payload && statusCode !== 403) {
|
||||
this.props.fetchRealtimeValidations(payload);
|
||||
} else {
|
||||
errors.username = '';
|
||||
}
|
||||
break;
|
||||
case 'password':
|
||||
if (!value || !LETTER_REGEX.test(value) || !NUMBER_REGEX.test(value) || value.length < 8) {
|
||||
errors.password = intl.formatMessage(messages['password.validation.message']);
|
||||
} else if (payload && statusCode !== 403) {
|
||||
this.props.fetchRealtimeValidations(payload);
|
||||
} else {
|
||||
errors.password = '';
|
||||
}
|
||||
break;
|
||||
case 'country':
|
||||
if (!value) {
|
||||
errors.country = intl.formatMessage(messages['empty.country.field.error']);
|
||||
} else {
|
||||
errors.country = '';
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
this.setState({ errors });
|
||||
return errors;
|
||||
}
|
||||
|
||||
handleOnClose() {
|
||||
const { errors } = this.state;
|
||||
errors.email = '';
|
||||
this.setState({
|
||||
errors,
|
||||
suggestedTopLevelDomain: '',
|
||||
skipEmailValidation: true,
|
||||
suggestedTldMessage: '',
|
||||
});
|
||||
}
|
||||
|
||||
renderEmailFeedback() {
|
||||
if (this.state.suggestedTopLevelDomain) {
|
||||
return (
|
||||
<Alert variant="danger" onClose={this.handleOnClose} dismissible className="pb-2 pt-2 mt-2" icon={Error}>
|
||||
<p className="mb-0">{this.state.suggestedTldMessage}
|
||||
<Alert.Link
|
||||
href="#"
|
||||
name="email"
|
||||
className="email-top-domain-suggestion-alert-link"
|
||||
onClick={e => { e.preventDefault(); this.handleSuggestionClick(e, this.state.suggestedTopLevelDomain); }}
|
||||
>{this.state.suggestedTopLevelDomain}
|
||||
</Alert.Link>?
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (this.state.suggestedServiceLevelDomain) {
|
||||
return (
|
||||
<span className="one-rem-font">{this.state.suggestedSldMessage}
|
||||
<Alert.Link
|
||||
href="#"
|
||||
name="email"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
this.handleSuggestionClick(e, this.state.suggestedServiceLevelDomain);
|
||||
}}
|
||||
>{this.state.suggestedServiceLevelDomain}
|
||||
</Alert.Link>?
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
renderThirdPartyAuth(providers, secondaryProviders, currentProvider, thirdPartyAuthApiStatus, intl) {
|
||||
const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider;
|
||||
const isSocialAuthActive = !!providers.length && !currentProvider;
|
||||
const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN;
|
||||
|
||||
return (
|
||||
<>
|
||||
{((isEnterpriseLoginDisabled && isInstitutionAuthActive) || isSocialAuthActive) && (
|
||||
<div className="mt-4 mb-3 h4">
|
||||
{intl.formatMessage(messages['registration.other.options.heading'])}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{thirdPartyAuthApiStatus === PENDING_STATE ? (
|
||||
<Skeleton className="tpa-skeleton" height={36} count={2} />
|
||||
) : (
|
||||
<>
|
||||
{(isEnterpriseLoginDisabled && isInstitutionAuthActive) && (
|
||||
<RenderInstitutionButton
|
||||
onSubmitHandler={this.props.handleInstitutionLogin}
|
||||
buttonTitle={intl.formatMessage(messages['register.institution.login.button'])}
|
||||
/>
|
||||
)}
|
||||
{isSocialAuthActive && (
|
||||
<div className="row m-0">
|
||||
<SocialAuthProviders socialAuthProviders={providers} referrer={REGISTER_PAGE} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderForm(currentProvider,
|
||||
providers,
|
||||
secondaryProviders,
|
||||
thirdPartyAuthApiStatus,
|
||||
finishAuthUrl,
|
||||
submitState,
|
||||
intl) {
|
||||
if (this.props.institutionLogin) {
|
||||
return (
|
||||
<InstitutionLogistration
|
||||
secondaryProviders={this.props.thirdPartyAuthContext.secondaryProviders}
|
||||
headingTitle={intl.formatMessage(messages['register.institution.login.page.title'])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.registrationResult.success) {
|
||||
setSurveyCookie('register');
|
||||
window.optimizely = window.optimizely || [];
|
||||
|
||||
// Fire optimizely events
|
||||
window.optimizely.push({
|
||||
type: 'event',
|
||||
eventName: 'VAN-504-user-registered',
|
||||
});
|
||||
|
||||
if (window.optimizelyExperimentName !== 'VAN-504-PP-Exp') {
|
||||
window.optimizely.push({
|
||||
type: 'event',
|
||||
eventName: 'VAN-504-conversion',
|
||||
});
|
||||
['yearOfBirth', 'gender', 'levelOfEducation'].forEach(fieldName => {
|
||||
if (this.state[fieldName]) {
|
||||
window.optimizely.push({
|
||||
type: 'event',
|
||||
eventName: `van_504_${fieldName}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages['register.page.title'],
|
||||
{ siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
</Helmet>
|
||||
<RedirectLogistration
|
||||
success={this.props.registrationResult.success}
|
||||
redirectUrl={this.props.registrationResult.redirectUrl}
|
||||
finishAuthUrl={finishAuthUrl}
|
||||
redirectToWelcomePage={window.optimizelyExperimentName === 'VAN-504-PP-Exp'}
|
||||
/>
|
||||
<div className="mw-xs mt-3">
|
||||
{this.state.errorCode ? (
|
||||
<RegistrationFailure
|
||||
errorCode={this.state.errorCode}
|
||||
failureCount={this.state.failureCount}
|
||||
context={{ provider: currentProvider }}
|
||||
/>
|
||||
) : null}
|
||||
{currentProvider && (
|
||||
<>
|
||||
<ThirdPartyAuthAlert
|
||||
currentProvider={currentProvider}
|
||||
platformName={this.props.thirdPartyAuthContext.platformName}
|
||||
referrer={REGISTER_PAGE}
|
||||
/>
|
||||
<h4 className="mt-4 mb-4">{intl.formatMessage(messages['registration.using.tpa.form.heading'])}</h4>
|
||||
</>
|
||||
)}
|
||||
<Form>
|
||||
<FormGroup
|
||||
name="name"
|
||||
value={this.state.name}
|
||||
handleBlur={this.handleOnBlur}
|
||||
handleChange={this.handleOnChange}
|
||||
handleFocus={this.handleOnFocus}
|
||||
errorMessage={this.state.errors.name}
|
||||
helpText={[intl.formatMessage(messages['help.text.name'])]}
|
||||
floatingLabel={intl.formatMessage(messages['registration.fullname.label'])}
|
||||
/>
|
||||
<UsernameField
|
||||
name="username"
|
||||
value={this.state.username}
|
||||
handleBlur={this.handleOnBlur}
|
||||
handleChange={this.handleOnChange}
|
||||
handleFocus={this.handleOnFocus}
|
||||
errorMessage={this.state.errors.username}
|
||||
helpText={[intl.formatMessage(messages['help.text.username.1']), intl.formatMessage(messages['help.text.username.2'])]}
|
||||
floatingLabel={intl.formatMessage(messages['registration.username.label'])}
|
||||
handleSuggestionClick={this.handleSuggestionClick}
|
||||
usernameSuggestions={this.props.usernameSuggestions}
|
||||
/>
|
||||
<FormGroup
|
||||
name="email"
|
||||
value={this.state.email}
|
||||
handleBlur={this.handleOnBlur}
|
||||
handleChange={this.handleOnChange}
|
||||
errorMessage={this.state.errors.email}
|
||||
handleFocus={this.handleOnFocus}
|
||||
helpText={[intl.formatMessage(messages['help.text.email'])]}
|
||||
floatingLabel={intl.formatMessage(messages['registration.email.label'])}
|
||||
borderClass={this.state.borderClass}
|
||||
>
|
||||
{this.renderEmailFeedback()}
|
||||
</FormGroup>
|
||||
|
||||
{!currentProvider && (
|
||||
<PasswordField
|
||||
name="password"
|
||||
value={this.state.password}
|
||||
handleBlur={this.handleOnBlur}
|
||||
handleChange={this.handleOnChange}
|
||||
handleFocus={this.handleOnFocus}
|
||||
errorMessage={this.state.errors.password}
|
||||
floatingLabel={intl.formatMessage(messages['registration.password.label'])}
|
||||
/>
|
||||
)}
|
||||
<CountryDropdown
|
||||
name="country"
|
||||
floatingLabel={intl.formatMessage(messages['registration.country.label'])}
|
||||
options={getCountryList(getLocale())}
|
||||
valueKey="code"
|
||||
displayValueKey="name"
|
||||
value={this.state.country}
|
||||
handleBlur={this.handleOnBlur}
|
||||
handleFocus={this.handleOnFocus}
|
||||
errorMessage={intl.formatMessage(messages['empty.country.field.error'])}
|
||||
handleChange={(value) => this.setState({ country: value })}
|
||||
errorCode={this.state.errorCode}
|
||||
/>
|
||||
<div id="honor-code" className="micro text-muted mt-4">
|
||||
<FormattedMessage
|
||||
id="register.page.terms.of.service.and.honor.code"
|
||||
defaultMessage="By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each
|
||||
Member process your personal data in accordance with the {privacyPolicy}."
|
||||
description="Text that appears on registration form stating honor code and privacy policy"
|
||||
values={{
|
||||
platformName: getConfig().SITE_NAME,
|
||||
tosAndHonorCode: (
|
||||
<Hyperlink variant="muted" destination={getConfig().TOS_AND_HONOR_CODE || '#'} target="_blank">
|
||||
{intl.formatMessage(messages['terms.of.service.and.honor.code'])}
|
||||
</Hyperlink>
|
||||
),
|
||||
privacyPolicy: (
|
||||
<Hyperlink variant="muted" destination={getConfig().PRIVACY_POLICY || '#'} target="_blank">
|
||||
{intl.formatMessage(messages['privacy.policy'])}
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{getConfig().REGISTRATION_OPTIONAL_FIELDS && this.state.optimizelyExperimentName !== 'progressive_profiling_phase1' ? (
|
||||
<Form.Group className="mb-0 mt-2 small">
|
||||
<Form.Check
|
||||
id="optional-field-checkbox"
|
||||
type="checkbox"
|
||||
name="optionalFields"
|
||||
value={this.state.showOptionalField}
|
||||
onClick={this.handleOnChange}
|
||||
onChange={this.handleOnChange}
|
||||
label={intl.formatMessage(messages['support.education.research'])}
|
||||
/>
|
||||
</Form.Group>
|
||||
) : null}
|
||||
{ this.state.showOptionalField ? this.getOptionalFields() : null }
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
variant="brand"
|
||||
className="stateful-button-width mt-4 mb-4"
|
||||
state={submitState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['create.account.button']),
|
||||
pending: '',
|
||||
}}
|
||||
onClick={this.handleSubmit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
{this.renderThirdPartyAuth(providers,
|
||||
secondaryProviders,
|
||||
currentProvider,
|
||||
thirdPartyAuthApiStatus,
|
||||
intl)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, submitState, thirdPartyAuthApiStatus } = this.props;
|
||||
const {
|
||||
currentProvider, finishAuthUrl, providers, secondaryProviders,
|
||||
} = this.props.thirdPartyAuthContext;
|
||||
|
||||
if (this.tpaHint) {
|
||||
if (thirdPartyAuthApiStatus === PENDING_STATE) {
|
||||
return <Skeleton height={36} />;
|
||||
}
|
||||
const { provider, skipHintedLogin } = getTpaProvider(this.tpaHint, providers, secondaryProviders);
|
||||
if (skipHintedLogin) {
|
||||
window.location.href = getConfig().LMS_BASE_URL + provider.registerUrl;
|
||||
return null;
|
||||
}
|
||||
return provider ? (<EnterpriseSSO provider={provider} intl={intl} />)
|
||||
: this.renderForm(
|
||||
currentProvider,
|
||||
providers,
|
||||
secondaryProviders,
|
||||
thirdPartyAuthApiStatus,
|
||||
finishAuthUrl,
|
||||
submitState,
|
||||
intl,
|
||||
);
|
||||
}
|
||||
return this.renderForm(
|
||||
currentProvider,
|
||||
providers,
|
||||
secondaryProviders,
|
||||
thirdPartyAuthApiStatus,
|
||||
finishAuthUrl,
|
||||
submitState,
|
||||
intl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RegistrationPage.defaultProps = {
|
||||
registrationResult: null,
|
||||
registerNewUser: null,
|
||||
registrationErrorCode: null,
|
||||
submitState: DEFAULT_STATE,
|
||||
thirdPartyAuthApiStatus: 'pending',
|
||||
thirdPartyAuthContext: {
|
||||
currentProvider: null,
|
||||
finishAuthUrl: null,
|
||||
countryCode: null,
|
||||
providers: [],
|
||||
secondaryProviders: [],
|
||||
pipelineUserDetails: null,
|
||||
},
|
||||
validationDecisions: null,
|
||||
statusCode: null,
|
||||
usernameSuggestions: [],
|
||||
};
|
||||
|
||||
RegistrationPage.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
getThirdPartyAuthContext: PropTypes.func.isRequired,
|
||||
registerNewUser: PropTypes.func,
|
||||
resetRegistrationForm: PropTypes.func.isRequired,
|
||||
registrationResult: PropTypes.shape({
|
||||
redirectUrl: PropTypes.string,
|
||||
success: PropTypes.bool,
|
||||
}),
|
||||
registrationErrorCode: PropTypes.string,
|
||||
submitState: PropTypes.string,
|
||||
thirdPartyAuthApiStatus: PropTypes.string,
|
||||
thirdPartyAuthContext: PropTypes.shape({
|
||||
currentProvider: PropTypes.string,
|
||||
platformName: PropTypes.string,
|
||||
providers: PropTypes.array,
|
||||
secondaryProviders: PropTypes.array,
|
||||
finishAuthUrl: PropTypes.string,
|
||||
countryCode: PropTypes.string,
|
||||
pipelineUserDetails: PropTypes.shape({
|
||||
email: PropTypes.string,
|
||||
fullname: PropTypes.string,
|
||||
firstName: PropTypes.string,
|
||||
lastName: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
fetchRealtimeValidations: PropTypes.func.isRequired,
|
||||
validationDecisions: PropTypes.shape({
|
||||
country: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
password: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
}),
|
||||
clearUsernameSuggestions: PropTypes.func.isRequired,
|
||||
statusCode: PropTypes.number,
|
||||
usernameSuggestions: PropTypes.arrayOf(string),
|
||||
institutionLogin: PropTypes.bool.isRequired,
|
||||
handleInstitutionLogin: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const registrationResult = registrationRequestSelector(state);
|
||||
const thirdPartyAuthContext = thirdPartyAuthContextSelector(state);
|
||||
return {
|
||||
registrationErrorCode: registrationErrorSelector(state),
|
||||
submitState: state.register.submitState,
|
||||
thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus,
|
||||
registrationResult,
|
||||
thirdPartyAuthContext,
|
||||
validationDecisions: validationsSelector(state),
|
||||
statusCode: state.register.statusCode,
|
||||
usernameSuggestions: usernameSuggestionsSelector(state),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
getThirdPartyAuthContext,
|
||||
fetchRealtimeValidations,
|
||||
registerNewUser,
|
||||
resetRegistrationForm,
|
||||
clearUsernameSuggestions,
|
||||
},
|
||||
)(injectIntl(RegistrationPage));
|
||||
51
src/redesign/register/UsernameField.jsx
Normal file
51
src/redesign/register/UsernameField.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import PropTypes, { string } from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { FormGroup } from '../common-components';
|
||||
import messages from './messages';
|
||||
|
||||
const UsernameField = (props) => {
|
||||
const { intl, usernameSuggestions, errorMessage } = props;
|
||||
|
||||
return (
|
||||
<FormGroup {...props}>
|
||||
{usernameSuggestions.length > 0 && errorMessage ? (
|
||||
<div>
|
||||
<span className="text-gray username-suggestion-label">{intl.formatMessage(messages['registration.username.suggestion.label'])}</span>
|
||||
{usernameSuggestions.map((username, index) => (
|
||||
<Button
|
||||
type="button"
|
||||
name="username"
|
||||
variant="outline-dark"
|
||||
className="username-suggestion"
|
||||
key={`suggestion-${index.toString()}`}
|
||||
onClick={(e) => props.handleSuggestionClick(e, username)}
|
||||
>
|
||||
{username}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : <></>}
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
UsernameField.defaultProps = {
|
||||
usernameSuggestions: [],
|
||||
handleSuggestionClick: () => {},
|
||||
errorMessage: '',
|
||||
};
|
||||
|
||||
UsernameField.propTypes = {
|
||||
usernameSuggestions: PropTypes.arrayOf(string),
|
||||
handleSuggestionClick: PropTypes.func,
|
||||
errorMessage: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(UsernameField);
|
||||
55
src/redesign/register/data/actions.js
Normal file
55
src/redesign/register/data/actions.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { AsyncActionType } from '../../data/utils';
|
||||
|
||||
export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_NEW_USER');
|
||||
export const REGISTER_FORM_VALIDATIONS = new AsyncActionType('REGISTRATION', 'GET_FORM_VALIDATIONS');
|
||||
export const REGISTRATION_FORM = new AsyncActionType('REGISTRATION', 'REGISTRATION_FORM');
|
||||
export const REGISTER_CLEAR_USERNAME_SUGGESTIONS = 'REGISTRATION_CLEAR_USERNAME_SUGGESTIONS';
|
||||
|
||||
// Reset Form
|
||||
export const resetRegistrationForm = () => ({
|
||||
type: REGISTRATION_FORM.RESET,
|
||||
});
|
||||
|
||||
// Register
|
||||
export const registerNewUser = registrationInfo => ({
|
||||
type: REGISTER_NEW_USER.BASE,
|
||||
payload: { registrationInfo },
|
||||
});
|
||||
|
||||
export const registerNewUserBegin = () => ({
|
||||
type: REGISTER_NEW_USER.BEGIN,
|
||||
});
|
||||
|
||||
export const registerNewUserSuccess = (redirectUrl, success) => ({
|
||||
type: REGISTER_NEW_USER.SUCCESS,
|
||||
payload: { redirectUrl, success },
|
||||
|
||||
});
|
||||
|
||||
export const registerNewUserFailure = (error) => ({
|
||||
type: REGISTER_NEW_USER.FAILURE,
|
||||
payload: { ...error },
|
||||
});
|
||||
|
||||
// Realtime Field validations
|
||||
export const fetchRealtimeValidations = (formPayload) => ({
|
||||
type: REGISTER_FORM_VALIDATIONS.BASE,
|
||||
payload: { formPayload },
|
||||
});
|
||||
|
||||
export const fetchRealtimeValidationsBegin = () => ({
|
||||
type: REGISTER_FORM_VALIDATIONS.BEGIN,
|
||||
});
|
||||
|
||||
export const fetchRealtimeValidationsSuccess = (validations) => ({
|
||||
type: REGISTER_FORM_VALIDATIONS.SUCCESS,
|
||||
payload: { validations },
|
||||
});
|
||||
|
||||
export const fetchRealtimeValidationsFailure = () => ({
|
||||
type: REGISTER_FORM_VALIDATIONS.FAILURE,
|
||||
});
|
||||
|
||||
export const clearUsernameSuggestions = () => ({
|
||||
type: REGISTER_CLEAR_USERNAME_SUGGESTIONS,
|
||||
});
|
||||
35
src/redesign/register/data/constants.js
Normal file
35
src/redesign/register/data/constants.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Registration Error Codes
|
||||
export const FORBIDDEN_REQUEST = 'forbidden-request';
|
||||
export const FORM_SUBMISSION_ERROR = 'form-submission-error';
|
||||
export const INTERNAL_SERVER_ERROR = 'internal-server-error';
|
||||
export const TPA_SESSION_EXPIRED = 'tpa-session-expired';
|
||||
|
||||
export const YEAR_OF_BIRTH_OPTIONS = (() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [];
|
||||
let startYear = currentYear - 120;
|
||||
while (startYear < currentYear) {
|
||||
startYear += 1;
|
||||
|
||||
years.push({ value: startYear.toString(), label: startYear });
|
||||
}
|
||||
return years.reverse();
|
||||
})();
|
||||
|
||||
export const EDUCATION_LEVELS = [
|
||||
'',
|
||||
'p',
|
||||
'm',
|
||||
'b',
|
||||
'a',
|
||||
'hs',
|
||||
'jhs',
|
||||
'el',
|
||||
'none',
|
||||
'other',
|
||||
];
|
||||
|
||||
export const GENDER_OPTIONS = ['', 'f', 'm', 'o'];
|
||||
|
||||
// Other constants
|
||||
export const FORM_FIELDS = ['name', 'email', 'password', 'username', 'country'];
|
||||
71
src/redesign/register/data/reducers.js
Normal file
71
src/redesign/register/data/reducers.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
REGISTRATION_FORM, REGISTER_NEW_USER, REGISTER_FORM_VALIDATIONS, REGISTER_CLEAR_USERNAME_SUGGESTIONS,
|
||||
} from './actions';
|
||||
|
||||
import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants';
|
||||
|
||||
export const defaultState = {
|
||||
registrationError: {},
|
||||
registrationResult: {},
|
||||
formData: null,
|
||||
validations: null,
|
||||
statusCode: null,
|
||||
usernameSuggestions: [],
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action) => {
|
||||
switch (action.type) {
|
||||
case REGISTRATION_FORM.RESET:
|
||||
return {
|
||||
...defaultState,
|
||||
};
|
||||
case REGISTER_NEW_USER.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
submitState: PENDING_STATE,
|
||||
registrationError: {},
|
||||
};
|
||||
case REGISTER_NEW_USER.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
registrationResult: action.payload,
|
||||
};
|
||||
case REGISTER_NEW_USER.FAILURE: {
|
||||
const { usernameSuggestions } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
registrationError: { ...action.payload },
|
||||
submitState: DEFAULT_STATE,
|
||||
validations: null,
|
||||
usernameSuggestions: usernameSuggestions || state.usernameSuggestions,
|
||||
};
|
||||
}
|
||||
case REGISTER_FORM_VALIDATIONS.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
case REGISTER_FORM_VALIDATIONS.SUCCESS: {
|
||||
const { usernameSuggestions } = action.payload.validations;
|
||||
return {
|
||||
...state,
|
||||
validations: action.payload.validations,
|
||||
usernameSuggestions: usernameSuggestions || state.usernameSuggestions,
|
||||
};
|
||||
}
|
||||
case REGISTER_FORM_VALIDATIONS.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
statusCode: 403,
|
||||
validations: null,
|
||||
};
|
||||
case REGISTER_CLEAR_USERNAME_SUGGESTIONS:
|
||||
return {
|
||||
...state,
|
||||
usernameSuggestions: [],
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default reducer;
|
||||
63
src/redesign/register/data/sagas.js
Normal file
63
src/redesign/register/data/sagas.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError, logInfo } from '@edx/frontend-platform/logging';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
REGISTER_NEW_USER,
|
||||
registerNewUserBegin,
|
||||
registerNewUserFailure,
|
||||
registerNewUserSuccess,
|
||||
REGISTER_FORM_VALIDATIONS,
|
||||
fetchRealtimeValidationsBegin,
|
||||
fetchRealtimeValidationsSuccess,
|
||||
fetchRealtimeValidationsFailure,
|
||||
} from './actions';
|
||||
import { INTERNAL_SERVER_ERROR } from './constants';
|
||||
|
||||
// Services
|
||||
import { getFieldsValidations, registerRequest } from './service';
|
||||
|
||||
export function* handleNewUserRegistration(action) {
|
||||
try {
|
||||
yield put(registerNewUserBegin());
|
||||
|
||||
const { redirectUrl, success } = yield call(registerRequest, action.payload.registrationInfo);
|
||||
|
||||
yield put(registerNewUserSuccess(
|
||||
redirectUrl,
|
||||
success,
|
||||
));
|
||||
} catch (e) {
|
||||
const statusCodes = [400, 403, 409];
|
||||
if (e.response && statusCodes.includes(e.response.status)) {
|
||||
yield put(registerNewUserFailure(camelCaseObject(e.response.data)));
|
||||
logInfo(e);
|
||||
} else {
|
||||
yield put(registerNewUserFailure({ errorCode: INTERNAL_SERVER_ERROR }));
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* fetchRealtimeValidations(action) {
|
||||
try {
|
||||
yield put(fetchRealtimeValidationsBegin());
|
||||
const { fieldValidations } = yield call(getFieldsValidations, action.payload.formPayload);
|
||||
|
||||
yield put(fetchRealtimeValidationsSuccess(camelCaseObject(fieldValidations)));
|
||||
} catch (e) {
|
||||
if (e.response && e.response.status === 403) {
|
||||
yield put(fetchRealtimeValidationsFailure());
|
||||
logInfo(e);
|
||||
} else {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(REGISTER_NEW_USER.BASE, handleNewUserRegistration);
|
||||
yield takeEvery(REGISTER_FORM_VALIDATIONS.BASE, fetchRealtimeValidations);
|
||||
}
|
||||
43
src/redesign/register/data/selectors.js
Normal file
43
src/redesign/register/data/selectors.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { FORM_FIELDS } from './constants';
|
||||
|
||||
export const storeName = 'register';
|
||||
|
||||
export const registerSelector = state => ({ ...state[storeName] });
|
||||
|
||||
export const registrationRequestSelector = createSelector(
|
||||
registerSelector,
|
||||
register => register.registrationResult,
|
||||
);
|
||||
|
||||
export const registrationErrorSelector = createSelector(
|
||||
registerSelector,
|
||||
register => register.registrationError.errorCode,
|
||||
);
|
||||
|
||||
export const validationsSelector = createSelector(
|
||||
registerSelector,
|
||||
(register) => {
|
||||
const { registrationError, validations } = register;
|
||||
|
||||
if (validations) {
|
||||
return validations.validationDecisions;
|
||||
}
|
||||
|
||||
if (Object.keys(registrationError).length > 0) {
|
||||
const validationDecisions = {};
|
||||
FORM_FIELDS.forEach(field => {
|
||||
validationDecisions[field] = registrationError[field] ? registrationError[field][0].userMessage : '';
|
||||
});
|
||||
|
||||
return validationDecisions;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
export const usernameSuggestionsSelector = createSelector(
|
||||
registerSelector,
|
||||
register => register.usernameSuggestions,
|
||||
);
|
||||
45
src/redesign/register/data/service.js
Normal file
45
src/redesign/register/data/service.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getHttpClient, getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import querystring from 'querystring';
|
||||
|
||||
export async function registerRequest(registrationInformation) {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
isPublic: true,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
`${getConfig().LMS_BASE_URL}/api/user/v2/account/registration/`,
|
||||
querystring.stringify(registrationInformation),
|
||||
requestConfig,
|
||||
)
|
||||
.catch((e) => {
|
||||
throw (e);
|
||||
});
|
||||
|
||||
return {
|
||||
redirectUrl: data.redirect_url || `${getConfig().LMS_BASE_URL}/dashboard`,
|
||||
success: data.success || false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFieldsValidations(formPayload) {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getHttpClient()
|
||||
.post(
|
||||
`${getConfig().LMS_BASE_URL}/api/user/v1/validation/registration`,
|
||||
querystring.stringify(formPayload),
|
||||
requestConfig,
|
||||
)
|
||||
.catch((e) => {
|
||||
throw (e);
|
||||
});
|
||||
|
||||
return {
|
||||
fieldValidations: data,
|
||||
};
|
||||
}
|
||||
75
src/redesign/register/data/tests/reducers.test.js
Normal file
75
src/redesign/register/data/tests/reducers.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import reducer from '../reducers';
|
||||
import {
|
||||
REGISTER_CLEAR_USERNAME_SUGGESTIONS, REGISTER_FORM_VALIDATIONS, REGISTER_NEW_USER,
|
||||
} from '../actions';
|
||||
import { DEFAULT_STATE } from '../../../data/constants';
|
||||
|
||||
describe('register reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
expect(reducer(undefined, {})).toEqual(
|
||||
{
|
||||
registrationError: {},
|
||||
registrationResult: {},
|
||||
formData: null,
|
||||
validations: null,
|
||||
statusCode: null,
|
||||
usernameSuggestions: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should set username suggestions upon validation failed case', () => {
|
||||
const state = { usernameSuggestions: [] };
|
||||
const validations = { usernameSuggestions: ['test12'] };
|
||||
const action = {
|
||||
type: REGISTER_FORM_VALIDATIONS.SUCCESS,
|
||||
payload: { validations },
|
||||
};
|
||||
|
||||
expect(
|
||||
reducer(state, action),
|
||||
).toEqual(
|
||||
{
|
||||
validations,
|
||||
usernameSuggestions: validations.usernameSuggestions,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should set username suggestions upon registration error case', () => {
|
||||
const state = { usernameSuggestions: [] };
|
||||
const payload = { usernameSuggestions: ['test12'] };
|
||||
const action = {
|
||||
type: REGISTER_NEW_USER.FAILURE,
|
||||
payload,
|
||||
};
|
||||
|
||||
expect(
|
||||
reducer(state, action),
|
||||
).toEqual(
|
||||
{
|
||||
registrationError: payload,
|
||||
submitState: DEFAULT_STATE,
|
||||
usernameSuggestions: payload.usernameSuggestions,
|
||||
validations: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear username suggestions from validations state', () => {
|
||||
const state = {
|
||||
usernameSuggestions: ['test_1'],
|
||||
};
|
||||
const action = {
|
||||
type: REGISTER_CLEAR_USERNAME_SUGGESTIONS,
|
||||
};
|
||||
|
||||
expect(
|
||||
reducer(state, action),
|
||||
).toEqual(
|
||||
{
|
||||
usernameSuggestions: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
234
src/redesign/register/data/tests/sagas.test.js
Normal file
234
src/redesign/register/data/tests/sagas.test.js
Normal file
@@ -0,0 +1,234 @@
|
||||
import { runSaga } from 'redux-saga';
|
||||
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import * as actions from '../actions';
|
||||
import {
|
||||
fetchRealtimeValidations,
|
||||
handleNewUserRegistration,
|
||||
} from '../sagas';
|
||||
import * as api from '../service';
|
||||
import initializeMockLogging from '../../../../setupTest';
|
||||
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from '../constants';
|
||||
|
||||
const { loggingService } = initializeMockLogging();
|
||||
|
||||
describe('fetchRealtimeValidations', () => {
|
||||
const params = {
|
||||
payload: {
|
||||
formData: {
|
||||
email: 'test@test.com',
|
||||
username: '',
|
||||
password: 'test-password',
|
||||
name: 'test-name',
|
||||
honor_code: true,
|
||||
country: 'test-country',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
loggingService.logInfo.mockReset();
|
||||
});
|
||||
|
||||
const data = {
|
||||
validationDecisions: {
|
||||
username: 'Username must be between 2 and 30 characters long.',
|
||||
},
|
||||
};
|
||||
|
||||
it('should call service and dispatch success action', async () => {
|
||||
const getFieldsValidations = jest.spyOn(api, 'getFieldsValidations')
|
||||
.mockImplementation(() => Promise.resolve({ fieldValidations: data }));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
fetchRealtimeValidations,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(getFieldsValidations).toHaveBeenCalledTimes(1);
|
||||
expect(dispatched).toEqual([
|
||||
actions.fetchRealtimeValidationsBegin(),
|
||||
actions.fetchRealtimeValidationsSuccess(data),
|
||||
]);
|
||||
getFieldsValidations.mockClear();
|
||||
});
|
||||
|
||||
it('should call service and dispatch error action', async () => {
|
||||
const validationRatelimitResponse = {
|
||||
response: {
|
||||
status: 403,
|
||||
data: {
|
||||
detail: 'You do not have permission to perform this action.',
|
||||
},
|
||||
},
|
||||
};
|
||||
const getFieldsValidations = jest.spyOn(api, 'getFieldsValidations')
|
||||
.mockImplementation(() => Promise.reject(validationRatelimitResponse));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
fetchRealtimeValidations,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(getFieldsValidations).toHaveBeenCalledTimes(1);
|
||||
expect(loggingService.logInfo).toHaveBeenCalled();
|
||||
expect(dispatched).toEqual([
|
||||
actions.fetchRealtimeValidationsBegin(),
|
||||
actions.fetchRealtimeValidationsFailure(
|
||||
validationRatelimitResponse.response.data,
|
||||
validationRatelimitResponse.response.status,
|
||||
),
|
||||
]);
|
||||
getFieldsValidations.mockClear();
|
||||
});
|
||||
|
||||
it('should call logError on 500 server error', async () => {
|
||||
const validationRatelimitResponse = {
|
||||
response: {
|
||||
status: 500,
|
||||
data: {},
|
||||
},
|
||||
};
|
||||
const getFieldsValidations = jest.spyOn(api, 'getFieldsValidations')
|
||||
.mockImplementation(() => Promise.reject(validationRatelimitResponse));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
fetchRealtimeValidations,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(getFieldsValidations).toHaveBeenCalledTimes(1);
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
getFieldsValidations.mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleNewUserRegistration', () => {
|
||||
const params = {
|
||||
payload: {
|
||||
formData: {
|
||||
email: 'test@test.com',
|
||||
username: 'test-username',
|
||||
password: 'test-password',
|
||||
name: 'test-name',
|
||||
honor_code: true,
|
||||
country: 'test-country',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
loggingService.logError.mockReset();
|
||||
loggingService.logInfo.mockReset();
|
||||
});
|
||||
|
||||
it('should call service and dispatch success action', async () => {
|
||||
const data = { redirectUrl: '/dashboard', success: true };
|
||||
const registerRequest = jest.spyOn(api, 'registerRequest')
|
||||
.mockImplementation(() => Promise.resolve(data));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleNewUserRegistration,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(registerRequest).toHaveBeenCalledTimes(1);
|
||||
expect(dispatched).toEqual([
|
||||
actions.registerNewUserBegin(),
|
||||
actions.registerNewUserSuccess(data.redirectUrl, data.success),
|
||||
]);
|
||||
registerRequest.mockClear();
|
||||
});
|
||||
|
||||
it('should handle 500 error code', async () => {
|
||||
const registerErrorResponse = {
|
||||
response: {
|
||||
status: 500,
|
||||
data: {
|
||||
errorCode: INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const registerRequest = jest.spyOn(api, 'registerRequest').mockImplementation(() => Promise.reject(registerErrorResponse));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleNewUserRegistration,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(dispatched).toEqual([
|
||||
actions.registerNewUserBegin(),
|
||||
actions.registerNewUserFailure(camelCaseObject(registerErrorResponse.response.data)),
|
||||
]);
|
||||
registerRequest.mockClear();
|
||||
});
|
||||
|
||||
it('should call service and dispatch error action', async () => {
|
||||
const registerErrorResponse = {
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
error: 'something went wrong',
|
||||
},
|
||||
},
|
||||
};
|
||||
const registerRequest = jest.spyOn(api, 'registerRequest')
|
||||
.mockImplementation(() => Promise.reject(registerErrorResponse));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleNewUserRegistration,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(registerRequest).toHaveBeenCalledTimes(1);
|
||||
expect(loggingService.logInfo).toHaveBeenCalled();
|
||||
expect(dispatched).toEqual([
|
||||
actions.registerNewUserBegin(),
|
||||
actions.registerNewUserFailure(registerErrorResponse.response.data),
|
||||
]);
|
||||
registerRequest.mockClear();
|
||||
});
|
||||
|
||||
it('should handle rate limit error code', async () => {
|
||||
const registerErrorResponse = {
|
||||
response: {
|
||||
status: 403,
|
||||
data: {
|
||||
errorCode: FORBIDDEN_REQUEST,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const registerRequest = jest.spyOn(api, 'registerRequest')
|
||||
.mockImplementation(() => Promise.reject(registerErrorResponse));
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleNewUserRegistration,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(registerRequest).toHaveBeenCalledTimes(1);
|
||||
expect(loggingService.logInfo).toHaveBeenCalled();
|
||||
expect(dispatched).toEqual([
|
||||
actions.registerNewUserBegin(),
|
||||
actions.registerNewUserFailure(registerErrorResponse.response.data),
|
||||
]);
|
||||
registerRequest.mockClear();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user