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,