diff --git a/.env.development b/.env.development index a1691ec6..6123dbde 100644 --- a/.env.development +++ b/.env.development @@ -23,9 +23,10 @@ AUTHN_MINIMAL_HEADER=true LOGIN_ISSUE_SUPPORT_LINK='/login-issue-support-url' TOS_AND_HONOR_CODE='http://localhost:18000/honor' PRIVACY_POLICY='http://localhost:18000/privacy' -REGISTRATION_OPTIONAL_FIELDS='' +REGISTRATION_OPTIONAL_FIELDS='gender,goals,level_of_education,year_of_birth' USER_SURVEY_COOKIE_NAME='openedx-user-survey-type' COOKIE_DOMAIN='localhost' WELCOME_PAGE_SUPPORT_LINK='http://localhost:1999/welcome' INFO_EMAIL='info@edx.org' DISABLE_ENTERPRISE_LOGIN='' +DEFAULT_DESIGN='legacy' diff --git a/Makefile b/Makefile index 08e40a08..20eea5dc 100755 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ transifex_resource = frontend-app-authn transifex_langs = "ar,fr,es_419,zh_CN" transifex_utils = ./node_modules/.bin/transifex-utils.js -i18n = ./src/i18n +i18n = ./src/legacy/i18n transifex_input = $(i18n)/transifex_input.json tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/ tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/ diff --git a/jest.config.js b/jest.config.js index 35fcbc69..49f06480 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,7 +6,10 @@ module.exports = createConfig('jest', { ], coveragePathIgnorePatterns: [ 'src/setupTest.js', - 'src/i18n', + 'src/legacy/i18n', + 'src/redesign/i18n', 'src/index.jsx', + 'src/legacy/index.jsx', + 'src/redesign/index.jsx', ], }); diff --git a/package.json b/package.json index 8599cbf5..c484e6b7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ ], "scripts": { "build": "fedx-scripts webpack", - "i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null", + "i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src/legacy --quiet > /dev/null", "is-es5": "es-check es5 ./dist/*.js", "lint": "fedx-scripts eslint --ext .js --ext .jsx .", "snapshot": "fedx-scripts jest --updateSnapshot", diff --git a/src/index.jsx b/src/index.jsx index 95935465..b062dd64 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -4,49 +4,34 @@ import 'regenerator-runtime/runtime'; import { APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, } from '@edx/frontend-platform'; -import { AppProvider, ErrorPage } from '@edx/frontend-platform/react'; +import { ErrorPage } from '@edx/frontend-platform/react'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Redirect, Route, Switch } from 'react-router-dom'; import { messages as headerMessages } from '@edx/frontend-component-header'; -import { - BaseComponent, UnAuthOnlyRoute, registerIcons, NotFoundPage, Logistration, -} from './common-components'; -import configureStore from './data/configureStore'; -import { - LOGIN_PAGE, PAGE_NOT_FOUND, REGISTER_PAGE, RESET_PAGE, PASSWORD_RESET_CONFIRM, WELCOME_PAGE, -} from './data/constants'; -import appMessages from './i18n'; -import './index.scss'; +const RedesignApp = React.lazy(() => import('./redesign/index.jsx')); +const LegacyApp = React.lazy(() => import('./legacy/index.jsx')); -import ForgotPasswordPage from './forgot-password'; -import ResetPasswordPage from './reset-password'; -import WelcomePage from './welcome'; +const redesignAppMessages = React.lazy(() => import('./redesign/i18n')); +const legacyAppMessages = React.lazy(() => import('./legacy/i18n')); -registerIcons(); +const OLD_DESIGN = 'legacy'; +const NEW_DESIGN = 'redesign'; +const DEFAULT_DESIGN = process.env.DEFAULT_DESIGN || OLD_DESIGN; +const CHOSEN_DESIGN = localStorage.getItem('DESIGN_NAME') || DEFAULT_DESIGN; +const REGISTRATION_OPTIONAL_FIELDS = CHOSEN_DESIGN === DEFAULT_DESIGN ? process.env.REGISTRATION_OPTIONAL_FIELDS : ''; + +const AppSelector = () => ( + }> + {(CHOSEN_DESIGN === OLD_DESIGN) && } + {(CHOSEN_DESIGN === NEW_DESIGN) && } + +); subscribe(APP_READY, () => { ReactDOM.render( - - - - - - - } /> - - - - - - - - - - - , + , document.getElementById('root'), ); }); @@ -64,7 +49,7 @@ initialize({ PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK || null, TOS_AND_HONOR_CODE: process.env.TOS_AND_HONOR_CODE || null, PRIVACY_POLICY: process.env.PRIVACY_POLICY || null, - REGISTRATION_OPTIONAL_FIELDS: process.env.REGISTRATION_OPTIONAL_FIELDS || '', + REGISTRATION_OPTIONAL_FIELDS, USER_SURVEY_COOKIE_NAME: process.env.USER_SURVEY_COOKIE_NAME || null, COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, WELCOME_PAGE_SUPPORT_LINK: process.env.WELCOME_PAGE_SUPPORT_LINK || null, @@ -74,7 +59,7 @@ initialize({ }, }, messages: [ - appMessages, + CHOSEN_DESIGN === DEFAULT_DESIGN ? legacyAppMessages : redesignAppMessages, headerMessages, ], }); diff --git a/src/legacy/_style.scss b/src/legacy/_style.scss new file mode 100644 index 00000000..cf084862 --- /dev/null +++ b/src/legacy/_style.scss @@ -0,0 +1,340 @@ +// ---------------------------- +// #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; + +.sr-only { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} + +.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 { + width: 500px; +} + +.btn-social { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + margin-bottom: 1rem; + font-size: 14px; + + background-color: $white; + border: 1px solid $font-blue; + width: 242px; + 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: 20px; + + .icon-image { + background-color: transparent; + max-height: 24px; + max-width: 24px; + } +} + +.tpa-container { + display: flex; + flex-direction: row; + justify-content: flex-start; + + margin: 0 !important; +} + +.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; + } +} + +.field-link { + font-weight: normal; + display: block; + color: $primary; + margin-bottom: 5px; + margin-top: 5px; + border: none; + padding: 0; + background: transparent; + box-shadow: none; + text-transform: initial; + letter-spacing: normal; + text-decoration: none; + text-shadow: none; + + &:focus, + &:hover { + color: $primary; + } +} + +.login-help { + padding-left: 14px; +} + +.opt-inline-field { + display: inline-block; + width: 50%; + + .form-control { + width: 100%; + } +} + +.opt-year-field { + padding-left: 15px; +} + +.invalid-feedback { + color: $red; +} + +.full-vertical-height { + height: 100vh; +} + +.help-links { + margin-left: -5px; +} + +#honor-code p { + margin: 0; + padding: 0; +} + +#honor-code a span { + @extend .sr-only; +} + +.mw-420 { + max-width: 420px; +} + +.mw-500 { + max-width: 500px; +} + +.mw-32em { + max-width: 32em; +} + +.h-90 { + height: 90%; +} + +.mt-10 { + margin-top: 10px; +} + +.pt-10 { + padding-top: 10px; +} + +@media (min-width: 576px) { + .reset-password-container { + width: 420px; + max-width: 420px; + } +} + +@media (min-width: 1024px) { + .mw-500 { + width: 500px; + } +} + +@media (max-width: 600px) { + .btn-social { + width: 47%; + margin-bottom: 0.75rem; + } + + .tpa-container { + justify-content: center; + } + .form-control { + width: 100%; + } +} + +@media (max-width: 450px) { + .section-heading-line { + position: relative; + text-align: center; + + &:before, + &:after { + width: 10%; + } + } +} + +.custom-select-size { + background-size: 8px 10px; +} + +.x-small-label { + font-size: 0.75rem; + font-weight: 700; +} diff --git a/src/common-components/APIFailureMessage.jsx b/src/legacy/common-components/APIFailureMessage.jsx similarity index 100% rename from src/common-components/APIFailureMessage.jsx rename to src/legacy/common-components/APIFailureMessage.jsx diff --git a/src/legacy/common-components/AuthnValidationFormGroup.jsx b/src/legacy/common-components/AuthnValidationFormGroup.jsx new file mode 100644 index 00000000..d1201c62 --- /dev/null +++ b/src/legacy/common-components/AuthnValidationFormGroup.jsx @@ -0,0 +1,166 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Form, + Input, + ValidationFormGroup, +} from '@edx/paragon'; + +const AuthnCustomValidationFormGroup = (props) => { + const { + onBlur, onChange, onClick, onFocus, + } = props; + const [showHelpText, setShowHelpText] = useState(false); + const [showLabelText, setShowLabelText] = useState(false); + + // handler code that need to be invoked via input + const onClickHandler = (e, clickCb) => { + setShowHelpText(true); + setShowLabelText(true); + if (clickCb) { + clickCb(e); + } + }; + const onBlurHandler = (e, blurCb) => { + setShowHelpText(false); + setShowLabelText(false); + if (blurCb) { + blurCb(e); + } + }; + const onChangeHandler = (e, changeCb) => { + if (changeCb) { + changeCb(e); + } + }; + const onFocusHandler = (e, focusCb) => { + if (focusCb) { + focusCb(e); + } + }; + const onOptionalHandler = (e, clickCb) => { clickCb(e); }; + + const showLabel = () => { + let className; + if (props.optionalFieldCheckbox || (!showLabelText && (props.value !== '' || props.type === 'select'))) { + className = 'sr-only'; + } else if (showLabelText) { + className = 'pt-10 x-small-label'; + } else { + className = 'pt-10 focus-out'; + } + + return ( + {props.label} + ); + }; + const showOptional = () => { + const additionalField = props.optionalFieldCheckbox ? ( + + ) : ; + return additionalField; + }; + + const inputProps = { + name: props.name, + id: props.for, + type: props.type, + value: props.value, + className: props.inputFieldStyle, + 'aria-invalid': props.ariaInvalid, + autoComplete: 'on', + }; + inputProps.onChange = (e) => onChangeHandler(e, onChange); + inputProps.onClick = (e) => onClickHandler(e, onClick); + inputProps.onBlur = (e) => onBlurHandler(e, onBlur); + inputProps.onFocus = (e) => onFocusHandler(e, onFocus); + + if (props.type === 'select') { + inputProps.options = props.selectOptions; + inputProps.className = props.value === '' ? `${props.inputFieldStyle} text-muted` : props.inputFieldStyle; + } + if (props.type === 'checkbox') { + inputProps.checked = props.isChecked; + } + + const validationGroupProps = { + for: props.for, + }; + if (!props.optionalFieldCheckbox) { + validationGroupProps.invalid = props.invalid; + validationGroupProps.invalidMessage = props.invalidMessage; + validationGroupProps.helpText = showHelpText ? props.helpText : ''; + } else { + validationGroupProps.className = props.optionalFieldCheckbox ? 'custom-control pt-10 mb-0' : ''; + } + if (props.className) { + validationGroupProps.className = props.className; + } + + return ( + + {showLabel()} + + {showOptional()} + + ); +}; + +AuthnCustomValidationFormGroup.defaultProps = { + name: '', + for: '', + label: '', + optionalFieldCheckbox: false, + type: '', + value: '', + invalid: false, + ariaInvalid: false, + invalidMessage: '', + inputFieldStyle: '', + helpText: '', + className: '', + onClick: null, + onBlur: null, + onChange: null, + onFocus: null, + isChecked: false, + checkboxMessage: '', + selectOptions: null, +}; + +AuthnCustomValidationFormGroup.propTypes = { + name: PropTypes.string, + for: PropTypes.string, + label: PropTypes.string, + type: PropTypes.string, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool, + ]), + invalid: PropTypes.bool, + ariaInvalid: PropTypes.bool, + invalidMessage: PropTypes.string, + helpText: PropTypes.string, + className: PropTypes.string, + inputFieldStyle: PropTypes.string, + isChecked: PropTypes.bool, + optionalFieldCheckbox: PropTypes.bool, + onClick: PropTypes.func, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + checkboxMessage: PropTypes.string, + selectOptions: PropTypes.arrayOf(PropTypes.shape({ + key: PropTypes.string, + value: PropTypes.string, + })), +}; + +export default AuthnCustomValidationFormGroup; diff --git a/src/legacy/common-components/ConfirmationAlert.jsx b/src/legacy/common-components/ConfirmationAlert.jsx new file mode 100644 index 00000000..5a202373 --- /dev/null +++ b/src/legacy/common-components/ConfirmationAlert.jsx @@ -0,0 +1,49 @@ +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 messages from './messages'; + +const ConfirmationAlert = (props) => { + const { email, intl } = props; + + return ( + + {intl.formatMessage(messages['forgot.password.confirmation.title'])} +

+ {email} }} + /> +

+

{intl.formatMessage(messages['forgot.password.confirmation.info'])}

+

+ + {intl.formatMessage(messages['forgot.password.confirmation.support.link'])} + + ), + }} + /> +

