Invite new users (#290)
* API client - invite users * configure invites as a redux entity * Invite user form submits invite from user management page
This commit is contained in:
@@ -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 {
|
||||
<form onSubmit={onFormSubmit}>
|
||||
<InputFieldWithIcon
|
||||
autofocus
|
||||
error={errors.name}
|
||||
name="name"
|
||||
onChange={onInputChange('name')}
|
||||
placeholder="Name"
|
||||
/>
|
||||
<InputFieldWithIcon
|
||||
error={errors.email}
|
||||
name="email"
|
||||
onChange={onInputChange('email')}
|
||||
@@ -108,17 +139,17 @@ class InviteUserForm extends Component {
|
||||
<div style={radioElementStyles}>
|
||||
<p style={roleTitleStyles}>role</p>
|
||||
<input
|
||||
checked={role === 'user'}
|
||||
onChange={onInputChange('role')}
|
||||
checked={admin === 'false'}
|
||||
onChange={onInputChange('admin')}
|
||||
type="radio"
|
||||
value="user"
|
||||
value="false"
|
||||
/> USER (default)
|
||||
<br />
|
||||
<input
|
||||
checked={role === 'admin'}
|
||||
onChange={onInputChange('role')}
|
||||
checked={admin === 'true'}
|
||||
onChange={onInputChange('admin')}
|
||||
type="radio"
|
||||
value="admin"
|
||||
value="true"
|
||||
/> ADMIN
|
||||
</div>
|
||||
<div style={buttonWrapperStyles}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<InviteUserForm
|
||||
error={inviteError}
|
||||
invitedBy={currentUser}
|
||||
onCancel={onInviteCancel}
|
||||
onSubmit={onInviteUserSubmit}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Schema } from 'normalizr';
|
||||
|
||||
const invitesSchema = new Schema('invites');
|
||||
const usersSchema = new Schema('users');
|
||||
|
||||
export default {
|
||||
INVITES: invitesSchema,
|
||||
USERS: usersSchema,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import config from './config';
|
||||
|
||||
export default config.actions;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import config from './config';
|
||||
|
||||
export default config.reducer;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import invites from './invites/reducer';
|
||||
import users from './users/reducer';
|
||||
|
||||
export default combineReducers({
|
||||
invites,
|
||||
users,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user