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}
+
+ >
+ );
+ }
+ 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`] = `
+
+`;
+
+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 [
+ ,
+ ,
+]
+`;