Update reset password page (#303)

* Update reset password page

* add tests

* fix success message
This commit is contained in:
Zainab Amir
2021-06-01 16:39:41 +05:00
committed by Waheed Ahmed
parent b16285bb47
commit 92163ac7dc
26 changed files with 597 additions and 1177 deletions

2
.env
View File

@@ -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

View File

@@ -10,7 +10,7 @@ const MediumScreenHeader = (props) => {
return (
<>
<div className="medium-screen-header">
<div className="medium-screen-header mb-4">
<img alt="edx" className="logo" src={getConfig().LOGO_WHITE_URL} />
<div className="row mt-4">
<svg className="svg-line pl-5">

View File

@@ -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';

View File

@@ -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 = (
<FormattedMessage
id="forgot.password.confirmation.message"
defaultMessage="We sent an email to {email} with instructions to reset your password.
If you do not receive a password reset message after 1 minute, verify that you entered
the correct email address, or check your spam folder. If you need further assistance, {supportLink}."
description="Forgot password confirmation message"
values={{
email: <span className="data-hj-suppress">{email}</span>,
supportLink: (
<Alert.Link className="alert-link" href={getConfig().PASSWORD_RESET_SUPPORT_LINK}>
{intl.formatMessage(messages['confirmation.support.link'])}
</Alert.Link>
),
}}
/>
);
break;
case INTERNAL_SERVER_ERROR:
message = intl.formatMessage(messages['internal.server.error']);
break;
case 'forbidden':
heading = intl.formatMessage(messages['forgot.password.error.message.title']);
message = intl.formatMessage(messages['forgot.password.request.in.progress.message']);
break;
case 'form-submission-error':
message = intl.formatMessage(messages['extend.field.errors'], { emailError });
break;
case PASSWORD_RESET.INVALID_TOKEN:
heading = intl.formatMessage(messages['invalid.token.heading']);
message = intl.formatMessage(messages['invalid.token.error.message']);
break;
case PASSWORD_RESET.FORBIDDEN_REQUEST:
heading = intl.formatMessage(messages['token.validation.rate.limit.error.heading']);
message = intl.formatMessage(messages['token.validation.rate.limit.error']);
break;
case PASSWORD_RESET.INTERNAL_SERVER_ERROR:
heading = intl.formatMessage(messages['token.validation.internal.sever.error.heading']);
message = intl.formatMessage(messages['token.validation.internal.sever.error']);
break;
default:
break;
}
if (message) {
return (
<Alert id="validation-errors" className="mb-5" variant={`${status === 'complete' ? 'success' : 'danger'}`}>
{status === 'complete' ? <Icon src={CheckCircle} className="alert-icon" /> : <Icon src={Info} className="alert-icon" />}
<Alert.Heading>{heading}</Alert.Heading>
<p>{message}</p>
</Alert>
);
}
return null;
};
ForgotPasswordAlert.defaultProps = {
email: '',
emailError: '',
};
ForgotPasswordAlert.propTypes = {
status: PropTypes.string.isRequired,
email: PropTypes.string,
intl: intlShape.isRequired,
emailError: PropTypes.string,
};
export default injectIntl(ForgotPasswordAlert);

View File

@@ -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 (
<SuccessAlert email={email} />
);
}
if (errors.email) {
return (
<Alert variant="danger">
<Icon src={Info} className="alert-icon" />
<Alert.Heading>{header}</Alert.Heading>
<p>{`${errors.email}${intl.formatMessage(messages['extend.field.errors'])}`}</p>
</Alert>
);
}
if (status === INTERNAL_SERVER_ERROR) {
return <APIFailureMessage header={header} errorCode={INTERNAL_SERVER_ERROR} />;
}
return status === 'forbidden' ? <RequestInProgressAlert /> : null;
};
const getValidationMessage = (email) => {
let error = '';
@@ -109,46 +76,42 @@ const ForgotPasswordPage = (props) => {
{ siteName: getConfig().SITE_NAME })}
</title>
</Helmet>
<div className="d-flex justify-content-center">
<div className="d-flex flex-column">
<Form className="mw-xs">
{ renderAlertMessages(errors, values.email) }
<h3>
{intl.formatMessage(messages['forgot.password.page.heading'])}
</h3>
<p className="mb-4">
{intl.formatMessage(messages['forgot.password.page.instructions'])}
</p>
<FormGroup
floatingLabel={intl.formatMessage(messages['forgot.password.page.email.field.label'])}
name="email"
errorMessage={validationError}
value={values.email}
handleBlur={() => getValidationMessage(values.email)}
handleChange={e => setFieldValue('email', e.target.value)}
helpText={[intl.formatMessage(messages['forgot.password.email.help.text'], { platformName })]}
/>
<StatefulButton
type="submit"
variant="brand"
className="login-button-width"
state={status}
labels={{
default: intl.formatMessage(messages['forgot.password.page.submit.button']),
}}
icons={{ pending: <FontAwesomeIcon icon={faSpinner} spin /> }}
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
/>
<Hyperlink id="forgot-password" className="btn btn-link font-weight-500 text-body" destination={getConfig().LOGIN_ISSUE_SUPPORT_LINK}>
{intl.formatMessage(messages['need.help.sign.in.text'])}
</Hyperlink>
<p className="mt-5 one-rem-font">{intl.formatMessage(messages['additional.help.text'])}
<span><Hyperlink destination={`mailto:${getConfig().INFO_EMAIL}`}>{getConfig().INFO_EMAIL}</Hyperlink></span>
</p>
</Form>
</div>
</div>
<Form className="mw-xs">
<ForgotPasswordAlert email={values.email} emailError={errors.email} status={status} />
<h4>
{intl.formatMessage(messages['forgot.password.page.heading'])}
</h4>
<p className="mb-4">
{intl.formatMessage(messages['forgot.password.page.instructions'])}
</p>
<FormGroup
floatingLabel={intl.formatMessage(messages['forgot.password.page.email.field.label'])}
name="email"
errorMessage={validationError}
value={values.email}
handleBlur={() => getValidationMessage(values.email)}
handleChange={e => setFieldValue('email', e.target.value)}
helpText={[intl.formatMessage(messages['forgot.password.email.help.text'], { platformName })]}
/>
<StatefulButton
type="submit"
variant="brand"
className="login-button-width"
state={submitState}
labels={{
default: intl.formatMessage(messages['forgot.password.page.submit.button']),
}}
icons={{ pending: <FontAwesomeIcon icon={faSpinner} spin /> }}
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
/>
<Hyperlink id="forgot-password" className="btn btn-link font-weight-500 text-body" destination={getConfig().LOGIN_ISSUE_SUPPORT_LINK}>
{intl.formatMessage(messages['need.help.sign.in.text'])}
</Hyperlink>
<p className="mt-5 one-rem-font">{intl.formatMessage(messages['additional.help.text'])}
<span><Hyperlink destination={`mailto:${getConfig().INFO_EMAIL}`}>{getConfig().INFO_EMAIL}</Hyperlink></span>
</p>
</Form>
</>
)}
</Formik>
@@ -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(

View File

@@ -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 (
<Alert variant="danger">
<Alert.Heading>{intl.formatMessage(messages['forgot.password.error.message.title'])}</Alert.Heading>
<ul>
<li>{intl.formatMessage(messages['forgot.password.request.in.progress.message'])}</li>
</ul>
</Alert>
);
};
RequestInProgressAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(RequestInProgressAlert);

