diff --git a/src/data/reducers.js b/src/data/reducers.js index c36dfe77..8a5c92f9 100755 --- a/src/data/reducers.js +++ b/src/data/reducers.js @@ -8,9 +8,14 @@ import { reducer as forgotPasswordReducer, storeName as forgotPasswordStoreName, } from '../forgot-password'; +import { + reducer as resetPasswordReducer, + storeName as resetPasswordStoreName, +} from '../reset-password'; const createRootReducer = () => combineReducers({ [logistrationStoreName]: logistrationReducer, [forgotPasswordStoreName]: forgotPasswordReducer, + [resetPasswordStoreName]: resetPasswordReducer, }); export default createRootReducer; diff --git a/src/data/sagas.js b/src/data/sagas.js index 3e1332b6..eb836238 100644 --- a/src/data/sagas.js +++ b/src/data/sagas.js @@ -2,10 +2,12 @@ import { all } from 'redux-saga/effects'; import { saga as registrationSaga } from '../logistration'; import { saga as forgotPasswordSaga } from '../forgot-password'; +import { saga as resetPasswordSaga } from '../reset-password'; export default function* rootSaga() { yield all([ registrationSaga(), forgotPasswordSaga(), + resetPasswordSaga(), ]); } diff --git a/src/index.jsx b/src/index.jsx index 2ef1911b..f53279b7 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -14,6 +14,7 @@ import Footer, { messages as footerMessages } from '@edx/frontend-component-foot import configureStore from './data/configureStore'; import { LoginPage, RegistrationPage, NotFoundPage } from './logistration'; import ForgotPasswordPage from './forgot-password'; +import ResetPasswordPage from './reset-password'; import appMessages from './i18n'; import './index.scss'; @@ -37,6 +38,7 @@ subscribe(APP_READY, () => { + diff --git a/src/reset-password/InvalidToken.jsx b/src/reset-password/InvalidToken.jsx new file mode 100644 index 00000000..6295fe5d --- /dev/null +++ b/src/reset-password/InvalidToken.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Hyperlink } from '@edx/paragon'; + +import Alert from '../logistration/Alert'; + +const InvalidTokenMessage = () => { + const loginPasswordLink = ( + + + + ); + + const forgotPassword = (Forgot Password); + return ( +
+
+
+
+ +

+ +

