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:
Mike Stone
2016-10-11 11:34:52 -04:00
committed by GitHub
parent 23ffa5be62
commit 2c17e641cb
13 changed files with 277 additions and 20 deletions
@@ -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
View File
@@ -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',
+6
View File
@@ -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;
+23
View File
@@ -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;
+2
View File
@@ -1,6 +1,8 @@
import { combineReducers } from 'redux';
import invites from './invites/reducer';
import users from './users/reducer';
export default combineReducers({
invites,
users,
});
+11
View File
@@ -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,