Skip to content

Commit

Permalink
feat(user_password_reset): add password reset feature
Browse files Browse the repository at this point in the history
  • Loading branch information
techninja committed Sep 13, 2018
1 parent d4b9b16 commit 894ebcb
Show file tree
Hide file tree
Showing 20 changed files with 393 additions and 15 deletions.
42 changes: 39 additions & 3 deletions src/actions/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ import {
USER_LOG_OUT,
USER_SET_ROLE,
USER_ROLE_EDITOR,
USER_REGISTER
USER_REGISTER,
USER_RESET_PASSWORD
} from '../constants';
import { getAccessToken, getCsrfToken, registerUserAccount } from '../lib/api';
import {
getAccessToken,
getCsrfToken,
registerUserAccount,
resetUserPassword
} from '../lib/api';
import actionGenerator from '../lib/actionGenerator';

/**
Expand Down Expand Up @@ -77,7 +83,7 @@ export function* userRegister({
email,
password,
successHandler = () => {},
errorHandler = () => {},
errorHandler = () => {}
}) {
yield* actionGenerator(
USER_REGISTER,
Expand All @@ -96,6 +102,35 @@ export function* userRegister({
);
}

/**
* Resets a user's password.
*
* @param {object} payload - Payload for this saga action.
* @param {string} payload.email - Email of the user that will be registered.
* @param {function} payload.successHandler - Function to be executed on success.
* @param {function} payload.errorHandler - Function to be executed on error.
*/
export function* userResetPassword({
email,
successHandler = () => {},
errorHandler = () => {}
}) {
yield* actionGenerator(
USER_RESET_PASSWORD,
function* resetUserPasswordHandler() {
yield call(resetUserPassword, email);
yield put({
type: `${USER_RESET_PASSWORD}_SUCCESS`,
payload: {
email
}
});
},
successHandler,
errorHandler
);
}

/**
* Sets the user role.
*
Expand All @@ -115,4 +150,5 @@ export function* watchUserActions() {
yield takeLatest(USER_LOG_OUT, userLogOut);
yield takeLatest(USER_SET_ROLE, userSetRole);
yield takeLatest(USER_REGISTER, userRegister);
yield takeLatest(USER_RESET_PASSWORD, userResetPassword);
}
45 changes: 43 additions & 2 deletions src/actions/user.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,22 @@ import {
resetLoading
} from 'react-redux-loading-bar';

import { userLogIn, userLogOut, userRegister, userSetRole } from './user';
import { getAccessToken, getCsrfToken, registerUserAccount } from '../lib/api';
import {
userLogIn,
userLogOut,
userRegister,
userResetPassword,
userSetRole
} from './user';
import {
getAccessToken,
getCsrfToken,
registerUserAccount,
resetUserPassword
} from '../lib/api';
import {
USER_REGISTER,
USER_RESET_PASSWORD,
USER_LOG_IN,
USER_LOG_OUT,
USER_SET_ROLE,
Expand Down Expand Up @@ -102,6 +114,35 @@ describe('actions->user', () => {
.isDone();
});

it('user->userResetPassword()', () => {
const email = 'bender@moms-robots.com';
testSaga(userResetPassword, { email })
.next()
.put(resetLoading())
.next()
.put(showLoading())
.next()
.put({
type: `${USER_RESET_PASSWORD}_LOADING`
})
.next()
.call(resetUserPassword, email)
.next()
.put({
type: `${USER_RESET_PASSWORD}_SUCCESS`,
payload: {
email
}
})
.next()
// Next is executed twice here to step over the execution of an optional
// successHandler method.
.next()
.put(hideLoading())
.next()
.isDone();
});

it('user->userSetRole()', () => {
testSaga(userSetRole, USER_ROLE_EDITOR)
.next()
Expand Down
7 changes: 7 additions & 0 deletions src/components/App/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Login,
Logout,
Register,
ForgotPassword,
Dashboard,
ExperienceCreate,
ExperienceEdit,
Expand Down Expand Up @@ -41,6 +42,12 @@ const App = () => (
redirectTo="/dashboard"
component={Register}
/>
<PublicRoute
exact
path="/login/reset"
redirectTo="/login"
component={ForgotPassword}
/>
<PrivateRoute exact path="/logout" redirectTo="/" component={Logout} />
<PrivateRoute
exact
Expand Down
18 changes: 18 additions & 0 deletions src/components/ForgotPasswordForm/ForgotPasswordForm.container.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* @file ForgotPasswordForm.container.js
* Exports a redux-connected ForgotPasswordForm component.
*/

