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/Admin/EditUserForm/styles.js b/frontend/components/forms/Admin/EditUserForm/styles.js new file mode 100644 index 0000000000..01e7b4a9fe --- /dev/null +++ b/frontend/components/forms/Admin/EditUserForm/styles.js @@ -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, + }, +}; diff --git a/frontend/components/forms/InviteUserForm/InviteUserForm.jsx b/frontend/components/forms/InviteUserForm/InviteUserForm.jsx index ef91b51214..238d2150de 100644 --- a/frontend/components/forms/InviteUserForm/InviteUserForm.jsx +++ b/frontend/components/forms/InviteUserForm/InviteUserForm.jsx @@ -153,17 +153,18 @@ class InviteUserForm extends Component { /> ADMIN
-
); 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 ( -
- {options.map(option => { return renderOption(option); })} diff --git a/frontend/components/forms/queries/SaveQueryForm/SaveQueryForm.jsx b/frontend/components/forms/queries/SaveQueryForm/SaveQueryForm.jsx index 55c171440a..abb0ed9b7a 100644 --- a/frontend/components/forms/queries/SaveQueryForm/SaveQueryForm.jsx +++ b/frontend/components/forms/queries/SaveQueryForm/SaveQueryForm.jsx @@ -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} />
@@ -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} /> 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")