diff --git a/frontend/components/FlashMessage/styles.js b/frontend/components/FlashMessage/styles.js
index 29c976c665..442e4543b0 100644
--- a/frontend/components/FlashMessage/styles.js
+++ b/frontend/components/FlashMessage/styles.js
@@ -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') {
diff --git a/frontend/components/SidePanel/styles.js b/frontend/components/SidePanel/styles.js
index 8893fb029a..47d7a76b64 100644
--- a/frontend/components/SidePanel/styles.js
+++ b/frontend/components/SidePanel/styles.js
@@ -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',
diff --git a/frontend/components/forms/Admin/EditUserForm/EditUserForm.jsx b/frontend/components/forms/Admin/EditUserForm/EditUserForm.jsx
index ce1f96aa44..a96cdd605e 100644
--- a/frontend/components/forms/Admin/EditUserForm/EditUserForm.jsx
+++ b/frontend/components/forms/Admin/EditUserForm/EditUserForm.jsx
@@ -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 (
-
);
diff --git a/frontend/components/forms/InviteUserForm/styles.js b/frontend/components/forms/InviteUserForm/styles.js
index fb17e17cd2..f47f8a1184 100644
--- a/frontend/components/forms/InviteUserForm/styles.js
+++ b/frontend/components/forms/InviteUserForm/styles.js
@@ -15,6 +15,7 @@ export default {
},
buttonWrapperStyles: {
display: 'flex',
+ flexDirection: 'row-reverse',
justifyContent: 'space-between',
},
radioElementStyles: {
diff --git a/frontend/components/forms/fields/Dropdown/Dropdown.jsx b/frontend/components/forms/fields/Dropdown/Dropdown.jsx
index 0cc094f4c6..f2c6eba10e 100644
--- a/frontend/components/forms/fields/Dropdown/Dropdown.jsx
+++ b/frontend/components/forms/fields/Dropdown/Dropdown.jsx
@@ -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 (
-
-
diff --git a/frontend/kolide/base.js b/frontend/kolide/base.js
index a8a819d881..dd9a032d66 100644
--- a/frontend/kolide/base.js
+++ b/frontend/kolide/base.js
@@ -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) {
diff --git a/frontend/kolide/index.js b/frontend/kolide/index.js
index e3eea62689..946c372bac 100644
--- a/frontend/kolide/index.js
+++ b/frontend/kolide/index.js
@@ -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}`;
diff --git a/frontend/kolide/index.tests.js b/frontend/kolide/index.tests.js
index 544e82aae3..9d7c102775 100644
--- a/frontend/kolide/index.tests.js
+++ b/frontend/kolide/index.tests.js
@@ -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 };
diff --git a/frontend/pages/Admin/DashboardPage/DashboardPage.jsx b/frontend/pages/Admin/DashboardPage/DashboardPage.jsx
deleted file mode 100644
index b08b01dffc..0000000000
--- a/frontend/pages/Admin/DashboardPage/DashboardPage.jsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React, { Component } from 'react';
-
-class DashboardPage extends Component {
- render () {
- return (
-
-
Admin Dashboard
-
- );
- }
-}
-
-export default DashboardPage;
diff --git a/frontend/pages/Admin/DashboardPage/index.js b/frontend/pages/Admin/DashboardPage/index.js
deleted file mode 100644
index a2ce56af2d..0000000000
--- a/frontend/pages/Admin/DashboardPage/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export default from './DashboardPage';
diff --git a/frontend/pages/Admin/UserManagementPage/UserBlock/UserBlock.jsx b/frontend/pages/Admin/UserManagementPage/UserBlock/UserBlock.jsx
index 1adeafaa2e..61010fad7a 100644
--- a/frontend/pages/Admin/UserManagementPage/UserBlock/UserBlock.jsx
+++ b/frontend/pages/Admin/UserManagementPage/UserBlock/UserBlock.jsx
@@ -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 (
+
+ );
+ }
+
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 ;
+ return (
+
+
+
+ );
}
return (
@@ -118,17 +145,13 @@ class UserBlock extends Component {
{userLabel}
-
{activeLabel}
+
{statusLabel}
{username}
{position}
{email}
-
+ {renderCTAs()}
);
diff --git a/frontend/pages/Admin/UserManagementPage/UserBlock/helpers.js b/frontend/pages/Admin/UserManagementPage/UserBlock/helpers.js
new file mode 100644
index 0000000000..722879f012
--- /dev/null
+++ b/frontend/pages/Admin/UserManagementPage/UserBlock/helpers.js
@@ -0,0 +1,7 @@
+export const userStatusLabel = (user, invite) => {
+ if (invite) return 'Invited';
+
+ return user.enabled ? 'Active' : 'Disabled';
+};
+
+export default { userStatusLabel };
diff --git a/frontend/pages/Admin/UserManagementPage/UserBlock/styles.js b/frontend/pages/Admin/UserManagementPage/UserBlock/styles.js
index a1ddc99892..8921cd5bb6 100644
--- a/frontend/pages/Admin/UserManagementPage/UserBlock/styles.js
+++ b/frontend/pages/Admin/UserManagementPage/UserBlock/styles.js
@@ -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',
+ },
+ };
};
diff --git a/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx b/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx
index 7457e2f6ab..193cd08de9 100644
--- a/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx
+++ b/frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx
@@ -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 (
- Listing {users.length} users
+ Listing {resourcesCount} users
{this.renderModal()}
@@ -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);
diff --git a/frontend/pages/Admin/UserManagementPage/UserManagementPage.tests.jsx b/frontend/pages/Admin/UserManagementPage/UserManagementPage.tests.jsx
new file mode 100644
index 0000000000..7fcd842ed3
--- /dev/null
+++ b/frontend/pages/Admin/UserManagementPage/UserManagementPage.tests.jsx
@@ -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');
+ });
+});
diff --git a/frontend/pages/Admin/UserManagementPage/styles.js b/frontend/pages/Admin/UserManagementPage/styles.js
index 0342944b74..c39be48528 100644
--- a/frontend/pages/Admin/UserManagementPage/styles.js
+++ b/frontend/pages/Admin/UserManagementPage/styles.js
@@ -34,5 +34,7 @@ export default {
width: '260px',
},
usersWrapperStyles: {
+ display: 'flex',
+ flexWrap: 'wrap',
},
};
diff --git a/frontend/redux/nodes/entities/base/helpers.js b/frontend/redux/nodes/entities/base/helpers.js
new file mode 100644
index 0000000000..e2ceb64b14
--- /dev/null
+++ b/frontend/redux/nodes/entities/base/helpers.js
@@ -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 };
diff --git a/frontend/redux/nodes/entities/base/helpers.tests.js b/frontend/redux/nodes/entities/base/helpers.tests.js
new file mode 100644
index 0000000000..c1c1cd50f7
--- /dev/null
+++ b/frontend/redux/nodes/entities/base/helpers.tests.js
@@ -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' },
+ });
+ });
+ });
+});
diff --git a/frontend/redux/nodes/entities/base/reduxConfig.js b/frontend/redux/nodes/entities/base/reduxConfig.js
index d2bdcbc742..51dc865b4b 100644
--- a/frontend/redux/nodes/entities/base/reduxConfig.js
+++ b/frontend/redux/nodes/entities/base/reduxConfig.js
@@ -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 {
diff --git a/frontend/redux/nodes/entities/base/reduxConfig.tests.js b/frontend/redux/nodes/entities/base/reduxConfig.tests.js
index e6808536ab..125242ab59 100644
--- a/frontend/redux/nodes/entities/base/reduxConfig.tests.js
+++ b/frontend/redux/nodes/entities/base/reduxConfig.tests.js
@@ -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);
+ });
+ });
+ });
});
diff --git a/frontend/redux/nodes/entities/invites/config.js b/frontend/redux/nodes/entities/invites/config.js
index c9297471c3..93d12a4b4d 100644
--- a/frontend/redux/nodes/entities/invites/config.js
+++ b/frontend/redux/nodes/entities/invites/config.js
@@ -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,
});
diff --git a/frontend/redux/nodes/entities/users/config.js b/frontend/redux/nodes/entities/users/config.js
index 5320fc5acf..14ebb3317c 100644
--- a/frontend/redux/nodes/entities/users/config.js
+++ b/frontend/redux/nodes/entities/users/config.js
@@ -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,
});
diff --git a/frontend/router/index.jsx b/frontend/router/index.jsx
index 11467323aa..c6c425a22c 100644
--- a/frontend/router/index.jsx
+++ b/frontend/router/index.jsx
@@ -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 = (
-
diff --git a/frontend/test/mocks.js b/frontend/test/mocks.js
index 2aae7fc74d..e2ec2283af 100644
--- a/frontend/test/mocks.js
+++ b/frontend/test/mocks.js
@@ -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,
};
diff --git a/server/service/transport_invites.go b/server/service/transport_invites.go
index c708c5ffa5..8db81c177b 100644
--- a/server/service/transport_invites.go
+++ b/server/service/transport_invites.go
@@ -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) {
diff --git a/server/service/transport_invites_test.go b/server/service/transport_invites_test.go
index bc299ce885..59d76edb39 100644
--- a/server/service/transport_invites_test.go
+++ b/server/service/transport_invites_test.go
@@ -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")