Forgot password submit (#168)

* Authentication middleware

* API client refactor

* Configure API client to make forgot_password requests

* Successfully submit forgot password form

* Display server errors for unknown email address
This commit is contained in:
Mike Stone 2016-09-16 09:55:46 -04:00 committed by GitHub
parent a069ec9acf
commit 5ea9115a95
19 changed files with 577 additions and 75 deletions

View file

@ -7,6 +7,8 @@ import validEmail from '../validators/valid_email';
class ForgotPasswordForm extends Component {
static propTypes = {
clearErrors: PropTypes.func,
error: PropTypes.string,
onSubmit: PropTypes.func,
};
@ -21,6 +23,7 @@ class ForgotPasswordForm extends Component {
}
onInputFieldChange = (evt) => {
const { clearErrors, error: serverError } = this.props;
const { value } = evt.target;
this.setState({
@ -30,6 +33,8 @@ class ForgotPasswordForm extends Component {
},
});
if (serverError) clearErrors();
return false;
}
@ -61,7 +66,8 @@ class ForgotPasswordForm extends Component {
}
render () {
const { error, formData: { email } } = this.state;
const { error: serverError } = this.props;
const { error: clientError, formData: { email } } = this.state;
const { formStyles, inputStyles, submitButtonStyles } = componentStyles;
const { onFormSubmit, onInputFieldChange } = this;
const disabled = !email;
@ -69,7 +75,7 @@ class ForgotPasswordForm extends Component {
return (
<form onSubmit={onFormSubmit} style={formStyles}>
<InputFieldWithIcon
error={error}
error={clientError || serverError}
iconName="envelope"
name="email"
onChange={onInputFieldChange}

62
frontend/kolide/base.js Normal file
View file

@ -0,0 +1,62 @@
import config from '../config';
class Base {
constructor () {
this.baseURL = this.setBaseURL();
}
setBaseURL () {
const {
settings: { env },
environments: { development },
} = config;
if (env === development) {
return 'http://localhost:8080/api';
}
throw new Error(`API base URL is not configured for environment: ${env}`);
}
setBearerToken (bearerToken) {
this.bearerToken = bearerToken;
}
post(endpoint, body = {}, overrideHeaders = {}) {
return this._request('POST', endpoint, body, overrideHeaders);
}
_request (method, endpoint, body, overrideHeaders) {
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
return fetch(endpoint, {
method,
headers: {
...headers,
...overrideHeaders
},
body,
})
.then(response => {
return response.json()
.then(jsonResponse => {
if (response.ok) {
return jsonResponse;
}
const error = new Error(response.statusText);
error.response = jsonResponse;
error.message = jsonResponse;
error.error = jsonResponse.error;
throw error;
});
});
}
}
export default Base;

View file

@ -1,3 +1,4 @@
export default {
FORGOT_PASSWORD: '/v1/kolide/forgot_password',
LOGIN: '/v1/kolide/login',
};

View file

@ -1,61 +1,21 @@
import fetch from 'isomorphic-fetch';
import config from '../config';
import Base from './base';
import endpoints from './endpoints';
import local from '../utilities/local';
class Kolide {
constructor () {
this.baseURL = this.setBaseURL();
}
setBaseURL () {
const {
settings: { env },
environments: { development },
} = config;
if (env === development) {
return 'http://localhost:8080/api';
}
throw new Error(`API base URL is not configured for environment: ${env}`);
}
setBearerToken (bearerToken) {
this.bearerToken = bearerToken;
}
class Kolide extends Base {
loginUser ({ username, password }) {
const { LOGIN } = endpoints;
const endpoint = this.baseURL + LOGIN;
const loginEndpoint = this.baseURL + LOGIN;
return fetch(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
.then(response => {
return response.json()
.then(user => {
if (response.ok) {
const { token } = user;
return this.post(loginEndpoint, JSON.stringify({ username, password }));
}
local.setItem('auth_token', token);
this.setBearerToken(token);
forgotPassword ({ email }) {
const { FORGOT_PASSWORD } = endpoints;
const forgotPasswordEndpoint = this.baseURL + FORGOT_PASSWORD;
return user;
}
const error = new Error(response.statusText);
error.response = response;
error.message = user.error;
throw error;
});
});
return this.post(forgotPasswordEndpoint, JSON.stringify({ email }));
}
}

View file

@ -1,6 +1,13 @@
import expect from 'expect';
import Kolide from './index';
import { validLoginRequest } from '../test/mocks';
import mocks from '../test/mocks';
const {
invalidPasswordResetRequest,
validLoginRequest,
validPasswordResetRequest,
validUser,
} = mocks;
describe('Kolide - API client', () => {
describe('defaults', () => {
@ -10,19 +17,49 @@ describe('Kolide - API client', () => {
});
describe('#loginUser', () => {
it('sets the bearer token', (done) => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const request = validLoginRequest();
Kolide.loginUser({
username: 'admin',
password: 'secret',
})
.then(() => {
.then((user) => {
expect(user).toEqual(validUser);
expect(request.isDone()).toEqual(true);
expect(Kolide.bearerToken).toEqual('auth_token');
done();
})
.catch(done);
});
});
describe('#forgotPassword', () => {
it('calls the appropriate endpoint with the correct parameters when successful', (done) => {
const request = validPasswordResetRequest();
const email = 'hi@thegnar.co';
Kolide.forgotPassword({ email })
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
it('return errors correctly for unsuccessful requests', (done) => {
const error = 'Something went wrong';
const request = invalidPasswordResetRequest(error);
const email = 'hi@thegnar.co';
Kolide.forgotPassword({ email })
.then(done)
.catch(errorResponse => {
const { response } = errorResponse;
expect(response).toEqual({ error });
expect(request.isDone()).toEqual(true);
done();
});
});
});
});

View file

@ -1,10 +1,73 @@
import React, { Component } from 'react';
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { noop } from 'lodash';
import componentStyles from './styles';
import {
clearForgotPasswordErrors,
forgotPasswordAction,
} from '../../redux/nodes/components/ForgotPasswordPage/actions';
import ForgotPasswordForm from '../../components/forms/ForgotPasswordForm';
import Icon from '../../components/icons/Icon';
export class ForgotPasswordPage extends Component {
static propTypes = {
dispatch: PropTypes.func,
email: PropTypes.string,
error: PropTypes.string,
};
static defaultProps = {
dispatch: noop,
};
class ForgotPasswordPage extends Component {
onSubmit = (formData) => {
console.log('ForgotPasswordPage formData', formData);
const { dispatch } = this.props;
return dispatch(forgotPasswordAction(formData));
}
clearErrors = () => {
const { dispatch } = this.props;
return dispatch(clearForgotPasswordErrors);
}
renderContent = () => {
const { clearErrors } = this;
const { email, error } = this.props;
const {
emailSentButtonWrapperStyles,
emailSentIconStyles,
emailSentTextStyles,
emailSentTextWrapperStyles,
emailTextStyles,
} = componentStyles;
if (email) {
return (
<div>
<div style={emailSentTextWrapperStyles}>
<p style={emailSentTextStyles}>
An email was sent to
<span style={emailTextStyles}> {email}</span>.
Click the link on the email to proceed with the password reset process.
</p>
</div>
<div style={emailSentButtonWrapperStyles}>
<Icon name="check" style={emailSentIconStyles} />
EMAIL SENT
</div>
</div>
);
}
return (
<ForgotPasswordForm
clearErrors={clearErrors}
error={error}
onSubmit={this.onSubmit}
/>
);
}
render () {
@ -24,11 +87,15 @@ class ForgotPasswordPage extends Component {
<div style={forgotPasswordStyles}>
<p style={headerStyles}>Forgot Password</p>
<p style={textStyles}>If youve forgotten your password enter your email below and we will email you a link so that you can reset your password.</p>
<ForgotPasswordForm onSubmit={this.onSubmit} />
{this.renderContent()}
</div>
</div>
);
}
}
export default ForgotPasswordPage;
const mapStateToProps = (state) => {
return state.components.ForgotPasswordPage;
};
export default connect(mapStateToProps)(ForgotPasswordPage);

View file

@ -0,0 +1,21 @@
import React from 'react';
import expect from 'expect';
import { mount } from 'enzyme';
import { ForgotPasswordPage } from './ForgotPasswordPage';
describe('ForgotPasswordPage - component', () => {
it('renders the ForgotPasswordForm when there is no email prop', () => {
const page = mount(<ForgotPasswordPage />);
expect(page.find('ForgotPasswordForm').length).toEqual(1);
});
it('renders the email sent text when the email prop is present', () => {
const email = 'hi@thegnar.co';
const page = mount(<ForgotPasswordPage email={email} />);
expect(page.find('ForgotPasswordForm').length).toEqual(0);
expect(page.text()).toInclude(`An email was sent to ${email}.`);
});
});

View file

@ -9,6 +9,34 @@ export default {
justifyContent: 'center',
flexDirection: 'column',
},
emailSentButtonWrapperStyles: {
backgroundColor: color.successLight,
borderRadius: border.radius.base,
color: color.white,
padding: padding.base,
position: 'relative',
textAlign: 'center',
textTransform: 'uppercase',
},
emailSentIconStyles: {
height: '35px',
left: '18px',
position: 'absolute',
top: '14px',
width: '35px',
},
emailSentTextStyles: {
fontSize: font.medium,
},
emailSentTextWrapperStyles: {
padding: padding.base,
backgroundColor: color.accentLight,
borderRadius: border.radius.base,
marginBottom: padding.base,
},
emailTextStyles: {
color: color.link,
},
forgotPasswordStyles: {
alignItems: 'center',
backgroundColor: color.white,

View file

@ -0,0 +1,16 @@
/* eslint-disable no-unused-vars */
import kolide from '../../kolide';
import { LOGIN_SUCCESS } from '../nodes/auth/actions';
import local from '../../utilities/local';
const authMiddleware = store => next => action => {
if (action.type === LOGIN_SUCCESS) {
const { token } = action.payload.data;
local.setItem('auth_token', token);
kolide.setBearerToken(token);
}
return next(action);
};
export default authMiddleware;

View file

@ -27,7 +27,7 @@ export const loginUser = (formData) => {
return (dispatch) => {
return new Promise((resolve, reject) => {
dispatch(loginRequest);
Kolide.loginUser(formData)
return Kolide.loginUser(formData)
.then(user => {
dispatch(loginSuccess(user));
return resolve(user);

View file

@ -1,6 +1,12 @@
import configureStore from 'redux-mock-store';
import expect from 'expect';
import thunk from 'redux-thunk';
import authMiddleware from '../../middlewares/auth';
import kolide from '../../../kolide';
import local from '../../../utilities/local';
import { loginRequest, LOGIN_REQUEST, LOGIN_SUCCESS, loginUser } from './actions';
import reducer, { initialState } from './reducer';
import { loginRequest } from './actions';
import { validLoginRequest, validUser } from '../../../test/mocks';
describe('Auth - reducer', () => {
it('sets the initial state', () => {
@ -17,4 +23,71 @@ describe('Auth - reducer', () => {
loading: true,
});
});
context('loginUser action', () => {
const formData = {
username: 'username',
password: 'p@ssw0rd',
};
const middlewares = [thunk, authMiddleware];
const mockStore = configureStore(middlewares);
const store = mockStore({});
it('calls the api login endpoint', (done) => {
const loginRequestMock = validLoginRequest();
store.dispatch(loginUser(formData))
.then(user => {
expect(loginRequestMock.isDone()).toEqual(true);
expect(local.getItem('auth_token')).toEqual(user.token);
done();
})
.catch(done);
});
it('returns the authenticated user', (done) => {
validLoginRequest();
store.dispatch(loginUser(formData))
.then(user => {
expect(user).toEqual(validUser);
done();
})
.catch(done);
});
it('sets the users auth token in local storage', (done) => {
validLoginRequest();
store.dispatch(loginUser(formData))
.then(user => {
expect(local.getItem('auth_token')).toEqual(user.token);
done();
})
.catch(done);
});
it('sets the api client bearerToken', (done) => {
validLoginRequest();
store.dispatch(loginUser(formData))
.then(user => {
expect(kolide.bearerToken).toEqual(user.token);
done();
})
.catch(done);
});
it('dispatches LOGIN_REQUEST and LOGIN_SUCCESS actions', (done) => {
validLoginRequest();
store.dispatch(loginUser(formData))
.then(() => {
const actionTypes = store.getActions().map(a => a.type);
expect(actionTypes).toInclude(LOGIN_REQUEST, LOGIN_SUCCESS);
done();
})
.catch(done);
});
});
});

View file

@ -0,0 +1,46 @@
import Kolide from '../../../../kolide';
export const CLEAR_FORGOT_PASSWORD_ERRORS = 'CLEAR_FORGOT_PASSWORD_ERRORS';
export const FORGOT_PASSWORD_REQUEST = 'FORGOT_PASSWORD_REQUEST';
export const FORGOT_PASSWORD_SUCCESS = 'FORGOT_PASSWORD_SUCCESS';
export const FORGOT_PASSWORD_ERROR = 'FORGOT_PASSWORD_ERROR';
export const clearForgotPasswordErrors = { type: CLEAR_FORGOT_PASSWORD_ERRORS };
export const forgotPasswordRequestAction = { type: FORGOT_PASSWORD_REQUEST };
export const forgotPasswordSuccessAction = (email) => {
return {
type: FORGOT_PASSWORD_SUCCESS,
payload: {
data: {
email,
},
},
};
};
export const forgotPasswordErrorAction = (error) => {
return {
type: FORGOT_PASSWORD_ERROR,
payload: {
error,
},
};
};
// formData should be { email: <string> }
export const forgotPasswordAction = (formData) => {
return (dispatch) => {
dispatch(forgotPasswordRequestAction);
return Kolide.forgotPassword(formData)
.then(() => {
const { email } = formData;
return dispatch(forgotPasswordSuccessAction(email));
})
.catch(response => {
const { error } = response;
dispatch(forgotPasswordErrorAction(error));
throw response;
});
};
};

View file

@ -0,0 +1,45 @@
import {
CLEAR_FORGOT_PASSWORD_ERRORS,
FORGOT_PASSWORD_ERROR,
FORGOT_PASSWORD_REQUEST,
FORGOT_PASSWORD_SUCCESS,
} from './actions';
export const initialState = {
email: null,
error: null,
loading: false,
};
const reducer = (state = initialState, { type, payload }) => {
switch (type) {
case CLEAR_FORGOT_PASSWORD_ERRORS:
return {
...state,
error: null,
};
case FORGOT_PASSWORD_REQUEST:
return {
...state,
loading: true,
};
case FORGOT_PASSWORD_SUCCESS:
return {
...state,
email: payload.data.email,
error: null,
loading: false,
};
case FORGOT_PASSWORD_ERROR:
return {
...state,
email: null,
error: payload.error,
loading: false,
};
default:
return state;
}
};
export default reducer;

View file

@ -0,0 +1,108 @@
import configureStore from 'redux-mock-store';
import expect from 'expect';
import thunk from 'redux-thunk';
import {
clearForgotPasswordErrors,
forgotPasswordAction,
forgotPasswordRequestAction,
forgotPasswordSuccessAction,
forgotPasswordErrorAction,
} from './actions';
import { invalidPasswordResetRequest, validPasswordResetRequest } from '../../../../test/mocks';
import reducer, { initialState } from './reducer';
describe('ForgotPasswordPage - reducer', () => {
describe('initial state', () => {
it('sets the initial state', () => {
expect(reducer(undefined, { type: 'FAKE-ACTION' })).toEqual(initialState);
});
});
describe('clearForgotPasswordErrors', () => {
it('changes the loading state to true', () => {
const errorState = {
...initialState,
error: 'Something went wrong',
};
expect(reducer(errorState, clearForgotPasswordErrors)).toEqual({
...errorState,
error: null,
});
});
});
describe('forgotPasswordRequestAction', () => {
it('changes the loading state to true', () => {
expect(reducer(initialState, forgotPasswordRequestAction)).toEqual({
...initialState,
loading: true,
});
});
});
describe('forgotPasswordSuccessAction', () => {
it('changes the loading state to false and emailSent to true', () => {
const email = 'hi@thegnar.co';
expect(reducer(initialState, forgotPasswordSuccessAction(email))).toEqual({
...initialState,
email,
loading: false,
});
});
});
describe('forgotPasswordErrorAction', () => {
it('changes the loading state to false and sets the error state', () => {
const error = 'There was an error with your request';
expect(reducer(initialState, forgotPasswordErrorAction(error))).toEqual({
...initialState,
error,
loading: false,
});
});
});
describe('forgotPasswordAction', () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
it('dispatches the appropriate actions when successful', (done) => {
const formData = { email: 'hi@thegnar.co' };
const request = validPasswordResetRequest();
const store = mockStore({});
store.dispatch(forgotPasswordAction(formData))
.then(() => {
const actions = store.getActions();
expect(actions).toInclude(forgotPasswordRequestAction);
expect(actions).toInclude(forgotPasswordSuccessAction(formData.email));
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
it('dispatches the appropriate actions when unsuccessful', (done) => {
const formData = { email: 'hi@thegnar.co' };
const error = 'Something went wrong';
const invalidRequest = invalidPasswordResetRequest(error);
const store = mockStore({});
store.dispatch(forgotPasswordAction(formData))
.then(done)
.catch(errorResponse => {
const actions = store.getActions();
const { response } = errorResponse;
expect(response).toEqual({ error });
expect(actions).toInclude(forgotPasswordErrorAction(error));
expect(invalidRequest.isDone()).toEqual(true);
done();
});
});
});
});

View file

@ -0,0 +1,7 @@
import { combineReducers } from 'redux';
import ForgotPasswordPage from './ForgotPasswordPage/reducer';
export default combineReducers({
ForgotPasswordPage,
});

View file

@ -2,9 +2,11 @@ import { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux';
import app from './nodes/app/reducer';
import auth from './nodes/auth/reducer';
import components from './nodes/components/reducer';
export default combineReducers({
app,
auth,
components,
routing: routerReducer,
});

View file

@ -2,18 +2,21 @@ import { createStore, applyMiddleware, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { browserHistory } from 'react-router';
import { routerMiddleware } from 'react-router-redux';
import authMiddleware from './middlewares/auth';
import reducers from './reducers';
const initialState = {};
const middleware = [
const appliedMiddleware = applyMiddleware(
thunkMiddleware,
routerMiddleware(browserHistory),
];
const appliedMiddleware = applyMiddleware(...middleware);
authMiddleware,
);
const store = createStore(
reducers,
initialState,
compose(appliedMiddleware),
);
export default store;

View file

@ -1,16 +1,19 @@
const grey = '#66696f';
export default {
accentLight: '#EAEDFB',
brightPurple: '#AE6DDF',
darkGrey: '#202532',
green: '#4FD061',
grey,
lightGrey: '#B4B4B4',
link: '#4A90E2',
logoPurple: '#9651CA',
mediumGrey: '#6F737F',
primary: grey,
purple: '#c38dec',
purpleGrey: '#858495',
red: '#FF5850',
successLight: '#94E39F',
white: '#FFF',
};

View file

@ -1,20 +1,37 @@
import nock from 'nock';
export const validUser = {
token: 'auth_token',
id: 1,
username: 'admin',
email: 'admin@kolide.co',
name: '',
admin: true,
enabled: true,
needs_password_reset: false,
};
export const validLoginRequest = () => {
return nock('http://localhost:8080')
.post('/api/v1/kolide/login')
.reply(200, {
token: 'auth_token',
id: 1,
username: 'admin',
email: 'admin@kolide.co',
name: '',
admin: true,
enabled: true,
needs_password_reset: false,
});
.reply(200, validUser);
};
export const validPasswordResetRequest = () => {
return nock('http://localhost:8080')
.post('/api/v1/kolide/forgot_password')
.reply(200, validUser);
};
export const invalidPasswordResetRequest = (error) => {
return nock('http://localhost:8080')
.post('/api/v1/kolide/forgot_password')
.reply(422, { error });
};
export default {
invalidPasswordResetRequest,
validLoginRequest,
validPasswordResetRequest,
validUser,
};