+ +
+
+
+
+
+ ); +}; + +export default InvalidTokenMessage; diff --git a/src/reset-password/ResetFailure.jsx b/src/reset-password/ResetFailure.jsx new file mode 100644 index 00000000..3a531924 --- /dev/null +++ b/src/reset-password/ResetFailure.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import Alert from '../logistration/Alert'; + +const ResetFailureMessage = (props) => { + const errorMessage = props.errors; + return ( +
+
+
+
+ +
+ +
+
+
+
+
+
+ ); +}; + +ResetFailureMessage.defaultProps = { + errors: '', +}; +ResetFailureMessage.propTypes = { + errors: PropTypes.string, +}; + +export default ResetFailureMessage; diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/reset-password/ResetPasswordPage.jsx new file mode 100644 index 00000000..c0c79728 --- /dev/null +++ b/src/reset-password/ResetPasswordPage.jsx @@ -0,0 +1,164 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Button, Input, ValidationFormGroup } from '@edx/paragon'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import { resetPassword, validateToken } from './data/actions'; +import { resetPasswordResultSelector } from './data/selectors'; +import InvalidTokenMessage from './InvalidToken'; +import ResetSuccessMessage from './ResetSuccess'; +import ResetFailureMessage from './ResetFailure'; +import Spinner from './Spinner'; + + +const ResetPasswordPage = (props) => { + const { intl } = props; + + const [newPasswordInput, setNewPasswordValue] = useState(''); + const [confirmPasswordInput, setConfirmPasswordValue] = useState(''); + const [passwordValid, setPasswordValidValue] = useState(true); + const [passwordMatch, setPasswordMatchValue] = useState(true); + + const handleNewPasswordChange = (e) => { + const newPassword = e.target.value; + setNewPasswordValue(newPassword); + const isValid = newPassword.length >= 8 && newPassword.match(/\d+/g); + setPasswordValidValue(isValid !== false); + }; + const handleConfirmPasswordChange = (e) => { + const confirmPassword = e.target.value; + setConfirmPasswordValue(confirmPassword); + setPasswordMatchValue(confirmPassword === newPasswordInput); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + if (newPasswordInput === '') { + setPasswordValidValue(false); + return; + } + if (newPasswordInput !== confirmPasswordInput) { + setPasswordMatchValue(false); + return; + } + if (passwordValid && passwordMatch) { + const formPayload = { + new_password1: newPasswordInput, + new_password2: confirmPasswordInput, + }; + props.resetPassword(formPayload, props.token); + } + }; + + if (props.token_status === 'pending') { + const { token } = props.match.params; + if (token) { + props.validateToken(token); + return ; + } + } else if (props.token_status === 'invalid') { + return (); + } else if (props.status === 'success') { + return (); + } else { + return ( + <> + {props.status === 'failure' ? : null} +
+
+
+
+

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

+

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

+
+ + + handleNewPasswordChange(e)} + style={{ width: '400px' }} + /> + + + + handleConfirmPasswordChange(e)} + style={{ width: '400px' }} + /> + +
+
+ +
+
+
+ + ); + } + return null; +}; + +ResetPasswordPage.defaultProps = { + status: null, + token_status: null, + token: null, + match: null, + errors: null, +}; + +ResetPasswordPage.propTypes = { + intl: intlShape.isRequired, + resetPassword: PropTypes.func.isRequired, + validateToken: PropTypes.func.isRequired, + token_status: PropTypes.string, + token: PropTypes.string, + match: PropTypes.shape({ + params: PropTypes.shape({ + token: PropTypes.string, + }), + }), + status: PropTypes.string, + errors: PropTypes.string, +}; + +export default connect( + resetPasswordResultSelector, + { + resetPassword, + validateToken, + }, +)(injectIntl(ResetPasswordPage)); diff --git a/src/reset-password/ResetSuccess.jsx b/src/reset-password/ResetSuccess.jsx new file mode 100644 index 00000000..6239f164 --- /dev/null +++ b/src/reset-password/ResetSuccess.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Hyperlink } from '@edx/paragon'; + +import Alert from '../logistration/Alert'; + +const ResetSuccessMessage = () => { + const loginPasswordLink = ( + + + + ); + + return ( +
+
+
+
+ +

+ +

+ +
+
+
+
+
+ ); +}; + +export default ResetSuccessMessage; diff --git a/src/reset-password/Spinner.jsx b/src/reset-password/Spinner.jsx new file mode 100644 index 00000000..1589b695 --- /dev/null +++ b/src/reset-password/Spinner.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +const Spinner = (props) => { + const textMessage = props.text; + return ( +
+
+
+
+

+ + +

+
+
+
+
+ ); +}; + +Spinner.defaultProps = { + text: '', +}; +Spinner.propTypes = { + text: PropTypes.string, +}; +export default Spinner; diff --git a/src/reset-password/data/actions.js b/src/reset-password/data/actions.js new file mode 100644 index 00000000..7e8eb146 --- /dev/null +++ b/src/reset-password/data/actions.js @@ -0,0 +1,44 @@ +import { AsyncActionType } from '../../data/utils'; + +export const RESET_PASSWORD = new AsyncActionType('RESET', 'PASSWORD'); +export const VALIDATE_TOKEN = new AsyncActionType('VALIDATE', 'TOKEN'); + +// Validate confirmation token +export const validateToken = (token) => ({ + type: VALIDATE_TOKEN.BASE, + payload: { token }, +}); + +export const validateTokenBegin = () => ({ + type: VALIDATE_TOKEN.BEGIN, +}); + +export const validateTokenSuccess = (tokenStatus, token) => ({ + type: VALIDATE_TOKEN.SUCCESS, + payload: { tokenStatus, token }, +}); + +export const validateTokenFailure = (tokenStatus) => ({ + type: VALIDATE_TOKEN.FAILURE, + payload: { tokenStatus }, +}); + +// Reset Password +export const resetPassword = (formPayload, token) => ({ + type: RESET_PASSWORD.BASE, + payload: { formPayload, token }, +}); + +export const resetPasswordBegin = () => ({ + type: RESET_PASSWORD.BEGIN, +}); + +export const resetPasswordSuccess = data => ({ + type: RESET_PASSWORD.SUCCESS, + payload: { data }, +}); + +export const resetPasswordFailure = errors => ({ + type: RESET_PASSWORD.FAILURE, + payload: { errors }, +}); diff --git a/src/reset-password/data/reducers.js b/src/reset-password/data/reducers.js new file mode 100644 index 00000000..13b60d92 --- /dev/null +++ b/src/reset-password/data/reducers.js @@ -0,0 +1,49 @@ +import { RESET_PASSWORD, VALIDATE_TOKEN } from './actions'; + +export const defaultState = { + status: null, + token_status: 'pending', + token: null, + errors: null, +}; + +const reducer = (state = defaultState, action = null) => { + switch (action.type) { + case VALIDATE_TOKEN.BEGIN: + return { + ...state, + token_status: 'pending', + }; + case VALIDATE_TOKEN.SUCCESS: + return { + ...state, + token_status: 'valid', + token: action.payload.token, + }; + case VALIDATE_TOKEN.FAILURE: + return { + ...state, + token_status: 'invalid', + }; + case RESET_PASSWORD.BEGIN: + return { + ...state, + status: 'pending', + }; + case RESET_PASSWORD.SUCCESS: + return { + ...state, + status: 'success', + }; + case RESET_PASSWORD.FAILURE: + return { + ...state, + status: 'failure', + errors: action.payload.errors, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/reset-password/data/sagas.js b/src/reset-password/data/sagas.js new file mode 100644 index 00000000..849f7200 --- /dev/null +++ b/src/reset-password/data/sagas.js @@ -0,0 +1,54 @@ +import { call, put, takeEvery } from 'redux-saga/effects'; + +// Actions +import { + VALIDATE_TOKEN, + validateTokenBegin, + validateTokenSuccess, + validateTokenFailure, + RESET_PASSWORD, + resetPasswordBegin, + resetPasswordSuccess, + resetPasswordFailure, +} from './actions'; + +import { validateToken, resetPassword } from './service'; + + +// Services +export function* handleValidateToken(action) { + try { + yield put(validateTokenBegin()); + const data = yield call(validateToken, action.payload.token); + const isValid = data.is_valid; + if (isValid) { + yield put(validateTokenSuccess(isValid, action.payload.token)); + } else { + yield put(validateTokenFailure(isValid)); + } + } catch (err) { + yield put(validateTokenFailure(err)); + } +} + +export function* handleResetPassword(action) { + try { + yield put(resetPasswordBegin()); + const data = yield call(resetPassword, action.payload.formPayload, action.payload.token); + const resetStatus = data.reset_status; + const resetErrors = data.err_msg; + + if (resetStatus) { + yield put(resetPasswordSuccess(resetStatus)); + } else { + yield put(resetPasswordFailure(resetErrors)); + } + } catch (err) { + yield put(resetPasswordFailure(err)); + } +} + +export default function* saga() { + yield takeEvery(RESET_PASSWORD.BASE, handleResetPassword); + yield takeEvery(VALIDATE_TOKEN.BASE, handleValidateToken); +} diff --git a/src/reset-password/data/selectors.js b/src/reset-password/data/selectors.js new file mode 100644 index 00000000..a280d6f9 --- /dev/null +++ b/src/reset-password/data/selectors.js @@ -0,0 +1,10 @@ +import { createSelector } from 'reselect'; + +export const storeName = 'resetPassword'; + +export const resetPasswordSelector = state => ({ ...state[storeName] }); + +export const resetPasswordResultSelector = createSelector( + resetPasswordSelector, + resetPassword => resetPassword, +); diff --git a/src/reset-password/data/service.js b/src/reset-password/data/service.js new file mode 100644 index 00000000..8f0d5e0c --- /dev/null +++ b/src/reset-password/data/service.js @@ -0,0 +1,41 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import formurlencoded from 'form-urlencoded'; + +// eslint-disable-next-line import/prefer-default-export +export async function validateToken(token) { + const requestConfig = { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + isPublic: true, + }; + + const { data } = await getAuthenticatedHttpClient() + .post( + `${getConfig().LMS_BASE_URL}/user_api/v1/account/password_reset/token/validate/`, + formurlencoded({ token }), + requestConfig, + ) + .catch((e) => { + throw (e); + }); + return data; +} + +// eslint-disable-next-line import/prefer-default-export +export async function resetPassword(payload, token) { + const requestConfig = { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + isPublic: true, + }; + + const { data } = await getAuthenticatedHttpClient() + .post( + `${getConfig().LMS_BASE_URL}/password/reset/${token}/?track=pwreset`, + formurlencoded(payload), + requestConfig, + ) + .catch((e) => { + throw (e); + }); + return data; +} diff --git a/src/reset-password/index.js b/src/reset-password/index.js new file mode 100644 index 00000000..2116170a --- /dev/null +++ b/src/reset-password/index.js @@ -0,0 +1,5 @@ +export { default } from './ResetPasswordPage'; +export { default as reducer } from './data/reducers'; +export { RESET_PASSWORD } from './data/actions'; +export { default as saga } from './data/sagas'; +export { storeName } from './data/selectors'; diff --git a/src/reset-password/messages.js b/src/reset-password/messages.js new file mode 100644 index 00000000..9019a8e4 --- /dev/null +++ b/src/reset-password/messages.js @@ -0,0 +1,41 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'logistration.reset.password.page.heading': { + id: 'logistration.reset.password.page.heading', + defaultMessage: 'Reset Your Password', + description: 'The page heading for the Reset password page.', + }, + 'logistration.reset.password.page.instructions': { + id: 'logistration.reset.password.page.instructions', + defaultMessage: 'Enter and confirm your new password.', + description: 'Instructions message for reset password page.', + }, + 'logistration.reset.password.page.invalid.password.message': { + id: 'logistration.reset.password.page.invalid.password.message', + defaultMessage: 'This password is too short. It must contain at least 8 characters. This password must contain at least 1 number', + description: 'Password format error.', + }, + 'logistration.reset.password.page.invalid.match.message': { + id: 'logistration.reset.password.page.invalid.match.message', + defaultMessage: 'Passwords do not match.', + description: 'Password format error.', + }, + 'logistration.reset.password.page.new.field.label': { + id: 'logistration.forgot.password.page.new.field.label', + defaultMessage: 'New Password', + description: 'New password field label for the reset password page.', + }, + 'logistration.reset.password.page.confirm.field.label': { + id: 'logistration.forgot.password.page.confirm.field.label', + defaultMessage: 'Confirm Password', + description: 'Confirm password field label for the reset password page.', + }, + 'logistration.reset.password.page.submit.button': { + id: 'logistration.reset.password.page.submit.button', + defaultMessage: 'Reset my password', + description: 'Submit button text for the reset password page.', + }, +}); + +export default messages; diff --git a/src/reset-password/tests/ResetPasswordPage.test.jsx b/src/reset-password/tests/ResetPasswordPage.test.jsx new file mode 100644 index 00000000..59ecd2d1 --- /dev/null +++ b/src/reset-password/tests/ResetPasswordPage.test.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import renderer from 'react-test-renderer'; +import configureStore from 'redux-mock-store'; +import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; + +import ResetPasswordPage from '../ResetPasswordPage'; + +jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ resetPasswordSelector: () => ({}) }))); + +const IntlResetPasswordPage = injectIntl(ResetPasswordPage); +const mockStore = configureStore(); + +describe('ResetPasswordPage', () => { + let props = {}; + let store = {}; + + const reduxWrapper = children => ( + + {children} + + ); + + beforeEach(() => { + store = mockStore(); + props = { + resetPassword: jest.fn(), + status: null, + token_status: 'pending', + token: null, + }; + }); + + it('should match reset password default section snapshot', () => { + props = { + ...props, + token: 'token', + token_status: 'valid', + }; + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should match invalid token message section snapshot', () => { + props = { + ...props, + token_status: 'invalid', + }; + 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 match unsuccessful reset message section snapshot', () => { + props = { + ...props, + token_status: 'valid', + status: 'failure', + }; + const tree = renderer.create(reduxWrapper()) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/reset-password/tests/__snapshots__/ResetPasswordPage.test.jsx.snap b/src/reset-password/tests/__snapshots__/ResetPasswordPage.test.jsx.snap new file mode 100644 index 00000000..1634cd5a --- /dev/null +++ b/src/reset-password/tests/__snapshots__/ResetPasswordPage.test.jsx.snap @@ -0,0 +1,373 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ResetPasswordPage should match invalid token message section snapshot 1`] = ` +
+
+
+
+
+
+
+

+ + Invalid Password Reset Link + +

+ + This password reset link is invalid. It may have been used already. To reset your password, go to the + + + sign-in + + + page and select + + Forgot Password + + +
+
+
+
+
+
+`; + +exports[`ResetPasswordPage should match reset password default section snapshot 1`] = ` +
+
+
+
+

+ Reset Your Password +

+

+ Enter and confirm your new password. +

+
+
+ + + + This password is too short. It must contain at least 8 characters. This password must contain at least 1 number + +
+
+ + + + 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 should match unsuccessful reset message section snapshot 1`] = ` +Array [ +
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
, +
+
+
+
+

+ Reset Your Password +

+

+ Enter and confirm your new password. +

+
+
+ + + + This password is too short. It must contain at least 8 characters. This password must contain at least 1 number + +
+
+ + + + Passwords do not match. + +
+
+
+ +
+
+
, +] +`;