View File

@@ -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 (
<Alert id="forgotpassword-success-alert" variant="success">
<Icon src={CheckCircle} className="alert-icon" />
<Alert.Heading>{intl.formatMessage(messages['confirmation.message.title'])}</Alert.Heading>
<p>
<FormattedMessage
id="forgot.password.confirmation.message"
defaultMessage="We sent an email to {email} with instructions to reset your password.
If you do not receive a password reset message after 1 minute, verify that you entered
the correct email address, or check your spam folder. If you need further assistance, {supportLink}."
description="Forgot password confirmation message"
values={{
email: <span className="data-hj-suppress">{email}</span>,
supportLink: (
<Alert.Link className="alert-link" href={getConfig().PASSWORD_RESET_SUPPORT_LINK}>
{intl.formatMessage(messages['confirmation.support.link'])}
</Alert.Link>
),
}}
/>
</p>
</Alert>
);
};
SuccessAlert.propTypes = {
email: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(SuccessAlert);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(<IntlForgotPasswordPage {...props} />))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match forbidden section snapshot', () => {
props = {
...props,
status: 'forbidden',
};
const tree = renderer.create(reduxWrapper(<IntlForgotPasswordPage {...props} />))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match pending section snapshot', () => {
props = {
...props,
status: 'pending',
};
const tree = renderer.create(reduxWrapper(<IntlForgotPasswordPage {...props} />))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should display need other help signing in button', () => {
const wrapper = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
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(<IntlForgotPasswordPage {...props} />));
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 () => {

View File

@@ -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 ? <LoginFailureMessage loginError={this.props.loginError} /> : null}
{submitState === DEFAULT_STATE && this.state.isSubmitted ? windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }) : null}
{activationMsgType && <AccountActivationMessage messageType={activationMsgType} />}
<Form className="test">
{this.props.resetPassword && !this.props.loginError ? <ResetPasswordSuccess /> : null}
<Form>
<FormGroup
name="email"
value={this.state.emailOrUsername}
@@ -288,6 +290,7 @@ LoginPage.defaultProps = {
forgotPassword: null,
loginResult: null,
loginError: null,
resetPassword: false,
submitState: DEFAULT_STATE,
thirdPartyAuthApiStatus: 'pending',
thirdPartyAuthContext: {
@@ -313,6 +316,7 @@ LoginPage.propTypes = {
redirectUrl: PropTypes.string,
success: PropTypes.bool,
}),
resetPassword: PropTypes.bool,
submitState: PropTypes.string,
thirdPartyAuthApiStatus: PropTypes.string,
thirdPartyAuthContext: PropTypes.shape({
@@ -338,6 +342,7 @@ const mapStateToProps = state => {
loginError,
loginResult,
thirdPartyAuthContext,
resetPassword: state.login.resetPassword,
};
};

View File

@@ -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,
};
}
};

