From cc37cfa828afb924e2b97bf97b0459f62fe25f15 Mon Sep 17 00:00:00 2001 From: Mike Stone Date: Fri, 24 Feb 2017 10:08:59 -0500 Subject: [PATCH] Allows users to update their email address (#1232) --- .../EmailTokenRedirect/EmailTokenRedirect.jsx | 45 +++++++ .../EmailTokenRedirect.tests.jsx | 67 ++++++++++ .../components/EmailTokenRedirect/helpers.js | 25 ++++ .../EmailTokenRedirect/helpers.tests.js | 122 ++++++++++++++++++ .../components/EmailTokenRedirect/index.js | 1 + .../forms/ChangeEmailForm/ChangeEmailForm.jsx | 53 ++++++++ .../forms/ChangeEmailForm/_styles.scss | 15 +++ .../components/forms/ChangeEmailForm/index.js | 1 + .../UserSettingsForm/UserSettingsForm.jsx | 17 +++ .../forms/UserSettingsForm/_styles.scss | 4 + frontend/components/modals/Modal/_styles.scss | 2 + frontend/kolide/endpoints.js | 3 + frontend/kolide/index.js | 9 ++ frontend/kolide/index.tests.js | 18 +++ .../UserSettingsPage/UserSettingsPage.jsx | 100 +++++++++++--- .../UserSettingsPage.tests.jsx | 57 +++++++- .../redux/nodes/entities/base/reduxConfig.js | 2 +- .../redux/nodes/entities/users/actions.js | 33 ++++- .../nodes/entities/users/actions.tests.js | 113 ++++++++++++++++ frontend/router/index.jsx | 2 + frontend/router/paths.js | 1 + frontend/test/mocks.js | 11 ++ 22 files changed, 677 insertions(+), 24 deletions(-) create mode 100644 frontend/components/EmailTokenRedirect/EmailTokenRedirect.jsx create mode 100644 frontend/components/EmailTokenRedirect/EmailTokenRedirect.tests.jsx create mode 100644 frontend/components/EmailTokenRedirect/helpers.js create mode 100644 frontend/components/EmailTokenRedirect/helpers.tests.js create mode 100644 frontend/components/EmailTokenRedirect/index.js create mode 100644 frontend/components/forms/ChangeEmailForm/ChangeEmailForm.jsx create mode 100644 frontend/components/forms/ChangeEmailForm/_styles.scss create mode 100644 frontend/components/forms/ChangeEmailForm/index.js diff --git a/frontend/components/EmailTokenRedirect/EmailTokenRedirect.jsx b/frontend/components/EmailTokenRedirect/EmailTokenRedirect.jsx new file mode 100644 index 0000000000..c8b7880223 --- /dev/null +++ b/frontend/components/EmailTokenRedirect/EmailTokenRedirect.jsx @@ -0,0 +1,45 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; + +import helpers from 'components/EmailTokenRedirect/helpers'; +import userInterface from 'interfaces/user'; + +export class EmailTokenRedirect extends Component { + static propTypes = { + dispatch: PropTypes.func.isRequired, + token: PropTypes.string.isRequired, + user: userInterface, + }; + + componentWillMount () { + const { dispatch, token, user } = this.props; + + return helpers.confirmEmailChange(dispatch, token, user); + } + + componentWillReceiveProps (nextProps) { + const { dispatch, token: newToken, user: newUser } = nextProps; + const { token: oldToken, user: oldUser } = this.props; + + const missingProps = !oldToken || !oldUser; + + if (missingProps) { + return helpers.confirmEmailChange(dispatch, newToken, newUser); + } + + return false; + } + + render () { + return
; + } +} + +const mapStateToProps = (state, { params }) => { + const { token } = params; + const { user } = state.auth; + + return { token, user }; +}; + +export default connect(mapStateToProps)(EmailTokenRedirect); diff --git a/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tests.jsx b/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tests.jsx new file mode 100644 index 0000000000..c0fb2ee983 --- /dev/null +++ b/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tests.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import expect, { spyOn, restoreSpies } from 'expect'; +import { mount } from 'enzyme'; + +import { connectedComponent, reduxMockStore } from 'test/helpers'; +import ConnectedEmailTokenRedirect, { EmailTokenRedirect } from 'components/EmailTokenRedirect/EmailTokenRedirect'; +import Kolide from 'kolide'; +import { userStub } from 'test/stubs'; + +describe('EmailTokenRedirect - component', () => { + afterEach(restoreSpies); + + beforeEach(() => { + spyOn(Kolide.users, 'confirmEmailChange') + .andReturn(Promise.resolve({ ...userStub, email: 'new@email.com' })); + }); + + const authStore = { + auth: { + user: userStub, + }, + }; + const token = 'KFBR392'; + const defaultProps = { + params: { + token, + }, + }; + + describe('componentWillMount', () => { + it('calls the API when a token and user are present', () => { + const mockStore = reduxMockStore(authStore); + + mount(connectedComponent(ConnectedEmailTokenRedirect, { + mockStore, + props: defaultProps, + })); + + expect(Kolide.users.confirmEmailChange).toHaveBeenCalledWith(userStub, token); + }); + + it('does not call the API when only a token is present', () => { + const mockStore = reduxMockStore({ auth: {} }); + + mount(connectedComponent(ConnectedEmailTokenRedirect, { + mockStore, + props: defaultProps, + })); + + expect(Kolide.users.confirmEmailChange).toNotHaveBeenCalled(); + }); + }); + + describe('componentWillReceiveProps', () => { + it('calls the API when a user is received', () => { + const mockStore = reduxMockStore(); + const props = { dispatch: mockStore.dispatch, token }; + const Component = mount(); + + expect(Kolide.users.confirmEmailChange).toNotHaveBeenCalled(); + + Component.setProps({ user: userStub }); + + expect(Kolide.users.confirmEmailChange).toHaveBeenCalledWith(userStub, token); + }); + }); +}); diff --git a/frontend/components/EmailTokenRedirect/helpers.js b/frontend/components/EmailTokenRedirect/helpers.js new file mode 100644 index 0000000000..38962f916b --- /dev/null +++ b/frontend/components/EmailTokenRedirect/helpers.js @@ -0,0 +1,25 @@ +import PATHS from 'router/paths'; +import { push } from 'react-router-redux'; +import { renderFlash } from 'redux/nodes/notifications/actions'; +import userActions from 'redux/nodes/entities/users/actions'; + +const confirmEmailChange = (dispatch, token, user) => { + if (user && token) { + return dispatch(userActions.confirmEmailChange(user, token)) + .then(() => { + dispatch(push(PATHS.USER_SETTINGS)); + dispatch(renderFlash('success', 'Email updated successfully!')); + + return false; + }) + .catch(() => { + dispatch(push(PATHS.LOGIN)); + + return false; + }); + } + + return Promise.resolve(); +}; + +export default { confirmEmailChange }; diff --git a/frontend/components/EmailTokenRedirect/helpers.tests.js b/frontend/components/EmailTokenRedirect/helpers.tests.js new file mode 100644 index 0000000000..0599c349c2 --- /dev/null +++ b/frontend/components/EmailTokenRedirect/helpers.tests.js @@ -0,0 +1,122 @@ +import expect, { spyOn, restoreSpies } from 'expect'; +import { reduxMockStore } from 'test/helpers'; + +import helpers from 'components/EmailTokenRedirect/helpers'; +import Kolide from 'kolide'; +import { userStub } from 'test/stubs'; + +describe('EmailTokenRedirect - helpers', () => { + afterEach(restoreSpies); + + describe('#confirmEmailChage', () => { + const { confirmEmailChange } = helpers; + const token = 'KFBR392'; + const authStore = { + auth: { + user: userStub, + }, + }; + + describe('successfully dispatching the confirmEmailChange action', () => { + beforeEach(() => { + spyOn(Kolide.users, 'confirmEmailChange') + .andReturn(Promise.resolve({ ...userStub, email: 'new@email.com' })); + }); + + it('pushes the user to the settings page', (done) => { + const mockStore = reduxMockStore(authStore); + const { dispatch } = mockStore; + + confirmEmailChange(dispatch, userStub, token) + .then(() => { + const dispatchedActions = mockStore.getActions(); + + expect(dispatchedActions).toInclude({ + type: '@@router/CALL_HISTORY_METHOD', + payload: { + method: 'push', + args: ['/settings'], + }, + }); + + done(); + }) + .catch(done); + }); + }); + + describe('unsuccessfully dispatching the confirmEmailChange action', () => { + beforeEach(() => { + const errors = [ + { + name: 'base', + reason: 'Unable to confirm your email address', + }, + ]; + const errorResponse = { + status: 422, + message: { + message: 'Unable to confirm email address', + errors, + }, + }; + + spyOn(Kolide.users, 'confirmEmailChange') + .andReturn(Promise.reject(errorResponse)); + }); + + it('pushes the user to the login page', (done) => { + const mockStore = reduxMockStore(authStore); + const { dispatch } = mockStore; + + confirmEmailChange(dispatch, userStub, token) + .then(done) + .catch(() => { + const dispatchedActions = mockStore.getActions(); + + expect(dispatchedActions).toInclude({ + type: '@@router/CALL_HISTORY_METHOD', + payload: { + method: 'push', + args: ['/login'], + }, + }); + + done(); + }); + }); + }); + + describe('when the user or token are not present', () => { + it('does not dispatch any actions when the user is not present', (done) => { + const mockStore = reduxMockStore(authStore); + const { dispatch } = mockStore; + + confirmEmailChange(dispatch, undefined, token) + .then(() => { + const dispatchedActions = mockStore.getActions(); + + expect(dispatchedActions).toEqual([]); + + done(); + }) + .catch(done); + }); + + it('does not dispatch any actions when the token is not present', (done) => { + const mockStore = reduxMockStore(authStore); + const { dispatch } = mockStore; + + confirmEmailChange(dispatch, userStub, undefined) + .then(() => { + const dispatchedActions = mockStore.getActions(); + + expect(dispatchedActions).toEqual([]); + + done(); + }) + .catch(done); + }); + }); + }); +}); diff --git a/frontend/components/EmailTokenRedirect/index.js b/frontend/components/EmailTokenRedirect/index.js new file mode 100644 index 0000000000..4a6535db15 --- /dev/null +++ b/frontend/components/EmailTokenRedirect/index.js @@ -0,0 +1 @@ +export default from './EmailTokenRedirect'; diff --git a/frontend/components/forms/ChangeEmailForm/ChangeEmailForm.jsx b/frontend/components/forms/ChangeEmailForm/ChangeEmailForm.jsx new file mode 100644 index 0000000000..d6e29e02cf --- /dev/null +++ b/frontend/components/forms/ChangeEmailForm/ChangeEmailForm.jsx @@ -0,0 +1,53 @@ +import React, { Component, PropTypes } from 'react'; + +import Button from 'components/buttons/Button'; +import Form from 'components/forms/Form'; +import formFieldInterface from 'interfaces/form_field'; +import InputField from 'components/forms/fields/InputField'; + +const baseClass = 'change-email-form'; + +class ChangeEmailForm extends Component { + static propTypes = { + fields: PropTypes.shape({ + password: formFieldInterface.isRequired, + }).isRequired, + handleSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + }; + + render () { + const { fields, handleSubmit, onCancel } = this.props; + + return ( +
+ +
+ + +
+ + ); + } +} + +export default Form(ChangeEmailForm, { + fields: ['password'], + validate: (formData) => { + if (!formData.password) { + return { + valid: false, + errors: { password: 'Password must be present' }, + }; + } + + return { valid: true, errors: {} }; + }, +}); diff --git a/frontend/components/forms/ChangeEmailForm/_styles.scss b/frontend/components/forms/ChangeEmailForm/_styles.scss new file mode 100644 index 0000000000..0428bcc633 --- /dev/null +++ b/frontend/components/forms/ChangeEmailForm/_styles.scss @@ -0,0 +1,15 @@ +.change-email-form { + &__btn-wrap { + @include display(flex); + @include flex-direction(row-reverse); + } + + &__btn { + font-size: $small; + height: 38px; + margin-bottom: 5px; + margin-left: 15px; + padding: 0; + width: 120px; + } +} diff --git a/frontend/components/forms/ChangeEmailForm/index.js b/frontend/components/forms/ChangeEmailForm/index.js new file mode 100644 index 0000000000..72fba1548d --- /dev/null +++ b/frontend/components/forms/ChangeEmailForm/index.js @@ -0,0 +1 @@ +export default from './ChangeEmailForm'; diff --git a/frontend/components/forms/UserSettingsForm/UserSettingsForm.jsx b/frontend/components/forms/UserSettingsForm/UserSettingsForm.jsx index c4b3b00dc6..e86d3072e6 100644 --- a/frontend/components/forms/UserSettingsForm/UserSettingsForm.jsx +++ b/frontend/components/forms/UserSettingsForm/UserSettingsForm.jsx @@ -18,11 +18,27 @@ class UserSettingsForm extends Component { username: formFieldInterface.isRequired, }).isRequired, handleSubmit: PropTypes.func.isRequired, + pendingEmail: PropTypes.string, onCancel: PropTypes.func.isRequired, }; + renderEmailHint = () => { + const { pendingEmail } = this.props; + + if (!pendingEmail) { + return undefined; + } + + return ( + + Pending change to {pendingEmail} + + ); + } + render () { const { fields, handleSubmit, onCancel } = this.props; + const { renderEmailHint } = this; return (
@@ -34,6 +50,7 @@ class UserSettingsForm extends Component { { + return `/v1/kolide/email/change/${token}`; + }, ENABLE_USER: (id) => { return `/v1/kolide/users/${id}/enable`; }, diff --git a/frontend/kolide/index.js b/frontend/kolide/index.js index 19dcfa5156..2a95bef364 100644 --- a/frontend/kolide/index.js +++ b/frontend/kolide/index.js @@ -204,6 +204,15 @@ class Kolide extends Base { return this.authenticatedPost(this.endpoint(CHANGE_PASSWORD), JSON.stringify(passwordParams)); }, + confirmEmailChange: (user, token) => { + const { CONFIRM_EMAIL_CHANGE } = endpoints; + const endpoint = this.endpoint(CONFIRM_EMAIL_CHANGE(token)); + + return this.authenticatedGet(endpoint) + .then((response) => { + return { ...user, email: response.new_email }; + }); + }, enable: (user, { enabled }) => { const { ENABLE_USER } = endpoints; diff --git a/frontend/kolide/index.tests.js b/frontend/kolide/index.tests.js index 1985ad7467..3ce473da45 100644 --- a/frontend/kolide/index.tests.js +++ b/frontend/kolide/index.tests.js @@ -19,6 +19,7 @@ const { invalidForgotPasswordRequest, invalidResetPasswordRequest, validChangePasswordRequest, + validConfirmEmailChangeRequest, validCreateLabelRequest, validCreateLicenseRequest, validCreatePackRequest, @@ -544,6 +545,23 @@ describe('Kolide - API client', () => { }); }); + describe('#confirmEmailChange', () => { + it('calls the appropriate endpoint with the correct parameters', (done) => { + const token = 'KFBR392'; + const request = validConfirmEmailChangeRequest(bearerToken, token); + + Kolide.setBearerToken(bearerToken); + Kolide.users.confirmEmailChange(userStub, token) + .then(() => { + expect(request.isDone()).toEqual(true); + done(); + }) + .catch(() => { + throw new Error('Expected request to have been stubbed'); + }); + }); + }); + describe('#enable', () => { it('calls the appropriate endpoint with the correct parameters', (done) => { const enableParams = { enabled: true }; diff --git a/frontend/pages/UserSettingsPage/UserSettingsPage.jsx b/frontend/pages/UserSettingsPage/UserSettingsPage.jsx index c79adb3245..f133aaddc9 100644 --- a/frontend/pages/UserSettingsPage/UserSettingsPage.jsx +++ b/frontend/pages/UserSettingsPage/UserSettingsPage.jsx @@ -5,6 +5,7 @@ import moment from 'moment'; import Avatar from 'components/Avatar'; import Button from 'components/buttons/Button'; +import ChangeEmailForm from 'components/forms/ChangeEmailForm'; import ChangePasswordForm from 'components/forms/ChangePasswordForm'; import deepDifference from 'utilities/deep_difference'; import Icon from 'components/icons/Icon'; @@ -35,7 +36,12 @@ export class UserSettingsPage extends Component { constructor (props) { super(props); - this.state = { showModal: false }; + this.state = { + pendingEmail: undefined, + showEmailModal: false, + showPasswordModal: false, + updatedUser: {}, + }; } onCancel = (evt) => { @@ -61,17 +67,28 @@ export class UserSettingsPage extends Component { onShowModal = (evt) => { evt.preventDefault(); - this.setState({ showModal: true }); + this.setState({ showPasswordModal: true }); return false; } - onToggleModal = (evt) => { + onToggleEmailModal = (updatedUser = {}) => { + const { showEmailModal } = this.state; + + this.setState({ + showEmailModal: !showEmailModal, + updatedUser, + }); + + return false; + } + + onTogglePasswordModal = (evt) => { evt.preventDefault(); - const { showModal } = this.state; + const { showPasswordModal } = this.state; - this.setState({ showModal: !showModal }); + this.setState({ showPasswordModal: !showPasswordModal }); return false; } @@ -80,9 +97,19 @@ export class UserSettingsPage extends Component { const { dispatch, user } = this.props; const updatedUser = deepDifference(formData, user); + if (updatedUser.email && !updatedUser.password) { + return this.onToggleEmailModal(updatedUser); + } + return dispatch(updateUser(user, updatedUser)) .then(() => { - return dispatch(renderFlash('success', 'Account updated!')); + if (updatedUser.email) { + this.setState({ pendingEmail: updatedUser.email }); + } + + dispatch(renderFlash('success', 'Account updated!')); + + return true; }) .catch(() => false); } @@ -93,29 +120,60 @@ export class UserSettingsPage extends Component { return dispatch(userActions.changePassword(user, formData)) .then(() => { dispatch(renderFlash('success', 'Password changed successfully')); - this.setState({ showModal: false }); + this.setState({ showPasswordModal: false }); return false; }); } - renderModal = () => { - const { userErrors } = this.props; - const { showModal } = this.state; - const { handleSubmitPasswordForm, onToggleModal } = this; + renderEmailModal = () => { + const { errors } = this.props; + const { updatedUser, showEmailModal } = this.state; + const { handleSubmit, onToggleEmailModal } = this; - if (!showModal) { + const emailSubmit = (formData) => { + handleSubmit(formData) + .then((r) => { + return r ? onToggleEmailModal() : false; + }); + }; + + if (!showEmailModal) { + return false; + } + + return ( + + + + ); + } + + renderPasswordModal = () => { + const { userErrors } = this.props; + const { showPasswordModal } = this.state; + const { handleSubmitPasswordForm, onTogglePasswordModal } = this; + + if (!showPasswordModal) { return false; } return ( @@ -123,8 +181,16 @@ export class UserSettingsPage extends Component { } render () { - const { handleSubmit, onCancel, onLogout, onShowModal, renderModal } = this; + const { + handleSubmit, + onCancel, + onLogout, + onShowModal, + renderEmailModal, + renderPasswordModal, + } = this; const { errors, user } = this.props; + const { pendingEmail } = this.state; if (!user) { return false; @@ -142,6 +208,7 @@ export class UserSettingsPage extends Component { formData={user} handleSubmit={handleSubmit} onCancel={onCancel} + pendingEmail={pendingEmail} serverErrors={errors} />
@@ -169,7 +236,8 @@ export class UserSettingsPage extends Component { LOGOUT - {renderModal()} + {renderEmailModal()} + {renderPasswordModal()} ); } diff --git a/frontend/pages/UserSettingsPage/UserSettingsPage.tests.jsx b/frontend/pages/UserSettingsPage/UserSettingsPage.tests.jsx index 3a538d139d..807ad5a005 100644 --- a/frontend/pages/UserSettingsPage/UserSettingsPage.tests.jsx +++ b/frontend/pages/UserSettingsPage/UserSettingsPage.tests.jsx @@ -8,18 +8,22 @@ import testHelpers from 'test/helpers'; import { userStub } from 'test/stubs'; import * as authActions from 'redux/nodes/auth/actions'; -const { connectedComponent, reduxMockStore } = testHelpers; +const { + connectedComponent, + fillInFormInput, + reduxMockStore, +} = testHelpers; describe('UserSettingsPage - component', () => { afterEach(restoreSpies); + const store = { auth: { user: userStub }, entities: { users: {} } }; + const mockStore = reduxMockStore(store); + it('renders a UserSettingsForm component', () => { - const store = { auth: { user: userStub }, entities: { users: {} } }; - const mockStore = reduxMockStore(store); + const Page = mount(connectedComponent(ConnectedPage, { mockStore })); - const page = mount(connectedComponent(ConnectedPage, { mockStore })); - - expect(page.find('UserSettingsForm').length).toEqual(1); + expect(Page.find('UserSettingsForm').length).toEqual(1); }); it('renders a UserSettingsForm component', () => { @@ -46,4 +50,45 @@ describe('UserSettingsPage - component', () => { expect(authActions.updateUser).toHaveBeenCalledWith(userStub, updatedAttrs); }); + + describe('changing email address', () => { + it('renders the ChangeEmailForm when the user changes their email', () => { + const Page = mount(connectedComponent(ConnectedPage, { mockStore })); + const UserSettingsForm = Page.find('UserSettingsForm'); + const emailInput = UserSettingsForm.find({ name: 'email' }); + + expect(Page.find('ChangeEmailForm').length).toEqual(0, 'Expected the ChangeEmailForm to not render'); + + fillInFormInput(emailInput, 'new@email.org'); + UserSettingsForm.simulate('submit'); + + expect(Page.find('ChangeEmailForm').length).toEqual(1, 'Expected the ChangeEmailForm to render'); + }); + + it('does not render the ChangeEmailForm when the user does not change their email', () => { + const Page = mount(connectedComponent(ConnectedPage, { mockStore })); + const UserSettingsForm = Page.find('UserSettingsForm'); + const emailInput = UserSettingsForm.find({ name: 'email' }); + + expect(Page.find('ChangeEmailForm').length).toEqual(0, 'Expected the ChangeEmailForm to not render'); + + fillInFormInput(emailInput, userStub.email); + UserSettingsForm.simulate('submit'); + + expect(Page.find('ChangeEmailForm').length).toEqual(0, 'Expected the ChangeEmailForm to not render'); + }); + + it('displays pending email text when the user is pending an email change', () => { + const props = { dispatch: noop, user: userStub }; + const Page = mount(); + const UserSettingsForm = () => Page.find('UserSettingsForm'); + const emailHint = () => UserSettingsForm().find('.manage-user__email-hint'); + + expect(emailHint().length).toEqual(0, 'Expected the form to not render an email hint'); + + Page.setState({ pendingEmail: 'new@email.org' }); + + expect(emailHint().length).toEqual(1, 'Expected the form to render an email hint'); + }); + }); }); diff --git a/frontend/redux/nodes/entities/base/reduxConfig.js b/frontend/redux/nodes/entities/base/reduxConfig.js index a2c891d1cb..eb3bc72649 100644 --- a/frontend/redux/nodes/entities/base/reduxConfig.js +++ b/frontend/redux/nodes/entities/base/reduxConfig.js @@ -292,7 +292,7 @@ const reduxConfig = ({ dispatch(updateFailure(errorsObject)); - throw errorsObject; + throw response; }); }; }; diff --git a/frontend/redux/nodes/entities/users/actions.js b/frontend/redux/nodes/entities/users/actions.js index 467c2d28ec..ecdbc34174 100644 --- a/frontend/redux/nodes/entities/users/actions.js +++ b/frontend/redux/nodes/entities/users/actions.js @@ -2,6 +2,7 @@ import Kolide from 'kolide'; import config from 'redux/nodes/entities/users/config'; import { formatErrorResponse } from 'redux/nodes/entities/base/helpers'; +import { logoutUser, updateUserSuccess } from 'redux/nodes/auth/actions'; const { extendedActions } = config; @@ -43,6 +44,29 @@ export const changePassword = (user, { new_password: newPassword, old_password: }; }; +export const confirmEmailChange = (user, token) => { + const { loadRequest, successAction, updateFailure, updateSuccess } = extendedActions; + + return (dispatch) => { + dispatch(loadRequest); + + return Kolide.users.confirmEmailChange(user, token) + .then((updatedUser) => { + dispatch(successAction(updatedUser, updateSuccess)); + dispatch(updateUserSuccess(updatedUser)); + + return updatedUser; + }) + .catch((response) => { + const errorsObject = formatErrorResponse(response); + + dispatch(updateFailure(errorsObject)); + + return dispatch(logoutUser()); + }); + }; +}; + export const enableUser = (user, { enabled }) => { const { successAction, updateFailure, updateSuccess } = extendedActions; @@ -96,4 +120,11 @@ export const updateAdmin = (user, { admin }) => { }; }; -export default { ...config.actions, changePassword, enableUser, requirePasswordReset, updateAdmin }; +export default { + ...config.actions, + changePassword, + confirmEmailChange, + enableUser, + requirePasswordReset, + updateAdmin, +}; diff --git a/frontend/redux/nodes/entities/users/actions.tests.js b/frontend/redux/nodes/entities/users/actions.tests.js index 757b98a5e7..b0a5407cb7 100644 --- a/frontend/redux/nodes/entities/users/actions.tests.js +++ b/frontend/redux/nodes/entities/users/actions.tests.js @@ -3,9 +3,11 @@ import expect, { restoreSpies, spyOn } from 'expect'; import * as Kolide from 'kolide'; import { reduxMockStore } from 'test/helpers'; +import { updateUserSuccess } from 'redux/nodes/auth/actions'; import { changePassword, + confirmEmailChange, enableUser, requirePasswordReset, REQUIRE_PASSWORD_RESET_FAILURE, @@ -211,6 +213,117 @@ describe('Users - actions', () => { }); }); + describe('confirmEmailChange', () => { + const token = 'KFBR392'; + const updatedUser = { ...user, email: 'new@email.com' }; + + describe('successful request', () => { + beforeEach(() => { + spyOn(Kolide.default.users, 'confirmEmailChange').andCall(() => { + return Promise.resolve(updatedUser); + }); + }); + + afterEach(restoreSpies); + + it('calls the API', (done) => { + const mockStore = reduxMockStore(store); + + mockStore.dispatch(confirmEmailChange(user, token)) + .then(() => { + expect(Kolide.default.users.confirmEmailChange).toHaveBeenCalledWith(user, token); + done(); + }) + .catch(done); + }); + + it('dispatches the correct actions', (done) => { + const mockStore = reduxMockStore(store); + + mockStore.dispatch(confirmEmailChange(user, token)) + .then(() => { + const dispatchedActions = mockStore.getActions(); + + expect(dispatchedActions).toEqual([ + config.extendedActions.loadRequest, + config.extendedActions.updateSuccess({ + users: { + [user.id]: updatedUser, + }, + }), + updateUserSuccess(updatedUser), + ]); + + done(); + }) + .catch(done); + }); + }); + + describe('unsuccessful request', () => { + const errors = [ + { + name: 'base', + reason: 'Unable to confirm your email address', + }, + ]; + const errorResponse = { + status: 422, + message: { + message: 'Unable to confirm email address', + errors, + }, + }; + beforeEach(() => { + spyOn(Kolide.default.users, 'confirmEmailChange').andCall(() => { + return Promise.reject(errorResponse); + }); + spyOn(Kolide.default, 'logout').andCall(() => { + return Promise.resolve({}); + }); + }); + + afterEach(restoreSpies); + + it('calls the API', (done) => { + const mockStore = reduxMockStore(store); + + mockStore.dispatch(confirmEmailChange(user, token)) + .then(() => { + expect(Kolide.default.users.confirmEmailChange).toHaveBeenCalledWith(user, token); + done(); + }) + .catch(done); + }); + + it('dispatches the correct actions', (done) => { + const mockStore = reduxMockStore(store); + + mockStore.dispatch(confirmEmailChange(user, token)) + .then(() => { + const dispatchedActions = mockStore.getActions(); + + expect(dispatchedActions).toEqual([ + config.extendedActions.loadRequest, + config.extendedActions.updateFailure({ base: 'Unable to confirm your email address', http_status: 422 }), + { type: 'LOGOUT_REQUEST' }, + { + type: '@@router/CALL_HISTORY_METHOD', + payload: { + method: 'push', + args: ['/login'], + }, + }, + { type: 'LOGOUT_SUCCESS' }, + ]); + + done(); + }) + .catch(done); + }); + }); + }); + describe('updateAdmin', () => { describe('successful request', () => { beforeEach(() => { diff --git a/frontend/router/index.jsx b/frontend/router/index.jsx index de36cac5b6..602575fffd 100644 --- a/frontend/router/index.jsx +++ b/frontend/router/index.jsx @@ -13,6 +13,7 @@ import ConfigOptionsPage from 'pages/config/ConfigOptionsPage'; import ConfirmInvitePage from 'pages/ConfirmInvitePage'; import CoreLayout from 'layouts/CoreLayout'; import EditPackPage from 'pages/packs/EditPackPage'; +import EmailTokenRedirect from 'components/EmailTokenRedirect'; import LicensePage from 'pages/LicensePage'; import LoginRoutes from 'components/LoginRoutes'; import LogoutPage from 'pages/LogoutPage'; @@ -42,6 +43,7 @@ const routes = ( + diff --git a/frontend/router/paths.js b/frontend/router/paths.js index f95d5e098b..a0185868f0 100644 --- a/frontend/router/paths.js +++ b/frontend/router/paths.js @@ -16,4 +16,5 @@ export default { NEW_QUERY: '/queries/new', RESET_PASSWORD: '/login/reset', SETUP: '/setup', + USER_SETTINGS: '/settings', }; diff --git a/frontend/test/mocks.js b/frontend/test/mocks.js index d5ac7cebaa..478b0dd1b2 100644 --- a/frontend/test/mocks.js +++ b/frontend/test/mocks.js @@ -24,6 +24,16 @@ export const validChangePasswordRequest = (bearerToken, params) => { .reply(200, {}); }; +export const validConfirmEmailChangeRequest = (bearerToken, token) => { + return nock('http://localhost:8080', { + reqHeaders: { + Authorization: `Bearer ${bearerToken}`, + }, + }) + .get(`/api/v1/kolide/email/change/${token}`) + .reply(200, { new_email: 'new@email.com' }); +}; + export const validCreateLabelRequest = (bearerToken, labelParams) => { return nock('http://localhost:8080', { reqHeaders: { @@ -518,6 +528,7 @@ export default { invalidGetQueryRequest, invalidResetPasswordRequest, validChangePasswordRequest, + validConfirmEmailChangeRequest, validCreateLabelRequest, validCreateLicenseRequest, validCreatePackRequest,