+
+ ); +}; + +ConfirmationAlert.propTypes = { + email: PropTypes.string.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(ConfirmationAlert); diff --git a/src/legacy/common-components/EnterpriseSSO.jsx b/src/legacy/common-components/EnterpriseSSO.jsx new file mode 100644 index 00000000..d195e4c2 --- /dev/null +++ b/src/legacy/common-components/EnterpriseSSO.jsx @@ -0,0 +1,105 @@ +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 ( +
+
+
+

Sign in

+
+

{intl.formatMessage(messages['enterprisetpa.title.heading'], { providerName: tpaProvider.name })}

+ +
+ + +
+
+
+ ); + } + return
; +}; + +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); diff --git a/src/legacy/common-components/HeaderLayout.jsx b/src/legacy/common-components/HeaderLayout.jsx new file mode 100644 index 00000000..a519b0da --- /dev/null +++ b/src/legacy/common-components/HeaderLayout.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner'; +import PropTypes from 'prop-types'; +import { getLocale } from '@edx/frontend-platform/i18n'; + +import Header from '@edx/frontend-component-header'; + +const HeaderLayout = ({ children }) => ( +
+ +
+
+ {children} +
+
+); + +HeaderLayout.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default HeaderLayout; diff --git a/src/legacy/common-components/InstitutionLogistration.jsx b/src/legacy/common-components/InstitutionLogistration.jsx new file mode 100644 index 00000000..4557ea7e --- /dev/null +++ b/src/legacy/common-components/InstitutionLogistration.jsx @@ -0,0 +1,101 @@ +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 } from '@edx/paragon'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faChevronLeft } from '@fortawesome/free-solid-svg-icons'; +import messages from './messages'; + +export const RenderInstitutionButton = props => { + const { onSubmitHandler, secondaryProviders, buttonTitle } = props; + if (secondaryProviders !== undefined && secondaryProviders.length > 0) { + return ( + + ); + } + return <>; +}; + +const InstitutionLogistration = props => { + const lmsBaseUrl = getConfig().LMS_BASE_URL; + const { + intl, + onSubmitHandler, + secondaryProviders, + headingTitle, + buttonTitle, + } = props; + + return ( + <> +
+
+
+ + + {buttonTitle} + +
+

+ {headingTitle} +

+

+ {intl.formatMessage(messages['institution.login.page.sub.heading'])} +

+
+
    + {secondaryProviders.map(provider => ( +
  • + {provider.name} +
  • + ))} +
+
+
+
+ + ); +}; + +const LogistrationDefaultProps = { + secondaryProviders: [], + buttonTitle: '', +}; +const LogistrationProps = { + onSubmitHandler: PropTypes.func.isRequired, + secondaryProviders: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string.isRequried, + loginUrl: PropTypes.string.isRequired, + })), + buttonTitle: PropTypes.string, +}; + +RenderInstitutionButton.propTypes = { + ...LogistrationProps, +}; +RenderInstitutionButton.defaultProps = { + ...LogistrationDefaultProps, +}; + +InstitutionLogistration.propTypes = { + ...LogistrationProps, + intl: intlShape.isRequired, + headingTitle: PropTypes.string, +}; +InstitutionLogistration.defaultProps = { + ...LogistrationDefaultProps, + headingTitle: '', +}; + +export default injectIntl(InstitutionLogistration); diff --git a/src/common-components/NotFoundPage.jsx b/src/legacy/common-components/NotFoundPage.jsx similarity index 100% rename from src/common-components/NotFoundPage.jsx rename to src/legacy/common-components/NotFoundPage.jsx diff --git a/src/common-components/RedirectLogistration.jsx b/src/legacy/common-components/RedirectLogistration.jsx similarity index 100% rename from src/common-components/RedirectLogistration.jsx rename to src/legacy/common-components/RedirectLogistration.jsx diff --git a/src/common-components/RegisterFaIcons.jsx b/src/legacy/common-components/RegisterFaIcons.jsx similarity index 100% rename from src/common-components/RegisterFaIcons.jsx rename to src/legacy/common-components/RegisterFaIcons.jsx diff --git a/src/legacy/common-components/SocialAuthProviders.jsx b/src/legacy/common-components/SocialAuthProviders.jsx new file mode 100644 index 00000000..ad29ce51 --- /dev/null +++ b/src/legacy/common-components/SocialAuthProviders.jsx @@ -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) => ( + + )); + + 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); diff --git a/src/common-components/SwitchContent.jsx b/src/legacy/common-components/SwitchContent.jsx similarity index 100% rename from src/common-components/SwitchContent.jsx rename to src/legacy/common-components/SwitchContent.jsx diff --git a/src/legacy/common-components/ThirdPartyAuthAlert.jsx b/src/legacy/common-components/ThirdPartyAuthAlert.jsx new file mode 100644 index 00000000..0504a9f2 --- /dev/null +++ b/src/legacy/common-components/ThirdPartyAuthAlert.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Alert } from '@edx/paragon'; +import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; + +const ThirdPartyAuthAlert = (props) => { + const { currentProvider, referrer, platformName } = props; + let message; + + if (referrer === LOGIN_PAGE) { + message = ( + + ); + } else { + message = ( + + ); + } + + return { message }; +}; + +ThirdPartyAuthAlert.defaultProps = { + referrer: LOGIN_PAGE, +}; + +ThirdPartyAuthAlert.propTypes = { + currentProvider: PropTypes.string.isRequired, + platformName: PropTypes.string.isRequired, + referrer: PropTypes.string, +}; + +export default ThirdPartyAuthAlert; diff --git a/src/common-components/UnAuthOnlyRoute.jsx b/src/legacy/common-components/UnAuthOnlyRoute.jsx similarity index 100% rename from src/common-components/UnAuthOnlyRoute.jsx rename to src/legacy/common-components/UnAuthOnlyRoute.jsx diff --git a/src/common-components/data/actions.js b/src/legacy/common-components/data/actions.js similarity index 100% rename from src/common-components/data/actions.js rename to src/legacy/common-components/data/actions.js diff --git a/src/common-components/data/reducers.js b/src/legacy/common-components/data/reducers.js similarity index 100% rename from src/common-components/data/reducers.js rename to src/legacy/common-components/data/reducers.js diff --git a/src/common-components/data/sagas.js b/src/legacy/common-components/data/sagas.js similarity index 100% rename from src/common-components/data/sagas.js rename to src/legacy/common-components/data/sagas.js diff --git a/src/common-components/data/selectors.js b/src/legacy/common-components/data/selectors.js similarity index 100% rename from src/common-components/data/selectors.js rename to src/legacy/common-components/data/selectors.js diff --git a/src/legacy/common-components/data/service.js b/src/legacy/common-components/data/service.js new file mode 100644 index 00000000..566a6fb8 --- /dev/null +++ b/src/legacy/common-components/data/service.js @@ -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/third_party_auth_context`, + requestConfig, + ) + .catch((e) => { + throw (e); + }); + return { + thirdPartyAuthContext: camelCaseObject(convertKeyNames(data, { fullname: 'name' })), + }; +} diff --git a/src/common-components/data/tests/sagas.test.js b/src/legacy/common-components/data/tests/sagas.test.js similarity index 96% rename from src/common-components/data/tests/sagas.test.js rename to src/legacy/common-components/data/tests/sagas.test.js index fe0feb8a..ffbc33da 100644 --- a/src/common-components/data/tests/sagas.test.js +++ b/src/legacy/common-components/data/tests/sagas.test.js @@ -3,7 +3,7 @@ import { runSaga } from 'redux-saga'; import * as actions from '../actions'; import { fetchThirdPartyAuthContext } from '../sagas'; import * as api from '../service'; -import initializeMockLogging from '../../../setupTest'; +import initializeMockLogging from '../../../../setupTest'; const { loggingService } = initializeMockLogging(); diff --git a/src/legacy/common-components/index.jsx b/src/legacy/common-components/index.jsx new file mode 100644 index 00000000..20894220 --- /dev/null +++ b/src/legacy/common-components/index.jsx @@ -0,0 +1,14 @@ +export { default as HeaderLayout } from './HeaderLayout'; +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 AuthnValidationFormGroup } from './AuthnValidationFormGroup'; +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'; diff --git a/src/legacy/common-components/messages.jsx b/src/legacy/common-components/messages.jsx new file mode 100644 index 00000000..d667457d --- /dev/null +++ b/src/legacy/common-components/messages.jsx @@ -0,0 +1,65 @@ +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', + }, + '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', + }, +}); + +export default messages; diff --git a/src/legacy/common-components/tests/AuthnValidationFormGroup.test.jsx b/src/legacy/common-components/tests/AuthnValidationFormGroup.test.jsx new file mode 100644 index 00000000..6a2eeebb --- /dev/null +++ b/src/legacy/common-components/tests/AuthnValidationFormGroup.test.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import AuthnCustomValidationFormGroup from '../AuthnValidationFormGroup'; + +describe('AuthnCustomValidationFormGroup', () => { + let props = { + label: 'Email Label', + for: 'email', + name: 'email', + type: 'email', + value: '', + helpText: 'Email field help text', + }; + + it('should show label in place of placeholder when field is empty', () => { + const validationFormGroup = mount(); + expect(validationFormGroup.find('label').prop('className')).toEqual('pgn__form-label pt-10 focus-out'); + }); + + it('should show label on top of field when field is focused in', () => { + const validationFormGroup = mount(); + + validationFormGroup.find('input').simulate('focus'); + expect(validationFormGroup.find('label').prop('className')).toEqual('pgn__form-label pt-10 focus-out'); + }); + + it('should keep label hidden for checkbox field', () => { + props = { + ...props, + type: 'checkbox', + optionalFieldCheckbox: true, + }; + const validationFormGroup = mount(); + expect(validationFormGroup.find('label').prop('className')).toEqual('pgn__form-label sr-only'); + }); + + it('should keep label hidden when input field is not empty', () => { + props = { + ...props, + value: 'test', + }; + const validationFormGroup = mount(); + expect(validationFormGroup.find('label').prop('className')).toEqual('pgn__form-label sr-only'); + }); +}); diff --git a/src/legacy/common-components/tests/ConfirmationAlert.test.jsx b/src/legacy/common-components/tests/ConfirmationAlert.test.jsx new file mode 100644 index 00000000..b80f1dbb --- /dev/null +++ b/src/legacy/common-components/tests/ConfirmationAlert.test.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { mergeConfig } from '@edx/frontend-platform'; +import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; + +import ConfirmationAlert from '../ConfirmationAlert'; + +const IntlConfirmationAlertMessage = injectIntl(ConfirmationAlert); + +describe('ConfirmationAlert', () => { + const supportLink = 'https://support.test.com/What-if-I-did-not-receive-a-password-reset-message'; + mergeConfig({ + PASSWORD_RESET_SUPPORT_LINK: supportLink, + }); + + it('should match default confirmation message', () => { + const confirmationAlertMessage = mount( + + + , + ); + + const expectedMessage = 'Check your email' + + 'You entered test@example.com. If this email address is associated with your edX account, ' + + 'we will send a message with password recovery instructions to this email address.' + + '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.'; + + expect(confirmationAlertMessage.find('#confirmation-alert').first().text()).toEqual(expectedMessage); + expect(confirmationAlertMessage.find('#confirmation-alert').find('a').props().href).toEqual(supportLink); + }); +}); diff --git a/src/common-components/tests/SocialAuthProviders.test.jsx b/src/legacy/common-components/tests/SocialAuthProviders.test.jsx similarity index 100% rename from src/common-components/tests/SocialAuthProviders.test.jsx rename to src/legacy/common-components/tests/SocialAuthProviders.test.jsx diff --git a/src/legacy/common-components/tests/ThirdPartyAuthAlert.test.jsx b/src/legacy/common-components/tests/ThirdPartyAuthAlert.test.jsx new file mode 100644 index 00000000..548bb513 --- /dev/null +++ b/src/legacy/common-components/tests/ThirdPartyAuthAlert.test.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import ThirdPartyAuthAlert from '../ThirdPartyAuthAlert'; + +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( + + + , + ).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match register page third party auth alert message snapshot', () => { + props = { + ...props, + referrer: 'register', + }; + + const tree = renderer.create( + + + , + ).toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/common-components/tests/UnAuthOnlyRoute.test.jsx b/src/legacy/common-components/tests/UnAuthOnlyRoute.test.jsx similarity index 100% rename from src/common-components/tests/UnAuthOnlyRoute.test.jsx rename to src/legacy/common-components/tests/UnAuthOnlyRoute.test.jsx diff --git a/src/legacy/common-components/tests/__snapshots__/SocialAuthProviders.test.jsx.snap b/src/legacy/common-components/tests/__snapshots__/SocialAuthProviders.test.jsx.snap new file mode 100644 index 00000000..972437cf --- /dev/null +++ b/src/legacy/common-components/tests/__snapshots__/SocialAuthProviders.test.jsx.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SocialAuthProviders should match social auth provider with default icon snapshot 1`] = ` + +`; + +exports[`SocialAuthProviders should match social auth provider with iconClass snapshot 1`] = ` + +`; + +exports[`SocialAuthProviders should match social auth provider with iconImage snapshot 1`] = ` +Array [ + , + , +] +`; diff --git a/src/legacy/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap b/src/legacy/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap new file mode 100644 index 00000000..c38d0308 --- /dev/null +++ b/src/legacy/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ThirdPartyAuthAlert should match login page third party auth alert message snapshot 1`] = ` + +`; + +exports[`ThirdPartyAuthAlert should match register page third party auth alert message snapshot 1`] = ` + +`; diff --git a/src/data/configureStore.js b/src/legacy/data/configureStore.js similarity index 100% rename from src/data/configureStore.js rename to src/legacy/data/configureStore.js diff --git a/src/legacy/data/constants.js b/src/legacy/data/constants.js new file mode 100644 index 00000000..45f2bfb0 --- /dev/null +++ b/src/legacy/data/constants.js @@ -0,0 +1,31 @@ +// 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}\\]$'; + +// 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']; diff --git a/src/data/reducers.js b/src/legacy/data/reducers.js similarity index 100% rename from src/data/reducers.js rename to src/legacy/data/reducers.js diff --git a/src/data/sagas.js b/src/legacy/data/sagas.js similarity index 100% rename from src/data/sagas.js rename to src/legacy/data/sagas.js diff --git a/src/data/utils/cookies.js b/src/legacy/data/utils/cookies.js similarity index 100% rename from src/data/utils/cookies.js rename to src/legacy/data/utils/cookies.js diff --git a/src/data/utils/dataUtils.js b/src/legacy/data/utils/dataUtils.js similarity index 100% rename from src/data/utils/dataUtils.js rename to src/legacy/data/utils/dataUtils.js diff --git a/src/data/utils/dataUtils.test.js b/src/legacy/data/utils/dataUtils.test.js similarity index 100% rename from src/data/utils/dataUtils.test.js rename to src/legacy/data/utils/dataUtils.test.js diff --git a/src/data/utils/index.js b/src/legacy/data/utils/index.js similarity index 100% rename from src/data/utils/index.js rename to src/legacy/data/utils/index.js diff --git a/src/data/utils/reduxUtils.js b/src/legacy/data/utils/reduxUtils.js similarity index 100% rename from src/data/utils/reduxUtils.js rename to src/legacy/data/utils/reduxUtils.js diff --git a/src/data/utils/reduxUtils.test.js b/src/legacy/data/utils/reduxUtils.test.js similarity index 100% rename from src/data/utils/reduxUtils.test.js rename to src/legacy/data/utils/reduxUtils.test.js diff --git a/src/legacy/forgot-password/ForgotPasswordPage.jsx b/src/legacy/forgot-password/ForgotPasswordPage.jsx new file mode 100644 index 00000000..7d8d2c5b --- /dev/null +++ b/src/legacy/forgot-password/ForgotPasswordPage.jsx @@ -0,0 +1,158 @@ +import React, { useState } 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 } from '@edx/frontend-platform/analytics'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + Alert, + Form, + StatefulButton, +} from '@edx/paragon'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +import { forgotPassword } from './data/actions'; +import { forgotPasswordResultSelector } from './data/selectors'; +import RequestInProgressAlert from './RequestInProgressAlert'; + +import messages from './messages'; +import { + AuthnValidationFormGroup, +} from '../common-components'; +import APIFailureMessage from '../common-components/APIFailureMessage'; +import { INTERNAL_SERVER_ERROR, LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants'; +import LoginHelpLinks from '../login/LoginHelpLinks'; +import { updatePathWithQueryParams, windowScrollTo } from '../data/utils'; + +const ForgotPasswordPage = (props) => { + const { intl, status } = props; + + const platformName = getConfig().SITE_NAME; + const regex = new RegExp(VALID_EMAIL_REGEX, 'i'); + const [validationError, setValidationError] = useState(''); + + const getErrorMessage = (errors) => { + const header = intl.formatMessage(messages['forgot.password.request.server.error']); + if (errors.email) { + return ( + + {header} +
  • {errors.email}
+
+ ); + } + if (status === INTERNAL_SERVER_ERROR) { + return ; + } + return status === 'forbidden' ? : null; + }; + + 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; + }; + + sendPageEvent('login_and_registration', 'reset'); + + return ( + { + 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, + }) => ( + <> + + {intl.formatMessage(messages['forgot.password.page.title'], + { siteName: getConfig().SITE_NAME })} + + + {status === 'complete' ? : null} +
+
+
+ { getErrorMessage(errors) } +

+ {intl.formatMessage(messages['forgot.password.page.heading'])} +

+

+ {intl.formatMessage(messages['forgot.password.page.instructions'])} +

+ getValidationMessage(values.email)} + onChange={e => setFieldValue('email', e.target.value)} + helpText={intl.formatMessage(messages['forgot.password.email.help.text'], { platformName })} + className="mb-0 w-100" + inputFieldStyle="border-gray-600" + /> + + }} + onClick={handleSubmit} + onMouseDown={(e) => e.preventDefault()} + /> + +
+
+ + )} +
+ ); +}; + +ForgotPasswordPage.propTypes = { + intl: intlShape.isRequired, + forgotPassword: PropTypes.func.isRequired, + status: PropTypes.string, +}; + +ForgotPasswordPage.defaultProps = { + status: null, +}; + +export default connect( + forgotPasswordResultSelector, + { + forgotPassword, + }, +)(injectIntl(ForgotPasswordPage)); diff --git a/src/legacy/forgot-password/RequestInProgressAlert.jsx b/src/legacy/forgot-password/RequestInProgressAlert.jsx new file mode 100644 index 00000000..7a69cd45 --- /dev/null +++ b/src/legacy/forgot-password/RequestInProgressAlert.jsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Alert } from '@edx/paragon'; + +import messages from './messages'; + +const RequestInProgressAlert = (props) => { + const { intl } = props; + + return ( + + {intl.formatMessage(messages['forgot.password.error.message.title'])} +
    +
  • {intl.formatMessage(messages['forgot.password.request.in.progress.message'])}
  • +
+
+ ); +}; + +RequestInProgressAlert.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(RequestInProgressAlert); diff --git a/src/forgot-password/data/actions.js b/src/legacy/forgot-password/data/actions.js similarity index 100% rename from src/forgot-password/data/actions.js rename to src/legacy/forgot-password/data/actions.js diff --git a/src/legacy/forgot-password/data/reducers.js b/src/legacy/forgot-password/data/reducers.js new file mode 100644 index 00000000..3dfa9b43 --- /dev/null +++ b/src/legacy/forgot-password/data/reducers.js @@ -0,0 +1,35 @@ +import { FORGOT_PASSWORD } from './actions'; +import { INTERNAL_SERVER_ERROR } from '../../data/constants'; + +export const defaultState = { + status: null, +}; + +const reducer = (state = defaultState, action = null) => { + if (action !== null) { + switch (action.type) { + case FORGOT_PASSWORD.BEGIN: + return { + status: 'pending', + }; + 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, + }; + default: + return state; + } + } + return state; +}; + +export default reducer; diff --git a/src/forgot-password/data/sagas.js b/src/legacy/forgot-password/data/sagas.js similarity index 100% rename from src/forgot-password/data/sagas.js rename to src/legacy/forgot-password/data/sagas.js diff --git a/src/forgot-password/data/selectors.js b/src/legacy/forgot-password/data/selectors.js similarity index 100% rename from src/forgot-password/data/selectors.js rename to src/legacy/forgot-password/data/selectors.js diff --git a/src/forgot-password/data/service.js b/src/legacy/forgot-password/data/service.js similarity index 100% rename from src/forgot-password/data/service.js rename to src/legacy/forgot-password/data/service.js diff --git a/src/forgot-password/data/tests/sagas.test.js b/src/legacy/forgot-password/data/tests/sagas.test.js similarity index 96% rename from src/forgot-password/data/tests/sagas.test.js rename to src/legacy/forgot-password/data/tests/sagas.test.js index d3e85654..9e4de10e 100644 --- a/src/forgot-password/data/tests/sagas.test.js +++ b/src/legacy/forgot-password/data/tests/sagas.test.js @@ -3,7 +3,7 @@ import { runSaga } from 'redux-saga'; import * as actions from '../actions'; import { handleForgotPassword } from '../sagas'; import * as api from '../service'; -import initializeMockLogging from '../../../setupTest'; +import initializeMockLogging from '../../../../setupTest'; const { loggingService } = initializeMockLogging(); diff --git a/src/forgot-password/index.js b/src/legacy/forgot-password/index.js similarity index 100% rename from src/forgot-password/index.js rename to src/legacy/forgot-password/index.js diff --git a/src/legacy/forgot-password/messages.js b/src/legacy/forgot-password/messages.js new file mode 100644 index 00000000..8c33acc5 --- /dev/null +++ b/src/legacy/forgot-password/messages.js @@ -0,0 +1,70 @@ +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: 'Password assistance', + description: 'The page heading for the forgot password page.', + }, + 'forgot.password.page.instructions': { + id: 'forgot.password.page.instructions', + defaultMessage: 'Please enter your log-in or recovery email address below and we will send you an email with instructions.', + description: 'Instructions message for forgot password page.', + }, + 'forgot.password.page.invalid.email.message': { + id: 'forgot.password.page.invalid.email.message', + defaultMessage: "The email address you've provided isn't formatted correctly.", + description: 'Invalid email address message for the forgot password page.', + }, + '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: 'Recover my password', + description: 'Submit button text for the forgot password page.', + }, + 'forgot.password.request.server.error': { + id: 'forgot.password.request.server.error', + defaultMessage: 'We couldn’t send the password recovery email.', + 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: 'Please 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', + }, +}); +export default messages; diff --git a/src/legacy/forgot-password/tests/ForgotPasswordPage.test.jsx b/src/legacy/forgot-password/tests/ForgotPasswordPage.test.jsx new file mode 100644 index 00000000..5b83b0fb --- /dev/null +++ b/src/legacy/forgot-password/tests/ForgotPasswordPage.test.jsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import renderer from 'react-test-renderer'; +import { mount } from 'enzyme'; +import configureStore from 'redux-mock-store'; +import { createMemoryHistory } from 'history'; +import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; +import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner'; +import * as analytics from '@edx/frontend-platform/analytics'; + +import ForgotPasswordPage from '../ForgotPasswordPage'; +import { INTERNAL_SERVER_ERROR } from '../../data/constants'; + +jest.mock('@edx/frontend-platform/analytics'); + +analytics.sendPageEvent = jest.fn(); + +const IntlForgotPasswordPage = injectIntl(ForgotPasswordPage); +const mockStore = configureStore(); +const history = createMemoryHistory(); + +describe('ForgotPasswordPage', () => { + let props = {}; + let store = {}; + + const reduxWrapper = children => ( + + {children} + + ); + + beforeEach(() => { + store = mockStore(); + props = { + forgotPassword: jest.fn(), + status: null, + }; + }); + + it('should match default section snapshot', () => { + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match forbidden section snapshot', () => { + props = { + ...props, + status: 'forbidden', + }; + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match pending section snapshot', () => { + props = { + ...props, + status: 'pending', + }; + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match success section snapshot', () => { + props = { + ...props, + status: 'complete', + }; + renderer.create( + reduxWrapper( + + + , + ), + ); + expect(history.location.pathname).toEqual('/login'); + }); + + it('should display need other help signing in button', () => { + const wrapper = mount(reduxWrapper()); + expect(wrapper.find('button.field-link').first().text()).toEqual('Need other help signing in?'); + }); + + it('should display email validation error message', async () => { + const validationMessage = "We couldn’t send the password recovery email.The email address you've provided isn't formatted correctly."; + const wrapper = mount(reduxWrapper()); + + wrapper.find('input#forgot-password-input').simulate( + 'change', { target: { value: 'invalid-email', name: 'email' } }, + ); + await act(async () => { await wrapper.find('button.btn-primary').simulate('click'); }); + wrapper.update(); + + expect(wrapper.find('.alert-danger').text()).toEqual(validationMessage); + }); + + it('should show alert on server error', () => { + props = { + ...props, + status: INTERNAL_SERVER_ERROR, + }; + const expectedMessage = 'We couldn’t send the password recovery email.' + + 'An error has occurred. Try refreshing the page, or check your internet connection.'; + const wrapper = mount(reduxWrapper()); + + expect(wrapper.find('#internal-server-error').first().text()).toEqual(expectedMessage); + }); + + it('should display empty email validation message', async () => { + const validationMessage = 'We couldn’t send the password recovery email.Please enter your email.'; + const forgotPasswordPage = mount(reduxWrapper()); + + await act(async () => { await forgotPasswordPage.find('button.btn-primary').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()); + expect(forgotPasswordPage.find('.alert-danger').text()).toEqual(rateLimitMessage); + }); + + it('should not display any error message on change event', () => { + const forgotPasswordPage = mount(reduxWrapper()); + + const emailInput = forgotPasswordPage.find('input#forgot-password-input'); + 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 = 'Please enter your email.'; + const forgotPasswordPage = mount(reduxWrapper()); + const emailInput = forgotPasswordPage.find('input#forgot-password-input'); + + await act(async () => { + await emailInput.simulate('blur', { target: { value: '', name: 'email' } }); + }); + + forgotPasswordPage.update(); + expect(forgotPasswordPage.find('#forgot-password-input-invalid-feedback').text()).toEqual(validationMessage); + }); + + it('check cookie rendered', () => { + const forgotPage = mount(reduxWrapper()); + expect(forgotPage.find()).toBeTruthy(); + }); +}); diff --git a/src/legacy/forgot-password/tests/__snapshots__/ForgotPasswordPage.test.jsx.snap b/src/legacy/forgot-password/tests/__snapshots__/ForgotPasswordPage.test.jsx.snap new file mode 100644 index 00000000..b6581961 --- /dev/null +++ b/src/legacy/forgot-password/tests/__snapshots__/ForgotPasswordPage.test.jsx.snap @@ -0,0 +1,369 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ForgotPasswordPage should match default section snapshot 1`] = ` +
+
+
+

+ Password assistance +

+

+ Please enter your log-in or recovery email address below and we will send you an email with instructions. +

+
+ + + +
+ +
+
+
+ + +
+
+`; + +exports[`ForgotPasswordPage should match forbidden section snapshot 1`] = ` +
+
+
+
+
+
+ An error occurred. +
+
    +
  • + Your previous request is in progress, please try again in a few moments. +
  • +
+
+
+

+ Password assistance +

+

+ Please enter your log-in or recovery email address below and we will send you an email with instructions. +

+
+ + + +
+ +
+
+
+ + +
+
+`; + +exports[`ForgotPasswordPage should match pending section snapshot 1`] = ` +
+
+
+

+ Password assistance +

+

+ Please enter your log-in or recovery email address below and we will send you an email with instructions. +

+
+ + + +
+ +
+
+
+ + +
+
+`; diff --git a/src/i18n/index.jsx b/src/legacy/i18n/index.jsx similarity index 100% rename from src/i18n/index.jsx rename to src/legacy/i18n/index.jsx diff --git a/src/i18n/messages/ar.json b/src/legacy/i18n/messages/ar.json similarity index 100% rename from src/i18n/messages/ar.json rename to src/legacy/i18n/messages/ar.json diff --git a/src/i18n/messages/ca.json b/src/legacy/i18n/messages/ca.json similarity index 100% rename from src/i18n/messages/ca.json rename to src/legacy/i18n/messages/ca.json diff --git a/src/i18n/messages/es_419.json b/src/legacy/i18n/messages/es_419.json similarity index 100% rename from src/i18n/messages/es_419.json rename to src/legacy/i18n/messages/es_419.json diff --git a/src/i18n/messages/fr.json b/src/legacy/i18n/messages/fr.json similarity index 100% rename from src/i18n/messages/fr.json rename to src/legacy/i18n/messages/fr.json diff --git a/src/i18n/messages/he.json b/src/legacy/i18n/messages/he.json similarity index 100% rename from src/i18n/messages/he.json rename to src/legacy/i18n/messages/he.json diff --git a/src/i18n/messages/id.json b/src/legacy/i18n/messages/id.json similarity index 100% rename from src/i18n/messages/id.json rename to src/legacy/i18n/messages/id.json diff --git a/src/i18n/messages/ko_kr.json b/src/legacy/i18n/messages/ko_kr.json similarity index 100% rename from src/i18n/messages/ko_kr.json rename to src/legacy/i18n/messages/ko_kr.json diff --git a/src/i18n/messages/pl.json b/src/legacy/i18n/messages/pl.json similarity index 100% rename from src/i18n/messages/pl.json rename to src/legacy/i18n/messages/pl.json diff --git a/src/i18n/messages/pt_br.json b/src/legacy/i18n/messages/pt_br.json similarity index 100% rename from src/i18n/messages/pt_br.json rename to src/legacy/i18n/messages/pt_br.json diff --git a/src/i18n/messages/ru.json b/src/legacy/i18n/messages/ru.json similarity index 100% rename from src/i18n/messages/ru.json rename to src/legacy/i18n/messages/ru.json diff --git a/src/i18n/messages/th.json b/src/legacy/i18n/messages/th.json similarity index 100% rename from src/i18n/messages/th.json rename to src/legacy/i18n/messages/th.json diff --git a/src/i18n/messages/uk.json b/src/legacy/i18n/messages/uk.json similarity index 100% rename from src/i18n/messages/uk.json rename to src/legacy/i18n/messages/uk.json diff --git a/src/i18n/messages/zh_CN.json b/src/legacy/i18n/messages/zh_CN.json similarity index 100% rename from src/i18n/messages/zh_CN.json rename to src/legacy/i18n/messages/zh_CN.json diff --git a/src/legacy/i18n/transifex_input.json b/src/legacy/i18n/transifex_input.json new file mode 100644 index 00000000..a2d67e72 --- /dev/null +++ b/src/legacy/i18n/transifex_input.json @@ -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" +} \ No newline at end of file diff --git a/src/legacy/index.jsx b/src/legacy/index.jsx new file mode 100755 index 00000000..eb48a68c --- /dev/null +++ b/src/legacy/index.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; + +import { AppProvider } from '@edx/frontend-platform/react'; + +import configureStore from './data/configureStore'; +import { RegistrationPage } from './register'; +import { LoginPage } from './login'; +import { + LOGIN_PAGE, PAGE_NOT_FOUND, REGISTER_PAGE, RESET_PAGE, PASSWORD_RESET_CONFIRM, WELCOME_PAGE, +} from './data/constants'; +import ForgotPasswordPage from './forgot-password'; +import { + HeaderLayout, UnAuthOnlyRoute, registerIcons, NotFoundPage, +} from './common-components'; +import ResetPasswordPage from './reset-password'; +import WelcomePage from './welcome'; +import './index.scss'; + +registerIcons(); + +const LegacyApp = () => ( + + + + + + + + + + + + + + + + + + +); + +export default LegacyApp; diff --git a/src/index.scss b/src/legacy/index.scss similarity index 100% rename from src/index.scss rename to src/legacy/index.scss diff --git a/src/legacy/login/AccountActivationMessage.jsx b/src/legacy/login/AccountActivationMessage.jsx new file mode 100644 index 00000000..c86cd5db --- /dev/null +++ b/src/legacy/login/AccountActivationMessage.jsx @@ -0,0 +1,63 @@ +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 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; + + 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 = ( + + {intl.formatMessage(messages['account.activation.support.link'])} + + ); + + heading = intl.formatMessage(messages['account.activation.error.message.title']); + activationMessage = ( + + ); + break; + } + default: + break; + } + + return activationMessage ? ( + + {heading && {heading}} + {activationMessage} + + ) : null; +}; + +AccountActivationMessage.propTypes = { + messageType: PropTypes.string.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(AccountActivationMessage); diff --git a/src/legacy/login/LoginFailure.jsx b/src/legacy/login/LoginFailure.jsx new file mode 100644 index 00000000..60efa5f7 --- /dev/null +++ b/src/legacy/login/LoginFailure.jsx @@ -0,0 +1,195 @@ +import React from 'react'; + +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Alert } from '@edx/paragon'; +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; + + switch (errorCode) { + case NON_COMPLIANT_PASSWORD_EXCEPTION: { + errorList = ( +
  • + {intl.formatMessage(messages['non.compliant.password.title'])}, + lineBreak:
    , + }} + /> +
  • + ); + break; + } + case FORBIDDEN_REQUEST: + errorList = ( +
  • + {intl.formatMessage(messages['login.rate.limit.reached.message'])} +
  • + ); + break; + case INACTIVE_USER: { + const contextSupportLink = typeof context.supportLink === 'string' ? context.supportLink : ''; + const supportLink = ( + + {intl.formatMessage(messages['contact.support.link'], { platformName: context.platformName })} + + ); + errorList = ( +
  • + , + email: {props.loginError.email}, + supportLink, + }} + /> +
  • + ); + break; + } + case INTERNAL_SERVER_ERROR: + errorList = ( +
  • + {intl.formatMessage(messages['internal.server.error.message'])} +
  • + ); + break; + case INVALID_FORM: + errorList = ( + <> + {context.email &&
  • {context.email}
  • } + {context.password &&
  • {context.password}
  • } + + ); + break; + case FAILED_LOGIN_ATTEMPT: { + const resetLink = ( + + {intl.formatMessage(messages['login.failed.link.text'])} + + ); + errorList = ( + <> +
  • + {intl.formatMessage(messages['login.incorrect.credentials.error'])} +
  • +
  • + {intl.formatMessage(messages['login.failed.attempt.error'], { remainingAttempts: context.remainingAttempts })} +
  • +
  • + +
  • + + ); + break; + } + case ACCOUNT_LOCKED_OUT: { + const resetLink = ( + + {intl.formatMessage(messages['login.failed.link.text'])} + + ); + errorList = ( + <> +
  • + {intl.formatMessage(messages['login.locked.out.error.message'], { lockedOutPeriod: context.lockedOutPeriod })} +
  • +
  • + +
  • + + ); + break; + } + case INCORRECT_EMAIL_PASSWORD: + errorList = ( +
  • + {intl.formatMessage(messages['login.incorrect.credentials.error'])} +
  • + ); + 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 ( +
  • + {beforeLink} + {linkText} + {afterLink} +
  • + ); + } + return
  • {error}
  • ; + }); + } + + return ( + + {intl.formatMessage(messages['login.failure.header.title'])} +
      {errorList}
    +
    + ); +}; + +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); diff --git a/src/legacy/login/LoginHelpLinks.jsx b/src/legacy/login/LoginHelpLinks.jsx new file mode 100644 index 00000000..bba14ed5 --- /dev/null +++ b/src/legacy/login/LoginHelpLinks.jsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import { Hyperlink } from '@edx/paragon'; + +import SwitchContent from '../common-components/SwitchContent'; +import { + LOGIN_PAGE, + REGISTER_PAGE, + RESET_PAGE, +} from '../data/constants'; +import messages from './messages'; +import { updatePathWithQueryParams } from '../data/utils'; + +const LoginHelpLinks = (props) => { + const { intl, page } = props; + const [showLoginHelp, setShowLoginHelpValue] = useState(false); + + const toggleLoginHelp = (e) => { + e.preventDefault(); + setShowLoginHelpValue(!showLoginHelp); + }; + + const handleForgotPasswordLinkClickEvent = () => { + sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement' }); + }; + + const forgotPasswordLink = () => ( + + {intl.formatMessage(messages['forgot.password.link'])} + + ); + + const signUpLink = () => ( + + {intl.formatMessage(messages['register.link'])} + + ); + + const loginIssueSupportURL = (config) => (config.LOGIN_ISSUE_SUPPORT_LINK + ? ( + + {intl.formatMessage(messages['other.sign.in.issues'])} + + ) + : null); + + const getHelpButtonMessage = () => { + let mid = 'need.other.help.signing.in.collapsible.menu'; + if (page === LOGIN_PAGE) { + mid = 'need.help.signing.in.collapsible.menu'; + } + + return intl.formatMessage(messages[mid]); + }; + + const renderLoginHelp = () => ( +
    + { page === LOGIN_PAGE ? forgotPasswordLink() : signUpLink() } + { loginIssueSupportURL(getConfig()) } +
    + ); + + return ( + <> + + , + }} + /> + + ); +}; + +LoginHelpLinks.propTypes = { + intl: intlShape.isRequired, + page: PropTypes.string.isRequired, +}; + +export default injectIntl(LoginHelpLinks); diff --git a/src/legacy/login/LoginPage.jsx b/src/legacy/login/LoginPage.jsx new file mode 100644 index 00000000..ffe9d1f1 --- /dev/null +++ b/src/legacy/login/LoginPage.jsx @@ -0,0 +1,377 @@ +import React from 'react'; +import Skeleton from 'react-loading-skeleton'; +import { Helmet } from 'react-helmet'; + +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { + Form, Hyperlink, StatefulButton, +} from '@edx/paragon'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +import AccountActivationMessage from './AccountActivationMessage'; +import ConfirmationAlert from '../common-components/ConfirmationAlert'; +import { loginRequest, loginRequestFailure } from './data/actions'; +import { INVALID_FORM } from './data/constants'; +import { getThirdPartyAuthContext } from '../common-components/data/actions'; +import { loginErrorSelector, loginRequestSelector } from './data/selectors'; +import { thirdPartyAuthContextSelector } from '../common-components/data/selectors'; +import LoginHelpLinks from './LoginHelpLinks'; +import LoginFailureMessage from './LoginFailure'; +import EnterpriseSSO from '../common-components/EnterpriseSSO'; +import messages from './messages'; +import { + RedirectLogistration, SocialAuthProviders, ThirdPartyAuthAlert, RenderInstitutionButton, + InstitutionLogistration, AuthnValidationFormGroup, +} from '../common-components'; +import { + DEFAULT_STATE, LOGIN_PAGE, REGISTER_PAGE, ENTERPRISE_LOGIN_URL, PENDING_STATE, VALID_EMAIL_REGEX, +} from '../data/constants'; +import { forgotPasswordResultSelector } from '../forgot-password'; +import { + getTpaHint, + getTpaProvider, + windowScrollTo, + setSurveyCookie, + getActivationStatus, + getAllPossibleQueryParam, + updatePathWithQueryParams, +} from '../data/utils'; + +class LoginPage extends React.Component { + constructor(props, context) { + super(props, context); + + sendPageEvent('login_and_registration', 'login'); + this.state = { + password: '', + email: '', + errors: { + email: '', + password: '', + }, + institutionLogin: false, + 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); + } + + getEnterPriseLoginURL() { + return getConfig().LMS_BASE_URL + ENTERPRISE_LOGIN_URL; + } + + handleInstitutionLogin = () => { + sendTrackEvent('edx.bi.institution_login_form.toggled', { category: 'user-engagement' }); + sendPageEvent('login_and_registration', 'institution_login'); + this.setState(prevState => ({ institutionLogin: !prevState.institutionLogin })); + } + + handleSubmit = (e) => { + e.preventDefault(); + this.setState({ isSubmitted: true }); + + const { email, password } = this.state; + const emailValidationError = this.validateEmail(email); + const passwordValidationError = this.validatePassword(password); + + if (emailValidationError !== '' || passwordValidationError !== '') { + this.props.loginRequestFailure({ + errorCode: INVALID_FORM, + context: { email: emailValidationError, password: passwordValidationError }, + }); + return; + } + + const payload = { + email, password, ...this.queryParams, + }; + this.props.loginRequest(payload); + } + + validateEmail(email) { + const { errors } = this.state; + const regex = new RegExp(VALID_EMAIL_REGEX, 'i'); + + if (email === '') { + errors.email = this.props.intl.formatMessage(messages['email.validation.message']); + } else if (email.length < 3) { + errors.email = this.props.intl.formatMessage(messages['email.format.validation.less.chars.message']); + } else if (!regex.test(email)) { + errors.email = this.props.intl.formatMessage(messages['email.format.validation.message']); + } else { + errors.email = ''; + } + this.setState({ errors }); + return errors.email; + } + + 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; + } + + handleCreateAccountLinkClickEvent() { + sendTrackEvent('edx.bi.register_form.toggled', { category: 'user-engagement' }); + } + + renderThirdPartyAuth(providers, secondaryProviders, currentProvider, thirdPartyAuthApiStatus, intl) { + let thirdPartyComponent = null; + if ((providers.length || secondaryProviders.length) && !currentProvider) { + thirdPartyComponent = ( + <> + +
    + +
    + + ); + } else if (thirdPartyAuthApiStatus === PENDING_STATE) { + thirdPartyComponent = ; + } return thirdPartyComponent; + } + + renderForm( + currentProvider, + providers, + secondaryProviders, + thirdPartyAuthContext, + thirdPartyAuthApiStatus, + submitState, + intl, + ) { + const { email, errors, password } = this.state; + const activationMsgType = getActivationStatus(); + if (this.state.institutionLogin) { + return ( + + ); + } + + if (this.props.loginResult.success) { + setSurveyCookie('login'); + } + + return ( + <> + + {intl.formatMessage(messages['login.page.title'], + { siteName: getConfig().SITE_NAME })} + + + +
    +
    +
    + {thirdPartyAuthContext.currentProvider + && ( + + )} + {this.props.loginError ? : null} + {submitState === DEFAULT_STATE && this.state.isSubmitted ? windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }) : null} + {activationMsgType && } + {this.props.forgotPassword.status === 'complete' && !this.props.loginError ? ( + + ) : null} +

    + {intl.formatMessage(messages['first.time.here'])} + + {intl.formatMessage(messages['create.an.account'])}. + +

    +
    +

    + {intl.formatMessage(messages['sign.in.heading'])} +

    +
    + this.setState({ email: e.target.value, isSubmitted: false })} + inputFieldStyle="border-gray-600" + /> + this.setState({ password: e.target.value, isSubmitted: false })} + inputFieldStyle="border-gray-600" + /> + + + {intl.formatMessage(messages['enterprise.login.link.text'])} + + }} + onClick={this.handleSubmit} + onMouseDown={(e) => e.preventDefault()} + /> + + {(providers.length || secondaryProviders.length || thirdPartyAuthApiStatus === PENDING_STATE) + && !currentProvider ? ( +
    +
    + {intl.formatMessage(messages['or.sign.in.with'])} +
    + ) : null} + {this.renderThirdPartyAuth(providers, secondaryProviders, currentProvider, thirdPartyAuthApiStatus, intl)} +
    +
    +
    + + ); + } + + render() { + const { + intl, submitState, thirdPartyAuthContext, thirdPartyAuthApiStatus, + } = this.props; + const { currentProvider, providers, secondaryProviders } = this.props.thirdPartyAuthContext; + + if (this.tpaHint) { + if (thirdPartyAuthApiStatus === PENDING_STATE) { + return ; + } + const { provider, skipHintedLogin } = getTpaProvider(this.tpaHint, providers, secondaryProviders); + if (skipHintedLogin) { + window.location.href = getConfig().LMS_BASE_URL + provider.loginUrl; + return null; + } + return provider ? () : 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, + 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, + loginResult: PropTypes.shape({ + redirectUrl: PropTypes.string, + success: 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, + }), +}; + +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, + }; +}; + +export default connect( + mapStateToProps, + { + getThirdPartyAuthContext, + loginRequest, + loginRequestFailure, + }, +)(injectIntl(LoginPage)); diff --git a/src/legacy/login/data/actions.js b/src/legacy/login/data/actions.js new file mode 100644 index 00000000..7d70a324 --- /dev/null +++ b/src/legacy/login/data/actions.js @@ -0,0 +1,23 @@ +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 }, +}); diff --git a/src/login/data/constants.js b/src/legacy/login/data/constants.js similarity index 100% rename from src/login/data/constants.js rename to src/legacy/login/data/constants.js diff --git a/src/legacy/login/data/reducers.js b/src/legacy/login/data/reducers.js new file mode 100644 index 00000000..04d38b96 --- /dev/null +++ b/src/legacy/login/data/reducers.js @@ -0,0 +1,33 @@ +import { LOGIN_REQUEST } from './actions'; + +import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants'; + +export const defaultState = { + loginError: null, + loginResult: {}, +}; + +const reducer = (state = defaultState, action) => { + switch (action.type) { + case LOGIN_REQUEST.BEGIN: + return { + ...state, + submitState: PENDING_STATE, + }; + case LOGIN_REQUEST.SUCCESS: + return { + ...state, + loginResult: action.payload, + }; + case LOGIN_REQUEST.FAILURE: + return { + ...state, + loginError: action.payload.loginError, + submitState: DEFAULT_STATE, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/login/data/sagas.js b/src/legacy/login/data/sagas.js similarity index 100% rename from src/login/data/sagas.js rename to src/legacy/login/data/sagas.js diff --git a/src/login/data/selectors.js b/src/legacy/login/data/selectors.js similarity index 100% rename from src/login/data/selectors.js rename to src/legacy/login/data/selectors.js diff --git a/src/legacy/login/data/service.js b/src/legacy/login/data/service.js new file mode 100644 index 00000000..24923d3c --- /dev/null +++ b/src/legacy/login/data/service.js @@ -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}/user_api/v1/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, + }; +} diff --git a/src/login/data/tests/sagas.test.js b/src/legacy/login/data/tests/sagas.test.js similarity index 98% rename from src/login/data/tests/sagas.test.js rename to src/legacy/login/data/tests/sagas.test.js index 377caf27..a7f40180 100644 --- a/src/login/data/tests/sagas.test.js +++ b/src/legacy/login/data/tests/sagas.test.js @@ -6,7 +6,7 @@ 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'; +import initializeMockLogging from '../../../../setupTest'; const { loggingService } = initializeMockLogging(); diff --git a/src/login/index.js b/src/legacy/login/index.js similarity index 100% rename from src/login/index.js rename to src/legacy/login/index.js diff --git a/src/legacy/login/messages.jsx b/src/legacy/login/messages.jsx new file mode 100644 index 00000000..d2eb4dfa --- /dev/null +++ b/src/legacy/login/messages.jsx @@ -0,0 +1,192 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'login.page.title': { + id: 'login.page.title', + defaultMessage: 'Login | {siteName}', + description: 'login page title', + }, + 'sign.in.button': { + id: 'sign.in.button', + defaultMessage: 'Sign in', + description: 'Button label that appears on login page', + }, + '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', + }, + '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: 'Use my university info', + 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', + }, + 'or.sign.in.with': { + id: 'or.sign.in.with', + defaultMessage: 'or sign in with', + description: 'gives hint about other sign in options', + }, + '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.label': { + id: 'email.label', + defaultMessage: 'Email', + description: 'Label that appears above email field', + }, + '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.link.text': { + id: 'enterprise.login.link.text', + defaultMessage: 'Sign in with your company or school', + 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: 'Please enter your email.', + description: 'Validation message that appears when email is empty', + }, + 'password.validation.message': { + id: 'password.validation.message', + defaultMessage: 'Please enter your password.', + description: 'Validation message that appears when password is empty', + }, + 'password.label': { + id: 'password.label', + defaultMessage: 'Password', + description: 'Text that appears above password field or as a placeholder', + }, + '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.failed.link.text': { + id: 'login.failed.link.text', + defaultMessage: 'here', + description: 'Link text used in failed login attempt user error message to reset password', + }, + 'login.incorrect.credentials.error': { + id: 'login.incorrect.credentials.error', + defaultMessage: 'Email or password is incorrect.', + description: 'Error message for incorrect email or password', + }, + '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', + }, +}); + +export default messages; diff --git a/src/login/tests/AccountActivationMessage.test.jsx b/src/legacy/login/tests/AccountActivationMessage.test.jsx similarity index 100% rename from src/login/tests/AccountActivationMessage.test.jsx rename to src/legacy/login/tests/AccountActivationMessage.test.jsx diff --git a/src/legacy/login/tests/LoginFailure.test.jsx b/src/legacy/login/tests/LoginFailure.test.jsx new file mode 100644 index 00000000..fc406320 --- /dev/null +++ b/src/legacy/login/tests/LoginFailure.test.jsx @@ -0,0 +1,155 @@ +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, +} 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( + + + , + ); + + 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( + + + , + ); + + 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('should match rate limit error message', () => { + props = { + loginError: { + errorCode: FORBIDDEN_REQUEST, + }, + }; + + const loginFailureMessage = mount( + + + , + ); + + 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( + + + , + ); + + 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, + context: { email: 'Please enter your email.', password: 'Please enter your password.' }, + }, + }; + + const loginFailureMessage = mount( + + + , + ); + + const expectedMessage = 'We couldn\'t sign you in.Please enter your email.Please enter your password.'; + 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( + + + , + ); + + 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 here before you try again.\n', + }, + }; + + const loginFailureMessage = mount( + + + , + ); + + 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'); + }); +}); diff --git a/src/legacy/login/tests/LoginHelpLinks.test.jsx b/src/legacy/login/tests/LoginHelpLinks.test.jsx new file mode 100644 index 00000000..e975aa1c --- /dev/null +++ b/src/legacy/login/tests/LoginHelpLinks.test.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import * as analytics from '@edx/frontend-platform/analytics'; +import { mount } from 'enzyme'; + +import LoginHelpLinks from '../LoginHelpLinks'; +import { LOGIN_PAGE } from '../../data/constants'; + +const otherSignInIssues = 'https://login-issue-support-url.com'; + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn().mockReturnValue({ LOGIN_ISSUE_SUPPORT_LINK: otherSignInIssues }), +})); + +jest.mock('@edx/frontend-platform/analytics'); +analytics.sendTrackEvent = jest.fn(); + +describe('LoginHelpLinks', () => { + let props = {}; + + const reduxWrapper = children => ( + + {children} + + ); + + it('renders help links on button click', () => { + props = { + ...props, + page: LOGIN_PAGE, + }; + const loginHelpLinks = mount(reduxWrapper()); + + expect(loginHelpLinks.find('.login-help').length).toBe(0); + loginHelpLinks.find('button').first().simulate('click'); + expect(loginHelpLinks.find('.login-help').length).toBe(1); + }); + + it('should display login page help links', () => { + props = { + ...props, + page: LOGIN_PAGE, + }; + + const wrapper = mount(reduxWrapper()); + wrapper.find('button').first().simulate('click'); + + const loginHelpLinks = wrapper.find('a'); + + expect(loginHelpLinks.at(0).prop('href')).toEqual('/reset'); + expect(loginHelpLinks.at(1).prop('href')).toEqual(otherSignInIssues); + }); + + it('should display forget password page help links', () => { + props = { + ...props, + page: 'forget-password', + }; + + const wrapper = mount(reduxWrapper()); + wrapper.find('button').first().simulate('click'); + + const loginHelpLinks = wrapper.find('a'); + + expect(loginHelpLinks.at(0).prop('href')).toEqual('/register'); + expect(loginHelpLinks.at(1).prop('href')).toEqual(otherSignInIssues); + }); +}); diff --git a/src/legacy/login/tests/LoginPage.test.jsx b/src/legacy/login/tests/LoginPage.test.jsx new file mode 100644 index 00000000..18c48c8c --- /dev/null +++ b/src/legacy/login/tests/LoginPage.test.jsx @@ -0,0 +1,523 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import renderer from 'react-test-renderer'; +import { mount } from 'enzyme'; +import configureStore from 'redux-mock-store'; + +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 LoginFailureMessage from '../LoginFailure'; +import LoginPage from '../LoginPage'; +import { loginRequest, loginRequestFailure } from '../data/actions'; +import { RenderInstitutionButton } from '../../common-components'; +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, + }); + + const initialState = { + forgotPassword: { status: null }, + login: { + loginResult: { success: false, redirectUrl: '' }, + }, + commonComponents: { + thirdPartyAuthApiStatus: null, + thirdPartyAuthContext: { + currentProvider: null, + finishAuthUrl: null, + providers: [], + secondaryProviders: [], + }, + }, + }; + + let props = {}; + let store = {}; + + const secondaryProviders = { + id: 'saml-test', + name: 'Test University', + loginUrl: '/dummy-auth', + registerUrl: '/dummy_auth', + skipHintedLogin: false, + }; + + 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 reduxWrapper = children => ( + + {children} + + ); + + beforeEach(() => { + store = mockStore(initialState); + props = { + loginRequest: jest.fn(), + }; + }); + + it('should match default section snapshot', () => { + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match pending button state snapshot', () => { + store = mockStore({ + ...initialState, + login: { + ...initialState.login, + submitState: PENDING_STATE, + }, + }); + + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match forget password alert message snapshot', () => { + store = mockStore({ + ...initialState, + forgotPassword: { status: 'complete', email: 'test@example.com' }, + }); + + const tree = renderer.create(reduxWrapper()).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match TPA provider snapshot', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], + }, + }, + }); + + const tree = renderer.create(reduxWrapper()).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should show error message', () => { + store = mockStore({ + ...initialState, + login: { + ...initialState.login, + loginError: { value: 'Email or password is incorrect.' }, + }, + }); + + const tree = renderer.create(reduxWrapper()).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should show account activation message', () => { + delete window.location; + window.location = { href: getConfig().BASE_URL.concat('/login'), search: '?account_activation_status=info' }; + + const expectedMessage = 'This account has already been activated.'; + + const loginPage = mount(reduxWrapper()); + expect(loginPage.find('#account-activation-message').find('div').first().text()).toEqual(expectedMessage); + }); + + it('should display login help button', () => { + const root = mount(reduxWrapper()); + expect(root.find('button.field-link').first().text()).toEqual('Need help signing in?'); + }); + + it('updates the error state for empty email input on form submission', () => { + const errorState = { email: 'Please enter your email.', password: '' }; + store.dispatch = jest.fn(store.dispatch); + + const loginPage = (mount(reduxWrapper())).find('LoginPage'); + + loginPage.find('input#password').simulate('change', { target: { value: 'test', name: 'password' } }); + loginPage.find('button.btn-brand').simulate('click'); + + expect(loginPage.state('errors')).toEqual(errorState); + expect(store.dispatch).toHaveBeenCalledWith( + loginRequestFailure({ errorCode: 'invalid-form', context: errorState }), + ); + }); + + it('updates the error state for invalid email; less than 3 characters on form submission', () => { + const errorState = { email: 'Email must have at least 3 characters.', password: '' }; + store.dispatch = jest.fn(store.dispatch); + + const loginPage = (mount(reduxWrapper())).find('LoginPage'); + + loginPage.find('input#password').simulate('change', { target: { value: 'test', name: 'password' } }); + loginPage.find('input#email').simulate('change', { target: { value: 'te', name: 'email' } }); + loginPage.find('button.btn-brand').simulate('click'); + + expect(loginPage.state('errors')).toEqual(errorState); + expect(store.dispatch).toHaveBeenCalledWith( + loginRequestFailure({ errorCode: 'invalid-form', context: errorState }), + ); + }); + + it('updates the error state for invalid email format validation on form submission', () => { + const errorState = { email: 'The email address you\'ve provided isn\'t formatted correctly.', password: '' }; + store.dispatch = jest.fn(store.dispatch); + + const loginPage = (mount(reduxWrapper())).find('LoginPage'); + + loginPage.find('input#password').simulate('change', { target: { value: 'test', name: 'password' } }); + loginPage.find('input#email').simulate('change', { target: { value: 'test@', name: 'email' } }); + loginPage.find('button.btn-brand').simulate('click'); + + expect(loginPage.state('errors')).toEqual(errorState); + }); + + it('updates the error state for invalid password', () => { + const errorState = { email: '', password: 'Please enter your password.' }; + store.dispatch = jest.fn(store.dispatch); + + const loginPage = (mount(reduxWrapper())).find('LoginPage'); + + loginPage.find('input#email').simulate('change', { target: { value: 'test@example.com', name: 'email' } }); + loginPage.find('button.btn-brand').simulate('click'); + + expect(loginPage.state('errors')).toEqual(errorState); + expect(store.dispatch).toHaveBeenCalledWith( + loginRequestFailure({ errorCode: 'invalid-form', context: errorState }), + ); + }); + + it('submits login request for valid email and password values', () => { + store.dispatch = jest.fn(store.dispatch); + const loginPage = (mount(reduxWrapper())).find('LoginPage'); + loginPage.find('input#email').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: 'test@example.com', password: 'password' }), + ); + }); + + it('should match url after redirection', () => { + const dasboardUrl = 'http://test.com/testing-dashboard/'; + store = mockStore({ + ...initialState, + login: { + ...initialState.login, + loginResult: { + success: true, + redirectUrl: dasboardUrl, + }, + }, + }); + delete window.location; + window.location = { href: getConfig().BASE_URL }; + renderer.create(reduxWrapper()); + expect(window.location.href).toBe(dasboardUrl); + }); + + it('should match url after TPA redirection', () => { + 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()); + expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl); + }); + + it('should redirect to enterprise selection page', () => { + const authCompleteUrl = '/auth/complete/google-oauth2/'; + const enterpriseSelectionPage = 'http://localhost:18000/enterprise/select/active/?success_url='.concat(authCompleteUrl); + store = mockStore({ + ...initialState, + login: { + ...initialState.login, + loginResult: { + success: true, + redirectUrl: enterpriseSelectionPage, + }, + }, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + finishAuthUrl: authCompleteUrl, + }, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL }; + renderer.create(reduxWrapper()); + expect(window.location.href).toBe(enterpriseSelectionPage); + }); + + it('should redirect to social auth provider url', () => { + const loginUrl = '/auth/login/apple-id/?auth_entry=login&next=/dashboard'; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [{ + ...appleProvider, + loginUrl, + }], + }, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL }; + + const loginPage = mount(reduxWrapper()); + + loginPage.find('button#oa2-apple-id').simulate('click'); + expect(window.location.href).toBe(getConfig().LMS_BASE_URL + loginUrl); + }); + + 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()); + expect(loginPage.find('#tpa-alert').find('span').text()).toEqual(expectedMessage); + }); + + it('should display institution login button', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + secondaryProviders: [secondaryProviders], + }, + }, + }); + const root = mount(reduxWrapper()); + expect(root.text().includes('Use my university info')).toBe(true); + }); + + it('should not display institution login button', () => { + const root = mount(reduxWrapper()); + expect(root.text().includes('Use my university info')).toBe(false); + }); + + it('should display institution login page', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + secondaryProviders: [secondaryProviders], + }, + }, + }); + const loginPage = mount(reduxWrapper()); + loginPage.find(RenderInstitutionButton).simulate('click', { institutionLogin: true }); + expect(loginPage.text().includes('Test University')).toBe(true); + }); + + it('send tracking event when create account link is clicked', () => { + const loginPage = mount(reduxWrapper()); + + loginPage.find('a[href*="/register"]').simulate('click'); + loginPage.update(); + expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.bi.register_form.toggled', { category: 'user-engagement' }); + }); + + it('send page event when login page is rendered', () => { + mount(reduxWrapper()); + expect(analytics.sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'login'); + }); + + it('send tracking and page events when institutional button is clicked', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + secondaryProviders: [secondaryProviders], + }, + }, + }); + const loginPage = mount(reduxWrapper()); + loginPage.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'); + }); + + it('check cookie rendered', () => { + const loginPage = mount(reduxWrapper()); + expect(loginPage.find()).toBeTruthy(); + }); + + it('form only be scrollable on submission', () => { + const loginPage = mount(reduxWrapper()); + + loginPage.find('input#password').simulate('change', { target: { value: 'test@example.com', name: 'password' } }); + loginPage.find('button.btn-brand').simulate('click'); + + expect(loginPage.find()).toBeTruthy(); + expect(loginPage.find('LoginPage').state('isSubmitted')).toEqual(true); + }); + + it('should render tpa button for tpa_hint id in primary provider', () => { + const expectedMessage = `Sign in using ${appleProvider.name}`; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL.concat('/login'), search: `?next=/dashboard&tpa_hint=${appleProvider.id}` }; + appleProvider.iconImage = null; + + const loginPage = mount(reduxWrapper()); + expect(loginPage.find(`button#${appleProvider.id}`).find('span').text()).toEqual(expectedMessage); + }); + + it('should render regular tpa button for invalid tpa_hint value', () => { + const expectedMessage = `${appleProvider.name}`; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL.concat('/login'), search: '?next=/dashboard&tpa_hint=invalid' }; + appleProvider.iconImage = null; + + const loginPage = mount(reduxWrapper()); + expect(loginPage.find(`button#${appleProvider.id}`).find('span#provider-name').text()).toEqual(expectedMessage); + }); + + it('should render tpa button for tpa_hint id in secondary provider', () => { + const expectedMessage = `Sign in using ${secondaryProviders.name}`; + 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; + + const loginPage = mount(reduxWrapper()); + expect(loginPage.find(`button#${secondaryProviders.id}`).find('span').text()).toEqual(expectedMessage); + }); + + it('should redirect to idp page if skipHinetedLogin is true', () => { + 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()); + expect(window.location.href).toEqual(getConfig().LMS_BASE_URL + secondaryProviders.loginUrl); + }); + + it('should set login survey cookie', () => { + store = mockStore({ + ...initialState, + login: { + ...initialState.login, + loginResult: { + success: true, + }, + }, + }); + + renderer.create(reduxWrapper()); + expect(document.cookie).toMatch(`${getConfig().USER_SURVEY_COOKIE_NAME}=login`); + }); +}); diff --git a/src/legacy/login/tests/__snapshots__/LoginPage.test.jsx.snap b/src/legacy/login/tests/__snapshots__/LoginPage.test.jsx.snap new file mode 100644 index 00000000..bae8b297 --- /dev/null +++ b/src/legacy/login/tests/__snapshots__/LoginPage.test.jsx.snap @@ -0,0 +1,914 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoginPage should match TPA provider snapshot 1`] = ` +
    +
    +
    +

    + First time here? + + Create an account + . + +

    +
    +

    + Sign in +

    +
    +
    + + + +
    +
    + + + +
    + +
    +
    +
    + + Sign in with your company or school + + + +
    +
    + or sign in with +
    +
    + +
    +
    +
    +
    +`; + +exports[`LoginPage should match default section snapshot 1`] = ` +
    +
    +
    +

    + First time here? + + Create an account + . + +

    +
    +

    + Sign in +

    +
    +
    + + + +
    +
    + + + +
    + +
    +
    +
    + + Sign in with your company or school + + + +
    +
    +
    +`; + +exports[`LoginPage should match forget password alert message snapshot 1`] = ` +
    +
    +
    + +

    + First time here? + + Create an account + . + +

    +
    +

    + Sign in +

    +
    +
    + + + +
    +
    + + + +
    + +
    +
    +
    + + Sign in with your company or school + + + +
    +
    +
    +`; + +exports[`LoginPage should match pending button state snapshot 1`] = ` +
    +
    +
    +

    + First time here? + + Create an account + . + +

    +
    +

    + Sign in +

    +
    +
    + + + +
    +
    + + + +
    + +
    +
    +
    + + Sign in with your company or school + + + +
    +
    +
    +`; + +exports[`LoginPage should show error message 1`] = ` +
    +
    +
    + +

    + First time here? + + Create an account + . + +

    +
    +

    + Sign in +

    +
    +
    + + + +
    +
    + + + +
    + +
    +
    +
    + + Sign in with your company or school + + + +
    +
    +
    +`; diff --git a/src/legacy/register/OptionalFields.jsx b/src/legacy/register/OptionalFields.jsx new file mode 100644 index 00000000..d20d4a30 --- /dev/null +++ b/src/legacy/register/OptionalFields.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import { EDUCATION_LEVELS, GENDER_OPTIONS, YEAR_OF_BIRTH_OPTIONS } from './data/constants'; +import messages from './messages'; + +import { AuthnValidationFormGroup } from '../common-components'; + +const OptionalFields = (props) => { + const { intl, onChangeHandler, values } = props; + + const getOptions = () => ({ + yearOfBirthOptions: [{ + value: '', + label: intl.formatMessage(messages['registration.year.of.birth.label']), + }].concat(YEAR_OF_BIRTH_OPTIONS), + educationLevelOptions: EDUCATION_LEVELS.map(key => ({ + value: key, + label: intl.formatMessage(messages[`registration.field.education.levels.${key || 'label'}`]), + })), + genderOptions: GENDER_OPTIONS.map(key => ({ + value: key, + label: intl.formatMessage(messages[`registration.field.gender.options.${key || 'label'}`]), + })), + }); + + return ( + <> + onChangeHandler('gender', e.target.value)} + selectOptions={getOptions().genderOptions} + inputFieldStyle="border-gray-600 custom-select-size" + /> + onChangeHandler('yearOfBirth', e.target.value)} + selectOptions={getOptions().yearOfBirthOptions} + inputFieldStyle="border-gray-600 custom-select-size" + /> + onChangeHandler('levelOfEducation', e.target.value)} + selectOptions={getOptions().educationLevelOptions} + inputFieldStyle="border-gray-600 custom-select-size" + /> + onChangeHandler('goals', e.target.value)} + inputFieldStyle="border-gray-600 custom-select-size" + /> + + ); +}; + +OptionalFields.propTypes = { + intl: intlShape.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); diff --git a/src/legacy/register/RegistrationFailure.jsx b/src/legacy/register/RegistrationFailure.jsx new file mode 100644 index 00000000..45edb90e --- /dev/null +++ b/src/legacy/register/RegistrationFailure.jsx @@ -0,0 +1,86 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Alert } from '@edx/paragon'; + +import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from './data/constants'; +import messages from './messages'; +import { DEFAULT_STATE, PENDING_STATE } from '../data/constants'; +import { windowScrollTo } from '../data/utils'; + +const RegistrationFailureMessage = (props) => { + const errorMessage = props.errors; + const { errorCode } = props.errors; + const userErrors = []; + + useEffect(() => { + if (props.isSubmitted && props.submitButtonState !== PENDING_STATE) { + windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }); + } + }); + + let serverError; + switch (errorCode) { + case INTERNAL_SERVER_ERROR: + serverError = ( +
  • + {props.intl.formatMessage(messages['registration.request.server.error'])} +
  • + ); + userErrors.push(serverError); + break; + case FORBIDDEN_REQUEST: + userErrors.push( + ( +
  • + {props.intl.formatMessage(messages['register.rate.limit.reached.message'])} +
  • + ), + ); + break; + default: + Object.keys(errorMessage).forEach((key) => { + if (key !== 'error_code') { + const errors = errorMessage[key]; + const suppressionClass = ['email', 'username'].includes(key) ? 'data-hj-suppress' : ''; + const errorList = errors.map((error) => ( + (error.user_message) ? ( +
  • + {error.user_message} +
  • + ) : null + )); + userErrors.push(errorList); + } + }); + } + + return ( + !userErrors.length ? null : ( + + {props.intl.formatMessage(messages['registration.request.failure.header'])} +
      {userErrors}
    +
    + ) + ); +}; + +RegistrationFailureMessage.defaultProps = { + errors: '', + submitButtonState: DEFAULT_STATE, + isSubmitted: false, +}; + +RegistrationFailureMessage.propTypes = { + errors: PropTypes.shape({ + email: PropTypes.array, + username: PropTypes.array, + errorCode: PropTypes.string, + }), + submitButtonState: PropTypes.string, + isSubmitted: PropTypes.bool, + intl: intlShape.isRequired, +}; + +export default injectIntl(RegistrationFailureMessage); diff --git a/src/legacy/register/RegistrationPage.jsx b/src/legacy/register/RegistrationPage.jsx new file mode 100644 index 00000000..11ba8ae4 --- /dev/null +++ b/src/legacy/register/RegistrationPage.jsx @@ -0,0 +1,783 @@ +import React from 'react'; + +import camelCase from 'lodash.camelcase'; +import { connect } from 'react-redux'; +import Skeleton from 'react-loading-skeleton'; +import { Helmet } from 'react-helmet'; +import PropTypes 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 { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { Form, Hyperlink, StatefulButton } from '@edx/paragon'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import { registerNewUser, fetchRealtimeValidations } from './data/actions'; +import { registrationRequestSelector } from './data/selectors'; +import messages from './messages'; +import OptionalFields from './OptionalFields'; +import RegistrationFailure from './RegistrationFailure'; + +import { + RedirectLogistration, SocialAuthProviders, ThirdPartyAuthAlert, RenderInstitutionButton, + InstitutionLogistration, AuthnValidationFormGroup, +} 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, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, VALID_EMAIL_REGEX, +} from '../data/constants'; +import { + getTpaProvider, getTpaHint, updatePathWithQueryParams, getAllPossibleQueryParam, setSurveyCookie, +} from '../data/utils'; + +class RegistrationPage extends React.Component { + constructor(props, context) { + super(props, context); + + sendPageEvent('login_and_registration', 'register'); + this.intl = props.intl; + this.queryParams = getAllPossibleQueryParam(); + this.tpaHint = getTpaHint(); + + this.state = { + email: '', + name: '', + username: '', + password: '', + country: '', + gender: '', + yearOfBirth: '', + goals: '', + levelOfEducation: '', + enableOptionalField: false, + validationAlertMessages: { + name: [{ user_message: '' }], + username: [{ user_message: '' }], + email: [{ user_message: '' }], + password: [{ user_message: '' }], + country: [{ user_message: '' }], + }, + errors: { + email: '', + name: '', + username: '', + password: '', + country: '', + }, + institutionLogin: false, + formValid: false, + startTime: Date.now(), + updateFieldErrors: false, + updateAlertErrors: false, + registrationErrorsUpdated: false, + optimizelyExperimentName: '', + }; + } + + componentDidMount() { + const payload = { ...this.queryParams }; + + if (this.tpaHint) { + payload.tpa_hint = this.tpaHint; + } + this.props.getThirdPartyAuthContext(payload); + this.getExperiments(); + } + + shouldComponentUpdate(nextProps) { + if (nextProps.statusCode !== 403 && this.props.validations !== nextProps.validations) { + const { errors } = this.state; + const { fieldName } = this.state; + const errorMsg = nextProps.validations.validation_decisions[fieldName]; + errors[fieldName] = errorMsg; + this.setState({ + errors, + }); + return false; + } + + if (this.props.thirdPartyAuthContext.pipelineUserDetails !== nextProps.thirdPartyAuthContext.pipelineUserDetails) { + this.setState({ + ...nextProps.thirdPartyAuthContext.pipelineUserDetails, + }); + return false; + } + + if (this.props.registrationError !== nextProps.registrationError) { + this.setState({ + formValid: false, + registrationErrorsUpdated: true, + }); + return false; + } + + if (this.state.registrationErrorsUpdated && this.props.registrationError === nextProps.registrationError) { + this.setState({ + formValid: false, + registrationErrorsUpdated: false, + }); + return false; + } + + return true; + } + + getExperiments = () => { + const { optimizelyExperimentName } = window; + + if (optimizelyExperimentName) { + this.setState({ optimizelyExperimentName }); + } + }; + + getCountryOptions = () => { + const { intl } = this.props; + return [{ + value: '', + label: intl.formatMessage(messages['registration.country.label']), + }].concat(getCountryList(getLocale()).map(({ code, name }) => ({ value: code, label: name }))); + } + + getOptionalFields() { + const values = {}; + const optionalFields = getConfig().REGISTRATION_OPTIONAL_FIELDS.split(','); + optionalFields.forEach((key) => { + values[camelCase(key)] = this.state[camelCase(key)]; + }); + + return ( + { this.setState({ [fieldName]: value }); }} + /> + ); + } + + handleInstitutionLogin = () => { + this.setState(prevState => ({ institutionLogin: !prevState.institutionLogin })); + } + + 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, + }; + + if (this.props.thirdPartyAuthContext.currentProvider) { + payload.social_auth_provider = this.props.thirdPartyAuthContext.currentProvider; + } else { + payload.password = this.state.password; + } + + const postParams = getAllPossibleQueryParam(); + payload = { ...payload, ...postParams }; + + let finalValidation = this.state.formValid; + if (!this.state.formValid) { + Object.keys(payload).forEach(key => { + finalValidation = this.validateInput(key, payload[key], payload); + }); + } + // Since optional fields are not validated we can add it to payload after required fields + // have been validated. This will save us unwanted calls to validateInput() + const optionalFields = getConfig().REGISTRATION_OPTIONAL_FIELDS.split(','); + optionalFields.forEach((key) => { + const stateKey = camelCase(key); + if (this.state[stateKey]) { + payload[key] = this.state[stateKey]; + } + }); + if (finalValidation) { + payload.totalRegistrationTime = totalRegistrationTime; + this.props.registerNewUser(payload); + } + } + + checkNoFieldErrors(validations) { + const keyValidList = Object.entries(validations).map(([key]) => !validations[key]); + return keyValidList.every((current) => current === true); + } + + checkNoAlertErrors(validations) { + const keyValidList = Object.entries(validations).map(([key]) => { + const validation = validations[key][0]; + return !validation.user_message; + }); + return keyValidList.every((current) => current === true); + } + + handleOnBlur(e) { + const payload = { + email: this.state.email, + username: this.state.username, + password: this.state.password, + name: this.state.name, + honor_code: true, + country: this.state.country, + }; + const { name, value } = e.target; + this.setState({ + updateFieldErrors: false, + updateAlertErrors: false, + fieldName: e.target.name, + }, () => { + this.validateInput(name, value, payload, false); + }); + } + + handleOnChange(e) { + if (!(e.target.name === 'username' && e.target.value.length > 30)) { + this.setState({ + [e.target.name]: e.target.value, + updateFieldErrors: false, + updateAlertErrors: false, + }); + } + } + + handleOnFocus(e) { + const { errors } = this.state; + errors[e.target.name] = ''; + this.setState({ errors }); + } + + handleOnOptional(e) { + const optionalEnable = this.state.enableOptionalField; + const targetValue = e.target.id === 'additionalFields' ? !optionalEnable : e.target.checked; + this.setState({ + enableOptionalField: targetValue, + updateAlertErrors: false, + updateFieldErrors: false, + }); + sendTrackEvent('edx.bi.user.register.optional_fields_selected', {}); + } + + handleLoginLinkClickEvent() { + sendTrackEvent('edx.bi.login_form.toggled', { category: 'user-engagement' }); + } + + validateInput(inputName, value, payload, updateAlertMessage = true) { + const { errors } = this.state; + const { intl, statusCode } = this.props; + const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i'); + + let { + formValid, + updateFieldErrors, + updateAlertErrors, + } = this.state; + switch (inputName) { + case 'email': + if (value.length < 1) { + errors.email = intl.formatMessage(messages['email.validation.message']); + } else if (value.length <= 2) { + errors.email = intl.formatMessage(messages['email.ratelimit.less.chars.validation.message']); + } else if (!emailRegex.test(value)) { + errors.email = intl.formatMessage(messages['email.ratelimit.incorrect.format.validation.message']); + } else if (payload && statusCode !== 403) { + this.props.fetchRealtimeValidations(payload); + } else { + errors.email = ''; + } + break; + case 'name': + if (value.length < 1) { + errors.name = intl.formatMessage(messages['fullname.validation.message']); + } else { + errors.name = ''; + } + break; + case 'username': + if (value.length < 1) { + errors.username = intl.formatMessage(messages['username.validation.message']); + } else if (value.length <= 1 || value.length > 30) { + errors.username = intl.formatMessage(messages['username.ratelimit.less.chars.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.length < 1) { + errors.password = intl.formatMessage(messages['register.page.password.validation.message']); + } else if (value.length < 8) { + errors.password = intl.formatMessage(messages['email.ratelimit.password.validation.message']); + } else if (!value.match(/.*[0-9].*/i)) { + errors.password = intl.formatMessage(messages['username.number.validation.message']); + } else if (!value.match(/.*[a-zA-Z].*/i)) { + errors.password = intl.formatMessage(messages['username.character.validation.message']); + } else if (payload && statusCode !== 403) { + this.props.fetchRealtimeValidations(payload); + } else { + errors.password = ''; + } + break; + case 'country': + if (!value) { + errors.country = intl.formatMessage(messages['country.validation.message']); + } else { + errors.country = ''; + } + break; + default: + break; + } + + if (updateAlertMessage) { + updateFieldErrors = true; + updateAlertErrors = true; + formValid = this.checkNoFieldErrors(errors); + } + this.setState({ + formValid, + updateFieldErrors, + updateAlertErrors, + errors, + }); + return formValid; + } + + updateFieldErrors(registrationError) { + const { + errors, + } = this.state; + Object.entries(registrationError).map(([key]) => { + if (registrationError[key]) { + errors[key] = registrationError[key][0].user_message; + } + return errors; + }); + } + + updateValidationAlertMessages() { + const { + errors, + validationAlertMessages, + } = this.state; + Object.entries(errors).map(([key, value]) => { + if (validationAlertMessages[key]) { + validationAlertMessages[key][0].user_message = value; + } + return validationAlertMessages; + }); + } + + renderErrors() { + let errorsObject = null; + let { registrationErrorsUpdated } = this.state; + const { + updateAlertErrors, + updateFieldErrors, + validationAlertMessages, + } = this.state; + const { registrationError, submitState } = this.props; + if (registrationError && registrationErrorsUpdated) { + if (updateFieldErrors && submitState !== PENDING_STATE) { + this.updateFieldErrors(registrationError); + } + registrationErrorsUpdated = false; + errorsObject = registrationError; + } else { + if (updateAlertErrors && submitState !== PENDING_STATE) { + this.updateValidationAlertMessages(); + } + errorsObject = !this.checkNoAlertErrors(validationAlertMessages) ? validationAlertMessages : {}; + } + return ( + + ); + } + + renderThirdPartyAuth(providers, secondaryProviders, currentProvider, thirdPartyAuthApiStatus, intl) { + let thirdPartyComponent = null; + if ((providers.length || secondaryProviders.length) && !currentProvider) { + thirdPartyComponent = ( + <> + +
    + +
    + + ); + } else if (thirdPartyAuthApiStatus === PENDING_STATE) { + thirdPartyComponent = ; + } + return thirdPartyComponent; + } + + renderForm(currentProvider, + providers, + secondaryProviders, + thirdPartyAuthApiStatus, + finishAuthUrl, + submitState, + intl) { + if (this.state.institutionLogin) { + return ( + + ); + } + + if (this.props.registrationResult.success) { + setSurveyCookie('register'); + window.optimizely = window.optimizely || []; + + // Fire optimizely events + window.optimizely.push({ + type: 'event', + eventName: 'van_504_total_registrations', + }); + + if (this.state.optimizelyExperimentName !== 'progressiveProfilingConcept1') { + window.optimizely.push({ + type: 'event', + eventName: 'van_504_conversion_rate', + }); + ['yearOfBirth', 'gender', 'levelOfEducation'].forEach(fieldName => { + if (this.state[fieldName]) { + window.optimizely.push({ + type: 'event', + eventName: `van_504_${fieldName}`, + }); + } + }); + } + } + + return ( + <> + + {intl.formatMessage(messages['register.page.title'], + { siteName: getConfig().SITE_NAME })} + + + +
    +
    +
    + {this.renderErrors()} + {currentProvider && ( + + )} +

    + {intl.formatMessage(messages['already.have.an.edx.account'])} + + {intl.formatMessage(messages['sign.in.hyperlink'])} + +

    +
    +

    {intl.formatMessage(messages['create.a.new.account'])}

    +
    + this.handleOnBlur(e)} + onChange={(e) => this.handleOnChange(e)} + onFocus={(e) => this.handleOnFocus(e)} + helpText={intl.formatMessage(messages['helptext.name'])} + inputFieldStyle="border-gray-600" + /> + this.handleOnBlur(e)} + onChange={(e) => this.handleOnChange(e)} + onFocus={(e) => this.handleOnFocus(e)} + helpText={intl.formatMessage(messages['helptext.username'])} + inputFieldStyle="border-gray-600" + /> + this.handleOnBlur(e)} + onChange={(e) => this.handleOnChange(e)} + onFocus={(e) => this.handleOnFocus(e)} + helpText={intl.formatMessage(messages['helptext.email'])} + inputFieldStyle="border-gray-600" + /> + {!currentProvider && ( + this.handleOnBlur(e)} + onChange={(e) => this.handleOnChange(e)} + onFocus={(e) => this.handleOnFocus(e)} + helpText={intl.formatMessage(messages['helptext.password'])} + inputFieldStyle="border-gray-600" + /> + )} + this.handleOnBlur(e)} + onChange={(e) => this.handleOnChange(e)} + onFocus={(e) => this.handleOnFocus(e)} + selectOptions={this.getCountryOptions()} + inputFieldStyle="border-gray-600 custom-select-size" + /> +
    + + {intl.formatMessage(messages['terms.of.service.and.honor.code'])} + + ), + privacyPolicy: ( + + {intl.formatMessage(messages['privacy.policy'])} + + ), + }} + /> +
    + {getConfig().REGISTRATION_OPTIONAL_FIELDS && this.state.optimizelyExperimentName !== 'progressiveProfilingConcept1' ? ( + this.handleOnOptional(e)} + onBlur={null} + onChange={(e) => this.handleOnOptional(e)} + optionalFieldCheckbox + isChecked={this.state.enableOptionalField} + checkboxMessage={intl.formatMessage(messages['support.education.research'])} + /> + ) : null} + { this.state.enableOptionalField ? this.getOptionalFields() : null } + }} + onClick={this.handleSubmit} + onMouseDown={(e) => e.preventDefault()} + /> + {(providers.length || secondaryProviders.length || thirdPartyAuthApiStatus === PENDING_STATE) + && !currentProvider ? ( +
    +
    + + {intl.formatMessage(messages['create.an.account.using'])} + +
    + ) : null} + {this.renderThirdPartyAuth(providers, + secondaryProviders, + currentProvider, + thirdPartyAuthApiStatus, + intl)} + +
    +
    +
    + + ); + } + + render() { + const { intl, submitState, thirdPartyAuthApiStatus } = this.props; + const { + currentProvider, finishAuthUrl, providers, secondaryProviders, + } = this.props.thirdPartyAuthContext; + + if (this.tpaHint) { + if (thirdPartyAuthApiStatus === PENDING_STATE) { + return ; + } + const { provider, skipHintedLogin } = getTpaProvider(this.tpaHint, providers, secondaryProviders); + if (skipHintedLogin) { + window.location.href = getConfig().LMS_BASE_URL + provider.registerUrl; + return null; + } + return provider ? () + : 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, + registrationError: null, + submitState: DEFAULT_STATE, + thirdPartyAuthApiStatus: 'pending', + thirdPartyAuthContext: { + currentProvider: null, + finishAuthUrl: null, + providers: [], + secondaryProviders: [], + pipelineUserDetails: null, + }, + validations: null, + statusCode: null, +}; + +RegistrationPage.propTypes = { + intl: intlShape.isRequired, + getThirdPartyAuthContext: PropTypes.func.isRequired, + registerNewUser: PropTypes.func, + registrationResult: PropTypes.shape({ + redirectUrl: PropTypes.string, + success: PropTypes.bool, + }), + registrationError: PropTypes.shape({ + email: PropTypes.array, + username: PropTypes.array, + country: PropTypes.array, + password: PropTypes.array, + name: PropTypes.array, + }), + submitState: PropTypes.string, + thirdPartyAuthApiStatus: PropTypes.string, + thirdPartyAuthContext: PropTypes.shape({ + currentProvider: PropTypes.string, + platformName: PropTypes.string, + providers: PropTypes.array, + secondaryProviders: PropTypes.array, + finishAuthUrl: PropTypes.string, + pipelineUserDetails: PropTypes.shape({ + email: PropTypes.string, + fullname: PropTypes.string, + firstName: PropTypes.string, + lastName: PropTypes.string, + username: PropTypes.string, + }), + }), + fetchRealtimeValidations: PropTypes.func.isRequired, + validations: PropTypes.shape({ + validation_decisions: PropTypes.shape({ + country: PropTypes.string, + email: PropTypes.string, + name: PropTypes.string, + password: PropTypes.string, + username: PropTypes.string, + }), + }), + statusCode: PropTypes.number, +}; + +const mapStateToProps = state => { + const registrationResult = registrationRequestSelector(state); + const thirdPartyAuthContext = thirdPartyAuthContextSelector(state); + return { + registrationError: state.register.registrationError, + submitState: state.register.submitState, + thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus, + registrationResult, + thirdPartyAuthContext, + validations: state.register.validations, + statusCode: state.register.statusCode, + }; +}; + +export default connect( + mapStateToProps, + { + getThirdPartyAuthContext, + fetchRealtimeValidations, + registerNewUser, + }, +)(injectIntl(RegistrationPage)); diff --git a/src/legacy/register/data/actions.js b/src/legacy/register/data/actions.js new file mode 100644 index 00000000..71556a43 --- /dev/null +++ b/src/legacy/register/data/actions.js @@ -0,0 +1,45 @@ +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'); + +// 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 = (error, statusCode) => ({ + type: REGISTER_FORM_VALIDATIONS.FAILURE, + payload: { error, statusCode }, +}); diff --git a/src/legacy/register/data/constants.js b/src/legacy/register/data/constants.js new file mode 100644 index 00000000..f6d923c7 --- /dev/null +++ b/src/legacy/register/data/constants.js @@ -0,0 +1,30 @@ +// Registration Error Codes +export const INTERNAL_SERVER_ERROR = 'internal-server-error'; +export const FORBIDDEN_REQUEST = 'forbidden-request'; + +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']; diff --git a/src/legacy/register/data/reducers.js b/src/legacy/register/data/reducers.js new file mode 100644 index 00000000..42d926be --- /dev/null +++ b/src/legacy/register/data/reducers.js @@ -0,0 +1,51 @@ +import { REGISTER_NEW_USER, REGISTER_FORM_VALIDATIONS } from './actions'; + +import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants'; + +export const defaultState = { + registrationError: null, + registrationResult: {}, + formData: null, + validations: null, + statusCode: null, +}; + +const reducer = (state = defaultState, action) => { + switch (action.type) { + case REGISTER_NEW_USER.BEGIN: + return { + ...state, + submitState: PENDING_STATE, + }; + case REGISTER_NEW_USER.SUCCESS: + return { + ...state, + registrationResult: action.payload, + }; + case REGISTER_NEW_USER.FAILURE: + return { + ...state, + registrationError: action.payload.error, + submitState: DEFAULT_STATE, + }; + case REGISTER_FORM_VALIDATIONS.BEGIN: + return { + ...state, + }; + case REGISTER_FORM_VALIDATIONS.SUCCESS: + return { + ...state, + validations: action.payload.validations, + }; + case REGISTER_FORM_VALIDATIONS.FAILURE: + return { + ...state, + validations: action.payload.error, + statusCode: action.payload.statusCode, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/legacy/register/data/sagas.js b/src/legacy/register/data/sagas.js new file mode 100644 index 00000000..271e4c3a --- /dev/null +++ b/src/legacy/register/data/sagas.js @@ -0,0 +1,69 @@ +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, 409]; + if (e.response && statusCodes.includes(e.response.status)) { + yield put(registerNewUserFailure(e.response.data)); + logInfo(e); + } else if (e.response.status === 403) { + 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( + fieldValidations, + )); + } catch (e) { + const statusCodes = [403]; + if (e.response && statusCodes.includes(e.response.status)) { + yield put(fetchRealtimeValidationsFailure(e.response.data, e.response.status)); + logInfo(e); + } else { + logError(e); + } + } +} + +export default function* saga() { + yield takeEvery(REGISTER_NEW_USER.BASE, handleNewUserRegistration); + yield takeEvery(REGISTER_FORM_VALIDATIONS.BASE, fetchRealtimeValidations); +} diff --git a/src/legacy/register/data/selectors.js b/src/legacy/register/data/selectors.js new file mode 100644 index 00000000..1935831b --- /dev/null +++ b/src/legacy/register/data/selectors.js @@ -0,0 +1,10 @@ +import { createSelector } from 'reselect'; + +export const storeName = 'register'; + +export const registerSelector = state => ({ ...state[storeName] }); + +export const registrationRequestSelector = createSelector( + registerSelector, + register => register.registrationResult, +); diff --git a/src/legacy/register/data/service.js b/src/legacy/register/data/service.js new file mode 100644 index 00000000..2e638c59 --- /dev/null +++ b/src/legacy/register/data/service.js @@ -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}/user_api/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, + }; +} diff --git a/src/legacy/register/data/tests/sagas.test.js b/src/legacy/register/data/tests/sagas.test.js new file mode 100644 index 00000000..0680950f --- /dev/null +++ b/src/legacy/register/data/tests/sagas.test.js @@ -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 = { + validation_decisions: { + 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(); + }); +}); diff --git a/src/register/index.js b/src/legacy/register/index.js similarity index 100% rename from src/register/index.js rename to src/legacy/register/index.js diff --git a/src/legacy/register/messages.jsx b/src/legacy/register/messages.jsx new file mode 100644 index 00000000..25156129 --- /dev/null +++ b/src/legacy/register/messages.jsx @@ -0,0 +1,268 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'register.page.title': { + id: 'register.page.title', + defaultMessage: 'Register | {siteName}', + description: 'register page title', + }, + 'create.account.button': { + id: 'create.account.button', + defaultMessage: 'Create account', + description: 'Button label that appears on register page', + }, + 'already.have.an.edx.account': { + id: 'already.have.an.edx.account', + defaultMessage: 'Already have an edX account?', + description: 'A message on registration page asking the user if he already has an edX account', + }, + 'sign.in.hyperlink': { + id: 'sign.in.hyperlink', + defaultMessage: 'Sign in.', + description: 'Text for the hyperlink that takes user to login page', + }, + 'create.an.account.using': { + id: 'create.an.account.using', + defaultMessage: 'or create an account using', + description: 'A message that after optional form fields checkbox', + }, + 'create.a.new.account': { + id: 'create.a.new.account', + defaultMessage: 'Create a new account', + description: 'Text that appears before social auth buttons and before the registration form', + }, + 'register.institution.login.button': { + id: 'register.institution.login.button', + defaultMessage: 'Use my institution/campus credentials', + description: 'shows institutions list', + }, + 'register.institution.login.page.title': { + id: 'register.institution.login.page.title', + defaultMessage: 'Register with institution/campus credentials', + description: 'Heading of institution page', + }, + 'create.an.account': { + id: 'create.an.account', + defaultMessage: 'Create an account', + description: 'Message on button to return to register page', + }, + 'register.page.email.label': { + id: 'register.page.email.label', + defaultMessage: 'Email (required)', + description: 'Label that appears above email field on register page', + }, + 'register.rate.limit.reached.message': { + id: 'register.rate.limit.reached.message', + defaultMessage: 'Too many failed registration attempts. Try again later.', + description: 'Error message that appears when an anonymous user has made too many failed registration attempts', + }, + 'email.validation.message': { + id: 'email.validation.message', + defaultMessage: 'Please enter your email.', + description: 'Validation message that appears when email address is empty', + }, + 'email.ratelimit.less.chars.validation.message': { + id: 'email.ratelimit.less.chars.validation.message', + defaultMessage: 'Email must have 3 characters.', + description: 'Validation message that appears when email address is less than 3 characters', + }, + 'email.ratelimit.incorrect.format.validation.message': { + id: 'email.ratelimit.incorrect.format.validation.message', + defaultMessage: 'The email address you provided isn\'t formatted correctly.', + description: 'Validation message that appears when email address is not formatted correctly with no backend validations available.', + }, + 'email.ratelimit.password.validation.message': { + id: 'email.ratelimit.password.validation.message', + defaultMessage: 'Your password must contain at least 8 characters', + description: 'Validation message that appears when password is not formatted correctly with no backend validations available.', + }, + 'password.label': { + id: 'password.label', + defaultMessage: 'Password (required)', + description: 'Label that appears above password field', + }, + 'register.page.password.validation.message': { + id: 'register.page.password.validation.message', + defaultMessage: 'Please enter your password.', + description: 'Validation message that appears when password is non compliant with edX requirement', + }, + 'fullname.label': { + id: 'fullname.label', + defaultMessage: 'Full name (required)', + description: 'Label that appears above fullname field', + }, + 'fullname.validation.message': { + id: 'fullname.validation.message', + defaultMessage: 'Please enter your full name.', + description: 'Validation message that appears when fullname is empty', + }, + 'username.label': { + id: 'username.label', + defaultMessage: 'Public username (required)', + description: 'Label that appears above username field', + }, + 'username.validation.message': { + id: 'username.validation.message', + defaultMessage: 'Please enter your public username.', + description: 'Validation message that appears when username is invalid', + }, + 'username.format.validation.message': { + id: 'username.format.validation.message', + defaultMessage: 'Usernames can only contain letters (A-Z, a-z), numerals (0-9), underscores (_), and hyphens (-).', + description: 'Validation message that appears when username format is invalid', + }, + 'username.character.validation.message': { + id: 'username.character.validation.message', + defaultMessage: 'Your password must contain at least 1 letter.', + description: 'Validation message that appears when password does not contain letter', + }, + 'username.number.validation.message': { + id: 'username.number.validation.message', + defaultMessage: 'Your password must contain at least 1 number.', + description: 'Validation message that appears when password does not contain number', + }, + 'username.ratelimit.less.chars.message': { + id: 'username.ratelimit.less.chars.message', + defaultMessage: 'Public username must have atleast 2 characters.', + description: 'Validation message that appears when username is less than 2 characters and with no backend validations available.', + }, + 'country.validation.message': { + id: 'country.validation.message', + defaultMessage: 'Select your country or region of residence.', + description: 'Validation message that appears when country is not selected', + }, + 'support.education.research': { + id: 'support.education.research', + defaultMessage: 'Support education research by providing additional information. (Optional)', + description: 'Text for a checkbox to ask user for if they are willing to provide extra information for education research', + }, + 'registration.request.server.error': { + id: 'registration.request.server.error', + defaultMessage: 'An error has occurred. Try refreshing the page, or check your internet connection.', + description: 'error message on server error.', + }, + 'registration.request.failure.header': { + id: 'registration.request.failure.header', + defaultMessage: 'We couldn\'t create your account.', + description: 'error message when registration failure.', + }, + 'helptext.name': { + id: 'helptext.name', + defaultMessage: 'This name will be used by any certificates that you earn.', + description: 'help text for name field on registration field', + }, + 'helptext.username': { + id: 'helptext.username', + defaultMessage: 'The name that will identify you in your courses. It cannot be changed later.', + description: 'helptext for username field on registration page', + }, + 'helptext.password': { + id: 'helptext.password', + defaultMessage: 'Your password must contain at least 8 characters, including 1 letter & 1 number.', + description: 'help text for password field on registration page', + }, + 'helptext.email': { + id: 'helptext.email', + defaultMessage: 'This is what you will use to login.', + description: 'help text for email field on registration page', + }, + // Terms of Service and Honor Code + 'terms.of.service.and.honor.code': { + id: 'terms.of.service.and.honor.code', + defaultMessage: 'Terms of Service and Honor Code', + description: 'Text for the hyperlink that redirects user to terms of service and honor code', + }, + 'privacy.policy': { + id: 'privacy.policy', + defaultMessage: 'Privacy Policy', + description: 'Text for the hyperlink that redirects user to privacy policy', + }, + // Registration Fields + 'registration.year.of.birth.label': { + id: 'registration.year.of.birth.label', + defaultMessage: 'Year of birth (optional)', + description: 'Placeholder for the year of birth options dropdown', + }, + 'registration.country.label': { + id: 'registration.country.label', + defaultMessage: 'Country or region of residence (required)', + description: 'Placeholder for the country options dropdown.', + }, + 'registration.field.gender.options.label': { + id: 'registration.field.gender.options.label', + defaultMessage: 'Gender (optional)', + description: 'Placeholder for the gender options dropdown', + }, + 'registration.goals.label': { + id: 'registration.goals.label', + defaultMessage: 'Tell us why you\'re interested in edX (optional)', + description: 'Placeholder for the goals options dropdown', + }, + 'registration.field.gender.options.f': { + id: 'registration.field.gender.options.f', + defaultMessage: 'Female', + description: 'The label for the female gender option.', + }, + 'registration.field.gender.options.m': { + id: 'registration.field.gender.options.m', + defaultMessage: 'Male', + description: 'The label for the male gender option.', + }, + 'registration.field.gender.options.o': { + id: 'registration.field.gender.options.o', + defaultMessage: 'Other/Prefer not to say', + description: 'The label for catch-all gender option.', + }, + 'registration.field.education.levels.label': { + id: 'registration.field.education.levels.label', + defaultMessage: 'Highest level of education completed (optional)', + description: 'Placeholder for the education levels dropdown.', + }, + 'registration.field.education.levels.p': { + id: 'registration.field.education.levels.p', + defaultMessage: 'Doctorate', + description: 'Selected by the user if their highest level of education is a doctorate degree.', + }, + 'registration.field.education.levels.m': { + id: 'registration.field.education.levels.m', + defaultMessage: "Master's or professional degree", + description: "Selected by the user if their highest level of education is a master's or professional degree from a college or university.", + }, + 'registration.field.education.levels.b': { + id: 'registration.field.education.levels.b', + defaultMessage: "Bachelor's degree", + description: "Selected by the user if their highest level of education is a four year college or university bachelor's degree.", + }, + 'registration.field.education.levels.a': { + id: 'registration.field.education.levels.a', + defaultMessage: "Associate's degree", + description: "Selected by the user if their highest level of education is an associate's degree. 1-2 years of college or university.", + }, + 'registration.field.education.levels.hs': { + id: 'registration.field.education.levels.hs', + defaultMessage: 'Secondary/high school', + description: 'Selected by the user if their highest level of education is secondary or high school. 9-12 years of education.', + }, + 'registration.field.education.levels.jhs': { + id: 'registration.field.education.levels.jhs', + defaultMessage: 'Junior secondary/junior high/middle school', + description: 'Selected by the user if their highest level of education is junior or middle school. 6-8 years of education.', + }, + 'registration.field.education.levels.el': { + id: 'registration.field.education.levels.el', + defaultMessage: 'Elementary/primary school', + description: 'Selected by the user if their highest level of education is elementary or primary school. 1-5 years of education.', + }, + 'registration.field.education.levels.none': { + id: 'registration.field.education.levels.none', + defaultMessage: 'No formal education', + description: 'Selected by the user to describe their education.', + }, + 'registration.field.education.levels.other': { + id: 'registration.field.education.levels.other', + defaultMessage: 'Other education', + description: 'Selected by the user if they have a type of education not described by the other choices.', + }, +}); + +export default messages; diff --git a/src/legacy/register/tests/RegistrationPage.test.jsx b/src/legacy/register/tests/RegistrationPage.test.jsx new file mode 100644 index 00000000..60f386b2 --- /dev/null +++ b/src/legacy/register/tests/RegistrationPage.test.jsx @@ -0,0 +1,789 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import renderer from 'react-test-renderer'; +import { mount } from 'enzyme'; +import configureStore from 'redux-mock-store'; +import { getConfig, mergeConfig } from '@edx/frontend-platform'; +import { IntlProvider, injectIntl, configure } from '@edx/frontend-platform/i18n'; +import * as analytics from '@edx/frontend-platform/analytics'; +import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner'; + +import RegistrationPage from '../RegistrationPage'; +import { RenderInstitutionButton } from '../../common-components'; +import RegistrationFailureMessage from '../RegistrationFailure'; +import { COMPLETE_STATE, PENDING_STATE } from '../../data/constants'; +import { fetchRealtimeValidations, registerNewUser } from '../data/actions'; +import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from '../data/constants'; + +jest.mock('@edx/frontend-platform/analytics'); + +analytics.sendTrackEvent = jest.fn(); +analytics.sendPageEvent = jest.fn(); + +const IntlRegistrationPage = injectIntl(RegistrationPage); +const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage); +const mockStore = configureStore(); + +describe('RegistrationPageTests', () => { + mergeConfig({ + PRIVACY_POLICY: 'http://privacy-policy.com', + REGISTRATION_OPTIONAL_FIELDS: 'gender,goals,level_of_education,year_of_birth', + TOS_AND_HONOR_CODE: 'http://tos-and-honot-code.com', + USER_SURVEY_COOKIE_NAME: process.env.USER_SURVEY_COOKIE_NAME, + }); + + const initialState = { + register: { + registrationResult: { success: false, redirectUrl: '' }, + registrationError: null, + }, + commonComponents: { + thirdPartyAuthApiStatus: null, + thirdPartyAuthContext: { + platformName: 'openedX', + currentProvider: null, + finishAuthUrl: null, + providers: [], + secondaryProviders: [], + pipelineUserDetails: null, + }, + }, + }; + + let props = {}; + let store = {}; + + 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 secondaryProviders = { + id: 'saml-test', + name: 'Test University', + loginUrl: '/dummy-auth', + registerUrl: '/dummy_auth', + skipHintedLogin: false, + }; + + const emptyFieldValidation = { + name: 'Please enter your full name.', + username: 'Please enter your public username.', + email: 'Please enter your email.', + password: 'Please enter your password.', + country: 'Select your country or region of residence.', + }; + + const reduxWrapper = children => ( + + {children} + + ); + + const submitForm = (payload, submitOptionalFields = true, isThirdPartyAuth = false) => { + const registerPage = mount(reduxWrapper()); + + registerPage.find('input#name').simulate('change', { target: { value: payload.name, name: 'name' } }); + registerPage.find('input#username').simulate('change', { target: { value: payload.username, name: 'username' } }); + registerPage.find('input#email').simulate('change', { target: { value: payload.email, name: 'email' } }); + registerPage.find('select#country').simulate('change', { target: { value: payload.country, name: 'country' } }); + + if (!isThirdPartyAuth) { + registerPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } }); + } + + // Send optional field + if (submitOptionalFields) { + registerPage.find('input#optional').simulate('change', { target: { checked: true } }); + registerPage.find('select#gender').simulate('change', { target: { value: payload.gender || null, name: 'gender' } }); + registerPage.find('select#yearOfBirth').simulate('change', { target: { values: payload.yearOfBirth || null, name: 'yearOfBirth' } }); + registerPage.find('select#levelOfEducation').simulate('change', { target: { values: payload.levelOfEducation || null, name: 'levelOfEducation' } }); + registerPage.find('textarea#goals').simulate('change', { target: { value: payload.goals || '', name: 'goals' } }); + } + + registerPage.find('button.btn-brand').simulate('click'); + }; + + beforeEach(() => { + store = mockStore(initialState); + configure({ + loggingService: { logError: jest.fn() }, + config: { + ENVIRONMENT: 'production', + LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum', + }, + messages: { 'es-419': {}, de: {}, 'en-us': {} }, + }); + props = { + registrationResult: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not show optional field check when optimizely experiment is set', () => { + window.optimizelyExperimentName = 'progressiveProfilingConcept1'; + + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('RegistrationPage').state('optimizelyExperimentName')).toEqual('progressiveProfilingConcept1'); + expect(registrationPage.find('#optional').length).toEqual(0); + + delete window.optimizelyExperimentName; + }); + + it('should toggle optional fields state on checkbox click', () => { + const registrationPage = mount(reduxWrapper()); + + registrationPage.find('input#optional').simulate('change', { target: { checked: true } }); + expect(registrationPage.find('RegistrationPage').state('enableOptionalField')).toEqual(true); + }); + + it('should toggle optional fields state on text click', () => { + const registrationPage = mount(reduxWrapper()); + + registrationPage.find('#additionalFields').simulate('click'); + expect(registrationPage.find('RegistrationPage').state('enableOptionalField')).toEqual(true); + }); + + it('send tracking event on optional checkbox enabled', () => { + const registrationPage = mount(reduxWrapper()); + + registrationPage.find('input#optional').simulate('change', { target: { checked: true } }); + registrationPage.update(); + expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.register.optional_fields_selected', {}); + }); + + it('send tracking event when login link is clicked', () => { + const registrationPage = mount(reduxWrapper()); + + registrationPage.find('a[href*="/login"]').simulate('click'); + registrationPage.update(); + expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.bi.login_form.toggled', { category: 'user-engagement' }); + }); + + it('send page event when register page is rendered', () => { + mount(reduxWrapper()); + expect(analytics.sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register'); + }); + + it('should show optional fields section on optional check enabled', () => { + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#optional').simulate('change', { target: { checked: true } }); + registrationPage.update(); + + expect(registrationPage.find('textarea#goals').length).toEqual(1); + expect(registrationPage.find('select#levelOfEducation').length).toEqual(1); + expect(registrationPage.find('select#yearOfBirth').length).toEqual(1); + expect(registrationPage.find('select#gender').length).toEqual(1); + }); + + it('should not show optional field check if process env has empty optional fields list', () => { + mergeConfig({ + REGISTRATION_OPTIONAL_FIELDS: '', + }); + let registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('input#optional').length).toEqual(0); + + mergeConfig({ + REGISTRATION_OPTIONAL_FIELDS: 'gender,goals,level_of_education,year_of_birth', + }); + + registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('input#optional').length).toEqual(1); + }); + + it('should dispatch fetchRealtimeValidations on Blur after frontend validations ', () => { + const formPayload = { + email: '', + name: '', + username: 'test', + password: '', + country: '', + honor_code: true, + }; + store.dispatch = jest.fn(store.dispatch); + + const registrationPage = mount(reduxWrapper()); + IntlRegistrationPage.prototype.componentDidMount = jest.fn(); + registrationPage.find('input#username').simulate('change', { target: { value: 'test', name: 'username' } }); + registrationPage.find('input#username').simulate('blur'); + expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); + + registrationPage.find('input#email').simulate('change', { target: { value: 'test@test.com', name: 'email' } }); + registrationPage.find('input#email').simulate('blur'); + formPayload.email = 'test@test.com'; + expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); + + registrationPage.find('input#password').simulate('change', { target: { value: 'random123', name: 'password' } }); + registrationPage.find('input#password').simulate('blur'); + formPayload.password = 'random123'; + + expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); + }); + + it('should call validations function on Blur', () => { + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#username').simulate('blur', { target: { value: '', name: 'username' } }); + registrationPage.find('input#name').simulate('blur', { target: { value: '', name: 'name' } }); + registrationPage.find('input#email').simulate('blur', { target: { value: '', name: 'email' } }); + registrationPage.find('input#password').simulate('blur', { target: { value: '', name: 'password' } }); + registrationPage.find('select#country').simulate('blur', { target: { value: '', name: 'country' } }); + expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(emptyFieldValidation); + }); + + it('validate password validations', () => { + const errors = { + email: '', + name: '', + username: '', + password: 'Your password must contain at least 8 characters', + country: '', + }; + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#password').simulate('blur', { target: { value: 'pas', name: 'password' } }); + expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(errors); + + errors.password = 'Your password must contain at least 1 number.'; + registrationPage.find('input#password').simulate('blur', { target: { value: 'passwordd', name: 'password' } }); + expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(errors); + + errors.password = 'Your password must contain at least 1 letter.'; + registrationPage.find('input#password').simulate('blur', { target: { value: '123456789', name: 'password' } }); + expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(errors); + }); + + it('tests shouldComponentUpdate change validations and formValid state', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + updateFieldErrors: false, + }, + }); + const nextProps = { + validations: { + validation_decisions: { + username: 'Username must be between 2 and 30 characters long.', + }, + }, + registrationError: { + username: [{ username: 'Username must be between 2 and 30 characters long.' }], + }, + }; + + const root = mount(reduxWrapper()); + // calling this to update the state + root.find('input#username').simulate('blur', { target: { value: '', name: 'username' } }); + const shouldUpdate = root.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); + expect(root.find('RegistrationPage').state('formValid')).not.toEqual(true); + expect(shouldUpdate).toBe(false); + }); + + it('should not dispatch registerNewUser on empty form Submission', () => { + const formPayload = { + email: '', + username: '', + password: '', + name: '', + honor_code: true, + country: '', + }; + store.dispatch = jest.fn(store.dispatch); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + expect(store.dispatch).not.toHaveBeenCalledWith(registerNewUser(formPayload)); + }); + + it('should show error messages for required fields on empty form submission', () => { + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + + expect(registrationPage.find('#name-invalid-feedback').text()).toEqual(emptyFieldValidation.name); + expect(registrationPage.find('#username-invalid-feedback').text()).toEqual(emptyFieldValidation.username); + expect(registrationPage.find('#email-invalid-feedback').text()).toEqual(emptyFieldValidation.email); + expect(registrationPage.find('#password-invalid-feedback').text()).toEqual(emptyFieldValidation.password); + expect(registrationPage.find('#country-invalid-feedback').text()).toEqual(emptyFieldValidation.country); + + let alertBanner = 'We couldn\'t create your account.'; + Object.keys(emptyFieldValidation).forEach(key => { + alertBanner += emptyFieldValidation[key]; + }); + + expect(registrationPage.find('#validation-errors').first().text()).toEqual(alertBanner); + }); + + it('should clear field related error messages on input field Focus', () => { + const errors = { + email: '', + name: '', + username: '', + password: '', + country: '', + }; + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + + expect(registrationPage.find('#name-invalid-feedback').text()).toEqual(emptyFieldValidation.name); + registrationPage.find('input#name').simulate('focus'); + expect(registrationPage.find('#username-invalid-feedback').text()).toEqual(emptyFieldValidation.username); + registrationPage.find('input#username').simulate('focus'); + expect(registrationPage.find('#email-invalid-feedback').text()).toEqual(emptyFieldValidation.email); + registrationPage.find('input#email').simulate('focus'); + expect(registrationPage.find('#password-invalid-feedback').text()).toEqual(emptyFieldValidation.password); + registrationPage.find('input#password').simulate('focus'); + registrationPage.find('select#country').simulate('focus', { target: { value: 'US', name: 'country' } }); + expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(errors); + }); + + it('should show error message on alert and below the fields in case of 409', () => { + const errors = { + email: 'It looks like test@gmail.com belongs to an existing account. Try again with a different email address.', + username: 'It looks like test belongs to an existing account. Try again with a different username.', + }; + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationError: { + email: [{ user_message: errors.email }], + username: [{ user_message: errors.username }], + }, + }, + }); + + const nextProps = { + validations: null, + thirdPartyAuthContext: { + pipelineUserDetails: null, + }, + registrationError: { + username: [{ username: errors.username }], + }, + }; + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + registrationPage.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); + expect(registrationPage.find('#email-invalid-feedback').text()).toEqual(errors.email); + expect(registrationPage.find('#username-invalid-feedback').text()).toEqual(errors.username); + expect(registrationPage.find('#validation-errors').first().text()).toEqual( + 'We couldn\'t create your account.'.concat(errors.email + errors.username), + ); + }); + + it('should submit form for valid input', () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + + const formPayload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@example.com', + password: 'password1', + country: 'Pakistan', + gender: 'm', + honor_code: true, + totalRegistrationTime: 0, + }; + + store.dispatch = jest.fn(store.dispatch); + submitForm(formPayload); + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser(formPayload)); + }); + + it('should submit form with no password when current provider is present', () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + currentProvider: appleProvider.name, + }, + }, + }); + + const formPayload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@example.com', + country: 'Pakistan', + honor_code: true, + social_auth_provider: appleProvider.name, + totalRegistrationTime: 0, + }; + + store.dispatch = jest.fn(store.dispatch); + submitForm(formPayload, false, true); + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser(formPayload)); + }); + + it('should display validationAlertMessages in case of invalid form submission', () => { + const alertMessages = { + name: [{ user_message: 'Please enter your full name.' }], + username: [{ user_message: 'Please enter your public username.' }], + email: [{ user_message: 'Please enter your email.' }], + password: [{ user_message: 'Please enter your password.' }], + country: [{ user_message: 'Select your country or region of residence.' }], + }; + store.dispatch = jest.fn(store.dispatch); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + expect(registrationPage.find('RegistrationPage').state('validationAlertMessages')).toEqual(alertMessages); + }); + + it('should not update validationAlertMessages on blur event', () => { + const alertMessages = { + name: [{ user_message: 'Please enter your full name.' }], + username: [{ user_message: 'Please enter your public username.' }], + email: [{ user_message: 'Please enter your email.' }], + password: [{ user_message: 'Please enter your password.' }], + country: [{ user_message: 'Select your country or region of residence.' }], + }; + store.dispatch = jest.fn(store.dispatch); + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('button.btn-brand').simulate('click'); + expect(registrationPage.find('RegistrationPage').state('validationAlertMessages')).toEqual(alertMessages); + + registrationPage.find('input#password').simulate('blur', { target: { value: 'test12345', name: 'password' } }); + registrationPage.find('input#email').simulate('blur', { target: { value: 'test@test.com', name: 'email' } }); + registrationPage.find('input#name').simulate('blur', { target: { value: 'test', name: 'name' } }); + + expect(registrationPage.find('RegistrationPage').state('validationAlertMessages')).toEqual(alertMessages); + }); + + it('should match default section snapshot', () => { + const tree = renderer.create(reduxWrapper()); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('should match registration api rate limit error message', () => { + props = { + errors: { + errorCode: FORBIDDEN_REQUEST, + }, + }; + + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('div.alert-heading').length).toEqual(1); + const expectedMessage = 'We couldn\'t create your account.Too many failed registration attempts. Try again later.'; + expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage); + }); + + it('should match internal server error message', () => { + props = { + errors: { + errorCode: INTERNAL_SERVER_ERROR, + }, + }; + + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('div.alert-heading').length).toEqual(1); + const expectedMessage = 'We couldn\'t create your account.An error has occurred. Try refreshing the page, or check your internet connection.'; + expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage); + }); + + it('should match pending button state snapshot', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + submitState: PENDING_STATE, + }, + }); + + const tree = renderer.create(reduxWrapper()); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('should match TPA provider snapshot', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], + }, + }, + }); + + const tree = renderer.create(reduxWrapper()).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should display no password field when current provider is present', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + currentProvider: appleProvider.name, + }, + }, + }); + + const registrationPage = mount(reduxWrapper()); + expect(registrationPage.find('input#password').length).toEqual(0); + }); + + it('tests shouldComponentUpdate with pipeline user data', () => { + const nextProps = { + validations: null, + thirdPartyAuthContext: { + pipelineUserDetails: { + name: 'test', + email: 'test@example.com', + username: 'test-username', + }, + }, + }; + + const root = mount(reduxWrapper()); + + const shouldUpdate = root.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); + expect(shouldUpdate).toBe(false); + }); + + it('should match url after redirection', () => { + const dasboardUrl = 'http://test.com/testing-dashboard/'; + + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationResult: { + success: true, + redirectUrl: dasboardUrl, + }, + }, + }); + delete window.location; + window.location = { href: getConfig().BASE_URL }; + renderer.create(reduxWrapper()); + expect(window.location.href).toBe(dasboardUrl); + }); + + it('should set registration survey cookie', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationResult: { + success: true, + }, + }, + }); + + renderer.create(reduxWrapper()); + expect(document.cookie).toMatch(`${getConfig().USER_SURVEY_COOKIE_NAME}=register`); + }); + + it('should display institution register button', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + secondaryProviders: [secondaryProviders], + }, + }, + }); + delete window.location; + window.location = { href: getConfig().BASE_URL }; + const root = mount(reduxWrapper()); + expect(root.text().includes('Use my institution/campus credentials')).toBe(true); + }); + + it('should not display institution register button', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + secondaryProviders: [secondaryProviders], + }, + }, + }); + delete window.location; + window.location = { href: getConfig().BASE_URL }; + const root = mount(reduxWrapper()); + root.find(RenderInstitutionButton).simulate('click', { institutionLogin: true }); + expect(root.text().includes('Test University')).toBe(true); + }); + + it('should match url after TPA redirection', () => { + const authCompleteUrl = '/auth/complete/google-oauth2/'; + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + registrationResult: { + success: true, + redirectUrl: '', + }, + }, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + finishAuthUrl: authCompleteUrl, + }, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL }; + + renderer.create(reduxWrapper()); + expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl); + }); + + it('should redirect to social auth provider url', () => { + const registerUrl = '/auth/login/apple-id/?auth_entry=register&next=/dashboard'; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [{ + ...appleProvider, + registerUrl, + }], + }, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL }; + + const loginPage = mount(reduxWrapper()); + + loginPage.find('button#oa2-apple-id').simulate('click'); + expect(window.location.href).toBe(getConfig().LMS_BASE_URL + registerUrl); + }); + + it('should match third party auth alert', () => { + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + currentProvider: 'Apple', + }, + }, + }); + + const expectedMessage = 'You\'ve successfully signed into Apple. We just need a little more information before ' + + 'you start learning with openedX.'; + + const registerPage = mount(reduxWrapper()); + expect(registerPage.find('#tpa-alert').find('span').text()).toEqual(expectedMessage); + }); + + it('check cookie rendered', () => { + const registerPage = mount(reduxWrapper()); + expect(registerPage.find()).toBeTruthy(); + }); + + it('should render tpa button for tpa_hint id in primary provider', () => { + const expectedMessage = `Sign in using ${appleProvider.name}`; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL.concat('/register'), search: `?next=/dashboard&tpa_hint=${appleProvider.id}` }; + appleProvider.iconImage = null; + + const registerPage = mount(reduxWrapper()); + expect(registerPage.find(`button#${appleProvider.id}`).find('span').text()).toEqual(expectedMessage); + }); + + it('should render regular tpa button for invalid tpa_hint value', () => { + const expectedMessage = `${appleProvider.name}`; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL.concat('/register'), search: '?next=/dashboard&tpa_hint=invalid' }; + appleProvider.iconImage = null; + + const registerPage = mount(reduxWrapper()); + expect(registerPage.find(`button#${appleProvider.id}`).find('span#provider-name').text()).toEqual(expectedMessage); + }); + + it('should render tpa button for tpa_hint id in secondary provider', () => { + const expectedMessage = `Sign in using ${secondaryProviders.name}`; + 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('/register'), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` }; + secondaryProviders.iconImage = null; + + const registerPage = mount(reduxWrapper()); + expect(registerPage.find(`button#${secondaryProviders.id}`).find('span').text()).toEqual(expectedMessage); + }); + + it('should redirect to idp page if skipHinetedLogin is true', () => { + 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('/register'), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` }; + secondaryProviders.iconImage = null; + + mount(reduxWrapper()); + expect(window.location.href).toEqual(getConfig().LMS_BASE_URL + secondaryProviders.registerUrl); + }); +}); diff --git a/src/legacy/register/tests/__snapshots__/RegistrationPage.test.jsx.snap b/src/legacy/register/tests/__snapshots__/RegistrationPage.test.jsx.snap new file mode 100644 index 00000000..add3b5f9 --- /dev/null +++ b/src/legacy/register/tests/__snapshots__/RegistrationPage.test.jsx.snap @@ -0,0 +1,4783 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RegistrationPageTests should match TPA provider snapshot 1`] = ` +
    +
    +
    +

    + Already have an edX account? + + Sign in. + +

    +
    +

    + Create a new account +

    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + + + Select your country or region of residence. + +
    +
    +

    + By creating an account, you agree to the + + Terms of Service and Honor Code + + + + + + + + + + and you acknowledge that edX and each Member process your personal data in accordance with the + + Privacy Policy + + + + + + + + + + . +

    +
    +
    + + + +
    + +
    +
    + + or create an account using + +
    +
    + +
    +
    +
    +
    +
    +`; + +exports[`RegistrationPageTests should match default section snapshot 1`] = ` +
    +
    +
    +

    + Already have an edX account? + + Sign in. + +

    +
    +

    + Create a new account +

    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + + + Select your country or region of residence. + +
    +
    +

    + By creating an account, you agree to the + + Terms of Service and Honor Code + + + + + + + + + + and you acknowledge that edX and each Member process your personal data in accordance with the + + Privacy Policy + + + + + + + + + + . +

    +
    +
    + + + +
    + +
    +
    +
    +
    +`; + +exports[`RegistrationPageTests should match pending button state snapshot 1`] = ` +
    +
    +
    +

    + Already have an edX account? + + Sign in. + +

    +
    +

    + Create a new account +

    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + + + Select your country or region of residence. + +
    +
    +

    + By creating an account, you agree to the + + Terms of Service and Honor Code + + + + + + + + + + and you acknowledge that edX and each Member process your personal data in accordance with the + + Privacy Policy + + + + + + + + + + . +

    +
    +
    + + + +
    + +
    +
    +
    +
    +`; diff --git a/src/legacy/reset-password/InvalidToken.jsx b/src/legacy/reset-password/InvalidToken.jsx new file mode 100644 index 00000000..fe927e4d --- /dev/null +++ b/src/legacy/reset-password/InvalidToken.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import { Alert } from '@edx/paragon'; + +import messages from './messages'; +import { LOGIN_PAGE } from '../data/constants'; + +const InvalidTokenMessage = props => { + const { intl } = props; + + const loginPasswordLink = ( + + {intl.formatMessage(messages['forgot.password.confirmation.sign.in.link'])} + + ); + + return ( + <> + + {intl.formatMessage(messages['reset.password.page.title'], + { siteName: getConfig().SITE_NAME })} + + +
    +
    + + {intl.formatMessage(messages['reset.password.request.invalid.token.header'])} + {intl.formatMessage(messages['reset.password.request.forgot.password.text'])} , + loginPasswordLink, + }} + /> + +
    +
    + + ); +}; + +InvalidTokenMessage.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(InvalidTokenMessage); diff --git a/src/legacy/reset-password/ResetPasswordPage.jsx b/src/legacy/reset-password/ResetPasswordPage.jsx new file mode 100644 index 00000000..923fe528 --- /dev/null +++ b/src/legacy/reset-password/ResetPasswordPage.jsx @@ -0,0 +1,225 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Helmet } from 'react-helmet'; +import { connect } from 'react-redux'; +import { + Alert, Form, StatefulButton, +} from '@edx/paragon'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { getQueryParameters, getConfig } from '@edx/frontend-platform'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +import messages from './messages'; +import { resetPassword, validateToken } from './data/actions'; +import { resetPasswordResultSelector } from './data/selectors'; +import { validatePassword } from './data/service'; +import InvalidTokenMessage from './InvalidToken'; +import ResetSuccessMessage from './ResetSuccess'; +import { + AuthnValidationFormGroup, + APIFailureMessage, +} from '../common-components'; +import Spinner from './Spinner'; +import { API_RATELIMIT_ERROR, INTERNAL_SERVER_ERROR } from '../data/constants'; +import { windowScrollTo } from '../data/utils'; + +const ResetPasswordPage = (props) => { + const { intl } = props; + const params = getQueryParameters(); + + const [newPasswordInput, setNewPasswordValue] = useState(''); + const [confirmPasswordInput, setConfirmPasswordValue] = useState(''); + const [passwordValid, setPasswordValidValue] = useState(true); + const [passwordMatch, setPasswordMatchValue] = useState(true); + const [validationMessage, setvalidationMessage] = useState(''); + const [bannerErrorMessage, setbannerErrorMessage] = useState(''); + + useEffect(() => { + if (props.status === 'failure' + && props.errors !== INTERNAL_SERVER_ERROR + && props.errors !== API_RATELIMIT_ERROR) { + setbannerErrorMessage(props.errors); + setvalidationMessage(props.errors); + setPasswordValidValue(false); + } + }, [props.status]); + + const validatePasswordFromBackend = async (newPassword) => { + let errorMessage; + try { + errorMessage = await validatePassword(newPassword); + } catch (err) { + errorMessage = ''; + } + setPasswordValidValue(!errorMessage); + setvalidationMessage(errorMessage); + }; + + const handleNewPasswordChange = (e) => { + const newPassword = e.target.value; + setNewPasswordValue(newPassword); + }; + + const handleNewPasswordOnBlur = (e) => { + const newPassword = e.target.value; + setNewPasswordValue(newPassword); + + if (newPassword === '') { + setPasswordValidValue(false); + setvalidationMessage(intl.formatMessage(messages['reset.password.empty.new.password.field.error'])); + } else { + validatePasswordFromBackend(newPassword); + } + }; + + const handleConfirmPasswordChange = (e) => { + const confirmPassword = e.target.value; + setConfirmPasswordValue(confirmPassword); + setPasswordMatchValue(confirmPassword === newPasswordInput); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + + windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }); + if (newPasswordInput === '') { + setPasswordValidValue(false); + setvalidationMessage(intl.formatMessage(messages['reset.password.empty.new.password.field.error'])); + setbannerErrorMessage(intl.formatMessage(messages['reset.password.empty.new.password.field.error'])); + return; + } + if (newPasswordInput !== confirmPasswordInput) { + setPasswordMatchValue(false); + return; + } + + const formPayload = { + new_password1: newPasswordInput, + new_password2: confirmPasswordInput, + }; + props.resetPassword(formPayload, props.token, params); + }; + + if (props.status === 'token-pending') { + const { token } = props.match.params; + if (token) { + props.validateToken(token); + return ; + } + } else if (props.status === 'invalid' && props.errors === INTERNAL_SERVER_ERROR) { + return ( + + ); + } else if (props.status === 'invalid' && props.errors === API_RATELIMIT_ERROR) { + return ( + + ); + } else if (props.status === 'invalid') { + return ; + } else if (props.status === 'success') { + return ; + } else { + return ( + <> + + {intl.formatMessage(messages['reset.password.page.title'], + { siteName: getConfig().SITE_NAME })} + + + {props.status === 'failure' && props.errors === INTERNAL_SERVER_ERROR ? ( + + ) : null} + {props.status === 'failure' && props.errors === API_RATELIMIT_ERROR ? ( + + ) : null} +
    +
    + {bannerErrorMessage ? ( + + {intl.formatMessage(messages['forgot.password.empty.new.password.error.heading'])} +
    • {bannerErrorMessage}
    +
    + ) : null} +
    +

    + {intl.formatMessage(messages['reset.password.page.heading'])} +

    +

    + {intl.formatMessage(messages['reset.password.page.instructions'])} +

    + handleNewPasswordChange(e)} + onBlur={e => handleNewPasswordOnBlur(e)} + className="w-100" + inputFieldStyle="border-gray-600" + /> + handleConfirmPasswordChange(e)} + className="w-100" + inputFieldStyle="border-gray-600" + /> + }} + onClick={e => handleSubmit(e)} + onMouseDown={(e) => e.preventDefault()} + /> + +
    +
    + + ); + } + return null; +}; + +ResetPasswordPage.defaultProps = { + status: null, + token: null, + match: null, + errors: null, +}; + +ResetPasswordPage.propTypes = { + intl: intlShape.isRequired, + resetPassword: PropTypes.func.isRequired, + validateToken: PropTypes.func.isRequired, + token: PropTypes.string, + match: PropTypes.shape({ + params: PropTypes.shape({ + token: PropTypes.string, + }), + }), + status: PropTypes.string, + errors: PropTypes.string, +}; + +export default connect( + resetPasswordResultSelector, + { + resetPassword, + validateToken, + }, +)(injectIntl(ResetPasswordPage)); diff --git a/src/legacy/reset-password/ResetSuccess.jsx b/src/legacy/reset-password/ResetSuccess.jsx new file mode 100644 index 00000000..37a298ea --- /dev/null +++ b/src/legacy/reset-password/ResetSuccess.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; + +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import { Alert } from '@edx/paragon'; + +import messages from './messages'; + +const ResetSuccessMessage = (props) => { + const { intl } = props; + + const loginPasswordLink = ( + + + + ); + + return ( + <> + + {intl.formatMessage(messages['reset.password.page.title'], + { siteName: getConfig().SITE_NAME })} + + +
    +
    +
    + + + {intl.formatMessage(messages['reset.password.request.success.header.message'])} + + + +
    +
    +
    + + ); +}; + +ResetSuccessMessage.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(ResetSuccessMessage); diff --git a/src/legacy/reset-password/Spinner.jsx b/src/legacy/reset-password/Spinner.jsx new file mode 100644 index 00000000..f02bff8e --- /dev/null +++ b/src/legacy/reset-password/Spinner.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Spinner as ParagonSpinner } from '@edx/paragon'; + +const Spinner = () => ( +
    +
    + +
    +
    +); + +export default Spinner; diff --git a/src/legacy/reset-password/data/actions.js b/src/legacy/reset-password/data/actions.js new file mode 100644 index 00000000..7e6b637b --- /dev/null +++ b/src/legacy/reset-password/data/actions.js @@ -0,0 +1,44 @@ +import { AsyncActionType } from '../../data/utils'; + +export const RESET_PASSWORD = new AsyncActionType('RESET', 'PASSWORD'); +export const VALIDATE_TOKEN = new AsyncActionType('VALIDATE', 'TOKEN'); + +// Validate confirmation token +export const validateToken = (token) => ({ + type: VALIDATE_TOKEN.BASE, + payload: { token }, +}); + +export const validateTokenBegin = () => ({ + type: VALIDATE_TOKEN.BEGIN, +}); + +export const validateTokenSuccess = (tokenStatus, token) => ({ + type: VALIDATE_TOKEN.SUCCESS, + payload: { tokenStatus, token }, +}); + +export const validateTokenFailure = errors => ({ + type: VALIDATE_TOKEN.FAILURE, + payload: { errors }, +}); + +// Reset Password +export const resetPassword = (formPayload, token, params) => ({ + type: RESET_PASSWORD.BASE, + payload: { formPayload, token, params }, +}); + +export const resetPasswordBegin = () => ({ + type: RESET_PASSWORD.BEGIN, +}); + +export const resetPasswordSuccess = data => ({ + type: RESET_PASSWORD.SUCCESS, + payload: { data }, +}); + +export const resetPasswordFailure = errors => ({ + type: RESET_PASSWORD.FAILURE, + payload: { errors }, +}); diff --git a/src/legacy/reset-password/data/reducers.js b/src/legacy/reset-password/data/reducers.js new file mode 100644 index 00000000..f7ce1898 --- /dev/null +++ b/src/legacy/reset-password/data/reducers.js @@ -0,0 +1,44 @@ +import { RESET_PASSWORD, VALIDATE_TOKEN } from './actions'; + +export const defaultState = { + status: 'token-pending', + token: null, + errors: null, +}; + +const reducer = (state = defaultState, action = null) => { + switch (action.type) { + case VALIDATE_TOKEN.SUCCESS: + return { + ...state, + status: 'valid', + token: action.payload.token, + }; + case VALIDATE_TOKEN.FAILURE: + return { + ...state, + status: 'invalid', + errors: action.payload.errors, + }; + case RESET_PASSWORD.BEGIN: + return { + ...state, + status: 'pending', + }; + case RESET_PASSWORD.SUCCESS: + return { + ...state, + status: 'success', + }; + case RESET_PASSWORD.FAILURE: + return { + ...state, + status: 'failure', + errors: action.payload.errors, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/legacy/reset-password/data/sagas.js b/src/legacy/reset-password/data/sagas.js new file mode 100644 index 00000000..ef16270c --- /dev/null +++ b/src/legacy/reset-password/data/sagas.js @@ -0,0 +1,69 @@ +import { call, put, takeEvery } from 'redux-saga/effects'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; + +// Actions +import { + VALIDATE_TOKEN, + validateTokenBegin, + validateTokenSuccess, + validateTokenFailure, + RESET_PASSWORD, + resetPasswordBegin, + resetPasswordSuccess, + resetPasswordFailure, +} from './actions'; + +import { validateToken, resetPassword } from './service'; +import { INTERNAL_SERVER_ERROR, API_RATELIMIT_ERROR } from '../../data/constants'; + +// Services +export function* handleValidateToken(action) { + try { + yield put(validateTokenBegin()); + const data = yield call(validateToken, action.payload.token); + const isValid = data.is_valid; + if (isValid) { + yield put(validateTokenSuccess(isValid, action.payload.token)); + } else { + yield put(validateTokenFailure(isValid)); + } + } catch (err) { + const statusCodes = [429]; + if (err.response && statusCodes.includes(err.response.status)) { + yield put(validateTokenFailure(API_RATELIMIT_ERROR)); + logInfo(err); + } else { + yield put(validateTokenFailure(INTERNAL_SERVER_ERROR)); + logError(err); + } + } +} + +export function* handleResetPassword(action) { + try { + yield put(resetPasswordBegin()); + const data = yield call(resetPassword, action.payload.formPayload, action.payload.token, action.payload.params); + const resetStatus = data.reset_status; + const resetErrors = data.err_msg; + + if (resetStatus) { + yield put(resetPasswordSuccess(resetStatus)); + } else { + yield put(resetPasswordFailure(resetErrors)); + } + } catch (err) { + const statusCodes = [429]; + if (err.response && statusCodes.includes(err.response.status)) { + yield put(resetPasswordFailure(API_RATELIMIT_ERROR)); + logInfo(err); + } else { + yield put(resetPasswordFailure(INTERNAL_SERVER_ERROR)); + logError(err); + } + } +} + +export default function* saga() { + yield takeEvery(RESET_PASSWORD.BASE, handleResetPassword); + yield takeEvery(VALIDATE_TOKEN.BASE, handleValidateToken); +} diff --git a/src/reset-password/data/selectors.js b/src/legacy/reset-password/data/selectors.js similarity index 100% rename from src/reset-password/data/selectors.js rename to src/legacy/reset-password/data/selectors.js diff --git a/src/reset-password/data/service.js b/src/legacy/reset-password/data/service.js similarity index 100% rename from src/reset-password/data/service.js rename to src/legacy/reset-password/data/service.js diff --git a/src/legacy/reset-password/data/tests/sagas.test.js b/src/legacy/reset-password/data/tests/sagas.test.js new file mode 100644 index 00000000..1183d44b --- /dev/null +++ b/src/legacy/reset-password/data/tests/sagas.test.js @@ -0,0 +1,167 @@ +import { runSaga } from 'redux-saga'; + +import { + resetPasswordBegin, + resetPasswordSuccess, + resetPasswordFailure, + validateTokenBegin, + validateTokenFailure, +} from '../actions'; +import { handleResetPassword, handleValidateToken } from '../sagas'; +import * as api from '../service'; +import initializeMockLogging from '../../../../setupTest'; +import { API_RATELIMIT_ERROR, INTERNAL_SERVER_ERROR } from '../../../data/constants'; + +const { loggingService } = initializeMockLogging(); + +describe('handleResetPassword', () => { + const params = { + payload: { + formPayload: { + new_password1: 'new_password1', + new_password2: 'new_password1', + }, + token: 'token', + params: {}, + }, + }; + + const responseData = { + reset_status: true, + err_msg: '', + }; + + beforeEach(() => { + loggingService.logError.mockReset(); + loggingService.logInfo.mockReset(); + }); + + it('should call service and dispatch success action', async () => { + const resetPassword = jest.spyOn(api, 'resetPassword') + .mockImplementation(() => Promise.resolve(responseData)); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + handleResetPassword, + params, + ); + + expect(resetPassword).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordSuccess(true)]); + resetPassword.mockClear(); + }); + + it('should call service and dispatch internal server error action', async () => { + const errorResponse = { + response: { + status: 500, + data: { + errorCode: INTERNAL_SERVER_ERROR, + }, + }, + }; + const resetPassword = jest.spyOn(api, 'resetPassword') + .mockImplementation(() => Promise.reject(errorResponse)); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + handleResetPassword, + params, + ); + + expect(loggingService.logError).toHaveBeenCalled(); + expect(resetPassword).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure(INTERNAL_SERVER_ERROR)]); + resetPassword.mockClear(); + }); + + it('should call service and dispatch ratelimit error', async () => { + const errorResponse = { + response: { + status: 429, + data: { + errorCode: API_RATELIMIT_ERROR, + }, + }, + }; + const resetPassword = jest.spyOn(api, 'resetPassword') + .mockImplementation(() => Promise.reject(errorResponse)); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + handleResetPassword, + params, + ); + + expect(loggingService.logInfo).toHaveBeenCalled(); + expect(resetPassword).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure(API_RATELIMIT_ERROR)]); + resetPassword.mockClear(); + }); +}); + +describe('handleValidateToken', () => { + const params = { + payload: { + token: 'token', + params: {}, + }, + }; + + beforeEach(() => { + loggingService.logError.mockReset(); + loggingService.logInfo.mockReset(); + }); + + it('check internal server error on api failure', async () => { + const errorResponse = { + response: { + status: 500, + data: { + errorCode: INTERNAL_SERVER_ERROR, + }, + }, + }; + const validateToken = jest.spyOn(api, 'validateToken') + .mockImplementation(() => Promise.reject(errorResponse)); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + handleValidateToken, + params, + ); + + expect(validateToken).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([validateTokenBegin(), validateTokenFailure(INTERNAL_SERVER_ERROR)]); + validateToken.mockClear(); + }); + + it('should call service and dispatch ratelimit error', async () => { + const errorResponse = { + response: { + status: 429, + data: { + errorCode: API_RATELIMIT_ERROR, + }, + }, + }; + const validateToken = jest.spyOn(api, 'validateToken') + .mockImplementation(() => Promise.reject(errorResponse)); + + const dispatched = []; + await runSaga( + { dispatch: (action) => dispatched.push(action) }, + handleValidateToken, + params, + ); + + expect(loggingService.logInfo).toHaveBeenCalled(); + expect(validateToken).toHaveBeenCalledTimes(1); + expect(dispatched).toEqual([validateTokenBegin(), validateTokenFailure(API_RATELIMIT_ERROR)]); + validateToken.mockClear(); + }); +}); diff --git a/src/reset-password/index.js b/src/legacy/reset-password/index.js similarity index 100% rename from src/reset-password/index.js rename to src/legacy/reset-password/index.js diff --git a/src/legacy/reset-password/messages.js b/src/legacy/reset-password/messages.js new file mode 100644 index 00000000..b68d82d6 --- /dev/null +++ b/src/legacy/reset-password/messages.js @@ -0,0 +1,86 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'reset.password.page.title': { + id: 'reset.password.page.title', + defaultMessage: 'Reset Password | {siteName}', + description: 'page title', + }, + 'reset.password.page.heading': { + id: 'reset.password.page.heading', + defaultMessage: 'Reset your password', + description: 'The page heading for the Reset password page.', + }, + 'reset.password.page.instructions': { + id: 'reset.password.page.instructions', + defaultMessage: 'Enter and confirm your new password.', + description: 'Instructions message for reset password page.', + }, + 'reset.password.page.invalid.match.message': { + id: 'reset.password.page.invalid.match.message', + defaultMessage: 'Passwords do not match.', + description: 'Password format error.', + }, + 'reset.password.page.new.field.label': { + id: 'forgot.password.page.new.field.label', + defaultMessage: 'New password', + description: 'New password field label for the reset password page.', + }, + 'reset.password.page.confirm.field.label': { + id: 'forgot.password.page.confirm.field.label', + defaultMessage: 'Confirm password', + description: 'Confirm password field label for the reset password page.', + }, + 'reset.password.page.submit.button': { + id: 'reset.password.page.submit.button', + defaultMessage: 'Reset my password', + description: 'Submit button text for the reset password page.', + }, + 'reset.password.request.success.header.message': { + id: 'reset.password.request.success.header.message', + defaultMessage: 'Password reset complete.', + description: 'header message when reset is successful.', + }, + 'forgot.password.confirmation.sign.in.link': { + id: 'forgot.password.confirmation.sign.in.link', + defaultMessage: 'sign in', + description: 'link text used in message to refer to sign in page', + }, + 'reset.password.request.forgot.password.text': { + id: 'reset.password.request.forgot.password.text', + defaultMessage: 'Forgot password', + description: 'Forgot password text', + }, + 'reset.password.request.invalid.token.header': { + id: 'reset.password.request.invalid.token.header', + defaultMessage: 'Invalid password reset link', + description: 'Invalid password reset link help text heading', + }, + 'reset.password.empty.new.password.field.error': { + id: 'reset.password.empty.new.password.field.error', + defaultMessage: 'Please enter your new password.', + description: 'Error message that appears when user tries to submit form with empty New Password field', + }, + 'forgot.password.empty.new.password.error.heading': { + id: 'forgot.password.empty.new.password.error.heading', + defaultMessage: 'We couldn\'t reset your password.', + description: 'Heading that appears above error message when user submits empty form.', + }, + 'reset.password.request.server.error': { + id: 'reset.password.request.server.error', + defaultMessage: 'Failed to reset password', + description: 'Failed to reset password error message heading.', + }, + 'reset.password.token.validation.sever.error': { + id: 'reset.password.token.validation.sever.error', + defaultMessage: 'Token validation failure', + description: 'Failed to validate reset password token error message.', + }, + 'reset.server.ratelimit.error': { + id: 'reset.server.ratelimit.error', + defaultMessage: 'Too many requests.', + description: 'Too many request at server end point', + }, +}); + +export default messages; diff --git a/src/legacy/reset-password/tests/ResetPasswordPage.test.jsx b/src/legacy/reset-password/tests/ResetPasswordPage.test.jsx new file mode 100644 index 00000000..25427cb1 --- /dev/null +++ b/src/legacy/reset-password/tests/ResetPasswordPage.test.jsx @@ -0,0 +1,291 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { act } from 'react-dom/test-utils'; +import renderer from 'react-test-renderer'; +import configureStore from 'redux-mock-store'; +import { mount } from 'enzyme'; +import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; +import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner'; +import * as auth from '@edx/frontend-platform/auth'; +import { resetPassword } from '../data/actions'; +import { APIFailureMessage } from '../../common-components'; + +import ResetPasswordPage from '../ResetPasswordPage'; +import { API_RATELIMIT_ERROR, INTERNAL_SERVER_ERROR } from '../../data/constants'; + +jest.mock('@edx/frontend-platform/auth'); + +const IntlResetPasswordPage = injectIntl(ResetPasswordPage); +const mockStore = configureStore(); + +describe('ResetPasswordPage', () => { + let props = {}; + let store = {}; + + const emptyFieldError = 'Please enter your new password.'; + const validationMessage = 'This password is too short. It must contain at least 8 characters. This password must contain at least 1 number.'; + + const reduxWrapper = children => ( + + {children} + + ); + + const submitForm = async (password) => { + const resetPasswordPage = mount(reduxWrapper()); + await act(async () => { + resetPasswordPage.find('input#reset-password-input').simulate('blur', { target: { value: password } }); + }); + resetPasswordPage.find('input#confirm-password-input').simulate('change', { target: { value: password } }); + resetPasswordPage.find('button.btn-primary').simulate('click'); + + return resetPasswordPage; + }; + + beforeEach(() => { + store = mockStore(); + props = { + resetPassword: jest.fn(), + status: null, + token_status: 'pending', + token: null, + errors: null, + match: { + params: {}, + }, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should match reset password default section snapshot', () => { + props = { + ...props, + token: 'token', + token_status: 'valid', + }; + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('show spinner component during token validation', () => { + props = { + ...props, + token_status: 'pending', + match: { + params: { + token: 'test-token', + }, + }, + }; + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match invalid token message section snapshot', () => { + props = { + ...props, + token_status: 'invalid', + }; + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match pending reset message section snapshot', () => { + props = { + ...props, + token_status: 'valid', + status: 'pending', + }; + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match successful reset message section snapshot', () => { + props = { + ...props, + token_status: 'valid', + status: 'success', + }; + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should display invalid password message', async () => { + props = { + ...props, + token_status: 'valid', + }; + + auth.getHttpClient = jest.fn(() => ({ + post: async () => ({ + data: { + validation_decisions: { + password: validationMessage, + }, + }, + catch: () => {}, + }), + })); + + const resetPasswordPage = mount(reduxWrapper()); + + // Focus out of empty field + await act(async () => { + await resetPasswordPage.find('input#reset-password-input').simulate('blur'); + }); + resetPasswordPage.update(); + expect(resetPasswordPage.find('#reset-password-input-invalid-feedback').text()).toEqual(emptyFieldError); + + // Enter non-compliant password + await act(async () => { + await resetPasswordPage.find('input#reset-password-input').simulate('blur', { target: { value: 'invalid' } }); + }); + expect(resetPasswordPage.find('#reset-password-input-invalid-feedback').text()).toEqual(validationMessage); + }); + + it('should display error message on empty form submission', () => { + const bannerMessage = 'We couldn\'t reset your password.'.concat(emptyFieldError); + props = { + ...props, + token_status: 'valid', + token: 'token', + }; + + const resetPasswordPage = mount(reduxWrapper()); + resetPasswordPage.find('button.btn-primary').simulate('click'); + + resetPasswordPage.update(); + expect(resetPasswordPage.find('#reset-password-input-invalid-feedback').text()).toEqual(emptyFieldError); + expect(resetPasswordPage.find('#validation-errors').first().text()).toEqual(bannerMessage); + }); + + it('with valid inputs resetPassword action is dispatch', async () => { + const newPassword = 'test-password1'; + props = { + ...props, + token_status: 'valid', + token: 'token', + }; + + auth.getHttpClient = jest.fn(() => ({ + post: async () => ({ + data: {}, + catch: () => {}, + }), + })); + + const formPayload = { + new_password1: newPassword, + new_password2: newPassword, + }; + + store.dispatch = jest.fn(store.dispatch); + + const resetPasswordPage = await submitForm(newPassword); + expect(store.dispatch).toHaveBeenCalledWith(resetPassword(formPayload, props.token, {})); + resetPasswordPage.unmount(); + }); + + it('should dispatch resetPassword action if validations have reached rate limit', async () => { + const password = 'test-password'; + + auth.getHttpClient = jest.fn(() => ({ + post: async () => { + throw new Error('error'); + }, + })); + store.dispatch = jest.fn(store.dispatch); + + props = { + ...props, + token_status: 'valid', + token: 'token', + }; + + const resetPasswordPage = await submitForm(password); + expect(store.dispatch).toHaveBeenCalledWith( + resetPassword({ new_password1: password, new_password2: password }, props.token, {}), + ); + + resetPasswordPage.unmount(); + }); + + it('should not update the banner message on focus out', async () => { + const bannerMessage = 'We couldn\'t reset your password.'.concat(validationMessage); + props = { + ...props, + token_status: 'valid', + token: 'token', + errors: validationMessage, + status: 'failure', + }; + + const resetPasswordPage = mount(reduxWrapper()); + expect(resetPasswordPage.find('#validation-errors').first().text()).toEqual(bannerMessage); + + await act(async () => { + await resetPasswordPage.find('input#reset-password-input').simulate('blur', { target: { value: '' } }); + }); + // On blur event, the banner message remains same + expect(resetPasswordPage.find('#reset-password-input-invalid-feedback').text()).toEqual(emptyFieldError); + expect(resetPasswordPage.find('#validation-errors').first().text()).toEqual(bannerMessage); + }); + + it('check cookie rendered', () => { + const resetPasswordPage = mount(reduxWrapper()); + expect(resetPasswordPage.find()).toBeTruthy(); + }); + + it('should display error banner on server error', () => { + const bannerMessage = 'Failed to reset passwordAn error has occurred. Try refreshing the page, or check your internet connection.'; + props = { + ...props, + status: 'failure', + errors: INTERNAL_SERVER_ERROR, + }; + + const resetPasswordPage = mount(reduxWrapper()); + resetPasswordPage.find('button.btn-primary').simulate('click'); + + resetPasswordPage.update(); + expect(resetPasswordPage.find('#internal-server-error').first().text()).toEqual(bannerMessage); + }); + + it('check api failure banner rendered', () => { + props = { + ...props, + status: 'invalid', + errors: INTERNAL_SERVER_ERROR, + }; + const resetPasswordPage = mount(reduxWrapper()); + expect(resetPasswordPage.find()).toBeTruthy(); + }); + + it('check failure banner rendered on validate token api ratelimit', () => { + props = { + ...props, + status: 'invalid', + errors: API_RATELIMIT_ERROR, + }; + const resetPasswordPage = mount(reduxWrapper()); + expect(resetPasswordPage.find()).toBeTruthy(); + }); + + it('check failure banner rendered on reset password api ratelimit', () => { + props = { + ...props, + status: 'failure', + errors: API_RATELIMIT_ERROR, + }; + const resetPasswordPage = mount(reduxWrapper()); + expect(resetPasswordPage.find()).toBeTruthy(); + }); +}); diff --git a/src/legacy/reset-password/tests/__snapshots__/ResetPasswordPage.test.jsx.snap b/src/legacy/reset-password/tests/__snapshots__/ResetPasswordPage.test.jsx.snap new file mode 100644 index 00000000..5c9b33dc --- /dev/null +++ b/src/legacy/reset-password/tests/__snapshots__/ResetPasswordPage.test.jsx.snap @@ -0,0 +1,474 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ResetPasswordPage should match invalid token message section snapshot 1`] = ` +
    +
    +
    +

    + Reset your password +

    +

    + Enter and confirm your new password. +

    +
    + + + +
    +
    + + + + + Passwords do not match. + +
    + +
    +
    +
    +`; + +exports[`ResetPasswordPage should match pending reset message section snapshot 1`] = ` +
    +
    +
    +

    + Reset your password +

    +

    + Enter and confirm your new password. +

    +
    + + + +
    +
    + + + + + Passwords do not match. + +
    + +
    +
    +
    +`; + +exports[`ResetPasswordPage should match reset password default section snapshot 1`] = ` +
    +
    +
    +

    + Reset your password +

    +

    + Enter and confirm your new password. +

    +
    + + + +
    +
    + + + + + Passwords do not match. + +
    + +
    +
    +
    +`; + +exports[`ResetPasswordPage should match successful reset message section snapshot 1`] = ` +
    +
    +
    +
    +
    +
    + Password reset complete. +
    + + Your password has been reset. + + + Sign in to your account. + + + +
    +
    +
    +
    +
    +`; + +exports[`ResetPasswordPage show spinner component during token validation 1`] = ` +
    +
    +
    +

    + Reset your password +

    +

    + Enter and confirm your new password. +

    +
    + + + +
    +
    + + + + + Passwords do not match. + +
    + +
    +
    +
    +`; diff --git a/src/legacy/welcome/WelcomePage.jsx b/src/legacy/welcome/WelcomePage.jsx new file mode 100644 index 00000000..4c3b1396 --- /dev/null +++ b/src/legacy/welcome/WelcomePage.jsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Helmet } from 'react-helmet'; + +import { getConfig } from '@edx/frontend-platform'; +import { sendPageEvent } from '@edx/frontend-platform/analytics'; +import { + ensureAuthenticatedUser, hydrateAuthenticatedUser, getAuthenticatedUser, +} from '@edx/frontend-platform/auth'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + Form, + StatefulButton, + Hyperlink, +} from '@edx/paragon'; + +import messages from './messages'; + +import { EDUCATION_LEVELS, GENDER_OPTIONS, YEAR_OF_BIRTH_OPTIONS } from '../register/data/constants'; +import { AuthnValidationFormGroup } from '../common-components'; +import { DEFAULT_REDIRECT_URL } from '../data/constants'; + +const WelcomePage = (props) => { + const { intl } = props; + const [registrationResult, setRegistrationResult] = useState({}); + const [values, setValues] = useState({}); + const [ready, setReady] = useState(false); + const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL); + + useEffect(() => { + ensureAuthenticatedUser(DASHBOARD_URL).then(() => { + hydrateAuthenticatedUser().then(() => { + setReady(true); + }); + }); + + if (props.location.state && props.location.state.registrationResult) { + setRegistrationResult(props.location.state.registrationResult); + sendPageEvent('login_and_registration', 'welcome'); + } + }, []); + + const authenticatedUser = getAuthenticatedUser(); + + if (!props.location.state || !props.location.state.registrationResult) { + global.location.assign(DASHBOARD_URL); + return null; + } + + if (!ready) { + return null; + } + + const getOptions = () => ({ + yearOfBirthOptions: [{ + value: '', + label: intl.formatMessage(messages['registration.year.of.birth.label']), + }].concat(YEAR_OF_BIRTH_OPTIONS), + educationLevelOptions: EDUCATION_LEVELS.map(key => ({ + value: key, + label: intl.formatMessage(messages[`registration.field.education.levels.${key || 'label'}`]), + })), + genderOptions: GENDER_OPTIONS.map(key => ({ + value: key, + label: intl.formatMessage(messages[`registration.field.gender.options.${key || 'label'}`]), + })), + }); + + const fireOptimizelyEvent = () => { + window.optimizely = window.optimizely || []; + window.optimizely.push({ + type: 'event', + eventName: 'van_504_conversion_rate', + }); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + fireOptimizelyEvent(); + window.optimizely = window.optimizely || []; + ['yearOfBirth', 'gender', 'levelOfEducation'].forEach(fieldName => { + if (values[fieldName]) { + window.optimizely.push({ + type: 'event', + eventName: `van_504_${fieldName}`, + }); + } + }); + if (registrationResult.success) { + window.location.href = registrationResult.redirectUrl; + } + return null; + }; + + const handleSkip = (e) => { + e.preventDefault(); + fireOptimizelyEvent(); + window.location.href = registrationResult.redirectUrl; + return null; + }; + + const onChangeHandler = (e) => { + setValues({ ...values, [e.target.name]: e.target.value }); + }; + + return ( + <> + + {intl.formatMessage(messages['optional.fields.page.title'], + { siteName: getConfig().SITE_NAME })} + + +
    +
    +
    +

    + { intl.formatMessage(messages['welcome.to.edx'], { username: authenticatedUser.username }) } +

    +
    +

    {intl.formatMessage(messages['optional.fields.page.heading'])}

    + onChangeHandler(e)} + selectOptions={getOptions().educationLevelOptions} + inputFieldStyle="border-gray-600 custom-select-size" + /> + onChangeHandler(e)} + selectOptions={getOptions().yearOfBirthOptions} + inputFieldStyle="border-gray-600 custom-select-size" + /> + onChangeHandler(e)} + selectOptions={getOptions().genderOptions} + inputFieldStyle="border-gray-600 custom-select-size" + /> +

    + + {intl.formatMessage(messages['optional.fields.information.link'])} + +

    +
    + e.preventDefault()} + /> + e.preventDefault()} + /> +
    + +
    +
    + + ); +}; + +WelcomePage.propTypes = { + intl: intlShape.isRequired, + location: PropTypes.shape({ + state: PropTypes.object, + }), +}; + +WelcomePage.defaultProps = { + location: { state: {} }, +}; + +export default (injectIntl(WelcomePage)); diff --git a/src/welcome/index.js b/src/legacy/welcome/index.js similarity index 100% rename from src/welcome/index.js rename to src/legacy/welcome/index.js diff --git a/src/welcome/messages.jsx b/src/legacy/welcome/messages.jsx similarity index 100% rename from src/welcome/messages.jsx rename to src/legacy/welcome/messages.jsx diff --git a/src/_style.scss b/src/redesign/_style.scss similarity index 100% rename from src/_style.scss rename to src/redesign/_style.scss diff --git a/src/redesign/common-components/APIFailureMessage.jsx b/src/redesign/common-components/APIFailureMessage.jsx new file mode 100644 index 00000000..2febce1a --- /dev/null +++ b/src/redesign/common-components/APIFailureMessage.jsx @@ -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 ( +
    +
    + + + {header} + +
      +
    • + {errorMessage} +
    • +
    +
    +
    +
    + ); +}; + +APIFailureMessage.propTypes = { + intl: intlShape.isRequired, + header: PropTypes.string.isRequired, + errorCode: PropTypes.string.isRequired, +}; + +export default injectIntl(APIFailureMessage); diff --git a/src/common-components/BaseComponent.jsx b/src/redesign/common-components/BaseComponent.jsx similarity index 100% rename from src/common-components/BaseComponent.jsx rename to src/redesign/common-components/BaseComponent.jsx diff --git a/src/common-components/EnterpriseSSO.jsx b/src/redesign/common-components/EnterpriseSSO.jsx similarity index 100% rename from src/common-components/EnterpriseSSO.jsx rename to src/redesign/common-components/EnterpriseSSO.jsx diff --git a/src/common-components/FormGroup.jsx b/src/redesign/common-components/FormGroup.jsx similarity index 100% rename from src/common-components/FormGroup.jsx rename to src/redesign/common-components/FormGroup.jsx diff --git a/src/common-components/InstitutionLogistration.jsx b/src/redesign/common-components/InstitutionLogistration.jsx similarity index 100% rename from src/common-components/InstitutionLogistration.jsx rename to src/redesign/common-components/InstitutionLogistration.jsx diff --git a/src/common-components/LargeScreenLayout.jsx b/src/redesign/common-components/LargeScreenLayout.jsx similarity index 100% rename from src/common-components/LargeScreenLayout.jsx rename to src/redesign/common-components/LargeScreenLayout.jsx diff --git a/src/common-components/LargeScreenLeftLayout.jsx b/src/redesign/common-components/LargeScreenLeftLayout.jsx similarity index 100% rename from src/common-components/LargeScreenLeftLayout.jsx rename to src/redesign/common-components/LargeScreenLeftLayout.jsx diff --git a/src/common-components/Logistration.jsx b/src/redesign/common-components/Logistration.jsx similarity index 100% rename from src/common-components/Logistration.jsx rename to src/redesign/common-components/Logistration.jsx diff --git a/src/common-components/MediumScreenHeader.jsx b/src/redesign/common-components/MediumScreenHeader.jsx similarity index 100% rename from src/common-components/MediumScreenHeader.jsx rename to src/redesign/common-components/MediumScreenHeader.jsx diff --git a/src/redesign/common-components/NotFoundPage.jsx b/src/redesign/common-components/NotFoundPage.jsx new file mode 100644 index 00000000..5386f9d1 --- /dev/null +++ b/src/redesign/common-components/NotFoundPage.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +export default function NotFoundPage() { + return ( +
    +

    + +

    +
    + ); +} diff --git a/src/common-components/PasswordField.jsx b/src/redesign/common-components/PasswordField.jsx similarity index 100% rename from src/common-components/PasswordField.jsx rename to src/redesign/common-components/PasswordField.jsx diff --git a/src/redesign/common-components/RedirectLogistration.jsx b/src/redesign/common-components/RedirectLogistration.jsx new file mode 100644 index 00000000..824d8872 --- /dev/null +++ b/src/redesign/common-components/RedirectLogistration.jsx @@ -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 ; + const registrationResult = { redirectUrl: finalRedirectUrl, success }; + return ; + } + + 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; diff --git a/src/redesign/common-components/RegisterFaIcons.jsx b/src/redesign/common-components/RegisterFaIcons.jsx new file mode 100644 index 00000000..870bbe6b --- /dev/null +++ b/src/redesign/common-components/RegisterFaIcons.jsx @@ -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); +} diff --git a/src/common-components/SmallScreenHeader.jsx b/src/redesign/common-components/SmallScreenHeader.jsx similarity index 100% rename from src/common-components/SmallScreenHeader.jsx rename to src/redesign/common-components/SmallScreenHeader.jsx diff --git a/src/common-components/SocialAuthProviders.jsx b/src/redesign/common-components/SocialAuthProviders.jsx similarity index 100% rename from src/common-components/SocialAuthProviders.jsx rename to src/redesign/common-components/SocialAuthProviders.jsx diff --git a/src/redesign/common-components/SwitchContent.jsx b/src/redesign/common-components/SwitchContent.jsx new file mode 100644 index 00000000..a4c13c74 --- /dev/null +++ b/src/redesign/common-components/SwitchContent.jsx @@ -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 ( + + {getContent(expression)} + + ); +} + +SwitchContent.propTypes = { + expression: PropTypes.string, + cases: PropTypes.objectOf(PropTypes.node).isRequired, + className: PropTypes.string, +}; + +SwitchContent.defaultProps = { + expression: null, + className: null, +}; + +export default SwitchContent; diff --git a/src/common-components/ThirdPartyAuthAlert.jsx b/src/redesign/common-components/ThirdPartyAuthAlert.jsx similarity index 100% rename from src/common-components/ThirdPartyAuthAlert.jsx rename to src/redesign/common-components/ThirdPartyAuthAlert.jsx diff --git a/src/redesign/common-components/UnAuthOnlyRoute.jsx b/src/redesign/common-components/UnAuthOnlyRoute.jsx new file mode 100644 index 00000000..f136f4f3 --- /dev/null +++ b/src/redesign/common-components/UnAuthOnlyRoute.jsx @@ -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 ; +}; + +export default UnAuthOnlyRoute; diff --git a/src/redesign/common-components/data/actions.js b/src/redesign/common-components/data/actions.js new file mode 100644 index 00000000..28a34982 --- /dev/null +++ b/src/redesign/common-components/data/actions.js @@ -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, +}); diff --git a/src/redesign/common-components/data/reducers.js b/src/redesign/common-components/data/reducers.js new file mode 100644 index 00000000..6a944bd2 --- /dev/null +++ b/src/redesign/common-components/data/reducers.js @@ -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; diff --git a/src/redesign/common-components/data/sagas.js b/src/redesign/common-components/data/sagas.js new file mode 100644 index 00000000..d3ac7f03 --- /dev/null +++ b/src/redesign/common-components/data/sagas.js @@ -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); +} diff --git a/src/redesign/common-components/data/selectors.js b/src/redesign/common-components/data/selectors.js new file mode 100644 index 00000000..f5bf2173 --- /dev/null +++ b/src/redesign/common-components/data/selectors.js @@ -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, +); diff --git a/src/common-components/data/service.js b/src/redesign/common-components/data/service.js similarity index 100% rename from src/common-components/data/service.js rename to src/redesign/common-components/data/service.js diff --git a/src/redesign/common-components/data/tests/sagas.test.js b/src/redesign/common-components/data/tests/sagas.test.js new file mode 100644 index 00000000..ffbc33da --- /dev/null +++ b/src/redesign/common-components/data/tests/sagas.test.js @@ -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(); + }); +}); diff --git a/src/common-components/index.jsx b/src/redesign/common-components/index.jsx similarity index 100% rename from src/common-components/index.jsx rename to src/redesign/common-components/index.jsx diff --git a/src/common-components/messages.jsx b/src/redesign/common-components/messages.jsx similarity index 100% rename from src/common-components/messages.jsx rename to src/redesign/common-components/messages.jsx diff --git a/src/common-components/tests/BaseComponent.test.jsx b/src/redesign/common-components/tests/BaseComponent.test.jsx similarity index 100% rename from src/common-components/tests/BaseComponent.test.jsx rename to src/redesign/common-components/tests/BaseComponent.test.jsx diff --git a/src/common-components/tests/FormField.test.jsx b/src/redesign/common-components/tests/FormField.test.jsx similarity index 100% rename from src/common-components/tests/FormField.test.jsx rename to src/redesign/common-components/tests/FormField.test.jsx diff --git a/src/common-components/tests/Logistration.test.jsx b/src/redesign/common-components/tests/Logistration.test.jsx similarity index 100% rename from src/common-components/tests/Logistration.test.jsx rename to src/redesign/common-components/tests/Logistration.test.jsx diff --git a/src/redesign/common-components/tests/SocialAuthProviders.test.jsx b/src/redesign/common-components/tests/SocialAuthProviders.test.jsx new file mode 100644 index 00000000..22aaf712 --- /dev/null +++ b/src/redesign/common-components/tests/SocialAuthProviders.test.jsx @@ -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( + + + , + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('should match social auth provider with iconClass snapshot', () => { + props = { + socialAuthProviders: [{ + ...appleProvider, + iconClass: 'google', + iconImage: null, + }], + }; + + const tree = renderer.create( + + + , + ).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( + + + , + ).toJSON(); + + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/common-components/tests/ThirdPartyAuthAlert.test.jsx b/src/redesign/common-components/tests/ThirdPartyAuthAlert.test.jsx similarity index 100% rename from src/common-components/tests/ThirdPartyAuthAlert.test.jsx rename to src/redesign/common-components/tests/ThirdPartyAuthAlert.test.jsx diff --git a/src/redesign/common-components/tests/UnAuthOnlyRoute.test.jsx b/src/redesign/common-components/tests/UnAuthOnlyRoute.test.jsx new file mode 100644 index 00000000..3b0ad0cb --- /dev/null +++ b/src/redesign/common-components/tests/UnAuthOnlyRoute.test.jsx @@ -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 }) =>
    { children }
    ; +module.exports = RRD; + +const TestApp = () => ( + +
    + + (Login Page)} /> + +
    +
    +); + +describe('UnAuthOnlyRoute', () => { + const routerWrapper = () => ( + + + + ); + + 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'); + }); +}); diff --git a/src/common-components/tests/__snapshots__/SocialAuthProviders.test.jsx.snap b/src/redesign/common-components/tests/__snapshots__/SocialAuthProviders.test.jsx.snap similarity index 100% rename from src/common-components/tests/__snapshots__/SocialAuthProviders.test.jsx.snap rename to src/redesign/common-components/tests/__snapshots__/SocialAuthProviders.test.jsx.snap diff --git a/src/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap b/src/redesign/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap similarity index 100% rename from src/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap rename to src/redesign/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap diff --git a/src/redesign/data/configureStore.js b/src/redesign/data/configureStore.js new file mode 100644 index 00000000..16a19631 --- /dev/null +++ b/src/redesign/data/configureStore.js @@ -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; +} diff --git a/src/data/constants.js b/src/redesign/data/constants.js similarity index 100% rename from src/data/constants.js rename to src/redesign/data/constants.js diff --git a/src/redesign/data/reducers.js b/src/redesign/data/reducers.js new file mode 100755 index 00000000..4442848e --- /dev/null +++ b/src/redesign/data/reducers.js @@ -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; diff --git a/src/redesign/data/sagas.js b/src/redesign/data/sagas.js new file mode 100644 index 00000000..30ad4799 --- /dev/null +++ b/src/redesign/data/sagas.js @@ -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(), + ]); +} diff --git a/src/redesign/data/utils/cookies.js b/src/redesign/data/utils/cookies.js new file mode 100644 index 00000000..369913c6 --- /dev/null +++ b/src/redesign/data/utils/cookies.js @@ -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); + } +} diff --git a/src/redesign/data/utils/dataUtils.js b/src/redesign/data/utils/dataUtils.js new file mode 100644 index 00000000..b61a4d20 --- /dev/null +++ b/src/redesign/data/utils/dataUtils.js @@ -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>(.*)/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); +}; diff --git a/src/redesign/data/utils/dataUtils.test.js b/src/redesign/data/utils/dataUtils.test.js new file mode 100644 index 00000000..b2dd3c58 --- /dev/null +++ b/src/redesign/data/utils/dataUtils.test.js @@ -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 = `${expectedText}`; + + 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); + }); +}); diff --git a/src/redesign/data/utils/index.js b/src/redesign/data/utils/index.js new file mode 100644 index 00000000..d452dc5d --- /dev/null +++ b/src/redesign/data/utils/index.js @@ -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'; diff --git a/src/redesign/data/utils/reduxUtils.js b/src/redesign/data/utils/reduxUtils.js new file mode 100644 index 00000000..45b0d762 --- /dev/null +++ b/src/redesign/data/utils/reduxUtils.js @@ -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`; + } +} diff --git a/src/redesign/data/utils/reduxUtils.test.js b/src/redesign/data/utils/reduxUtils.test.js new file mode 100644 index 00000000..3e5a3d80 --- /dev/null +++ b/src/redesign/data/utils/reduxUtils.test.js @@ -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'); + }); +}); diff --git a/src/forgot-password/ForgotPasswordAlert.jsx b/src/redesign/forgot-password/ForgotPasswordAlert.jsx similarity index 100% rename from src/forgot-password/ForgotPasswordAlert.jsx rename to src/redesign/forgot-password/ForgotPasswordAlert.jsx diff --git a/src/forgot-password/ForgotPasswordPage.jsx b/src/redesign/forgot-password/ForgotPasswordPage.jsx similarity index 100% rename from src/forgot-password/ForgotPasswordPage.jsx rename to src/redesign/forgot-password/ForgotPasswordPage.jsx diff --git a/src/redesign/forgot-password/data/actions.js b/src/redesign/forgot-password/data/actions.js new file mode 100644 index 00000000..dcdd871c --- /dev/null +++ b/src/redesign/forgot-password/data/actions.js @@ -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, +}); diff --git a/src/forgot-password/data/reducers.js b/src/redesign/forgot-password/data/reducers.js similarity index 100% rename from src/forgot-password/data/reducers.js rename to src/redesign/forgot-password/data/reducers.js diff --git a/src/redesign/forgot-password/data/sagas.js b/src/redesign/forgot-password/data/sagas.js new file mode 100644 index 00000000..439f6bf0 --- /dev/null +++ b/src/redesign/forgot-password/data/sagas.js @@ -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); +} diff --git a/src/redesign/forgot-password/data/selectors.js b/src/redesign/forgot-password/data/selectors.js new file mode 100644 index 00000000..dbb3f10e --- /dev/null +++ b/src/redesign/forgot-password/data/selectors.js @@ -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, +); diff --git a/src/redesign/forgot-password/data/service.js b/src/redesign/forgot-password/data/service.js new file mode 100644 index 00000000..25020c56 --- /dev/null +++ b/src/redesign/forgot-password/data/service.js @@ -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; +} diff --git a/src/redesign/forgot-password/data/tests/sagas.test.js b/src/redesign/forgot-password/data/tests/sagas.test.js new file mode 100644 index 00000000..9e4de10e --- /dev/null +++ b/src/redesign/forgot-password/data/tests/sagas.test.js @@ -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(); + }); +}); diff --git a/src/redesign/forgot-password/index.js b/src/redesign/forgot-password/index.js new file mode 100644 index 00000000..2d30ff06 --- /dev/null +++ b/src/redesign/forgot-password/index.js @@ -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'; diff --git a/src/forgot-password/messages.js b/src/redesign/forgot-password/messages.js similarity index 100% rename from src/forgot-password/messages.js rename to src/redesign/forgot-password/messages.js diff --git a/src/forgot-password/tests/ForgotPasswordPage.test.jsx b/src/redesign/forgot-password/tests/ForgotPasswordPage.test.jsx similarity index 100% rename from src/forgot-password/tests/ForgotPasswordPage.test.jsx rename to src/redesign/forgot-password/tests/ForgotPasswordPage.test.jsx diff --git a/src/redesign/i18n/index.jsx b/src/redesign/i18n/index.jsx new file mode 100644 index 00000000..57b2ee41 --- /dev/null +++ b/src/redesign/i18n/index.jsx @@ -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; diff --git a/src/redesign/i18n/messages/ar.json b/src/redesign/i18n/messages/ar.json new file mode 100644 index 00000000..804d7611 --- /dev/null +++ b/src/redesign/i18n/messages/ar.json @@ -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" +} \ No newline at end of file diff --git a/src/redesign/i18n/messages/ca.json b/src/redesign/i18n/messages/ca.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/redesign/i18n/messages/ca.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/redesign/i18n/messages/es_419.json b/src/redesign/i18n/messages/es_419.json new file mode 100644 index 00000000..d92ad83d --- /dev/null +++ b/src/redesign/i18n/messages/es_419.json @@ -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" +} \ No newline at end of file diff --git a/src/redesign/i18n/messages/fr.json b/src/redesign/i18n/messages/fr.json new file mode 100644 index 00000000..a2d67e72 --- /dev/null +++ b/src/redesign/i18n/messages/fr.json @@ -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" +} \ No newline at end of file diff --git a/src/redesign/i18n/messages/he.json b/src/redesign/i18n/messages/he.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/redesign/i18n/messages/he.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/redesign/i18n/messages/id.json b/src/redesign/i18n/messages/id.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/redesign/i18n/messages/id.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/redesign/i18n/messages/ko_kr.json b/src/redesign/i18n/messages/ko_kr.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/redesign/i18n/messages/ko_kr.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/redesign/i18n/messages/pl.json b/src/redesign/i18n/messages/pl.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/redesign/i18n/messages/pl.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/redesign/i18n/messages/pt_br.json b/src/redesign/i18n/messages/pt_br.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/redesign/i18n/messages/pt_br.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/redesign/i18n/messages/ru.json b/src/redesign/i18n/messages/ru.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/redesign/i18n/messages/ru.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/redesign/i18n/messages/th.json b/src/redesign/i18n/messages/th.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/redesign/i18n/messages/th.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/redesign/i18n/messages/uk.json b/src/redesign/i18n/messages/uk.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/redesign/i18n/messages/uk.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/redesign/i18n/messages/zh_CN.json b/src/redesign/i18n/messages/zh_CN.json new file mode 100644 index 00000000..a2d67e72 --- /dev/null +++ b/src/redesign/i18n/messages/zh_CN.json @@ -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" +} \ No newline at end of file diff --git a/src/redesign/index.jsx b/src/redesign/index.jsx new file mode 100755 index 00000000..38cd1cbd --- /dev/null +++ b/src/redesign/index.jsx @@ -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 = () => ( + + + + + + + } /> + + + + + + + + + + + +); + +export default RedesignApp; diff --git a/src/redesign/index.scss b/src/redesign/index.scss new file mode 100755 index 00000000..3b10b284 --- /dev/null +++ b/src/redesign/index.scss @@ -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"; diff --git a/src/login/AccountActivationMessage.jsx b/src/redesign/login/AccountActivationMessage.jsx similarity index 100% rename from src/login/AccountActivationMessage.jsx rename to src/redesign/login/AccountActivationMessage.jsx diff --git a/src/login/LoginFailure.jsx b/src/redesign/login/LoginFailure.jsx similarity index 100% rename from src/login/LoginFailure.jsx rename to src/redesign/login/LoginFailure.jsx diff --git a/src/login/LoginPage.jsx b/src/redesign/login/LoginPage.jsx similarity index 100% rename from src/login/LoginPage.jsx rename to src/redesign/login/LoginPage.jsx diff --git a/src/login/data/actions.js b/src/redesign/login/data/actions.js similarity index 100% rename from src/login/data/actions.js rename to src/redesign/login/data/actions.js diff --git a/src/redesign/login/data/constants.js b/src/redesign/login/data/constants.js new file mode 100644 index 00000000..a213714c --- /dev/null +++ b/src/redesign/login/data/constants.js @@ -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', +}; diff --git a/src/login/data/reducers.js b/src/redesign/login/data/reducers.js similarity index 100% rename from src/login/data/reducers.js rename to src/redesign/login/data/reducers.js diff --git a/src/redesign/login/data/sagas.js b/src/redesign/login/data/sagas.js new file mode 100644 index 00000000..825ed738 --- /dev/null +++ b/src/redesign/login/data/sagas.js @@ -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); +} diff --git a/src/redesign/login/data/selectors.js b/src/redesign/login/data/selectors.js new file mode 100644 index 00000000..a409a5ee --- /dev/null +++ b/src/redesign/login/data/selectors.js @@ -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, +); diff --git a/src/login/data/service.js b/src/redesign/login/data/service.js similarity index 100% rename from src/login/data/service.js rename to src/redesign/login/data/service.js diff --git a/src/redesign/login/data/tests/sagas.test.js b/src/redesign/login/data/tests/sagas.test.js new file mode 100644 index 00000000..a7f40180 --- /dev/null +++ b/src/redesign/login/data/tests/sagas.test.js @@ -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), + ]); + }); +}); diff --git a/src/redesign/login/index.js b/src/redesign/login/index.js new file mode 100644 index 00000000..769161d2 --- /dev/null +++ b/src/redesign/login/index.js @@ -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'; diff --git a/src/login/messages.jsx b/src/redesign/login/messages.jsx similarity index 100% rename from src/login/messages.jsx rename to src/redesign/login/messages.jsx diff --git a/src/redesign/login/tests/AccountActivationMessage.test.jsx b/src/redesign/login/tests/AccountActivationMessage.test.jsx new file mode 100644 index 00000000..4c20ba13 --- /dev/null +++ b/src/redesign/login/tests/AccountActivationMessage.test.jsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect(accountActivationMessage).toEqual({}); + }); +}); diff --git a/src/login/tests/LoginFailure.test.jsx b/src/redesign/login/tests/LoginFailure.test.jsx similarity index 100% rename from src/login/tests/LoginFailure.test.jsx rename to src/redesign/login/tests/LoginFailure.test.jsx diff --git a/src/login/tests/LoginPage.test.jsx b/src/redesign/login/tests/LoginPage.test.jsx similarity index 100% rename from src/login/tests/LoginPage.test.jsx rename to src/redesign/login/tests/LoginPage.test.jsx diff --git a/src/register/CountryDropdown.jsx b/src/redesign/register/CountryDropdown.jsx similarity index 100% rename from src/register/CountryDropdown.jsx rename to src/redesign/register/CountryDropdown.jsx diff --git a/src/register/OptionalFields.jsx b/src/redesign/register/OptionalFields.jsx similarity index 100% rename from src/register/OptionalFields.jsx rename to src/redesign/register/OptionalFields.jsx diff --git a/src/register/RegistrationFailure.jsx b/src/redesign/register/RegistrationFailure.jsx similarity index 100% rename from src/register/RegistrationFailure.jsx rename to src/redesign/register/RegistrationFailure.jsx diff --git a/src/register/RegistrationPage.jsx b/src/redesign/register/RegistrationPage.jsx similarity index 100% rename from src/register/RegistrationPage.jsx rename to src/redesign/register/RegistrationPage.jsx diff --git a/src/register/UsernameField.jsx b/src/redesign/register/UsernameField.jsx similarity index 100% rename from src/register/UsernameField.jsx rename to src/redesign/register/UsernameField.jsx diff --git a/src/register/data/actions.js b/src/redesign/register/data/actions.js similarity index 100% rename from src/register/data/actions.js rename to src/redesign/register/data/actions.js diff --git a/src/register/data/constants.js b/src/redesign/register/data/constants.js similarity index 100% rename from src/register/data/constants.js rename to src/redesign/register/data/constants.js diff --git a/src/register/data/reducers.js b/src/redesign/register/data/reducers.js similarity index 100% rename from src/register/data/reducers.js rename to src/redesign/register/data/reducers.js diff --git a/src/register/data/sagas.js b/src/redesign/register/data/sagas.js similarity index 100% rename from src/register/data/sagas.js rename to src/redesign/register/data/sagas.js diff --git a/src/register/data/selectors.js b/src/redesign/register/data/selectors.js similarity index 100% rename from src/register/data/selectors.js rename to src/redesign/register/data/selectors.js diff --git a/src/register/data/service.js b/src/redesign/register/data/service.js similarity index 100% rename from src/register/data/service.js rename to src/redesign/register/data/service.js diff --git a/src/register/data/tests/reducers.test.js b/src/redesign/register/data/tests/reducers.test.js similarity index 100% rename from src/register/data/tests/reducers.test.js rename to src/redesign/register/data/tests/reducers.test.js diff --git a/src/register/data/tests/sagas.test.js b/src/redesign/register/data/tests/sagas.test.js similarity index 99% rename from src/register/data/tests/sagas.test.js rename to src/redesign/register/data/tests/sagas.test.js index 9fb7e448..e55a6c31 100644 --- a/src/register/data/tests/sagas.test.js +++ b/src/redesign/register/data/tests/sagas.test.js @@ -7,7 +7,7 @@ import { handleNewUserRegistration, } from '../sagas'; import * as api from '../service'; -import initializeMockLogging from '../../../setupTest'; +import initializeMockLogging from '../../../../setupTest'; import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from '../constants'; const { loggingService } = initializeMockLogging(); diff --git a/src/redesign/register/index.js b/src/redesign/register/index.js new file mode 100644 index 00000000..496c0f7b --- /dev/null +++ b/src/redesign/register/index.js @@ -0,0 +1,4 @@ +export { default as RegistrationPage } from './RegistrationPage'; +export { default as reducer } from './data/reducers'; +export { default as saga } from './data/sagas'; +export { storeName } from './data/selectors'; diff --git a/src/register/messages.jsx b/src/redesign/register/messages.jsx similarity index 100% rename from src/register/messages.jsx rename to src/redesign/register/messages.jsx diff --git a/src/register/tests/RegistrationPage.test.jsx b/src/redesign/register/tests/RegistrationPage.test.jsx similarity index 100% rename from src/register/tests/RegistrationPage.test.jsx rename to src/redesign/register/tests/RegistrationPage.test.jsx diff --git a/src/reset-password/ResetPasswordFailure.jsx b/src/redesign/reset-password/ResetPasswordFailure.jsx similarity index 100% rename from src/reset-password/ResetPasswordFailure.jsx rename to src/redesign/reset-password/ResetPasswordFailure.jsx diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/redesign/reset-password/ResetPasswordPage.jsx similarity index 100% rename from src/reset-password/ResetPasswordPage.jsx rename to src/redesign/reset-password/ResetPasswordPage.jsx diff --git a/src/reset-password/ResetPasswordSuccess.jsx b/src/redesign/reset-password/ResetPasswordSuccess.jsx similarity index 100% rename from src/reset-password/ResetPasswordSuccess.jsx rename to src/redesign/reset-password/ResetPasswordSuccess.jsx diff --git a/src/reset-password/data/actions.js b/src/redesign/reset-password/data/actions.js similarity index 100% rename from src/reset-password/data/actions.js rename to src/redesign/reset-password/data/actions.js diff --git a/src/reset-password/data/constants.js b/src/redesign/reset-password/data/constants.js similarity index 100% rename from src/reset-password/data/constants.js rename to src/redesign/reset-password/data/constants.js diff --git a/src/reset-password/data/reducers.js b/src/redesign/reset-password/data/reducers.js similarity index 100% rename from src/reset-password/data/reducers.js rename to src/redesign/reset-password/data/reducers.js diff --git a/src/reset-password/data/sagas.js b/src/redesign/reset-password/data/sagas.js similarity index 100% rename from src/reset-password/data/sagas.js rename to src/redesign/reset-password/data/sagas.js diff --git a/src/redesign/reset-password/data/selectors.js b/src/redesign/reset-password/data/selectors.js new file mode 100644 index 00000000..a280d6f9 --- /dev/null +++ b/src/redesign/reset-password/data/selectors.js @@ -0,0 +1,10 @@ +import { createSelector } from 'reselect'; + +export const storeName = 'resetPassword'; + +export const resetPasswordSelector = state => ({ ...state[storeName] }); + +export const resetPasswordResultSelector = createSelector( + resetPasswordSelector, + resetPassword => resetPassword, +); diff --git a/src/redesign/reset-password/data/service.js b/src/redesign/reset-password/data/service.js new file mode 100644 index 00000000..8a4f234e --- /dev/null +++ b/src/redesign/reset-password/data/service.js @@ -0,0 +1,64 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getHttpClient } from '@edx/frontend-platform/auth'; +import formurlencoded from 'form-urlencoded'; + +// eslint-disable-next-line import/prefer-default-export +export async function validateToken(token) { + const requestConfig = { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }; + + const { data } = await getHttpClient() + .post( + `${getConfig().LMS_BASE_URL}/user_api/v1/account/password_reset/token/validate/`, + formurlencoded({ token }), + requestConfig, + ) + .catch((e) => { + throw (e); + }); + return data; +} + +// eslint-disable-next-line import/prefer-default-export +export async function resetPassword(payload, token, queryParams) { + const requestConfig = { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }; + const url = new URL(`${getConfig().LMS_BASE_URL}/password/reset/${token}/`); + + if (queryParams.is_account_recovery) { + url.searchParams.append('is_account_recovery', true); + } + + const { data } = await getHttpClient() + .post(url.href, formurlencoded(payload), requestConfig) + .catch((e) => { + throw (e); + }); + return data; +} + +export async function validatePassword(password) { + const requestConfig = { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }; + const { data } = await getHttpClient() + .post( + `${getConfig().LMS_BASE_URL}/api/user/v1/validation/registration`, + formurlencoded({ password }), + requestConfig, + ) + .catch((e) => { + throw (e); + }); + + let errorMessage = ''; + // Be careful about grabbing this message, since we could have received an HTTP error or the + // endpoint didn't give us what we expect. We only care if we get a clear error message. + if (data.validation_decisions && data.validation_decisions.password) { + errorMessage = data.validation_decisions.password; + } + + return errorMessage; +} diff --git a/src/reset-password/data/tests/sagas.test.js b/src/redesign/reset-password/data/tests/sagas.test.js similarity index 98% rename from src/reset-password/data/tests/sagas.test.js rename to src/redesign/reset-password/data/tests/sagas.test.js index f156d334..ebd89de1 100644 --- a/src/reset-password/data/tests/sagas.test.js +++ b/src/redesign/reset-password/data/tests/sagas.test.js @@ -10,7 +10,7 @@ import { PASSWORD_RESET } from '../constants'; import { handleResetPassword, handleValidateToken } from '../sagas'; import * as api from '../service'; -import initializeMockLogging from '../../../setupTest'; +import initializeMockLogging from '../../../../setupTest'; const { loggingService } = initializeMockLogging(); diff --git a/src/redesign/reset-password/index.js b/src/redesign/reset-password/index.js new file mode 100644 index 00000000..2116170a --- /dev/null +++ b/src/redesign/reset-password/index.js @@ -0,0 +1,5 @@ +export { default } from './ResetPasswordPage'; +export { default as reducer } from './data/reducers'; +export { RESET_PASSWORD } from './data/actions'; +export { default as saga } from './data/sagas'; +export { storeName } from './data/selectors'; diff --git a/src/reset-password/messages.js b/src/redesign/reset-password/messages.js similarity index 100% rename from src/reset-password/messages.js rename to src/redesign/reset-password/messages.js diff --git a/src/reset-password/tests/ResetPasswordPage.test.jsx b/src/redesign/reset-password/tests/ResetPasswordPage.test.jsx similarity index 100% rename from src/reset-password/tests/ResetPasswordPage.test.jsx rename to src/redesign/reset-password/tests/ResetPasswordPage.test.jsx diff --git a/src/welcome/WelcomePage.jsx b/src/redesign/welcome/WelcomePage.jsx similarity index 100% rename from src/welcome/WelcomePage.jsx rename to src/redesign/welcome/WelcomePage.jsx diff --git a/src/redesign/welcome/index.js b/src/redesign/welcome/index.js new file mode 100644 index 00000000..b031301e --- /dev/null +++ b/src/redesign/welcome/index.js @@ -0,0 +1 @@ +export { default } from './WelcomePage'; diff --git a/src/redesign/welcome/messages.jsx b/src/redesign/welcome/messages.jsx new file mode 100644 index 00000000..d3244f2a --- /dev/null +++ b/src/redesign/welcome/messages.jsx @@ -0,0 +1,110 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'optional.fields.page.title': { + id: 'optional.fields.page.title', + defaultMessage: 'Optional Fields | {siteName}', + description: 'optional fields page title', + }, + 'optional.fields.page.heading': { + id: 'optional.fields.page.heading', + defaultMessage: 'Support education research by providing additional information.', + description: 'The page heading for the optional fields page.', + }, + 'welcome.to.edx': { + id: 'welcome.to.edx', + defaultMessage: 'Welcome to edX, {username}!', + description: 'Welcome message on the optional fields page.', + }, + 'registration.field.gender.options.label': { + id: 'registration.field.gender.options.label', + defaultMessage: 'Gender (optional)', + description: 'Placeholder for the gender options dropdown', + }, + 'registration.field.gender.options.f': { + id: 'registration.field.gender.options.f', + defaultMessage: 'Female', + description: 'The label for the female gender option.', + }, + 'registration.field.gender.options.m': { + id: 'registration.field.gender.options.m', + defaultMessage: 'Male', + description: 'The label for the male gender option.', + }, + 'registration.field.gender.options.o': { + id: 'registration.field.gender.options.o', + defaultMessage: 'Other/Prefer not to say', + description: 'The label for catch-all gender option.', + }, + 'registration.field.education.levels.label': { + id: 'registration.field.education.levels.label', + defaultMessage: 'Highest level of education completed (optional)', + description: 'Placeholder for the education levels dropdown.', + }, + 'registration.field.education.levels.p': { + id: 'registration.field.education.levels.p', + defaultMessage: 'Doctorate', + description: 'Selected by the user if their highest level of education is a doctorate degree.', + }, + 'registration.field.education.levels.m': { + id: 'registration.field.education.levels.m', + defaultMessage: "Master's or professional degree", + description: "Selected by the user if their highest level of education is a master's or professional degree from a college or university.", + }, + 'registration.field.education.levels.b': { + id: 'registration.field.education.levels.b', + defaultMessage: "Bachelor's degree", + description: "Selected by the user if their highest level of education is a four year college or university bachelor's degree.", + }, + 'registration.field.education.levels.a': { + id: 'registration.field.education.levels.a', + defaultMessage: "Associate's degree", + description: "Selected by the user if their highest level of education is an associate's degree. 1-2 years of college or university.", + }, + 'registration.field.education.levels.hs': { + id: 'registration.field.education.levels.hs', + defaultMessage: 'Secondary/high school', + description: 'Selected by the user if their highest level of education is secondary or high school. 9-12 years of education.', + }, + 'registration.field.education.levels.jhs': { + id: 'registration.field.education.levels.jhs', + defaultMessage: 'Junior secondary/junior high/middle school', + description: 'Selected by the user if their highest level of education is junior or middle school. 6-8 years of education.', + }, + 'registration.field.education.levels.el': { + id: 'registration.field.education.levels.el', + defaultMessage: 'Elementary/primary school', + description: 'Selected by the user if their highest level of education is elementary or primary school. 1-5 years of education.', + }, + 'registration.field.education.levels.none': { + id: 'registration.field.education.levels.none', + defaultMessage: 'No formal education', + description: 'Selected by the user to describe their education.', + }, + 'registration.field.education.levels.other': { + id: 'registration.field.education.levels.other', + defaultMessage: 'Other education', + description: 'Selected by the user if they have a type of education not described by the other choices.', + }, + 'registration.year.of.birth.label': { + id: 'registration.year.of.birth.label', + defaultMessage: 'Year of birth (optional)', + description: 'Placeholder for the year of birth options dropdown', + }, + 'optional.fields.information.link': { + id: 'optional.fields.information.link', + defaultMessage: 'Learn more about how we use this information.', + description: 'Optional fields page information link', + }, + 'optional.fields.submit.button': { + id: 'optional.fields.submit.button', + defaultMessage: 'Submit', + description: 'Submit button text', + }, + 'optional.fields.skip.button': { + id: 'optional.fields.skip.button', + defaultMessage: 'Skip for now', + description: 'Skip button text', + }, +}); +export default messages;