diff --git a/.env b/.env index 10c73397..42af6184 100644 --- a/.env +++ b/.env @@ -15,7 +15,7 @@ SEGMENT_KEY=null SITE_NAME=null USER_INFO_COOKIE_NAME=null AUTHN_MINIMAL_HEADER=true -LOGIN_ISSUE_SUPPORT_LINK=null +LOGIN_ISSUE_SUPPORT_LINK='' REGISTRATION_OPTIONAL_FIELDS='' USER_SURVEY_COOKIE_NAME=null COOKIE_DOMAIN=null diff --git a/src/common-components/MediumScreenHeader.jsx b/src/common-components/MediumScreenHeader.jsx index e46550ce..c8285003 100644 --- a/src/common-components/MediumScreenHeader.jsx +++ b/src/common-components/MediumScreenHeader.jsx @@ -10,7 +10,7 @@ const MediumScreenHeader = (props) => { return ( <> -
+
edx
diff --git a/src/common-components/index.jsx b/src/common-components/index.jsx index de82e0a2..a4463735 100644 --- a/src/common-components/index.jsx +++ b/src/common-components/index.jsx @@ -9,7 +9,6 @@ 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 AlertDismissible } from './AlertDismissible'; export { default as reducer } from './data/reducers'; export { default as saga } from './data/sagas'; export { storeName } from './data/selectors'; diff --git a/src/forgot-password/ForgotPasswordAlert.jsx b/src/forgot-password/ForgotPasswordAlert.jsx new file mode 100644 index 00000000..471144aa --- /dev/null +++ b/src/forgot-password/ForgotPasswordAlert.jsx @@ -0,0 +1,94 @@ +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, Icon } from '@edx/paragon'; +import { CheckCircle, Info } from '@edx/paragon/icons'; + +import messages from './messages'; +import { INTERNAL_SERVER_ERROR } from '../data/constants'; +import { PASSWORD_RESET } from '../reset-password/data/constants'; + +const ForgotPasswordAlert = (props) => { + const { email, emailError, intl } = props; + let { status } = props; + + if (emailError) { + status = 'form-submission-error'; + } + + let message = ''; + let heading = intl.formatMessage(messages['forgot.password.error.alert.title']); + switch (status) { + case 'complete': + heading = intl.formatMessage(messages['confirmation.message.title']); + message = ( + {email}, + supportLink: ( + + {intl.formatMessage(messages['confirmation.support.link'])} + + ), + }} + /> + ); + break; + case INTERNAL_SERVER_ERROR: + message = intl.formatMessage(messages['internal.server.error']); + break; + case 'forbidden': + heading = intl.formatMessage(messages['forgot.password.error.message.title']); + message = intl.formatMessage(messages['forgot.password.request.in.progress.message']); + break; + case 'form-submission-error': + message = intl.formatMessage(messages['extend.field.errors'], { emailError }); + break; + case PASSWORD_RESET.INVALID_TOKEN: + heading = intl.formatMessage(messages['invalid.token.heading']); + message = intl.formatMessage(messages['invalid.token.error.message']); + break; + case PASSWORD_RESET.FORBIDDEN_REQUEST: + heading = intl.formatMessage(messages['token.validation.rate.limit.error.heading']); + message = intl.formatMessage(messages['token.validation.rate.limit.error']); + break; + case PASSWORD_RESET.INTERNAL_SERVER_ERROR: + heading = intl.formatMessage(messages['token.validation.internal.sever.error.heading']); + message = intl.formatMessage(messages['token.validation.internal.sever.error']); + break; + default: + break; + } + + if (message) { + return ( + + {status === 'complete' ? : } + {heading} +

{message}

+ + ); + } + return null; +}; + +ForgotPasswordAlert.defaultProps = { + email: '', + emailError: '', +}; + +ForgotPasswordAlert.propTypes = { + status: PropTypes.string.isRequired, + email: PropTypes.string, + intl: intlShape.isRequired, + emailError: PropTypes.string, +}; + +export default injectIntl(ForgotPasswordAlert); diff --git a/src/forgot-password/ForgotPasswordPage.jsx b/src/forgot-password/ForgotPasswordPage.jsx index 95a736c4..7a5ef7b1 100644 --- a/src/forgot-password/ForgotPasswordPage.jsx +++ b/src/forgot-password/ForgotPasswordPage.jsx @@ -9,59 +9,26 @@ import { Link } 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, - Hyperlink, - Icon, -} from '@edx/paragon'; -import { Info } from '@edx/paragon/icons'; +import { Form, StatefulButton, Hyperlink } from '@edx/paragon'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSpinner, faChevronLeft } from '@fortawesome/free-solid-svg-icons'; import { forgotPassword } from './data/actions'; import { forgotPasswordResultSelector } from './data/selectors'; -import RequestInProgressAlert from './RequestInProgressAlert'; -import SuccessAlert from './SuccessAlert'; import messages from './messages'; import { FormGroup } from '../common-components'; -import APIFailureMessage from '../common-components/APIFailureMessage'; -import { INTERNAL_SERVER_ERROR, LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants'; +import { DEFAULT_STATE, LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants'; import { updatePathWithQueryParams, windowScrollTo } from '../data/utils'; +import ForgotPasswordAlert from './ForgotPasswordAlert'; const ForgotPasswordPage = (props) => { - const { intl } = props; - let { status } = props; + const { intl, status, submitState } = props; const platformName = getConfig().SITE_NAME; const regex = new RegExp(VALID_EMAIL_REGEX, 'i'); const [validationError, setValidationError] = useState(''); - const renderAlertMessages = (errors, email) => { - const header = intl.formatMessage(messages['forgot.password.error.alert.title']); - if (status === 'complete') { - status = 'default'; - return ( - - ); - } - if (errors.email) { - return ( - - - {header} -

{`${errors.email}${intl.formatMessage(messages['extend.field.errors'])}`}

-
- ); - } - if (status === INTERNAL_SERVER_ERROR) { - return ; - } - return status === 'forbidden' ? : null; - }; - const getValidationMessage = (email) => { let error = ''; @@ -109,46 +76,42 @@ const ForgotPasswordPage = (props) => { { siteName: getConfig().SITE_NAME })} -
-
-
- { renderAlertMessages(errors, values.email) } -

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

-

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

- getValidationMessage(values.email)} - handleChange={e => setFieldValue('email', e.target.value)} - helpText={[intl.formatMessage(messages['forgot.password.email.help.text'], { platformName })]} - /> - }} - onClick={handleSubmit} - onMouseDown={(e) => e.preventDefault()} - /> - - {intl.formatMessage(messages['need.help.sign.in.text'])} - -

