Show invited users (#297)

* Adds loadAll action to redux entity config

* API Client get invites

* Add invites to the user management page

* Updates user block styles on user management page

* Submit modal form on enter

* Modify details form styles

* Enter submits edit user form

* Removes unused admin dashboard page

* API Client - revoke invites

* Delete invite entities in redux

* Revoke invites from admin manage users page

* Show success flash message after user invite is revoked
This commit is contained in:
Mike Stone
2016-10-14 17:08:57 -04:00
committed by GitHub
parent daeecfb244
commit beda051de2
29 changed files with 768 additions and 180 deletions
@@ -14,6 +14,14 @@ export default {
justifyContent: 'space-between',
paddingLeft: padding.half,
paddingRight: padding.half,
position: 'fixed',
left: '223px',
right: 0,
top: 0,
zIndex: '2',
'@media (max-width: 760px)': {
left: '54px',
},
};
if (alertType === 'success') {
+1
View File
@@ -146,6 +146,7 @@ const componentStyles = {
borderRightWidth: '1px',
bottom: 0,
boxShadow: '2px 0 8px 0 rgba(0, 0, 0, 0.1)',
boxSizing: 'border-box',
left: 0,
paddingLeft: '16px',
position: 'fixed',
@@ -1,12 +1,13 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import Avatar from '../../../Avatar';
import { avatarStyles } from '../../../../pages/Admin/UserManagementPage/UserBlock/styles';
import Button from '../../../buttons/Button';
import componentStyles from '../../../../pages/Admin/UserManagementPage/UserBlock/styles';
import componentStyles from './styles';
import InputField from '../../fields/InputField';
import Styleguide from '../../../../styles';
const { color, font, padding } = Styleguide;
const { color } = Styleguide;
class EditUserForm extends Component {
static propTypes = {
@@ -15,24 +16,6 @@ class EditUserForm extends Component {
user: PropTypes.object,
};
static inputStyles = {
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
borderBottomWidth: '1px',
fontSize: font.small,
borderBottomStyle: 'solid',
borderBottomColor: color.brand,
color: color.textMedium,
width: '100%',
};
static labelStyles = {
color: color.textLight,
textTransform: 'uppercase',
fontSize: font.mini,
};
constructor (props) {
super(props);
@@ -66,9 +49,13 @@ class EditUserForm extends Component {
render () {
const {
avatarStyles,
avatarWrapperStyles,
buttonWrapperStyles,
formButtonStyles,
userWrapperStyles,
formWrapperStyles,
inputStyles,
inputWrapperStyles,
labelStyles,
} = componentStyles;
const { user } = this.props;
const {
@@ -80,56 +67,58 @@ class EditUserForm extends Component {
const { onFormSubmit, onInputChange } = this;
return (
<form style={[userWrapperStyles, { boxSizing: 'border-box', padding: '10px' }]} onSubmit={onFormSubmit}>
<form style={formWrapperStyles} onSubmit={onFormSubmit}>
<InputField
defaultValue={name}
label="name"
labelStyles={EditUserForm.labelStyles}
labelStyles={labelStyles}
name="name"
onChange={onInputChange('name')}
inputWrapperStyles={{ marginTop: 0, marginBottom: padding.half }}
style={EditUserForm.inputStyles}
inputWrapperStyles={inputWrapperStyles}
style={inputStyles}
/>
<Avatar user={user} style={avatarStyles} />
<div style={avatarWrapperStyles}>
<Avatar user={user} style={avatarStyles} />
</div>
<InputField
defaultValue={username}
label="username"
labelStyles={EditUserForm.labelStyles}
labelStyles={labelStyles}
name="username"
onChange={onInputChange('username')}
inputWrapperStyles={{ marginTop: 0 }}
style={[EditUserForm.inputStyles, { color: color.brand }]}
style={[inputStyles, { color: color.brand }]}
/>
<InputField
defaultValue={position}
label="position"
labelStyles={EditUserForm.labelStyles}
labelStyles={labelStyles}
name="position"
onChange={onInputChange('position')}
inputWrapperStyles={{ marginTop: 0 }}
style={EditUserForm.inputStyles}
style={inputStyles}
/>
<InputField
defaultValue={email}
inputWrapperStyles={{ marginTop: 0 }}
label="email"
labelStyles={EditUserForm.labelStyles}
labelStyles={labelStyles}
name="email"
onChange={onInputChange('email')}
style={[EditUserForm.inputStyles, { color: color.link }]}
style={[inputStyles, { color: color.link }]}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '10px' }}>
<div style={buttonWrapperStyles}>
<Button
style={formButtonStyles}
text="Submit"
type="submit"
/>
<Button
onClick={this.props.onCancel}
style={formButtonStyles}
text="Cancel"
variant="inverse"
/>
<Button
style={formButtonStyles}
text="Submit"
type="submit"
/>
</div>
</form>
);
@@ -0,0 +1,44 @@
import Styles from '../../../../styles';
const { color, font, padding } = Styles;
export default {
avatarWrapperStyles: {
textAlign: 'center',
},
buttonWrapperStyles: {
display: 'flex',
flexDirection: 'row-reverse',
justifyContent: 'space-between',
marginTop: padding.half,
},
formButtonStyles: {
paddingLeft: padding.base,
paddingRight: padding.base,
},
formWrapperStyles: {
boxSizing: 'border-box',
paddingLeft: padding.half,
paddingRight: padding.half,
},
inputStyles: {
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
borderBottomWidth: '1px',
fontSize: font.small,
borderBottomStyle: 'solid',
borderBottomColor: color.brand,
color: color.textMedium,
width: '100%',
},
inputWrapperStyles: {
marginBottom: padding.half,
marginTop: 0,
},
labelStyles: {
color: color.textLight,
textTransform: 'uppercase',
fontSize: font.mini,
},
};
@@ -153,17 +153,18 @@ class InviteUserForm extends Component {
/> ADMIN
</div>
<div style={buttonWrapperStyles}>
<Button
onClick={onCancel}
style={buttonStyles}
text="Cancel"
variant="inverse"
/>
<Button
style={buttonStyles}
text="Invite"
type="submit"
/>
<Button
onClick={onCancel}
style={buttonStyles}
text="Cancel"
type="input"
variant="inverse"
/>
</div>
</form>
);
@@ -15,6 +15,7 @@ export default {
},
buttonWrapperStyles: {
display: 'flex',
flexDirection: 'row-reverse',
justifyContent: 'space-between',
},
radioElementStyles: {
@@ -5,12 +5,13 @@ import componentStyles from './styles';
class Dropdown extends Component {
static propTypes = {
containerStyles: PropTypes.object,
selectStyles: PropTypes.object,
options: PropTypes.arrayOf(PropTypes.shape({
text: PropTypes.string,
value: PropTypes.string,
})),
onSelect: PropTypes.func,
containerStyles: PropTypes.object,
};
static defaultProps = {
@@ -39,13 +40,13 @@ class Dropdown extends Component {
}
render () {
const { containerStyles, options } = this.props;
const { containerStyles, options, selectStyles } = this.props;
const { onOptionClick, renderOption } = this;
const { selectWrapperStyles } = componentStyles;
return (
<div className="kolide-dropdown-wrapper">
<select className="kolide-dropdown" style={[selectWrapperStyles, containerStyles]} onChange={onOptionClick}>
<div className="kolide-dropdown-wrapper" style={containerStyles}>
<select className="kolide-dropdown" style={[selectWrapperStyles, selectStyles]} onChange={onOptionClick}>
{options.map(option => {
return renderOption(option);
})}
@@ -201,7 +201,7 @@ class SaveQueryForm extends Component {
key="duration"
options={[{ text: 'Short', value: 'short' }, { text: 'Long', value: 'long' }]}
onSelect={onFieldChange('duration')}
containerStyles={dropdownInputStyles}
selectStyles={dropdownInputStyles}
/>
</div>
<small style={helpTextStyles}>
@@ -215,7 +215,7 @@ class SaveQueryForm extends Component {
key="platforms"
options={[{ text: 'ALL PLATFORMS', value: 'all' }, { text: 'NO PLATFORMS', value: 'none' }]}
onSelect={onFieldChange('platforms')}
containerStyles={dropdownInputStyles}
selectStyles={dropdownInputStyles}
/>
</div>
<small style={helpTextStyles}>
+17 -4
View File
@@ -2,6 +2,7 @@ import fetch from 'isomorphic-fetch';
import local from '../utilities/local';
const REQUEST_METHODS = {
DELETE: 'DELETE',
GET: 'GET',
PATCH: 'PATCH',
POST: 'POST',
@@ -23,6 +24,12 @@ class Base {
this.bearerToken = bearerToken;
}
authenticatedDelete (endpoint, overrideHeaders = {}) {
const { DELETE } = REQUEST_METHODS;
return this._authenticatedRequest(DELETE, endpoint, {}, overrideHeaders);
}
authenticatedGet (endpoint, overrideHeaders = {}) {
const { GET } = REQUEST_METHODS;
@@ -47,18 +54,22 @@ class Base {
return this._request(POST, endpoint, body, overrideHeaders);
}
_authenticatedRequest(method, endpoint, body, overrideHeaders) {
const headers = {
...overrideHeaders,
_authenticatedHeaders = (headers) => {
return {
...headers,
Authorization: `Bearer ${this.bearerToken}`,
};
}
_authenticatedRequest(method, endpoint, body, overrideHeaders) {
const headers = this._authenticatedHeaders(overrideHeaders);
return this._request(method, endpoint, body, headers);
}
_request (method, endpoint, body, overrideHeaders) {
const credentials = 'same-origin';
const { GET } = REQUEST_METHODS;
const { DELETE, GET } = REQUEST_METHODS;
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
@@ -70,6 +81,8 @@ class Base {
return fetch(endpoint, requestAttrs)
.then(response => {
if (method === DELETE) return false;
return response.json()
.then(jsonResponse => {
if (response.ok) {
+16 -1
View File
@@ -16,6 +16,13 @@ class Kolide extends Base {
.then(response => { return response.org_info; });
}
getInvites = () => {
const { INVITES } = endpoints;
return this.authenticatedGet(this.endpoint(INVITES))
.then(response => { return response.invites; });
}
getUsers = () => {
const { USERS } = endpoints;
@@ -26,7 +33,8 @@ class Kolide extends Base {
inviteUser = (formData) => {
const { INVITES } = endpoints;
return this.authenticatedPost(this.endpoint(INVITES), JSON.stringify(formData));
return this.authenticatedPost(this.endpoint(INVITES), JSON.stringify(formData))
.then(response => { return response.invite; });
}
loginUser ({ username, password }) {
@@ -57,6 +65,13 @@ class Kolide extends Base {
return this.post(resetPasswordEndpoint, JSON.stringify(formData));
}
revokeInvite = ({ entityID }) => {
const { INVITES } = endpoints;
const endpoint = `${this.endpoint(INVITES)}/${entityID}`;
return this.authenticatedDelete(endpoint);
}
updateUser = (user, formData) => {
const { USERS } = endpoints;
const updateUserEndpoint = `${this.baseURL}${USERS}/${user.id}`;
+33
View File
@@ -7,12 +7,14 @@ const {
invalidResetPasswordRequest,
validForgotPasswordRequest,
validGetConfigRequest,
validGetInvitesRequest,
validGetUsersRequest,
validInviteUserRequest,
validLoginRequest,
validLogoutRequest,
validMeRequest,
validResetPasswordRequest,
validRevokeInviteRequest,
validUpdateUserRequest,
validUser,
} = mocks;
@@ -38,6 +40,21 @@ describe('Kolide - API client', () => {
});
});
describe('#getInvites', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'valid-bearer-token';
const request = validGetInvitesRequest(bearerToken);
Kolide.setBearerToken(bearerToken);
Kolide.getInvites()
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
});
describe('#getUsers', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'valid-bearer-token';
@@ -193,6 +210,22 @@ describe('Kolide - API client', () => {
});
});
describe('#revokeInvite', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'valid-bearer-token';
const entityID = 1;
const request = validRevokeInviteRequest(bearerToken, entityID);
Kolide.setBearerToken(bearerToken);
Kolide.revokeInvite({ entityID })
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
});
describe('#updateUser', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const formData = { enabled: false };
@@ -1,13 +0,0 @@
import React, { Component } from 'react';
class DashboardPage extends Component {
render () {
return (
<div>
<h1>Admin Dashboard</h1>
</div>
);
}
}
export default DashboardPage;
@@ -1 +0,0 @@
export default from './DashboardPage';
@@ -1,20 +1,27 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import Avatar from '../../../../components/Avatar';
import componentStyles from './styles';
import Dropdown from '../../../../components/forms/fields/Dropdown';
import EditUserForm from '../../../../components/forms/Admin/EditUserForm';
import { userStatusLabel } from './helpers';
class UserBlock extends Component {
static propTypes = {
currentUser: PropTypes.object,
invite: PropTypes.bool,
onEditUser: PropTypes.func,
onSelect: PropTypes.func,
user: PropTypes.object,
};
static userActionOptions = (currentUser, user) => {
static userActionOptions = (currentUser, user, invite) => {
const disableActions = currentUser.id === user.id;
const inviteActions = [
{ text: 'Actions...', value: '' },
{ text: 'Revoke Invitation', value: 'revert_invitation' },
];
const userEnableAction = user.enabled
? { disabled: disableActions, text: 'Disable Account', value: 'disable_account' }
: { text: 'Enable Account', value: 'enable_account' };
@@ -22,6 +29,8 @@ class UserBlock extends Component {
? { disabled: disableActions, text: 'Demote User', value: 'demote_user' }
: { text: 'Promote User', value: 'promote_user' };
if (invite) return inviteActions;
return [
{ text: 'Actions...', value: '' },
userEnableAction,
@@ -76,7 +85,24 @@ class UserBlock extends Component {
return onSelect(user, action);
}
renderCTAs = () => {
const { currentUser, invite, user } = this.props;
const { onUserActionSelect } = this;
const userActionOptions = UserBlock.userActionOptions(currentUser, user, invite);
const { revokeInviteStyles } = componentStyles(user, invite);
return (
<Dropdown
containerStyles={invite ? revokeInviteStyles : {}}
options={userActionOptions}
initialOption={{ text: 'Actions...' }}
onSelect={onUserActionSelect}
/>
);
}
render () {
const { invite, user } = this.props;
const {
avatarStyles,
nameStyles,
@@ -89,24 +115,25 @@ class UserBlock extends Component {
userStatusStyles,
userStatusWrapperStyles,
userWrapperStyles,
} = componentStyles;
const { currentUser, user } = this.props;
} = componentStyles(user, invite);
const {
admin,
email,
enabled,
name,
position,
username,
} = user;
const userLabel = admin ? 'Admin' : 'User';
const activeLabel = enabled ? 'Active' : 'Disabled';
const userActionOptions = UserBlock.userActionOptions(currentUser, user);
const { isEdit } = this.state;
const { onEditUserFormSubmit, onToggleEditing } = this;
const { onEditUserFormSubmit, onToggleEditing, renderCTAs } = this;
const statusLabel = userStatusLabel(user, invite);
const userLabel = admin ? 'Admin' : 'User';
if (isEdit) {
return <EditUserForm onCancel={onToggleEditing} onSubmit={onEditUserFormSubmit} user={user} />;
return (
<div style={userWrapperStyles}>
<EditUserForm onCancel={onToggleEditing} onSubmit={onEditUserFormSubmit} user={user} />
</div>
);
}
return (
@@ -118,17 +145,13 @@ class UserBlock extends Component {
<Avatar user={user} style={avatarStyles} />
<div style={userStatusWrapperStyles}>
<span style={userLabelStyles}>{userLabel}</span>
<span style={userStatusStyles(enabled)}>{activeLabel}</span>
<span style={userStatusStyles}>{statusLabel}</span>
<div style={{ clear: 'both' }} />
</div>
<p style={usernameStyles}>{username}</p>
<p style={userPositionStyles}>{position}</p>
<p style={userEmailStyles}>{email}</p>
<Dropdown
options={userActionOptions}
initialOption={{ text: 'Actions...' }}
onSelect={this.onUserActionSelect}
/>
{renderCTAs()}
</div>
</div>
);
@@ -0,0 +1,7 @@
export const userStatusLabel = (user, invite) => {
if (invite) return 'Invited';
return user.enabled ? 'Active' : 'Disabled';
};
export default { userStatusLabel };
@@ -2,74 +2,123 @@ import Styles from '../../../../styles';
const { border, color, font, padding } = Styles;
export default {
avatarStyles: {
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
},
formButtonStyles: {
paddingLeft: padding.medium,
paddingRight: padding.medium,
},
nameStyles: {
fontWeight: font.weight.bold,
lineHeight: '51px',
margin: 0,
padding: 0,
},
userDetailsStyles: {
paddingLeft: padding.half,
paddingRight: padding.half,
},
userEmailStyles: {
fontSize: font.mini,
color: color.link,
},
userHeaderStyles: {
backgroundColor: color.brand,
color: color.white,
export default (user, invite) => {
const { admin, enabled } = user;
let avatarFilter = 'none';
const transition = 'all 0.3s ease-in-out';
let userEmailTextColor = color.link;
let userHeaderBgColor = '#F9F0FF';
let userHeaderTextColor = color.textUltradark;
let userStatusTextColor;
let userWrapperBgColor = color.white;
if (invite) {
userStatusTextColor = color.brand;
} else {
if (admin) {
userHeaderBgColor = color.brand;
userHeaderTextColor = color.white;
} else {
userHeaderBgColor = color.white;
userHeaderTextColor = color.textUltradark;
}
if (enabled) {
userStatusTextColor = color.success;
} else {
userEmailTextColor = color.textMedium;
avatarFilter = 'grayscale(100%)';
userWrapperBgColor = color.bgMedium;
userHeaderBgColor = color.textLight;
userHeaderTextColor = color.textUltradark;
userStatusTextColor = color.textMedium;
}
}
const userHeaderStyles = {
backgroundColor: userHeaderBgColor,
borderBottom: `1px solid ${color.accentLight}`,
color: userHeaderTextColor,
height: '51px',
marginBottom: padding.half,
textAlign: 'center',
transition,
width: '100%',
},
userLabelStyles: {
float: 'left',
fontSize: font.small,
},
usernameStyles: {
color: color.brand,
fontSize: font.medium,
textTransform: 'uppercase',
},
userPositionStyles: {
fontSize: font.small,
},
userStatusStyles: (enabled) => {
return {
color: enabled ? color.success : color.textMedium,
};
return {
avatarStyles: {
border: `1px solid ${enabled ? color.brand : color.textMedium}`,
filter: avatarFilter,
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
transition,
},
nameStyles: {
lineHeight: '51px',
margin: 0,
padding: 0,
},
revokeInviteStyles: {
position: 'absolute',
width: '221px',
bottom: '43px',
},
userDetailsStyles: {
paddingLeft: padding.half,
paddingRight: padding.half,
},
userEmailStyles: {
fontSize: font.mini,
color: userEmailTextColor,
transition,
},
userHeaderStyles,
userLabelStyles: {
float: 'left',
fontSize: font.small,
fontWeight: admin ? 'bold' : 'normal',
transition,
},
usernameStyles: {
color: enabled ? color.brand : color.textMedium,
fontSize: font.medium,
textTransform: 'uppercase',
transition,
},
userPositionStyles: {
fontSize: font.small,
},
userStatusStyles: {
color: userStatusTextColor,
float: 'right',
fontSize: font.small,
};
},
userStatusWrapperStyles: {
borderBottomColor: color.borderMedium,
borderBottomStyle: 'solid',
borderBottomWidth: '1px',
borderTopColor: color.borderMedium,
borderTopStyle: 'solid',
borderTopWidth: '1px',
marginTop: padding.half,
paddingTop: padding.half,
paddingBottom: padding.half,
},
userWrapperStyles: {
boxShadow: border.shadow.blur,
display: 'inline-block',
height: '390px',
marginLeft: padding.most,
marginTop: padding.most,
width: '239px',
},
textTransform: 'uppercase',
transition,
},
userStatusWrapperStyles: {
borderBottomColor: color.borderMedium,
borderBottomStyle: 'solid',
borderBottomWidth: '1px',
borderTopColor: color.borderMedium,
borderTopStyle: 'solid',
borderTopWidth: '1px',
marginTop: padding.half,
paddingTop: padding.half,
paddingBottom: padding.half,
},
userWrapperStyles: {
backgroundColor: userWrapperBgColor,
border: invite ? `1px dashed ${color.brand}` : 'none',
boxShadow: border.shadow.blur,
display: 'inline-block',
height: '390px',
marginLeft: padding.most,
marginTop: padding.most,
position: 'relative',
transition,
width: '239px',
},
};
};
@@ -14,6 +14,7 @@ class UserManagementPage extends Component {
static propTypes = {
currentUser: PropTypes.object,
dispatch: PropTypes.func,
invites: PropTypes.arrayOf(PropTypes.object),
users: PropTypes.arrayOf(PropTypes.object),
};
@@ -26,10 +27,10 @@ class UserManagementPage extends Component {
}
componentWillMount () {
const { dispatch, users } = this.props;
const { load } = userActions;
const { dispatch, invites, users } = this.props;
if (!users.length) dispatch(load());
if (!users.length) dispatch(userActions.loadAll());
if (!invites.length) dispatch(inviteActions.loadAll());
return false;
}
@@ -73,6 +74,11 @@ class UserManagementPage extends Component {
.then(() => {
return dispatch(renderFlash('success', 'User forced to reset password', update(user, { force_password_reset: false })));
});
case 'revert_invitation':
return dispatch(inviteActions.destroy({ entityID: user.id }))
.then(() => {
return dispatch(renderFlash('success', 'Invite revoked'));
});
default:
return false;
}
@@ -125,13 +131,15 @@ class UserManagementPage extends Component {
return false;
}
renderUserBlock = (user) => {
renderUserBlock = (user, options = { invite: false }) => {
const { currentUser } = this.props;
const { invite } = options;
const { onEditUser, onUserActionSelect } = this;
return (
<UserBlock
currentUser={currentUser}
invite={invite}
key={user.email}
onEditUser={onEditUser}
onSelect={onUserActionSelect}
@@ -171,11 +179,12 @@ class UserManagementPage extends Component {
usersWrapperStyles,
} = componentStyles;
const { toggleInviteUserModal } = this;
const { users } = this.props;
const { invites, users } = this.props;
const resourcesCount = users.length + invites.length;
return (
<div style={containerStyles}>
<span style={numUsersStyles}>Listing {users.length} users</span>
<span style={numUsersStyles}>Listing {resourcesCount} users</span>
<div style={addUserWrapperStyles}>
<Button
onClick={toggleInviteUserModal}
@@ -187,6 +196,9 @@ class UserManagementPage extends Component {
{users.map(user => {
return this.renderUserBlock(user);
})}
{invites.map(user => {
return this.renderUserBlock(user, { invite: true });
})}
</div>
{this.renderModal()}
</div>
@@ -195,10 +207,12 @@ class UserManagementPage extends Component {
}
const mapStateToProps = (state) => {
const stateEntityGetter = entityGetter(state);
const { user: currentUser } = state.auth;
const { entities: users } = entityGetter(state).get('users');
const { entities: users } = stateEntityGetter.get('users');
const { entities: invites } = stateEntityGetter.get('invites');
return { currentUser, users };
return { currentUser, invites, users };
};
export default connect(mapStateToProps)(UserManagementPage);
@@ -0,0 +1,57 @@
import expect from 'expect';
import { mount } from 'enzyme';
import { connectedComponent, reduxMockStore } from '../../../test/helpers';
import UserManagementPage from './UserManagementPage';
const currentUser = {
admin: true,
email: 'hi@gnar.dog',
enabled: true,
name: 'Gnar Dog',
position: 'Head of Gnar',
username: 'gnardog',
};
const store = {
auth: {
user: {
...currentUser,
},
},
entities: {
users: {
loading: false,
data: {
1: {
...currentUser,
},
},
},
invites: {
loading: false,
data: {
1: {
admin: false,
email: 'other@user.org',
name: 'Other user',
},
},
},
},
};
describe('UserManagementPage - component', () => {
it('renders user blocks for users and invites', () => {
const mockStore = reduxMockStore(store);
const page = mount(connectedComponent(UserManagementPage, { mockStore }));
expect(page.find('UserBlock').length).toEqual(2);
});
it('displays a count of the number of users & invites', () => {
const mockStore = reduxMockStore(store);
const page = mount(connectedComponent(UserManagementPage, { mockStore }));
expect(page.text()).toInclude('Listing 2 users');
});
});
@@ -34,5 +34,7 @@ export default {
width: '260px',
},
usersWrapperStyles: {
display: 'flex',
flexWrap: 'wrap',
},
};
@@ -0,0 +1,22 @@
import md5 from 'js-md5';
import { pickBy } from 'lodash';
export const addGravatarUrlToResource = (resource) => {
const { email } = resource;
const emailHash = md5(email.toLowerCase());
const gravatarURL = `https://www.gravatar.com/avatar/${emailHash}`;
return {
...resource,
gravatarURL,
};
};
export const entitiesExceptID = (entities, id) => {
return pickBy(entities, (entity, key) => {
return String(key) !== String(id);
});
};
export default { entitiesExceptID, addGravatarUrlToResource };
@@ -0,0 +1,28 @@
import expect from 'expect';
import { entitiesExceptID } from './helpers';
describe('reduxConfig - helpers', () => {
describe('#entitiesExceptID', () => {
it('returns an empty object if all ids are deleted', () => {
const entities = {
1: { name: 'Gnar' },
};
const id = 1;
expect(entitiesExceptID(entities, id)).toEqual({});
});
it('removes the object with the key of the specified id', () => {
const entities = {
1: { name: 'Gnar' },
2: { name: 'Dog' },
};
const id = 1;
expect(entitiesExceptID(entities, id)).toEqual({
2: { name: 'Dog' },
});
});
});
});
@@ -1,5 +1,6 @@
import { noop } from 'lodash';
import { normalize, arrayOf } from 'normalizr';
import { entitiesExceptID } from './helpers';
const initialState = {
loading: false,
@@ -9,7 +10,9 @@ const initialState = {
const reduxConfig = ({
createFunc = noop,
destroyFunc,
entityName,
loadAllFunc,
loadFunc,
parseFunc,
schema,
@@ -19,6 +22,9 @@ const reduxConfig = ({
CREATE_FAILURE: `${entityName}_CREATE_FAILURE`,
CREATE_REQUEST: `${entityName}_CREATE_REQUEST`,
CREATE_SUCCESS: `${entityName}_CREATE_SUCCESS`,
DESTROY_FAILURE: `${entityName}_DESTROY_FAILURE`,
DESTROY_REQUEST: `${entityName}_DESTROY_REQUEST`,
DESTROY_SUCCESS: `${entityName}_DESTROY_SUCCESS`,
LOAD_FAILURE: `${entityName}_LOAD_FAILURE`,
LOAD_REQUEST: `${entityName}_LOAD_REQUEST`,
LOAD_SUCCESS: `${entityName}_LOAD_SUCCESS`,
@@ -41,6 +47,20 @@ const reduxConfig = ({
};
};
const destroyFailure = (errors) => {
return {
type: actionTypes.DESTROY_FAILURE,
payload: { errors },
};
};
const destroyRequest = { type: actionTypes.DESTROY_REQUEST };
const destroySuccess = (id) => {
return {
type: actionTypes.DESTROY_SUCCESS,
payload: { id },
};
};
const loadFailure = (errors) => {
return {
type: actionTypes.LOAD_FAILURE,
@@ -101,6 +121,28 @@ const reduxConfig = ({
};
};
const destroy = (...args) => {
return (dispatch) => {
dispatch(destroyRequest);
return destroyFunc(...args)
.then(() => {
const { entityID } = args[0];
return dispatch(destroySuccess(entityID));
})
.catch(response => {
const { errors } = response;
const { error } = response.message || {};
const errorMessage = errors || error;
dispatch(destroyFailure(errorMessage));
throw error;
});
};
};
const load = (...args) => {
return (dispatch) => {
dispatch(loadRequest);
@@ -122,6 +164,27 @@ const reduxConfig = ({
};
};
const loadAll = (...args) => {
return (dispatch) => {
dispatch(loadRequest);
return loadAllFunc(...args)
.then(response => {
if (!response) return [];
const { entities } = normalize(parsedResponse(response), arrayOf(schema));
return dispatch(loadSuccess(entities));
})
.catch(response => {
const { errors } = response;
dispatch(loadFailure(errors));
throw response;
});
};
};
const update = (...args) => {
return (dispatch) => {
dispatch(updateRequest);
@@ -144,13 +207,16 @@ const reduxConfig = ({
const actions = {
create,
destroy,
load,
loadAll,
update,
};
const reducer = (state = initialState, { type, payload }) => {
switch (type) {
case actionTypes.CREATE_REQUEST:
case actionTypes.DESTROY_REQUEST:
case actionTypes.LOAD_REQUEST:
case actionTypes.UPDATE_REQUEST:
return {
@@ -168,7 +234,17 @@ const reduxConfig = ({
...payload.data[entityName],
},
};
case actionTypes.DESTROY_SUCCESS: {
return {
...state,
loading: false,
data: {
...entitiesExceptID(state.data, payload.id),
},
};
}
case actionTypes.CREATE_FAILURE:
case actionTypes.DESTROY_FAILURE:
case actionTypes.UPDATE_FAILURE:
case actionTypes.LOAD_FAILURE:
return {
@@ -3,7 +3,8 @@ import reduxConfig from './reduxConfig';
import { reduxMockStore } from '../../../../test/helpers';
import schemas from './schemas';
const store = { entities: { users: {} } };
const store = { entities: { invites: {}, users: {} } };
const invite = { id: 1, name: 'Gnar Dog', email: 'hi@thegnar.co' };
const user = { id: 1, email: 'hi@thegnar.co' };
describe('reduxConfig', () => {
@@ -111,6 +112,109 @@ describe('reduxConfig', () => {
});
});
describe('dispatching the destroy action', () => {
describe('successful destroy call', () => {
const mockStore = reduxMockStore(store);
const destroyFunc = createSpy().andCall(() => {
return Promise.resolve();
});
const config = reduxConfig({
destroyFunc,
entityName: 'invites',
schema: schemas.INVITES,
});
const { actions, reducer } = config;
it('calls the destroyFunc', () => {
mockStore.dispatch(actions.destroy({ inviteID: invite.id }));
expect(destroyFunc).toHaveBeenCalled();
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.destroy({ inviteID: invite.id }));
const dispatchedActions = mockStore.getActions();
const dispatchedActionTypes = dispatchedActions.map(action => { return action.type; });
expect(dispatchedActionTypes).toInclude('invites_DESTROY_REQUEST');
expect(dispatchedActionTypes).toInclude('invites_DESTROY_SUCCESS');
expect(dispatchedActionTypes).toNotInclude('invites_DESTROY_FAILURE');
});
it('removes the returned invite from state', () => {
const destroySuccessAction = {
type: 'invites_DESTROY_SUCCESS',
payload: {
id: 1,
},
};
const initialState = {
data: {
[invite.id]: invite,
2: { id: 2, name: 'Jason Meller' },
},
errors: {},
loading: false,
};
const newState = reducer(initialState, destroySuccessAction);
expect(newState.data).toEqual({
2: { id: 2, name: 'Jason Meller' },
});
});
});
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);
@@ -212,4 +316,106 @@ describe('reduxConfig', () => {
});
});
});
describe('dispatching the loadAll action', () => {
describe('successful load call', () => {
const mockStore = reduxMockStore(store);
const loadAllFunc = createSpy().andCall(() => {
return Promise.resolve([user]);
});
const config = reduxConfig({
entityName: 'users',
loadAllFunc,
schema: schemas.USERS,
});
const { actions, reducer } = config;
it('calls the loadAllFunc', () => {
mockStore.dispatch(actions.loadAll());
expect(loadAllFunc).toHaveBeenCalled();
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.loadAll());
const dispatchedActions = mockStore.getActions();
const dispatchedActionTypes = dispatchedActions.map(action => { return action.type; });
expect(dispatchedActionTypes).toInclude('users_LOAD_REQUEST');
expect(dispatchedActionTypes).toInclude('users_LOAD_SUCCESS');
expect(dispatchedActionTypes).toNotInclude('users_LOAD_FAILURE');
});
it('adds the returned user to state', () => {
const loadSuccessAction = {
type: 'users_LOAD_SUCCESS',
payload: {
data: {
users: {
[user.id]: user,
},
},
},
};
const initialState = {
loading: false,
entities: {},
errors: {},
};
const newState = reducer(initialState, loadSuccessAction);
expect(newState.data[user.id]).toEqual(user);
});
});
describe('unsuccessful loadAll call', () => {
const mockStore = reduxMockStore(store);
const errors = { base: 'Unable to load users' };
const loadAllFunc = createSpy().andCall(() => {
return Promise.reject({ errors });
});
const config = reduxConfig({
entityName: 'users',
loadAllFunc,
schema: schemas.USERS,
});
const { actions, reducer } = config;
it('calls the loadAllFunc', () => {
mockStore.dispatch(actions.loadAll());
expect(loadAllFunc).toHaveBeenCalled();
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.loadAll());
const dispatchedActions = mockStore.getActions();
const dispatchedActionTypes = dispatchedActions.map(action => { return action.type; });
expect(dispatchedActionTypes).toInclude('users_LOAD_REQUEST');
expect(dispatchedActionTypes).toNotInclude('users_LOAD_SUCCESS');
expect(dispatchedActionTypes).toInclude('users_LOAD_FAILURE');
});
it('adds the returned errors to state', () => {
const loadAllFailureAction = {
type: 'users_LOAD_FAILURE',
payload: {
errors,
},
};
const initialState = {
loading: false,
entities: {},
errors: {},
};
const newState = reducer(initialState, loadAllFailureAction);
expect(newState.errors).toEqual(errors);
});
});
});
});
@@ -1,3 +1,4 @@
import { addGravatarUrlToResource } from '../base/helpers';
import Kolide from '../../../../kolide';
import reduxConfig from '../base/reduxConfig';
import schemas from '../base/schemas';
@@ -6,7 +7,10 @@ const { INVITES: schema } = schemas;
export default reduxConfig({
createFunc: Kolide.inviteUser,
destroyFunc: Kolide.revokeInvite,
entityName: 'invites',
loadAllFunc: Kolide.getInvites,
parseFunc: addGravatarUrlToResource,
schema,
});
+3 -12
View File
@@ -1,4 +1,4 @@
import md5 from 'js-md5';
import { addGravatarUrlToResource } from '../base/helpers';
import Kolide from '../../../../kolide';
import reduxConfig from '../base/reduxConfig';
import schemas from '../base/schemas';
@@ -7,17 +7,8 @@ const { USERS } = schemas;
export default reduxConfig({
entityName: 'users',
loadFunc: Kolide.getUsers,
parseFunc: (user) => {
const { email } = user;
const emailHash = md5(email.toLowerCase());
const gravatarURL = `https://www.gravatar.com/avatar/${emailHash}`;
return {
...user,
gravatarURL,
};
},
loadAllFunc: Kolide.getUsers,
parseFunc: addGravatarUrlToResource,
schema: USERS,
updateFunc: Kolide.updateUser,
});
-2
View File
@@ -3,7 +3,6 @@ import { browserHistory, IndexRoute, Route, Router } from 'react-router';
import { Provider } from 'react-redux';
import radium, { StyleRoot } from 'radium';
import { syncHistoryWithStore } from 'react-router-redux';
import AdminDashboardPage from '../pages/Admin/DashboardPage';
import AdminUserManagementPage from '../pages/Admin/UserManagementPage';
import App from '../components/App';
import AuthenticatedAdminRoutes from '../components/AuthenticatedAdminRoutes';
@@ -35,7 +34,6 @@ const routes = (
<Route component={radium(CoreLayout)}>
<IndexRoute component={radium(HomePage)} />
<Route path="admin" component={AuthenticatedAdminRoutes}>
<IndexRoute component={radium(AdminDashboardPage)} />
<Route path="users" component={radium(AdminUserManagementPage)} />
</Route>
<Route path="queries" component={radium(QueryPageWrapper)}>
+26
View File
@@ -22,6 +22,20 @@ export const validGetConfigRequest = (bearerToken) => {
.reply(200, { config: { name: 'Kolide' } });
};
export const validGetInvitesRequest = (bearerToken) => {
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.get('/api/v1/kolide/invites')
.reply(200, {
invites: [
{ name: 'Joe Schmoe', email: 'joe@schmoe.org', admin: false },
],
});
};
export const validInviteUserRequest = (bearerToken, formData) => {
return nock('http://localhost:8080', {
reqHeaders: {
@@ -89,6 +103,16 @@ export const validResetPasswordRequest = (password, token) => {
.reply(200, validUser);
};
export const validRevokeInviteRequest = (bearerToken, inviteID) => {
return nock('http://localhost:8080', {
reqheaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.delete(`/api/v1/kolide/invites/${inviteID}`)
.reply(200);
};
export const invalidResetPasswordRequest = (password, token, error) => {
return nock('http://localhost:8080')
.post('/api/v1/kolide/reset_password', JSON.stringify({
@@ -110,12 +134,14 @@ export default {
invalidResetPasswordRequest,
validForgotPasswordRequest,
validGetConfigRequest,
validGetInvitesRequest,
validGetUsersRequest,
validInviteUserRequest,
validLoginRequest,
validLogoutRequest,
validMeRequest,
validResetPasswordRequest,
validRevokeInviteRequest,
validUpdateUserRequest,
validUser,
};
+1 -6
View File
@@ -25,12 +25,7 @@ func decodeDeleteInviteRequest(ctx context.Context, r *http.Request) (interface{
if err != nil {
return nil, err
}
var req deleteInviteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, err
}
req.ID = id
return req, nil
return deleteInviteRequest{ID: id}, nil
}
func decodeListInvitesRequest(ctx context.Context, r *http.Request) (interface{}, error) {
-2
View File
@@ -18,8 +18,6 @@ func TestDecodeCreateInviteRequest(t *testing.T) {
assert.Nil(t, err)
params := r.(createInviteRequest)
assert.Equal(t, "foo", *params.payload.Name)
assert.Equal(t, "foo@kolide.co", *params.payload.Email)
assert.Equal(t, uint(1), *params.payload.InvitedBy)
}).Methods("POST")