diff --git a/frontend/components/forms/InviteUserForm/InviteUserForm.jsx b/frontend/components/forms/InviteUserForm/InviteUserForm.jsx index 6aa0a21118..ef91b51214 100644 --- a/frontend/components/forms/InviteUserForm/InviteUserForm.jsx +++ b/frontend/components/forms/InviteUserForm/InviteUserForm.jsx @@ -8,6 +8,10 @@ import validEmail from '../validators/valid_email'; class InviteUserForm extends Component { static propTypes = { + error: PropTypes.string, + invitedBy: PropTypes.shape({ + id: PropTypes.number, + }), onCancel: PropTypes.func, onSubmit: PropTypes.func, }; @@ -17,16 +21,32 @@ class InviteUserForm extends Component { this.state = { errors: { + admin: null, email: null, - role: null, + name: null, }, formData: { + admin: 'false', email: null, - role: 'user', + name: null, }, }; } + componentWillReceiveProps (nextProps) { + const { error } = nextProps; + const { errors } = this.state; + + if (this.props.error !== error) { + this.setState({ + errors: { + ...errors, + email: error, + }, + }); + } + } + onInputChange = (formField) => { return ({ target }) => { const { errors, formData } = this.state; @@ -50,10 +70,15 @@ class InviteUserForm extends Component { const valid = this.validate(); if (valid) { - const { formData } = this.state; - const { onSubmit } = this.props; + const { formData: { admin, email, name } } = this.state; + const { invitedBy, onSubmit } = this.props; - return onSubmit(formData); + return onSubmit({ + admin: admin === 'true', + email, + invited_by: invitedBy.id, + name, + }); } return false; @@ -92,7 +117,7 @@ class InviteUserForm extends Component { render () { const { buttonStyles, buttonWrapperStyles, radioElementStyles, roleTitleStyles } = componentStyles; - const { errors, formData: { role } } = this.state; + const { errors, formData: { admin } } = this.state; const { onCancel } = this.props; const { onFormSubmit, onInputChange } = this; @@ -100,6 +125,12 @@ class InviteUserForm extends Component {
+

role

USER (default)
ADMIN
diff --git a/frontend/kolide/endpoints.js b/frontend/kolide/endpoints.js index 6df83ae14a..8b2283efdf 100644 --- a/frontend/kolide/endpoints.js +++ b/frontend/kolide/endpoints.js @@ -1,6 +1,7 @@ export default { CONFIG: '/v1/kolide/config', FORGOT_PASSWORD: '/v1/kolide/forgot_password', + INVITES: '/v1/kolide/invites', LOGIN: '/v1/kolide/login', LOGOUT: '/v1/kolide/logout', ME: '/v1/kolide/me', diff --git a/frontend/kolide/index.js b/frontend/kolide/index.js index 061ef47c86..b4155ae7b9 100644 --- a/frontend/kolide/index.js +++ b/frontend/kolide/index.js @@ -23,6 +23,12 @@ class Kolide extends Base { .then(response => { return response.users; }); } + inviteUser = (formData) => { + const { INVITES } = endpoints; + + return this.authenticatedPost(this.endpoint(INVITES), JSON.stringify(formData)); + } + loginUser ({ username, password }) { const { LOGIN } = endpoints; const loginEndpoint = this.baseURL + LOGIN; diff --git a/frontend/kolide/index.tests.js b/frontend/kolide/index.tests.js index 65255b1611..544e82aae3 100644 --- a/frontend/kolide/index.tests.js +++ b/frontend/kolide/index.tests.js @@ -8,6 +8,7 @@ const { validForgotPasswordRequest, validGetConfigRequest, validGetUsersRequest, + validInviteUserRequest, validLoginRequest, validLogoutRequest, validMeRequest, @@ -52,6 +53,28 @@ describe('Kolide - API client', () => { }); }); + describe('#inviteUser', () => { + it('calls the appropriate endpoint with the correct parameters', (done) => { + const bearerToken = 'valid-bearer-token'; + const formData = { + email: 'new@user.org', + admin: false, + invited_by: 1, + id: 1, + name: '', + }; + const request = validInviteUserRequest(bearerToken, formData); + + Kolide.setBearerToken(bearerToken); + Kolide.inviteUser(formData) + .then(() => { + expect(request.isDone()).toEqual(true); + done(); + }) + .catch(done); + }); + }); + describe('#me', () => { it('calls the appropriate endpoint with the correct parameters', (done) => { const bearerToken = 'ABC123'; diff --git a/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx b/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx index e98075d90e..7457e2f6ab 100644 --- a/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx +++ b/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import componentStyles from './styles'; import entityGetter from '../../../redux/entityGetter'; import Button from '../../../components/buttons/Button'; +import inviteActions from '../../../redux/nodes/entities/invites/actions'; import InviteUserForm from '../../../components/forms/InviteUserForm'; import Modal from '../../../components/Modal'; import userActions from '../../../redux/nodes/entities/users/actions'; @@ -93,8 +94,19 @@ class UserManagementPage extends Component { } onInviteUserSubmit = (formData) => { - console.log('user invited', formData); - return this.toggleInviteUserModal(); + const { dispatch } = this.props; + + dispatch(inviteActions.create(formData)) + .then(() => { + dispatch(renderFlash('success', 'User invited')); + return this.toggleInviteUserModal(); + }) + .catch(error => { + const inviteError = error === 'resource already created' + ? 'User has already been invited' + : error; + this.setState({ inviteError }); + }); } onInviteCancel = (evt) => { @@ -129,7 +141,8 @@ class UserManagementPage extends Component { } renderModal = () => { - const { showInviteUserModal } = this.state; + const { currentUser } = this.props; + const { inviteError, showInviteUserModal } = this.state; const { onInviteCancel, onInviteUserSubmit, toggleInviteUserModal } = this; if (!showInviteUserModal) return false; @@ -140,6 +153,8 @@ class UserManagementPage extends Component { onExit={toggleInviteUserModal} > diff --git a/frontend/redux/nodes/entities/base/reduxConfig.js b/frontend/redux/nodes/entities/base/reduxConfig.js index 0cab82778b..d2bdcbc742 100644 --- a/frontend/redux/nodes/entities/base/reduxConfig.js +++ b/frontend/redux/nodes/entities/base/reduxConfig.js @@ -8,13 +8,17 @@ const initialState = { }; const reduxConfig = ({ + createFunc = noop, entityName, loadFunc, - parseFunc = noop, + parseFunc, schema, updateFunc, }) => { const actionTypes = { + CREATE_FAILURE: `${entityName}_CREATE_FAILURE`, + CREATE_REQUEST: `${entityName}_CREATE_REQUEST`, + CREATE_SUCCESS: `${entityName}_CREATE_SUCCESS`, LOAD_FAILURE: `${entityName}_LOAD_FAILURE`, LOAD_REQUEST: `${entityName}_LOAD_REQUEST`, LOAD_SUCCESS: `${entityName}_LOAD_SUCCESS`, @@ -23,6 +27,20 @@ const reduxConfig = ({ UPDATE_SUCCESS: `${entityName}_UPDATE_SUCCESS`, }; + const createFailure = (errors) => { + return { + type: actionTypes.CREATE_FAILURE, + payload: { errors }, + }; + }; + const createRequest = { type: actionTypes.CREATE_REQUEST }; + const createSuccess = (data) => { + return { + type: actionTypes.CREATE_SUCCESS, + payload: { data }, + }; + }; + const loadFailure = (errors) => { return { type: actionTypes.LOAD_FAILURE, @@ -52,11 +70,37 @@ const reduxConfig = ({ }; const parsedResponse = (responseArray) => { + if (!parseFunc) return responseArray; + return responseArray.map(response => { return parseFunc(response); }); }; + const create = (...args) => { + return (dispatch) => { + dispatch(createRequest); + + return createFunc(...args) + .then(response => { + if (!response) return []; + + const { entities } = normalize(parsedResponse([response]), arrayOf(schema)); + + return dispatch(createSuccess(entities)); + }) + .catch(response => { + const { errors } = response; + const { error } = response.message || {}; + const errorMessage = errors || error; + + dispatch(createFailure(errorMessage)); + + throw error; + }); + }; + }; + const load = (...args) => { return (dispatch) => { dispatch(loadRequest); @@ -99,18 +143,21 @@ const reduxConfig = ({ }; const actions = { + create, load, update, }; const reducer = (state = initialState, { type, payload }) => { switch (type) { - case actionTypes.UPDATE_REQUEST: + case actionTypes.CREATE_REQUEST: case actionTypes.LOAD_REQUEST: + case actionTypes.UPDATE_REQUEST: return { ...state, loading: true, }; + case actionTypes.CREATE_SUCCESS: case actionTypes.UPDATE_SUCCESS: case actionTypes.LOAD_SUCCESS: return { @@ -121,14 +168,13 @@ const reduxConfig = ({ ...payload.data[entityName], }, }; + case actionTypes.CREATE_FAILURE: case actionTypes.UPDATE_FAILURE: case actionTypes.LOAD_FAILURE: return { ...state, loading: false, - errors: { - ...payload.errors, - }, + errors: payload.errors, }; default: return state; diff --git a/frontend/redux/nodes/entities/base/reduxConfig.tests.js b/frontend/redux/nodes/entities/base/reduxConfig.tests.js index 49630ccbd7..e6808536ab 100644 --- a/frontend/redux/nodes/entities/base/reduxConfig.tests.js +++ b/frontend/redux/nodes/entities/base/reduxConfig.tests.js @@ -9,6 +9,108 @@ const user = { id: 1, email: 'hi@thegnar.co' }; describe('reduxConfig', () => { afterEach(restoreSpies); + describe('dispatching the create action', () => { + describe('successful create call', () => { + const mockStore = reduxMockStore(store); + const createFunc = createSpy().andCall(() => { + return Promise.resolve([user]); + }); + + const config = reduxConfig({ + createFunc, + entityName: 'users', + schema: schemas.USERS, + }); + const { actions, reducer } = config; + + it('calls the createFunc', () => { + mockStore.dispatch(actions.create()); + + expect(createFunc).toHaveBeenCalled(); + }); + + it('dispatches the correct actions', () => { + mockStore.dispatch(actions.create()); + + const dispatchedActions = mockStore.getActions(); + const dispatchedActionTypes = dispatchedActions.map(action => { return action.type; }); + + expect(dispatchedActionTypes).toInclude('users_CREATE_REQUEST'); + expect(dispatchedActionTypes).toInclude('users_CREATE_SUCCESS'); + expect(dispatchedActionTypes).toNotInclude('users_CREATE_FAILURE'); + }); + + it('adds the returned user to state', () => { + const createSuccessAction = { + type: 'users_CREATE_SUCCESS', + payload: { + data: { + users: { + [user.id]: user, + }, + }, + }, + }; + const initialState = { + loading: false, + entities: {}, + errors: {}, + }; + const newState = reducer(initialState, createSuccessAction); + + expect(newState.data[user.id]).toEqual(user); + }); + }); + + describe('unsuccessful create call', () => { + const mockStore = reduxMockStore(store); + const errors = { base: 'Unable to create user' }; + const createFunc = createSpy().andCall(() => { + return Promise.reject({ errors }); + }); + const config = reduxConfig({ + createFunc, + entityName: 'users', + schema: schemas.USERS, + }); + const { actions, reducer } = config; + + it('calls the createFunc', () => { + mockStore.dispatch(actions.create()); + + expect(createFunc).toHaveBeenCalled(); + }); + + it('dispatches the correct actions', () => { + mockStore.dispatch(actions.create()); + + const dispatchedActions = mockStore.getActions(); + const dispatchedActionTypes = dispatchedActions.map(action => { return action.type; }); + + expect(dispatchedActionTypes).toInclude('users_CREATE_REQUEST'); + expect(dispatchedActionTypes).toNotInclude('users_CREATE_SUCCESS'); + expect(dispatchedActionTypes).toInclude('users_CREATE_FAILURE'); + }); + + it('adds the returned errors to state', () => { + const createFailureAction = { + type: 'users_CREATE_FAILURE', + payload: { + errors, + }, + }; + const initialState = { + loading: false, + entities: {}, + errors: {}, + }; + const newState = reducer(initialState, createFailureAction); + + expect(newState.errors).toEqual(errors); + }); + }); + }); + describe('dispatching the load action', () => { describe('successful load call', () => { const mockStore = reduxMockStore(store); diff --git a/frontend/redux/nodes/entities/base/schemas.js b/frontend/redux/nodes/entities/base/schemas.js index f40aae8241..333514f6cf 100644 --- a/frontend/redux/nodes/entities/base/schemas.js +++ b/frontend/redux/nodes/entities/base/schemas.js @@ -1,7 +1,9 @@ import { Schema } from 'normalizr'; +const invitesSchema = new Schema('invites'); const usersSchema = new Schema('users'); export default { + INVITES: invitesSchema, USERS: usersSchema, }; diff --git a/frontend/redux/nodes/entities/invites/actions.js b/frontend/redux/nodes/entities/invites/actions.js new file mode 100644 index 0000000000..b95c41eb89 --- /dev/null +++ b/frontend/redux/nodes/entities/invites/actions.js @@ -0,0 +1,3 @@ +import config from './config'; + +export default config.actions; diff --git a/frontend/redux/nodes/entities/invites/config.js b/frontend/redux/nodes/entities/invites/config.js new file mode 100644 index 0000000000..c9297471c3 --- /dev/null +++ b/frontend/redux/nodes/entities/invites/config.js @@ -0,0 +1,12 @@ +import Kolide from '../../../../kolide'; +import reduxConfig from '../base/reduxConfig'; +import schemas from '../base/schemas'; + +const { INVITES: schema } = schemas; + +export default reduxConfig({ + createFunc: Kolide.inviteUser, + entityName: 'invites', + schema, +}); + diff --git a/frontend/redux/nodes/entities/invites/reducer.js b/frontend/redux/nodes/entities/invites/reducer.js new file mode 100644 index 0000000000..93a1bd364b --- /dev/null +++ b/frontend/redux/nodes/entities/invites/reducer.js @@ -0,0 +1,3 @@ +import config from './config'; + +export default config.reducer; diff --git a/frontend/redux/nodes/entities/reducer.js b/frontend/redux/nodes/entities/reducer.js index ec2337741f..1529b7609f 100644 --- a/frontend/redux/nodes/entities/reducer.js +++ b/frontend/redux/nodes/entities/reducer.js @@ -1,6 +1,8 @@ import { combineReducers } from 'redux'; +import invites from './invites/reducer'; import users from './users/reducer'; export default combineReducers({ + invites, users, }); diff --git a/frontend/test/mocks.js b/frontend/test/mocks.js index 275935a723..2aae7fc74d 100644 --- a/frontend/test/mocks.js +++ b/frontend/test/mocks.js @@ -22,6 +22,16 @@ export const validGetConfigRequest = (bearerToken) => { .reply(200, { config: { name: 'Kolide' } }); }; +export const validInviteUserRequest = (bearerToken, formData) => { + return nock('http://localhost:8080', { + reqHeaders: { + Authorization: `Bearer ${bearerToken}`, + }, + }) + .post('/api/v1/kolide/invites', JSON.stringify(formData)) + .reply(200, formData); +}; + export const validGetUsersRequest = (bearerToken) => { return nock('http://localhost:8080', { reqHeaders: { @@ -101,6 +111,7 @@ export default { validForgotPasswordRequest, validGetConfigRequest, validGetUsersRequest, + validInviteUserRequest, validLoginRequest, validLogoutRequest, validMeRequest,