View File

@@ -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 = (
<Alert.Link href={LOGIN_PAGE}>
{intl.formatMessage(messages['forgot.password.confirmation.sign.in.link'])}
</Alert.Link>
);
return (
<>
<Helmet>
<title>{intl.formatMessage(messages['reset.password.page.title'],
{ siteName: getConfig().SITE_NAME })}
</title>
</Helmet>
<div className="d-flex justify-content-center m-4">
<div className="d-flex flex-column mw-500">
<Alert variant="danger">
<Alert.Heading> {intl.formatMessage(messages['reset.password.request.invalid.token.header'])}</Alert.Heading>
<FormattedMessage
id="reset.password.request.invalid.token.description.message"
defaultMessage="This password reset link is invalid. It may have been used already.
To reset your password, go to the {loginPasswordLink} page and select {forgotPassword}"
description="Invalid password reset link help text message."
values={{
forgotPassword: <strong> {intl.formatMessage(messages['reset.password.request.forgot.password.text'])} </strong>,
loginPasswordLink,
}}
/>
</Alert>
</div>
</div>
</>
);
};
InvalidTokenMessage.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(InvalidTokenMessage);

View File

@@ -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 (
<Alert id="validation-errors" className="mb-5" variant="danger">
<Icon src={Info} className="alert-icon" />
<Alert.Heading>{heading}</Alert.Heading>
<p>{errorMessage}</p>
</Alert>
);
}
return null;
};
ResetPasswordFailure.defaultProps = {
errorCode: null,
errorMsg: null,
};
ResetPasswordFailure.propTypes = {
errorCode: PropTypes.string,
errorMsg: PropTypes.string,
intl: intlShape.isRequired,
};
export default injectIntl(ResetPasswordFailure);

View File