import { connect } from 'react-redux';

import ForgotPasswordForm from './ForgotPasswordForm';

const mapDispatchToProps = dispatch => ({
dispatch
});

const mapState = ({ user }) => ({
user
});

export default connect(mapState, mapDispatchToProps)(ForgotPasswordForm);
117 changes: 117 additions & 0 deletions src/components/ForgotPasswordForm/ForgotPasswordForm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* @file ForgotPasswordForm.js
* Exports a component that allows users to reset their EditVR account password.
*/

import React from 'react';
import PropTypes from 'prop-types';
import { TextField, Button, withStyles } from '@material-ui/core';
import { withFormik } from 'formik';
import { string, object } from 'yup';

import { Message } from '../';
import ForgotPasswordFormStyles from './ForgotPasswordForm.style';
import {
USER_RESET_PASSWORD,
FORM_BUTTON_RESET_PASSWORD,
ERROR_API_USER_EMAIL_NOT_FOUND
} from '../../constants';

const ForgotPasswordForm = ({
classes,
user: { error },
errors,
touched,
isSubmitting,
handleSubmit,
handleChange,
handleBlur
}) => (
<form onSubmit={handleSubmit}>
{error && <Message type="error">{error}</Message>}
<TextField
required
id="email"
label="Email"
type="email"
className={classes.textField}
onChange={handleChange}
onBlur={handleBlur}
error={!!errors.email && touched.email}
disabled={isSubmitting}
helperText={
errors.email
? errors.email
: 'Enter the email address for your account.'
}
/>
<Button
variant="raised"
color="primary"
type="submit"
className={classes.button}
>
{`${FORM_BUTTON_RESET_PASSWORD}`}
</Button>
</form>
);

ForgotPasswordForm.propTypes = {
classes: PropTypes.shape({
textField: PropTypes.string.isRequired,
button: PropTypes.string.isRequired
}).isRequired,
user: PropTypes.shape({
error: PropTypes.string
}),
handleSubmit: PropTypes.func.isRequired,
handleChange: PropTypes.func.isRequired,
handleBlur: PropTypes.func.isRequired,
isSubmitting: PropTypes.bool,
errors: PropTypes.shape({
email: PropTypes.oneOfType([PropTypes.string, PropTypes.bool])
}).isRequired,
touched: PropTypes.shape({
email: PropTypes.bool
}).isRequired
};

ForgotPasswordForm.defaultProps = {
isSubmitting: false,
user: {
error: null
}
};

const FormikForgotPasswordForm = withFormik({
displayName: 'ForgotPasswordForm',
enableReinitialize: true,
validationSchema: object().shape({
email: string()
.email()
.required()
.min(3)
.max(100)
}),
handleSubmit: (values, { props, setSubmitting, setErrors }) => {
const { dispatch } = props;
const { email } = values;
dispatch({
type: USER_RESET_PASSWORD,
email,
successHandler: () => {
// After sending email, tell the user?
setSubmitting(false);
},
errorHandler: error => {
const message = error.toString();
if (message.includes(ERROR_API_USER_EMAIL_NOT_FOUND)) {
setErrors({ email: 'Account matching email not found.' });
}
setSubmitting(false);
}
});
}
})(ForgotPasswordForm);

export default withStyles(ForgotPasswordFormStyles)(FormikForgotPasswordForm);
15 changes: 15 additions & 0 deletions src/components/ForgotPasswordForm/ForgotPasswordForm.style.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* @file ForgotPasswordForm.style.js
* Exports ForgotPasswordForm component styles.
*/