{intl.formatMessage(messages['additional.help.text'])} - {getConfig().INFO_EMAIL} -

- -
-
+
+ +

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

+

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

+ getValidationMessage(values.email)} + handleChange={e => setFieldValue('email', e.target.value)} + helpText={[intl.formatMessage(messages['forgot.password.email.help.text'], { platformName })]} + /> + }} + onClick={handleSubmit} + onMouseDown={(e) => e.preventDefault()} + /> + + {intl.formatMessage(messages['need.help.sign.in.text'])} + +

{intl.formatMessage(messages['additional.help.text'])} + {getConfig().INFO_EMAIL} +

+ )} @@ -161,10 +124,12 @@ ForgotPasswordPage.propTypes = { intl: intlShape.isRequired, forgotPassword: PropTypes.func.isRequired, status: PropTypes.string, + submitState: PropTypes.string, }; ForgotPasswordPage.defaultProps = { status: null, + submitState: DEFAULT_STATE, }; export default connect( diff --git a/src/forgot-password/RequestInProgressAlert.jsx b/src/forgot-password/RequestInProgressAlert.jsx deleted file mode 100644 index 7a69cd45..00000000 --- a/src/forgot-password/RequestInProgressAlert.jsx +++ /dev/null @@ -1,25 +0,0 @@ -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/SuccessAlert.jsx b/src/forgot-password/SuccessAlert.jsx deleted file mode 100644 index 4cd7b810..00000000 --- a/src/forgot-password/SuccessAlert.jsx +++ /dev/null @@ -1,44 +0,0 @@ -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, Icon } from '@edx/paragon'; -import { CheckCircle } from '@edx/paragon/icons'; - -import messages from './messages'; - -const SuccessAlert = (props) => { - const { email, intl } = props; - - return ( - - - {intl.formatMessage(messages['confirmation.message.title'])} -

- {email}, - supportLink: ( - - {intl.formatMessage(messages['confirmation.support.link'])} - - ), - }} - /> -