@@ -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 <Spinner />;
return <Spinner animation="border" variant="primary" className="mt-5" />;
}
} else if (props.status === 'invalid' && props.errors === INTERNAL_SERVER_ERROR) {
return (
<APIFailureMessage header={intl.formatMessage(messages['reset.password.token.validation.sever.error'])} errorCode={INTERNAL_SERVER_ERROR} />
);
} else if (props.status === 'invalid' && props.errors === API_RATELIMIT_ERROR) {
return (
<APIFailureMessage header={intl.formatMessage(messages['reset.server.ratelimit.error'])} errorCode={API_RATELIMIT_ERROR} />
);
} else if (props.status === 'invalid') {
return <InvalidTokenMessage />;
} else if (props.status === PASSWORD_RESET_ERROR) {
return <Redirect to={RESET_PAGE} />;
} else if (props.status === 'success') {
return <ResetSuccessMessage />;
return <Redirect to={LOGIN_PAGE} />;
} else {
return (
<>
<div>
<Helmet>
<title>{intl.formatMessage(messages['reset.password.page.title'],
{ siteName: getConfig().SITE_NAME })}
</title>
</Helmet>
{props.status === 'failure' && props.errors === INTERNAL_SERVER_ERROR ? (
<APIFailureMessage header={intl.formatMessage(messages['reset.password.request.server.error'])} errorCode={INTERNAL_SERVER_ERROR} />
) : null}
{props.status === 'failure' && props.errors === API_RATELIMIT_ERROR ? (
<APIFailureMessage header={intl.formatMessage(messages['reset.server.ratelimit.error'])} errorCode={API_RATELIMIT_ERROR} />
) : null}
<div id="main" className="d-flex justify-content-center m-4">
<div className="d-flex flex-column mw-500">
{bannerErrorMessage ? (
<Alert id="validation-errors" variant="danger">
<Alert.Heading>{intl.formatMessage(messages['forgot.password.empty.new.password.error.heading'])}</Alert.Heading>
<ul><li>{bannerErrorMessage}</li></ul>
</Alert>
) : null}
<span className="nav nav-tabs">
<Link className="nav-item nav-link" to={updatePathWithQueryParams(LOGIN_PAGE)}>
<FontAwesomeIcon className="mr-2" icon={faChevronLeft} /> {intl.formatMessage(messages['sign.in'])}
</Link>
</span>
<div id="main-content" className="main-content">
<div className="mw-xs">
<ResetPasswordFailure errorCode={errorCode} errorMsg={props.errorMsg} />
<h4>{intl.formatMessage(messages['reset.password'])}</h4>
<p className="mb-4">{intl.formatMessage(messages['reset.password.page.instructions'])}</p>
<Form>
<h1 className="mt-3 h3">
{intl.formatMessage(messages['reset.password.page.heading'])}
</h1>
<p className="mb-4">
{intl.formatMessage(messages['reset.password.page.instructions'])}
</p>
<AuthnValidationFormGroup
label={intl.formatMessage(messages['reset.password.page.new.field.label'])}
for="reset-password-input"
name="new-password1"
type="password"
invalid={!passwordValid}
ariaInvalid={!passwordValid}
invalidMessage={validationMessage}
value={newPasswordInput}
onChange={e => handleNewPasswordChange(e)}
onBlur={e => handleNewPasswordOnBlur(e)}
className="w-100"
inputFieldStyle="border-gray-600"
<PasswordField
name="newPassword"
value={newPassword}
handleChange={(e) => setNewPassword(e.target.value)}
handleBlur={(e) => validateInput(e.target.name, e.target.value)}
errorMessage={formErrors.newPassword}
floatingLabel={intl.formatMessage(messages['new.password.label'])}
/>
<AuthnValidationFormGroup
label={intl.formatMessage(messages['reset.password.page.confirm.field.label'])}
for="confirm-password-input"
name="new-password2"
type="password"
invalid={!passwordMatch}
ariaInvalid={!passwordMatch}
invalidMessage={intl.formatMessage(messages['reset.password.page.invalid.match.message'])}
value={confirmPasswordInput}
onChange={e => handleConfirmPasswordChange(e)}
className="w-100"
inputFieldStyle="border-gray-600"
<PasswordField
name="confirmPassword"
value={confirmPassword}
handleChange={handleConfirmPasswordChange}
errorMessage={formErrors.confirmPassword}
showRequirements={false}
floatingLabel={intl.formatMessage(messages['confirm.password.label'])}
/>
<StatefulButton
type="submit"
className="btn-primary"
variant="brand"
state={props.status}
labels={{
default: intl.formatMessage(messages['reset.password.page.submit.button']),
default: intl.formatMessage(messages['reset.password']),
}}
icons={{ pending: <FontAwesomeIcon icon={faSpinner} spin /> }}
onClick={e => handleSubmit(e)}
@@ -189,7 +162,7 @@ const ResetPasswordPage = (props) => {
</Form>
</div>
</div>
</>
</div>
);
}
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(

View File

@@ -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 (
<Alert id="reset-password-success" variant="success" className="mb-4">
<Alert.Heading>
{intl.formatMessage(messages['reset.password.success.heading'])}
</Alert.Heading>
<p>{intl.formatMessage(messages['reset.password.success'])}</p>
</Alert>
);
};
ResetPasswordSuccess.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ResetPasswordSuccess);

