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 (