-
- ); -}; - -SuccessAlert.propTypes = { - email: PropTypes.string.isRequired, - intl: intlShape.isRequired, -}; - -export default injectIntl(SuccessAlert); diff --git a/src/forgot-password/data/reducers.js b/src/forgot-password/data/reducers.js index 3dfa9b43..8144e987 100644 --- a/src/forgot-password/data/reducers.js +++ b/src/forgot-password/data/reducers.js @@ -1,8 +1,10 @@ import { FORGOT_PASSWORD } from './actions'; -import { INTERNAL_SERVER_ERROR } from '../../data/constants'; +import { INTERNAL_SERVER_ERROR, PENDING_STATE } from '../../data/constants'; +import { PASSWORD_RESET_FAILURE } from '../../reset-password/data/actions'; export const defaultState = { - status: null, + status: '', + submitState: '', }; const reducer = (state = defaultState, action = null) => { @@ -11,6 +13,7 @@ const reducer = (state = defaultState, action = null) => { case FORGOT_PASSWORD.BEGIN: return { status: 'pending', + submitState: PENDING_STATE, }; case FORGOT_PASSWORD.SUCCESS: return { @@ -25,8 +28,12 @@ const reducer = (state = defaultState, action = null) => { return { status: INTERNAL_SERVER_ERROR, }; + case PASSWORD_RESET_FAILURE: + return { + status: action.payload.errorCode, + }; default: - return state; + return defaultState; } } return state; diff --git a/src/forgot-password/messages.js b/src/forgot-password/messages.js index 55a44307..99502195 100644 --- a/src/forgot-password/messages.js +++ b/src/forgot-password/messages.js @@ -21,7 +21,6 @@ const messages = defineMessages({ defaultMessage: 'Enter a valid email address', description: 'Invalid email address message for input field.', }, - 'forgot.password.page.email.field.label': { id: 'forgot.password.page.email.field.label', defaultMessage: 'Email', @@ -78,7 +77,6 @@ const messages = defineMessages({ defaultMessage: 'contact technical support', description: 'Technical support link text', }, - 'need.help.sign.in.text': { id: 'need.help.sign.in.text', defaultMessage: 'Need help signing in?', @@ -91,14 +89,55 @@ const messages = defineMessages({ }, 'sign.in.text': { id: 'sign.in.text', - defaultMessage: 'Sign In', + defaultMessage: 'Sign in', description: 'login page link on password page', }, 'extend.field.errors': { id: 'extend.field.errors', - defaultMessage: ' below.', + defaultMessage: '{emailError} below.', description: 'extends the field error for alert message', }, - + // Reset password token validation failure + 'invalid.token.heading': { + id: 'invalid.token.heading', + defaultMessage: 'Invalid password reset link', + description: 'Alert heading when reset password link is invalid', + }, + 'invalid.token.error.message': { + id: 'invalid.token.error.message', + defaultMessage: 'This link has expired. Enter your email below to receive a new link.', + description: 'Alert message when reset password link has expired', + }, + 'token.validation.rate.limit.error.heading': { + id: 'token.validation.rate.limit.error.heading', + defaultMessage: 'Too many requests', + description: 'Too many request at server end point', + }, + 'token.validation.rate.limit.error': { + id: 'token.validation.rate.limit.error', + defaultMessage: 'An error has occurred because of too many requests. Please try again after some time.', + description: 'Error message that appears when server responds with 429 error code', + }, + 'token.validation.internal.sever.error.heading': { + id: 'token.validation.internal.sever.error.heading', + defaultMessage: 'Token validation failure', + description: 'Failed to validate reset password token error message.', + }, + 'token.validation.internal.sever.error': { + id: 'token.validation.internal.sever.error', + defaultMessage: 'An error has occurred. Try refreshing the page, or check your internet connection.', + description: 'Error message that appears when server responds with 500 error code', + }, + // Error messages + 'internal.server.error': { + id: 'internal.server.error', + defaultMessage: 'An error has occurred. Try refreshing the page, or check your internet connection.', + description: 'Error message that appears when server responds with 500 error code', + }, + 'rate.limit.error': { + id: 'rate.limit.error', + defaultMessage: 'An error has occurred because of too many requests. Please try again after some time.', + description: 'Error message that appears when server responds with 429 error code', + }, }); export default messages; diff --git a/src/forgot-password/tests/ForgotPasswordPage.test.jsx b/src/forgot-password/tests/ForgotPasswordPage.test.jsx index 8b034515..aee861ee 100644 --- a/src/forgot-password/tests/ForgotPasswordPage.test.jsx +++ b/src/forgot-password/tests/ForgotPasswordPage.test.jsx @@ -2,9 +2,9 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; -import renderer from 'react-test-renderer'; import { mount } from 'enzyme'; import configureStore from 'redux-mock-store'; +import { mergeConfig } from '@edx/frontend-platform'; 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'; @@ -25,6 +25,11 @@ const initialState = { }; describe('ForgotPasswordPage', () => { + mergeConfig({ + LOGIN_ISSUE_SUPPORT_LINK: '', + INFO_EMAIL: '', + }); + let props = {}; let store = {}; @@ -44,32 +49,6 @@ describe('ForgotPasswordPage', () => { }; }); - 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 display need other help signing in button', () => { const wrapper = mount(reduxWrapper()); expect(wrapper.find('#forgot-password.btn-link').first().text()).toEqual('Need help signing in?'); @@ -96,7 +75,7 @@ describe('ForgotPasswordPage', () => { + '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); + expect(wrapper.find('#validation-errors').first().text()).toEqual(expectedMessage); }); it('should display empty email validation message', async () => { diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 42765177..a04371ea 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -42,6 +42,7 @@ import { getAllPossibleQueryParam, } from '../data/utils'; import { forgotPasswordResultSelector } from '../forgot-password'; +import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess'; class LoginPage extends React.Component { constructor(props, context) { @@ -206,7 +207,8 @@ class LoginPage extends React.Component { {this.props.loginError ? : null} {submitState === DEFAULT_STATE && this.state.isSubmitted ? windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }) : null} {activationMsgType && } -
+ {this.props.resetPassword && !this.props.loginError ? : null} + { loginError, loginResult, thirdPartyAuthContext, + resetPassword: state.login.resetPassword, }; }; diff --git a/src/login/data/reducers.js b/src/login/data/reducers.js index dee0d8b5..3937f95a 100644 --- a/src/login/data/reducers.js +++ b/src/login/data/reducers.js @@ -1,10 +1,12 @@ import { LOGIN_REQUEST } from './actions'; import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants'; +import { RESET_PASSWORD } from '../../reset-password'; export const defaultState = { loginError: null, loginResult: {}, + resetPassword: false, }; const reducer = (state = defaultState, action) => { @@ -13,6 +15,7 @@ const reducer = (state = defaultState, action) => { return { ...state, submitState: PENDING_STATE, + resetPassword: false, }; case LOGIN_REQUEST.SUCCESS: return { @@ -30,8 +33,15 @@ const reducer = (state = defaultState, action) => { ...state, loginError: null, }; + case RESET_PASSWORD.SUCCESS: + return { + ...state, + resetPassword: true, + }; default: - return state; + return { + ...state, + }; } }; diff --git a/src/reset-password/InvalidToken.jsx b/src/reset-password/InvalidToken.jsx deleted file mode 100644 index fe927e4d..00000000 --- a/src/reset-password/InvalidToken.jsx +++ /dev/null @@ -1,51 +0,0 @@ -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/reset-password/ResetPasswordFailure.jsx b/src/reset-password/ResetPasswordFailure.jsx new file mode 100644 index 00000000..f3e094a7 --- /dev/null +++ b/src/reset-password/ResetPasswordFailure.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Alert, Icon } from '@edx/paragon'; +import { Info } from '@edx/paragon/icons'; + +import { FORM_SUBMISSION_ERROR, PASSWORD_RESET, PASSWORD_VALIDATION_ERROR } from './data/constants'; + +import messages from './messages'; + +const ResetPasswordFailure = (props) => { + const { errorCode, errorMsg, intl } = props; + + let errorMessage = null; + let heading = intl.formatMessage(messages['reset.password.failure.heading']); + switch (errorCode) { + case PASSWORD_RESET.FORBIDDEN_REQUEST: + heading = intl.formatMessage(messages['reset.server.rate.limit.error']); + errorMessage = intl.formatMessage(messages['rate.limit.error']); + break; + case PASSWORD_RESET.INTERNAL_SERVER_ERROR: + errorMessage = intl.formatMessage(messages['internal.server.error']); + break; + case PASSWORD_VALIDATION_ERROR: + errorMessage = errorMsg; + break; + case FORM_SUBMISSION_ERROR: + errorMessage = intl.formatMessage(messages['reset.password.form.submission.error']); + break; + default: + break; + } + + if (errorMessage) { + return ( + + + {heading} +

{errorMessage}

+
+ ); + } + + return null; +}; + +ResetPasswordFailure.defaultProps = { + errorCode: null, + errorMsg: null, +}; + +ResetPasswordFailure.propTypes = { + errorCode: PropTypes.string, + errorMsg: PropTypes.string, + intl: intlShape.isRequired, +}; + +export default injectIntl(ResetPasswordFailure); diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/reset-password/ResetPasswordPage.jsx index 923fe528..13aae5f3 100644 --- a/src/reset-password/ResetPasswordPage.jsx +++ b/src/reset-password/ResetPasswordPage.jsx @@ -2,185 +2,158 @@ 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 { Link, Redirect } from 'react-router-dom'; + +import { Form, Spinner, 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 { faChevronLeft, 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 ResetPasswordFailure from './ResetPasswordFailure'; +import { PasswordField } from '../common-components'; 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'; + LETTER_REGEX, LOGIN_PAGE, NUMBER_REGEX, RESET_PAGE, +} from '../data/constants'; +import { + FORM_SUBMISSION_ERROR, PASSWORD_RESET_ERROR, PASSWORD_VALIDATION_ERROR, TOKEN_STATE, +} from './data/constants'; +import { updatePathWithQueryParams, 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(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [formErrors, setFormErrors] = useState({}); + const [errorCode, setErrorCode] = useState(null); useEffect(() => { - if (props.status === 'failure' - && props.errors !== INTERNAL_SERVER_ERROR - && props.errors !== API_RATELIMIT_ERROR) { - setbannerErrorMessage(props.errors); - setvalidationMessage(props.errors); - setPasswordValidValue(false); + if (props.status !== TOKEN_STATE.PENDING && props.status !== PASSWORD_RESET_ERROR) { + setErrorCode(props.status); } - }, [props.status]); + if (props.status === PASSWORD_VALIDATION_ERROR) { + formErrors.newPassword = intl.formatMessage(messages['password.validation.message']); + setFormErrors({ ...formErrors }); + } + }, [props.errorMsg]); - const validatePasswordFromBackend = async (newPassword) => { - let errorMessage; + const validatePasswordFromBackend = async (password) => { + let errorMessage = ''; try { - errorMessage = await validatePassword(newPassword); + errorMessage = await validatePassword(password); } catch (err) { errorMessage = ''; } - setPasswordValidValue(!errorMessage); - setvalidationMessage(errorMessage); + setFormErrors({ ...formErrors, newPassword: 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 validateInput = (name, value) => { + switch (name) { + case 'newPassword': + if (!value || !LETTER_REGEX.test(value) || !NUMBER_REGEX.test(value) || value.length < 8) { + formErrors.newPassword = intl.formatMessage(messages['password.validation.message']); + } else { + validatePasswordFromBackend(value); + } + break; + case 'confirmPassword': + if (!value) { + formErrors.confirmPassword = intl.formatMessage(messages['confirm.your.password']); + } else if (value !== newPassword) { + formErrors.confirmPassword = intl.formatMessage(messages['passwords.do.not.match']); + } else { + formErrors.confirmPassword = ''; + } + break; + default: + break; } + setFormErrors({ ...formErrors }); + return !Object.values(formErrors).some(x => (x !== '')); }; const handleConfirmPasswordChange = (e) => { - const confirmPassword = e.target.value; - setConfirmPasswordValue(confirmPassword); - setPasswordMatchValue(confirmPassword === newPasswordInput); + const { value } = e.target; + + setConfirmPassword(value); + validateInput('confirmPassword', value); }; 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 isPasswordValid = validateInput('newPassword', newPassword); + const isPasswordConfirmed = validateInput('confirmPassword', confirmPassword); - const formPayload = { - new_password1: newPasswordInput, - new_password2: confirmPasswordInput, - }; - props.resetPassword(formPayload, props.token, params); + if (isPasswordValid && isPasswordConfirmed) { + const formPayload = { + new_password1: newPassword, + new_password2: confirmPassword, + }; + const params = getQueryParameters(); + props.resetPassword(formPayload, props.token, params); + } else { + setErrorCode(FORM_SUBMISSION_ERROR); + windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }); + } }; - if (props.status === 'token-pending') { + if (props.status === TOKEN_STATE.PENDING) { const { token } = props.match.params; if (token) { props.validateToken(token); - return ; + 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 === PASSWORD_RESET_ERROR) { + return ; } else if (props.status === 'success') { - return ; + 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['sign.in'])} + + +
+
+ +

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

+

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

-

- {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" + setNewPassword(e.target.value)} + handleBlur={(e) => validateInput(e.target.name, e.target.value)} + errorMessage={formErrors.newPassword} + floatingLabel={intl.formatMessage(messages['new.password.label'])} /> - handleConfirmPasswordChange(e)} - className="w-100" - inputFieldStyle="border-gray-600" + }} onClick={e => handleSubmit(e)} @@ -189,7 +162,7 @@ const ResetPasswordPage = (props) => {
- +
); } return null; @@ -199,7 +172,7 @@ ResetPasswordPage.defaultProps = { status: null, token: null, match: null, - errors: null, + errorMsg: null, }; ResetPasswordPage.propTypes = { @@ -213,7 +186,7 @@ ResetPasswordPage.propTypes = { }), }), status: PropTypes.string, - errors: PropTypes.string, + errorMsg: PropTypes.string, }; export default connect( diff --git a/src/reset-password/ResetPasswordSuccess.jsx b/src/reset-password/ResetPasswordSuccess.jsx new file mode 100644 index 00000000..3b1bc19f --- /dev/null +++ b/src/reset-password/ResetPasswordSuccess.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 ResetPasswordSuccess = (props) => { + const { intl } = props; + + return ( + + + {intl.formatMessage(messages['reset.password.success.heading'])} + +

{intl.formatMessage(messages['reset.password.success'])}

+
+ ); +}; + +ResetPasswordSuccess.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(ResetPasswordSuccess); diff --git a/src/reset-password/ResetSuccess.jsx b/src/reset-password/ResetSuccess.jsx deleted file mode 100644 index 37a298ea..00000000 --- a/src/reset-password/ResetSuccess.jsx +++ /dev/null @@ -1,55 +0,0 @@ -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/reset-password/Spinner.jsx b/src/reset-password/Spinner.jsx deleted file mode 100644 index f02bff8e..00000000 --- a/src/reset-password/Spinner.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { Spinner as ParagonSpinner } from '@edx/paragon'; - -const Spinner = () => ( -
-
- -
-
-); - -export default Spinner; diff --git a/src/reset-password/data/actions.js b/src/reset-password/data/actions.js index 7e6b637b..846db6e9 100644 --- a/src/reset-password/data/actions.js +++ b/src/reset-password/data/actions.js @@ -2,6 +2,12 @@ import { AsyncActionType } from '../../data/utils'; export const RESET_PASSWORD = new AsyncActionType('RESET', 'PASSWORD'); export const VALIDATE_TOKEN = new AsyncActionType('VALIDATE', 'TOKEN'); +export const PASSWORD_RESET_FAILURE = 'PASSWORD_RESET_FAILURE'; + +export const passwordResetFailure = (errorCode) => ({ + type: PASSWORD_RESET_FAILURE, + payload: { errorCode }, +}); // Validate confirmation token export const validateToken = (token) => ({ @@ -18,9 +24,9 @@ export const validateTokenSuccess = (tokenStatus, token) => ({ payload: { tokenStatus, token }, }); -export const validateTokenFailure = errors => ({ +export const validateTokenFailure = errorCode => ({ type: VALIDATE_TOKEN.FAILURE, - payload: { errors }, + payload: { errorCode }, }); // Reset Password @@ -38,7 +44,7 @@ export const resetPasswordSuccess = data => ({ payload: { data }, }); -export const resetPasswordFailure = errors => ({ +export const resetPasswordFailure = (errorCode, errorMsg = null) => ({ type: RESET_PASSWORD.FAILURE, - payload: { errors }, + payload: { errorCode, errorMsg: errorMsg || errorCode }, }); diff --git a/src/reset-password/data/constants.js b/src/reset-password/data/constants.js new file mode 100644 index 00000000..6f50fcf7 --- /dev/null +++ b/src/reset-password/data/constants.js @@ -0,0 +1,15 @@ +export const TOKEN_STATE = { + PENDING: 'token-pending', + VALID: 'token-valid', +}; + +// password reset error codes +export const FORM_SUBMISSION_ERROR = 'form-submission-error'; +export const PASSWORD_RESET_ERROR = 'password-reset-error'; +export const PASSWORD_VALIDATION_ERROR = 'password-validation-failure'; + +export const PASSWORD_RESET = { + INVALID_TOKEN: 'invalid-token', + INTERNAL_SERVER_ERROR: 'password-reset-internal-server-error', + FORBIDDEN_REQUEST: 'password-reset-rate-limit-error', +}; diff --git a/src/reset-password/data/reducers.js b/src/reset-password/data/reducers.js index f7ce1898..554bcb2d 100644 --- a/src/reset-password/data/reducers.js +++ b/src/reset-password/data/reducers.js @@ -1,9 +1,10 @@ -import { RESET_PASSWORD, VALIDATE_TOKEN } from './actions'; +import { RESET_PASSWORD, VALIDATE_TOKEN, PASSWORD_RESET_FAILURE } from './actions'; +import { PASSWORD_RESET_ERROR, TOKEN_STATE } from './constants'; export const defaultState = { - status: 'token-pending', + status: TOKEN_STATE.PENDING, token: null, - errors: null, + errorMsg: null, }; const reducer = (state = defaultState, action = null) => { @@ -11,14 +12,13 @@ const reducer = (state = defaultState, action = null) => { case VALIDATE_TOKEN.SUCCESS: return { ...state, - status: 'valid', + status: TOKEN_STATE.VALID, token: action.payload.token, }; - case VALIDATE_TOKEN.FAILURE: + case PASSWORD_RESET_FAILURE: return { ...state, - status: 'invalid', - errors: action.payload.errors, + status: PASSWORD_RESET_ERROR, }; case RESET_PASSWORD.BEGIN: return { @@ -33,8 +33,8 @@ const reducer = (state = defaultState, action = null) => { case RESET_PASSWORD.FAILURE: return { ...state, - status: 'failure', - errors: action.payload.errors, + status: action.payload.errorCode, + errorMsg: action.payload.errorMsg, }; default: return state; diff --git a/src/reset-password/data/sagas.js b/src/reset-password/data/sagas.js index ef16270c..ea10b174 100644 --- a/src/reset-password/data/sagas.js +++ b/src/reset-password/data/sagas.js @@ -6,15 +6,15 @@ import { VALIDATE_TOKEN, validateTokenBegin, validateTokenSuccess, - validateTokenFailure, RESET_PASSWORD, resetPasswordBegin, resetPasswordSuccess, resetPasswordFailure, + passwordResetFailure, } from './actions'; import { validateToken, resetPassword } from './service'; -import { INTERNAL_SERVER_ERROR, API_RATELIMIT_ERROR } from '../../data/constants'; +import { PASSWORD_RESET, PASSWORD_VALIDATION_ERROR } from './constants'; // Services export function* handleValidateToken(action) { @@ -25,15 +25,14 @@ export function* handleValidateToken(action) { if (isValid) { yield put(validateTokenSuccess(isValid, action.payload.token)); } else { - yield put(validateTokenFailure(isValid)); + yield put(passwordResetFailure(PASSWORD_RESET.INVALID_TOKEN)); } } catch (err) { - const statusCodes = [429]; - if (err.response && statusCodes.includes(err.response.status)) { - yield put(validateTokenFailure(API_RATELIMIT_ERROR)); + if (err.response && err.response.status === 429) { + yield put(passwordResetFailure(PASSWORD_RESET.FORBIDDEN_REQUEST)); logInfo(err); } else { - yield put(validateTokenFailure(INTERNAL_SERVER_ERROR)); + yield put(passwordResetFailure(PASSWORD_RESET.INTERNAL_SERVER_ERROR)); logError(err); } } @@ -49,15 +48,14 @@ export function* handleResetPassword(action) { if (resetStatus) { yield put(resetPasswordSuccess(resetStatus)); } else { - yield put(resetPasswordFailure(resetErrors)); + yield put(resetPasswordFailure(PASSWORD_VALIDATION_ERROR, resetErrors)); } } catch (err) { - const statusCodes = [429]; - if (err.response && statusCodes.includes(err.response.status)) { - yield put(resetPasswordFailure(API_RATELIMIT_ERROR)); + if (err.response && err.response.status === 429) { + yield put(resetPasswordFailure(PASSWORD_RESET.FORBIDDEN_REQUEST)); logInfo(err); } else { - yield put(resetPasswordFailure(INTERNAL_SERVER_ERROR)); + yield put(resetPasswordFailure(PASSWORD_RESET.INTERNAL_SERVER_ERROR)); logError(err); } } diff --git a/src/reset-password/data/tests/sagas.test.js b/src/reset-password/data/tests/sagas.test.js index 47f9060f..f156d334 100644 --- a/src/reset-password/data/tests/sagas.test.js +++ b/src/reset-password/data/tests/sagas.test.js @@ -3,14 +3,14 @@ import { runSaga } from 'redux-saga'; import { resetPasswordBegin, resetPasswordSuccess, - resetPasswordFailure, validateTokenBegin, - validateTokenFailure, + passwordResetFailure, resetPasswordFailure, } from '../actions'; +import { PASSWORD_RESET } from '../constants'; 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(); @@ -57,7 +57,7 @@ describe('handleResetPassword', () => { response: { status: 500, data: { - errorCode: INTERNAL_SERVER_ERROR, + errorCode: PASSWORD_RESET.INTERNAL_SERVER_ERROR, }, }, }; @@ -73,7 +73,7 @@ describe('handleResetPassword', () => { expect(loggingService.logError).toHaveBeenCalled(); expect(resetPassword).toHaveBeenCalledTimes(1); - expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure(INTERNAL_SERVER_ERROR)]); + expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure(PASSWORD_RESET.INTERNAL_SERVER_ERROR)]); resetPassword.mockClear(); }); @@ -82,7 +82,7 @@ describe('handleResetPassword', () => { response: { status: 429, data: { - errorCode: API_RATELIMIT_ERROR, + errorCode: PASSWORD_RESET.FORBIDDEN_REQUEST, }, }, }; @@ -98,7 +98,7 @@ describe('handleResetPassword', () => { expect(loggingService.logInfo).toHaveBeenCalled(); expect(resetPassword).toHaveBeenCalledTimes(1); - expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure(API_RATELIMIT_ERROR)]); + expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure(PASSWORD_RESET.FORBIDDEN_REQUEST)]); resetPassword.mockClear(); }); }); @@ -121,7 +121,7 @@ describe('handleValidateToken', () => { response: { status: 500, data: { - errorCode: INTERNAL_SERVER_ERROR, + errorCode: PASSWORD_RESET.INTERNAL_SERVER_ERROR, }, }, }; @@ -136,16 +136,16 @@ describe('handleValidateToken', () => { ); expect(validateToken).toHaveBeenCalledTimes(1); - expect(dispatched).toEqual([validateTokenBegin(), validateTokenFailure(INTERNAL_SERVER_ERROR)]); + expect(dispatched).toEqual([validateTokenBegin(), passwordResetFailure(PASSWORD_RESET.INTERNAL_SERVER_ERROR)]); validateToken.mockClear(); }); - it('should call service and dispatch ratelimit error', async () => { + it('should call service and dispatch rate limit error', async () => { const errorResponse = { response: { status: 429, data: { - errorCode: API_RATELIMIT_ERROR, + errorCode: PASSWORD_RESET.FORBIDDEN_REQUEST, }, }, }; @@ -161,7 +161,7 @@ describe('handleValidateToken', () => { expect(loggingService.logInfo).toHaveBeenCalled(); expect(validateToken).toHaveBeenCalledTimes(1); - expect(dispatched).toEqual([validateTokenBegin(), validateTokenFailure(API_RATELIMIT_ERROR)]); + expect(dispatched).toEqual([validateTokenBegin(), passwordResetFailure(PASSWORD_RESET.FORBIDDEN_REQUEST)]); validateToken.mockClear(); }); }); diff --git a/src/reset-password/messages.js b/src/reset-password/messages.js index b68d82d6..6319b6bc 100644 --- a/src/reset-password/messages.js +++ b/src/reset-password/messages.js @@ -1,45 +1,51 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ + 'sign.in': { + id: 'sign.in', + defaultMessage: 'Sign in', + description: 'Sign in toggle text', + }, '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': { + id: 'reset.password', + defaultMessage: 'Reset password', + description: 'The page heading and button text for 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', + 'new.password.label': { + id: 'new.password.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', + 'confirm.password.label': { + id: 'confirm.password.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.', + // validation errors + 'password.validation.message': { + id: 'password.validation.message', + defaultMessage: 'Password criteria has not been met', + description: 'Error message for empty or invalid password', }, - 'reset.password.request.success.header.message': { - id: 'reset.password.request.success.header.message', - defaultMessage: 'Password reset complete.', - description: 'header message when reset is successful.', + 'passwords.do.not.match': { + id: 'passwords.do.not.match', + defaultMessage: 'Passwords do not match', + description: 'Password format error.', + }, + 'confirm.your.password': { + id: 'confirm.your.password', + defaultMessage: 'Confirm your password', + description: 'Field validation message when confirm password is empty', }, 'forgot.password.confirmation.sign.in.link': { id: 'forgot.password.confirmation.sign.in.link', @@ -61,10 +67,16 @@ const messages = defineMessages({ 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', + // alert banner strings + 'reset.password.failure.heading': { + id: 'reset.password.failure.heading', defaultMessage: 'We couldn\'t reset your password.', - description: 'Heading that appears above error message when user submits empty form.', + description: 'Heading for reset password request failure', + }, + 'reset.password.form.submission.error': { + id: 'reset.password.form.submission.error', + defaultMessage: 'Please check your responses and try again.', + description: 'Error message for reset password page', }, 'reset.password.request.server.error': { id: 'reset.password.request.server.error', @@ -76,11 +88,31 @@ const messages = defineMessages({ defaultMessage: 'Token validation failure', description: 'Failed to validate reset password token error message.', }, - 'reset.server.ratelimit.error': { - id: 'reset.server.ratelimit.error', + 'reset.server.rate.limit.error': { + id: 'reset.server.rate.limit.error', defaultMessage: 'Too many requests.', description: 'Too many request at server end point', }, + 'reset.password.success.heading': { + id: 'reset.password.success.heading', + defaultMessage: 'Password reset complete.', + description: 'Heading for alert box when reset password is successful', + }, + 'reset.password.success': { + id: 'reset.password.success', + defaultMessage: 'Your password has been reset. Sign in to your account.', + description: 'Reset password success message', + }, + 'internal.server.error': { + id: 'internal.server.error', + defaultMessage: 'An error has occurred. Try refreshing the page, or check your internet connection.', + description: 'Error message that appears when server responds with 500 error code', + }, + 'rate.limit.error': { + id: 'rate.limit.error', + defaultMessage: 'An error has occurred because of too many requests. Please try again after some time.', + description: 'Error message that appears when server responds with 429 error code', + }, }); export default messages; diff --git a/src/reset-password/tests/ResetPasswordPage.test.jsx b/src/reset-password/tests/ResetPasswordPage.test.jsx index 25427cb1..cb3cf6e0 100644 --- a/src/reset-password/tests/ResetPasswordPage.test.jsx +++ b/src/reset-password/tests/ResetPasswordPage.test.jsx @@ -1,17 +1,17 @@ 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 { mount } from 'enzyme'; +import configureStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; + +import * as auth from '@edx/frontend-platform/auth'; +import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner'; +import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; + +import { resetPassword } from '../data/actions'; +import { PASSWORD_RESET, TOKEN_STATE } from '../data/constants'; import ResetPasswordPage from '../ResetPasswordPage'; -import { API_RATELIMIT_ERROR, INTERNAL_SERVER_ERROR } from '../../data/constants'; jest.mock('@edx/frontend-platform/auth'); @@ -22,22 +22,19 @@ 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} + + {children} + ); - const submitForm = async (password) => { + const submitForm = (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'); + resetPasswordPage.find('input#newPassword').simulate('change', { target: { value: password, name: 'newPassword' } }); + resetPasswordPage.find('input#confirmPassword').simulate('change', { target: { value: password, name: 'confirmPassword' } }); + resetPasswordPage.find('button.btn-brand').simulate('click'); return resetPasswordPage; }; @@ -47,7 +44,6 @@ describe('ResetPasswordPage', () => { props = { resetPassword: jest.fn(), status: null, - token_status: 'pending', token: null, errors: null, match: { @@ -60,120 +56,16 @@ describe('ResetPasswordPage', () => { 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(); - }); + // ******** form submission tests ******** - it('show spinner component during token validation', () => { - props = { - ...props, - token_status: 'pending', - match: { - params: { - token: 'test-token', - }, + it('with valid inputs resetPassword action is dispatched', async () => { + const password = 'test-password-1'; + + store = mockStore({ + resetPassword: { + status: TOKEN_STATE.VALID, }, - }; - 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 () => ({ @@ -182,110 +74,83 @@ describe('ResetPasswordPage', () => { }), })); - const formPayload = { - new_password1: newPassword, - new_password2: newPassword, - }; - store.dispatch = jest.fn(store.dispatch); + const resetPasswordPage = submitForm(password); - const resetPasswordPage = await submitForm(newPassword); - expect(store.dispatch).toHaveBeenCalledWith(resetPassword(formPayload, props.token, {})); + expect(store.dispatch).toHaveBeenCalledWith(resetPassword( + { new_password1: password, new_password2: password }, props.token, {}, + )); resetPasswordPage.unmount(); }); - it('should dispatch resetPassword action if validations have reached rate limit', async () => { - const password = 'test-password'; + // ******** test reset password field validations ******** - auth.getHttpClient = jest.fn(() => ({ - post: async () => { - throw new Error('error'); + it('should show error messages for required fields on empty form submission', () => { + store = mockStore({ + resetPassword: { + status: TOKEN_STATE.VALID, }, - })); - store.dispatch = jest.fn(store.dispatch); + }); + const resetPasswordPage = mount(reduxWrapper()); + resetPasswordPage.find('button.btn-brand').simulate('click'); - 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, {}), + expect(resetPasswordPage.find('#validation-errors').first().text()).toEqual( + 'We couldn\'t reset your password.Please check your responses and try again.', ); - - resetPasswordPage.unmount(); + expect(resetPasswordPage.find('div[feedback-for="newPassword"]').text()).toEqual('Password criteria has not been met'); + expect(resetPasswordPage.find('div[feedback-for="confirmPassword"]').text()).toEqual('Confirm your password'); }); - 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', - }; + it('should show error message when new and confirm password do not match', () => { + store = mockStore({ + resetPassword: { + status: TOKEN_STATE.VALID, + }, + }); + const resetPasswordPage = mount(reduxWrapper()); + resetPasswordPage.find('input#confirmPassword').simulate( + 'change', { target: { value: 'password-mismatch', name: 'confirmPassword' } }, + ); + expect(resetPasswordPage.find('div[feedback-for="confirmPassword"]').text()).toEqual('Passwords do not match'); + }); + + // ******** alert message tests ******** + + it('should show reset password rate limit error', () => { + store = mockStore({ + resetPassword: { + status: PASSWORD_RESET.FORBIDDEN_REQUEST, + }, + }); 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); + expect(resetPasswordPage.find('#validation-errors').first().text()).toEqual( + 'Too many requests.An error has occurred because of too many requests. Please try again after some time.', + ); }); + it('should show reset password internal server error', () => { + store = mockStore({ + resetPassword: { + status: PASSWORD_RESET.INTERNAL_SERVER_ERROR, + }, + }); + + const resetPasswordPage = mount(reduxWrapper()); + expect(resetPasswordPage.find('#validation-errors').first().text()).toEqual( + 'We couldn\'t reset your password.An error has occurred. Try refreshing the page, or check your internet connection.', + ); + }); + + // ******** miscellaneous tests ******** + 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, - }; - + it('show spinner during token validation', () => { 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(); + expect(resetPasswordPage.find('div.spinner-header')).toBeTruthy(); }); }); diff --git a/src/reset-password/tests/__snapshots__/ResetPasswordPage.test.jsx.snap b/src/reset-password/tests/__snapshots__/ResetPasswordPage.test.jsx.snap deleted file mode 100644 index a24622c0..00000000 --- a/src/reset-password/tests/__snapshots__/ResetPasswordPage.test.jsx.snap +++ /dev/null @@ -1,464 +0,0 @@ -// 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. - -
- -
-
-
-`;