View File

@@ -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 = (
<Alert.Link href="/login" className="font-weight-normal text-info">
<FormattedMessage
id="reset.password.confirmation.support.link"
defaultMessage="Sign in to your account."
description="link text used in message: reset.password.invalid.token.description.message link 'sign-in.'"
/>
</Alert.Link>
);
return (
<>
<Helmet>
<title>{intl.formatMessage(messages['reset.password.page.title'],
{ siteName: getConfig().SITE_NAME })}
</title>
</Helmet>
<div className="d-flex justify-content-center m-4">
<div className="d-flex flex-column">
<div className="text-left mw-500">
<Alert variant="success">
<Alert.Heading>
{intl.formatMessage(messages['reset.password.request.success.header.message'])}
</Alert.Heading>
<FormattedMessage
id="reset.password.request.success.header.description.message"
defaultMessage="Your password has been reset. {loginPasswordLink}"
description="message when reset password is successful."
values={{ loginPasswordLink }}
/>
</Alert>
</div>
</div>
</div>
</>
);
};
ResetSuccessMessage.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ResetSuccessMessage);

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { Spinner as ParagonSpinner } from '@edx/paragon';
const Spinner = () => (
<div className="container position-absolute h-90">
<div className="d-flex justify-content-center align-items-center h-90">
<ParagonSpinner animation="border" variant="primary" />
</div>
</div>
);
export default Spinner;

View File

@@ -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 },
});

View File

@@ -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',
};

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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 => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
<MemoryRouter>
<Provider store={store}>{children}</Provider>
</MemoryRouter>
</IntlProvider>
);
const submitForm = async (password) => {
const submitForm = (password) => {
const resetPasswordPage = mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
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(<IntlResetPasswordPage {...props} />))
.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(<IntlResetPasswordPage {...props} />))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match invalid token message section snapshot', () => {
props = {
...props,
token_status: 'invalid',
};
const tree = renderer.create(reduxWrapper(<IntlResetPasswordPage {...props} />))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match pending reset message section snapshot', () => {
props = {
...props,
token_status: 'valid',
status: 'pending',
};
const tree = renderer.create(reduxWrapper(<IntlResetPasswordPage {...props} />))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match successful reset message section snapshot', () => {
props = {
...props,
token_status: 'valid',
status: 'success',
};
const tree = renderer.create(reduxWrapper(<IntlResetPasswordPage {...props} />))
.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(<IntlResetPasswordPage {...props} />));
// 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(<IntlResetPasswordPage {...props} />));
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(<IntlResetPasswordPage {...props} />));
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(<IntlResetPasswordPage {...props} />));
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(<IntlResetPasswordPage {...props} />));
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(<IntlResetPasswordPage {...props} />));
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(<IntlResetPasswordPage {...props} />));
expect(resetPasswordPage.find(<CookiePolicyBanner />)).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(<IntlResetPasswordPage {...props} />));
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(<IntlResetPasswordPage {...props} />));
expect(resetPasswordPage.find(<APIFailureMessage />)).toBeTruthy();
});
it('check failure banner rendered on validate token api ratelimit', () => {
props = {
...props,
status: 'invalid',
errors: API_RATELIMIT_ERROR,
};
const resetPasswordPage = mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
expect(resetPasswordPage.find(<APIFailureMessage />)).toBeTruthy();
});
it('check failure banner rendered on reset password api ratelimit', () => {
props = {
...props,
status: 'failure',
errors: API_RATELIMIT_ERROR,
};
const resetPasswordPage = mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
expect(resetPasswordPage.find(<APIFailureMessage />)).toBeTruthy();
expect(resetPasswordPage.find('div.spinner-header')).toBeTruthy();
});
});

