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:
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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,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}`;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user