export default theme => ({
textField: {
width: '100%',
marginTop: `${theme.spacing.unit * 2}px`
},
button: {
marginTop: `${theme.spacing.unit * 3}px`,
marginRight: theme.spacing.unit
}
});
23 changes: 23 additions & 0 deletions src/components/ForgotPasswordForm/ForgotPasswordForm.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @file ForgotPasswordForm.test.js
* Contains tests for ForgotPasswordForm.js.
*/

import React from 'react';
import configureStore from 'redux-mock-store';
import renderer from 'react-test-renderer';
import ForgotPasswordForm from './ForgotPasswordForm.container';

describe('<ForgotPasswordForm />', () => {
it('Matches its snapshot', () => {
const store = configureStore()({
user: {
error: null
}
});

expect(
renderer.create(<ForgotPasswordForm store={store} />).toJSON()
).toMatchSnapshot();
});
});
16 changes: 15 additions & 1 deletion src/components/LoginForm/LoginForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { TextField, Button, withStyles } from '@material-ui/core';
import { Link } from 'react-router-dom';

import { Message } from '../';
import LoginFormStyles from './LoginForm.style';
import { USER_LOG_IN, FORM_BUTTON_LOGIN } from '../../constants';
import {
USER_LOG_IN,
FORM_BUTTON_LOGIN,
FORM_BUTTON_FORGOT_PASSWORD
} from '../../constants';

class LoginForm extends Component {
static propTypes = {
Expand Down Expand Up @@ -79,6 +84,15 @@ class LoginForm extends Component {
>
{FORM_BUTTON_LOGIN}
</Button>
<Button
className={classes.button}
variant="raised"
color="secondary"
component={Link}
to="/login/reset"
>
{FORM_BUTTON_FORGOT_PASSWORD}
</Button>
</form>
);
}
Expand Down
7 changes: 3 additions & 4 deletions src/components/RegisterForm/RegisterForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,9 @@ const FormikRegisterForm = withFormik({
errorHandler: error => {
const message = error.toString();
if (message.includes(ERROR_API_REGISTER_FAILED_EMAIL)) {
setErrors({email: 'Email in use or invalid'});
}
else if (message.includes(ERROR_API_REGISTER_FAILED_USERNAME)) {
setErrors({username: 'Username in use or invalid'});
setErrors({ email: 'Email in use or invalid' });
} else if (message.includes(ERROR_API_REGISTER_FAILED_USERNAME)) {
setErrors({ username: 'Username in use or invalid' });
}
setSubmitting(false);
}
Expand Down
2 changes: 2 additions & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ComponentForm from './ComponentForm/ComponentForm.container';
import Loading from './Loading/Loading';
import Message from './Message/Message';
import RegisterForm from './RegisterForm/RegisterForm.container';
import ForgotPasswordForm from './ForgotPasswordForm/ForgotPasswordForm.container';
import SceneCards from './SceneCards/SceneCards.container';
import ToolsMenu from './ToolsMenu/ToolsMenu.container';
import ComponentCreateMenu from './ComponentCreateMenu/ComponentCreateMenu.container';
Expand All @@ -19,6 +20,7 @@ export {
App,
LoginForm,
RegisterForm,
ForgotPasswordForm,
ExperienceForm,
Loading,
Message,
Expand Down
1 change: 1 addition & 0 deletions src/constants/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

export const API_ENDPOINT_USER_LOGIN = 'oauth/token';
export const API_ENDPOINT_USER_REGISTER = 'user/register';
export const API_ENDPOINT_USER_PASSWORD = 'user/password';
export const API_ENDPOINT_XCSRF_TOKEN = 'rest/session/token';
export const API_ENDPOINT_EXPERIENCE = 'node/experience';
export const API_ENDPOINT_FILE_IMAGE = 'file/image';
Expand Down
Loading

0 comments on commit 894ebcb

Please sign in to comment.