View File

@@ -1,464 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResetPasswordPage should match invalid token message section snapshot 1`] = `
<div
className="d-flex justify-content-center m-4"
id="main"
>
<div
className="d-flex flex-column mw-500"
>
<form
className=""
>
<h1
className="mt-3 h3"
>
Reset your password
</h1>
<p
className="mb-4"
>
Enter and confirm your new password.
</p>
<div
className="form-group w-100"
>
<label
className="pgn__form-label pt-10 focus-out"
htmlFor="reset-password-input"
>
New password
</label>
<input
aria-describedby=""
aria-invalid={false}
autoComplete="on"
className="form-control border-gray-600"
id="reset-password-input"
name="new-password1"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
required={true}
type="password"
value=""
/>
<span />
</div>
<div
className="form-group w-100"
>
<label
className="pgn__form-label pt-10 focus-out"
htmlFor="confirm-password-input"
>
Confirm password
</label>
<input
aria-describedby=""
aria-invalid={false}
autoComplete="on"
className="form-control border-gray-600"
id="confirm-password-input"
name="new-password2"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
required={true}
type="password"
value=""
/>
<span />
<strong
className="invalid-feedback"
id="confirm-password-input-invalid-feedback"
>
Passwords do not match.
</strong>
</div>
<button
aria-disabled={false}
aria-live="assertive"
className="pgn__stateful-btn pgn__stateful-btn-state-null btn-primary btn btn-primary"
disabled={false}
onClick={[Function]}
onMouseDown={[Function]}
type="submit"
>
<span
className="d-flex align-items-center justify-content-center"
>
<span>
Reset my password
</span>
</span>
</button>
</form>
</div>
</div>
`;
exports[`ResetPasswordPage should match pending reset message section snapshot 1`] = `
<div
className="d-flex justify-content-center m-4"
id="main"
>
<div
className="d-flex flex-column mw-500"
>
<form
className=""
>
<h1
className="mt-3 h3"
>
Reset your password
</h1>
<p
className="mb-4"
>
Enter and confirm your new password.
</p>
<div
className="form-group w-100"
>
<label
className="pgn__form-label pt-10 focus-out"
htmlFor="reset-password-input"
>
New password
</label>
<input
aria-describedby=""
aria-invalid={false}
autoComplete="on"
className="form-control border-gray-600"
id="reset-password-input"
name="new-password1"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
required={true}
type="password"
value=""
/>
<span />
</div>
<div
className="form-group w-100"
>
<label
className="pgn__form-label pt-10 focus-out"
htmlFor="confirm-password-input"
>
Confirm password
</label>
<input
aria-describedby=""
aria-invalid={false}
autoComplete="on"
className="form-control border-gray-600"
id="confirm-password-input"
name="new-password2"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
required={true}
type="password"
value=""
/>
<span />
<strong
className="invalid-feedback"
id="confirm-password-input-invalid-feedback"
>
Passwords do not match.
</strong>
</div>
<button
aria-disabled={true}
aria-live="assertive"
className="pgn__stateful-btn pgn__stateful-btn-state-pending btn-primary disabled btn btn-primary"
disabled={false}
onClick={[Function]}
onMouseDown={[Function]}
type="submit"
>
<span
className="d-flex align-items-center justify-content-center"
>
<span
className="pgn__stateful-btn-icon"
>
<svg
aria-hidden="true"
className="svg-inline--fa fa-spinner fa-w-16 fa-spin "
data-icon="spinner"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M304 48c0 26.51-21.49 48-48 48s-48-21.49-48-48 21.49-48 48-48 48 21.49 48 48zm-48 368c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zm208-208c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zM96 256c0-26.51-21.49-48-48-48S0 229.49 0 256s21.49 48 48 48 48-21.49 48-48zm12.922 99.078c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48c0-26.509-21.491-48-48-48zm294.156 0c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48c0-26.509-21.49-48-48-48zM108.922 60.922c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.491-48-48-48z"
fill="currentColor"
style={Object {}}
/>
</svg>
</span>
<span>
Reset my password
</span>
</span>
</button>
</form>
</div>
</div>
`;
exports[`ResetPasswordPage should match reset password default section snapshot 1`] = `
<div
className="d-flex justify-content-center m-4"
id="main"
>
<div
className="d-flex flex-column mw-500"
>
<form
className=""
>
<h1
className="mt-3 h3"
>
Reset your password
</h1>
<p
className="mb-4"
>
Enter and confirm your new password.
</p>
<div
className="form-group w-100"
>
<label
className="pgn__form-label pt-10 focus-out"
htmlFor="reset-password-input"
>
New password
</label>
<input
aria-describedby=""
aria-invalid={false}
autoComplete="on"
className="form-control border-gray-600"
id="reset-password-input"
name="new-password1"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
required={true}
type="password"
value=""
/>
<span />
</div>
<div
className="form-group w-100"
>
<label
className="pgn__form-label pt-10 focus-out"
htmlFor="confirm-password-input"
>
Confirm password
</label>
<input
aria-describedby=""
aria-invalid={false}
autoComplete="on"
className="form-control border-gray-600"
id="confirm-password-input"
name="new-password2"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
required={true}
type="password"
value=""
/>
<span />
<strong
className="invalid-feedback"
id="confirm-password-input-invalid-feedback"
>
Passwords do not match.
</strong>
</div>
<button
aria-disabled={false}
aria-live="assertive"
className="pgn__stateful-btn pgn__stateful-btn-state-null btn-primary btn btn-primary"
disabled={false}
onClick={[Function]}
onMouseDown={[Function]}
type="submit"
>
<span
className="d-flex align-items-center justify-content-center"
>
<span>
Reset my password
</span>
</span>
</button>
</form>
</div>
</div>
`;
exports[`ResetPasswordPage should match successful reset message section snapshot 1`] = `
<div
className="d-flex justify-content-center m-4"
>
<div
className="d-flex flex-column"
>
<div
className="text-left mw-500"
>
<div
className="fade alert alert-success show"
role="alert"
>
<div
className="alert-heading h4"
>
Password reset complete.
</div>
<span>
Your password has been reset.
<a
className="font-weight-normal text-info alert-link"
href="/login"
onClick={[Function]}
onKeyDown={[Function]}
>
<span>
Sign in to your account.
</span>
</a>
</span>
</div>
</div>
</div>
</div>
`;
exports[`ResetPasswordPage show spinner component during token validation 1`] = `
<div
className="d-flex justify-content-center m-4"
id="main"
>
<div
className="d-flex flex-column mw-500"
>
<form
className=""
>
<h1
className="mt-3 h3"
>
Reset your password
</h1>
<p
className="mb-4"
>
Enter and confirm your new password.
</p>
<div
className="form-group w-100"
>
<label
className="pgn__form-label pt-10 focus-out"
htmlFor="reset-password-input"
>
New password
</label>
<input
aria-describedby=""
aria-invalid={false}
autoComplete="on"
className="form-control border-gray-600"
id="reset-password-input"
name="new-password1"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
required={true}
type="password"
value=""
/>
<span />
</div>
<div
className="form-group w-100"
>
<label
className="pgn__form-label pt-10 focus-out"
htmlFor="confirm-password-input"
>
Confirm password
</label>
<input
aria-describedby=""
aria-invalid={false}
autoComplete="on"
className="form-control border-gray-600"
id="confirm-password-input"
name="new-password2"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
required={true}
type="password"
value=""
/>
<span />
<strong
className="invalid-feedback"
id="confirm-password-input-invalid-feedback"
>
Passwords do not match.
</strong>
</div>
<button
aria-disabled={false}
aria-live="assertive"
className="pgn__stateful-btn pgn__stateful-btn-state-null btn-primary btn btn-primary"
disabled={false}
onClick={[Function]}
onMouseDown={[Function]}
type="submit"
>
<span
className="d-flex align-items-center justify-content-center"
>
<span>
Reset my password
</span>
</span>
</button>
</form>
</div>
</div>
`;