Admin user management (#233)

Admin user management page
This commit is contained in:
Mike Stone 2016-10-03 13:54:22 -04:00 committed by GitHub
parent a03347489c
commit 6ebc460b66
65 changed files with 1665 additions and 114 deletions

View File

@ -1,7 +1,7 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { noop } from 'lodash';
import { Style, StyleRoot } from 'radium';
import { Style } from 'radium';
import { fetchCurrentUser } from '../../redux/nodes/auth/actions';
import Footer from './Footer';
import globalStyles from '../../styles/global';
@ -33,11 +33,11 @@ export class App extends Component {
const { children, showBackgroundImage } = this.props;
return (
<StyleRoot>
<div>
<Style rules={globalStyles(showBackgroundImage)} />
{children}
<Footer />
</StyleRoot>
</div>
);
}
}

View File

@ -14,9 +14,10 @@ export class AuthenticatedRoutes extends Component {
componentWillMount () {
const { loading, user } = this.props;
const { redirectToLogin } = this;
const { redirectToLogin, redirectToPasswordReset } = this;
if (!loading && !user) return redirectToLogin();
if (user && user.force_password_reset) return redirectToPasswordReset();
return false;
}
@ -25,9 +26,10 @@ export class AuthenticatedRoutes extends Component {
if (isEqual(this.props, nextProps)) return false;
const { loading, user } = nextProps;
const { redirectToLogin } = this;
const { redirectToLogin, redirectToPasswordReset } = this;
if (!loading && !user) return redirectToLogin();
if (user && user.force_password_reset) return redirectToPasswordReset();
return false;
}
@ -39,6 +41,13 @@ export class AuthenticatedRoutes extends Component {
return dispatch(push(LOGIN));
}
redirectToPasswordReset = () => {
const { dispatch } = this.props;
const { RESET_PASSWORD } = paths;
return dispatch(push(RESET_PASSWORD));
}
render () {
const { children, user } = this.props;

View File

@ -13,6 +13,13 @@ describe('AuthenticatedRoutes - component', () => {
args: ['/login'],
},
};
const redirectToPasswordResetAction = {
type: '@@router/CALL_HISTORY_METHOD',
payload: {
method: 'push',
args: ['/reset_password'],
},
};
const renderedText = 'This text was rendered';
const storeWithUser = {
auth: {
@ -20,6 +27,17 @@ describe('AuthenticatedRoutes - component', () => {
user: {
id: 1,
email: 'hi@thegnar.co',
force_password_reset: false,
},
},
};
const storeWithUserRequiringPwReset = {
auth: {
loading: false,
user: {
id: 1,
email: 'hi@thegnar.co',
force_password_reset: true,
},
},
};
@ -50,6 +68,20 @@ describe('AuthenticatedRoutes - component', () => {
expect(component.text()).toEqual(renderedText);
});
it('redirects to reset password is force_password_reset is true', () => {
const { reduxMockStore } = helpers;
const mockStore = reduxMockStore(storeWithUserRequiringPwReset);
mount(
<Provider store={mockStore}>
<AuthenticatedRoutes>
<div>{renderedText}</div>
</AuthenticatedRoutes>
</Provider>
);
expect(mockStore.getActions()).toInclude(redirectToPasswordResetAction);
});
it('redirects to login without a user', () => {
const { reduxMockStore } = helpers;
const mockStore = reduxMockStore(storeWithoutUser);

View File

@ -3,7 +3,7 @@ import Styles from '../../styles';
const { border } = Styles;
export default (size) => {
const lowercaseSize = size.toLowerCase();
const lowercaseSize = size && size.toLowerCase();
const baseStyles = {
borderRadius: border.radius.circle,
};

View File

@ -0,0 +1,43 @@
import React, { PropTypes } from 'react';
import radium from 'radium';
import componentStyles from './styles';
import { hideFlash } from '../../redux/nodes/notifications/actions';
const FlashMessage = ({ notification, dispatch }) => {
const { alertType, isVisible, message, undoAction } = notification;
const { containerStyles, contentStyles, undoStyles } = componentStyles;
const submitUndoAction = () => {
dispatch(undoAction);
dispatch(hideFlash);
return false;
};
const removeFlashMessage = () => {
dispatch(hideFlash);
return false;
};
if (!isVisible) return false;
return (
<div style={containerStyles(alertType)}>
<div style={contentStyles}>
{message}
</div>
<div onClick={submitUndoAction} style={undoStyles}>
Undo
</div>
<div onClick={removeFlashMessage}>
X
</div>
</div>
);
};
FlashMessage.propTypes = {
dispatch: PropTypes.func,
notification: PropTypes.object,
};
export default radium(FlashMessage);

View File

@ -0,0 +1 @@
export default from './FlashMessage';

View File

@ -0,0 +1,26 @@
import Style from '../../styles';
const { color } = Style;
export default {
containerStyles: (alertType) => {
const successAlert = {
backgroundColor: color.success,
};
const baseStyles = {
color: color.white,
};
if (alertType === 'success') {
return {
...baseStyles,
...successAlert,
};
}
return {};
},
contentStyles: {},
undoStyles: {},
};

View File

@ -0,0 +1,39 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import componentStyles from './styles';
class Modal extends Component {
static propTypes = {
children: PropTypes.node,
onExit: PropTypes.func,
overrideStyles: PropTypes.object,
title: PropTypes.string,
};
render () {
const { children, onExit, title } = this.props;
const {
containerStyles,
contentStyles,
exStyles,
headerStyles,
modalStyles,
} = componentStyles;
return (
<div style={containerStyles}>
<div style={modalStyles}>
<div style={headerStyles}>
<span>{title}</span>
<span style={exStyles} onClick={onExit}></span>
</div>
<div style={contentStyles}>
{children}
</div>
</div>
</div>
);
}
}
export default radium(Modal);

View File

@ -0,0 +1 @@
export default from './Modal';

View File

@ -0,0 +1,43 @@
import styles from '../../styles';
const { color, padding } = styles;
export default {
containerStyles: {
position: 'fixed',
top: 0,
left: 0,
bottom: 0,
right: 0,
backgroundColor: 'rgba(0,0,0,0.25)',
},
contentStyles: {
paddingBottom: padding.half,
paddingLeft: padding.half,
paddingRight: padding.half,
paddingTop: padding.half,
},
exStyles: {
color: color.white,
cursor: 'pointer',
float: 'right',
textDecoration: 'none',
fontWeight: 'bold',
},
headerStyles: {
backgroundColor: color.brand,
color: color.white,
paddingBottom: padding.half,
paddingLeft: padding.half,
paddingRight: padding.half,
paddingTop: padding.half,
textTransform: 'uppercase',
},
modalStyles: {
backgroundColor: color.white,
width: '400px',
position: 'absolute',
top: '30%',
left: '30%',
},
};

View File

@ -18,6 +18,7 @@ class SidePanel extends Component {
constructor (props) {
super(props);
const { pathname, user: { admin } } = this.props;
this.userNavItems = navItems(admin);
@ -122,7 +123,7 @@ class SidePanel extends Component {
}
renderNavItem = (navItem, lastChild) => {
const { activeTab } = this.state;
const { activeTab = {} } = this.state;
const { icon, name, subItems } = navItem;
const active = activeTab.name === name;
const {
@ -202,6 +203,8 @@ class SidePanel extends Component {
const { renderCollapseSubItems, renderSubItem, setSubNavClass } = this;
const { showSubItems } = this.state;
if (!subItems.length) return false;
return (
<div className={setSubNavClass(showSubItems)} style={subItemsStyles}>
<ul style={subItemListStyles(showSubItems)}>
@ -222,7 +225,7 @@ class SidePanel extends Component {
return (
<div style={collapseSubItemsWrapper} onClick={toggleShowSubItems(!showSubItems)}>
<i className={iconName} style={{ color: '#FFF' }} />
<i className={iconName} />
</div>
);
}

View File

@ -263,13 +263,13 @@ const componentStyles = {
};
},
collapseSubItemsWrapper: {
boxSizing: 'border-box',
cursor: 'pointer',
height: '100%',
position: 'absolute',
bottom: '0',
color: color.white,
cursor: 'pointer',
lineHeight: '95px',
color: '#fff',
position: 'absolute',
right: '4px',
top: '0',
'@media (min-width: 761px)': {
display: 'none',
},

View File

@ -2,25 +2,30 @@ import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import componentStyles from './styles';
class GradientButton extends Component {
class Button extends Component {
static propTypes = {
onClick: PropTypes.func,
style: PropTypes.object,
text: PropTypes.string,
type: PropTypes.string,
variant: PropTypes.string,
};
static defaultProps = {
style: {},
variant: 'default',
};
render () {
const { onClick, style, text, type } = this.props;
const { onClick, style, text, type, variant } = this.props;
return (
<button
onClick={onClick}
style={[componentStyles, style]}
style={{
...componentStyles[variant],
...style,
}}
type={type}
>
{text}
@ -29,4 +34,4 @@ class GradientButton extends Component {
}
}
export default radium(GradientButton);
export default radium(Button);

View File

@ -0,0 +1 @@
export default from './Button';

View File

@ -0,0 +1,67 @@
import styles from '../../../styles';
const { border, color, font, padding } = styles;
const baseStyles = {
borderBottomLeftRadius: border.radius.base,
borderBottomRightRadius: border.radius.base,
borderTopLeftRadius: border.radius.base,
borderTopRightRadius: border.radius.base,
boxShadow: '0 3px 0 #734893',
boxSizing: 'border-box',
color: color.white,
cursor: 'pointer',
paddingBottom: padding.xSmall,
paddingLeft: padding.xSmall,
paddingRight: padding.xSmall,
paddingTop: padding.xSmall,
position: 'relative',
textTransform: 'uppercase',
':active': {
boxShadow: '0 1px 0 #734893, 0 -2px 0 #D1D9E9',
top: '2px',
},
':focus': {
outline: 'none',
},
};
export default {
default: {
...baseStyles,
backgroundColor: color.brandDark,
borderBottom: `1px solid ${color.brandDark}`,
borderLeft: `1px solid ${color.brandDark}`,
borderRight: `1px solid ${color.brandDark}`,
borderTop: `1px solid ${color.brandDark}`,
boxShadow: `0 3px 0 ${color.brandLight}`,
fontSize: font.medium,
},
inverse: {
...baseStyles,
backgroundColor: color.white,
borderBottom: `1px solid ${color.brandLight}`,
borderLeft: `1px solid ${color.brandLight}`,
borderRight: `1px solid ${color.brandLight}`,
borderTop: `1px solid ${color.brandLight}`,
boxShadow: `0 3px 0 ${color.brandDark}`,
color: color.brandDark,
fontSize: font.medium,
},
gradient: {
...baseStyles,
backgroundImage: 'linear-gradient(134deg, #7166D9 0%, #C86DD7 100%)',
backgroundColor: 'transparent',
borderBottom: 'none',
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
fontSize: font.large,
fontWeight: '300',
letterSpacing: '4px',
paddingBottom: padding.medium,
paddingLeft: padding.medium,
paddingRight: padding.medium,
paddingTop: padding.medium,
width: '100%',
},
};

View File

@ -1 +0,0 @@
export default from './GradientButton';

View File

@ -1,36 +0,0 @@
import styles from '../../../styles';
const { border, color, font, padding } = styles;
export default {
backgroundImage: 'linear-gradient(134deg, #7166D9 0%, #C86DD7 100%)',
borderBottom: 'none',
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
cursor: 'pointer',
borderBottomLeftRadius: border.radius.base,
borderBottomRightRadius: border.radius.base,
borderTopLeftRadius: border.radius.base,
borderTopRightRadius: border.radius.base,
boxShadow: '0 3px 0 #734893',
boxSizing: 'border-box',
color: color.white,
fontSize: font.large,
fontWeight: '300',
letterSpacing: '4px',
paddingBottom: padding.medium,
paddingLeft: padding.medium,
paddingRight: padding.medium,
paddingTop: padding.medium,
position: 'relative',
textTransform: 'uppercase',
width: '100%',
':active': {
boxShadow: '0 1px 0 #734893, 0 -2px 0 #D1D9E9',
top: '2px',
},
':focus': {
outline: 'none',
},
};

View File

@ -0,0 +1,143 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import Avatar from '../../../Avatar';
import Button from '../../../buttons/Button';
import componentStyles from '../../../../pages/Admin/UserManagementPage/UserBlock/styles';
import InputField from '../../fields/InputField';
import Styleguide from '../../../../styles';
const { color, font, padding } = Styleguide;
class EditUserForm extends Component {
static propTypes = {
onCancel: PropTypes.func,
onSubmit: PropTypes.func,
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);
const { user } = props;
this.state = {
formData: {
...user,
},
};
}
onInputChange = (fieldName) => {
return (evt) => {
const { formData } = this.state;
this.setState({
formData: {
...formData,
[fieldName]: evt.target.value,
},
});
return false;
};
}
onFormSubmit = (evt) => {
evt.preventDefault();
const { formData } = this.state;
const { onSubmit } = this.props;
return onSubmit(formData);
}
render () {
const {
avatarStyles,
formButtonStyles,
userWrapperStyles,
} = componentStyles;
const { user } = this.props;
const {
email,
name,
position,
username,
} = user;
const { onFormSubmit, onInputChange } = this;
return (
<form style={[userWrapperStyles, { boxSizing: 'border-box', padding: '10px' }]} onSubmit={onFormSubmit}>
<InputField
defaultValue={name}
label="name"
labelStyles={EditUserForm.labelStyles}
name="name"
onChange={onInputChange('name')}
inputWrapperStyles={{ marginTop: 0, marginBottom: padding.half }}
style={EditUserForm.inputStyles}
/>
<Avatar user={user} style={avatarStyles} />
<InputField
defaultValue={username}
label="username"
labelStyles={EditUserForm.labelStyles}
name="username"
onChange={onInputChange('username')}
inputWrapperStyles={{ marginTop: 0 }}
style={[EditUserForm.inputStyles, { color: color.brand }]}
/>
<InputField
defaultValue={position}
label="position"
labelStyles={EditUserForm.labelStyles}
name="position"
onChange={onInputChange('position')}
inputWrapperStyles={{ marginTop: 0 }}
style={EditUserForm.inputStyles}
/>
<InputField
defaultValue={email}
inputWrapperStyles={{ marginTop: 0 }}
label="email"
labelStyles={EditUserForm.labelStyles}
name="email"
onChange={onInputChange('email')}
style={[EditUserForm.inputStyles, { color: color.link }]}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '10px' }}>
<Button
onClick={this.props.onCancel}
style={formButtonStyles}
text="Cancel"
variant="inverse"
/>
<Button
style={formButtonStyles}
text="Submit"
type="submit"
/>
</div>
</form>
);
}
}
export default radium(EditUserForm);

View File

@ -0,0 +1 @@
export default from './EditUserForm';

View File

@ -1,7 +1,7 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import componentStyles from './styles';
import GradientButton from '../../buttons/GradientButton';
import Button from '../../buttons/Button';
import InputFieldWithIcon from '../fields/InputFieldWithIcon';
import validatePresence from '../validators/validate_presence';
import validEmail from '../validators/valid_email';
@ -100,10 +100,11 @@ class ForgotPasswordForm extends Component {
placeholder="Email Address"
/>
<div style={submitButtonContainerStyles}>
<GradientButton
<Button
style={submitButtonStyles}
type="submit"
text="Reset Password"
style={submitButtonStyles}
variant="gradient"
/>
</div>
</form>

View File

@ -0,0 +1,142 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import componentStyles from './styles';
import Button from '../../buttons/Button';
import InputFieldWithIcon from '../fields/InputFieldWithIcon';
import validatePresence from '../validators/validate_presence';
import validEmail from '../validators/valid_email';
class InviteUserForm extends Component {
static propTypes = {
onCancel: PropTypes.func,
onSubmit: PropTypes.func,
};
constructor (props) {
super(props);
this.state = {
errors: {
email: null,
role: null,
},
formData: {
email: null,
role: 'user',
},
};
}
onInputChange = (formField) => {
return ({ target }) => {
const { errors, formData } = this.state;
const { value } = target;
this.setState({
errors: {
...errors,
[formField]: null,
},
formData: {
...formData,
[formField]: value,
},
});
};
}
onFormSubmit = (evt) => {
evt.preventDefault();
const valid = this.validate();
if (valid) {
const { formData } = this.state;
const { onSubmit } = this.props;
return onSubmit(formData);
}
return false;
}
validate = () => {
const {
errors,
formData: { email },
} = this.state;
if (!validatePresence(email)) {
this.setState({
errors: {
...errors,
email: 'Email field must be completed',
},
});
return false;
}
if (!validEmail(email)) {
this.setState({
errors: {
...errors,
email: `${email} is not a valid email`,
},
});
return false;
}
return true;
}
render () {
const { buttonStyles, buttonWrapperStyles, radioElementStyles, roleTitleStyles } = componentStyles;
const { errors, formData: { role } } = this.state;
const { onCancel } = this.props;
const { onFormSubmit, onInputChange } = this;
return (
<form onSubmit={onFormSubmit}>
<InputFieldWithIcon
autofocus
error={errors.email}
name="email"
onChange={onInputChange('email')}
placeholder="Email"
/>
<div style={radioElementStyles}>
<p style={roleTitleStyles}>role</p>
<input
checked={role === 'user'}
onChange={onInputChange('role')}
type="radio"
value="user"
/> USER (default)
<br />
<input
checked={role === 'admin'}
onChange={onInputChange('role')}
type="radio"
value="admin"
/> ADMIN
</div>
<div style={buttonWrapperStyles}>
<Button
onClick={onCancel}
style={buttonStyles}
text="Cancel"
variant="inverse"
/>
<Button
style={buttonStyles}
text="Invite"
type="submit"
/>
</div>
</form>
);
}
}
export default radium(InviteUserForm);

View File

@ -0,0 +1 @@
export default from './InviteUserForm';

View File

@ -0,0 +1,28 @@
import Styles from '../../../styles';
const { color, font, padding } = Styles;
export default {
buttonStyles: {
fontSize: font.small,
height: '38px',
marginButtom: '5px',
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
width: '180px',
},
buttonWrapperStyles: {
display: 'flex',
justifyContent: 'space-between',
},
radioElementStyles: {
paddingBottom: padding.base,
},
roleTitleStyles: {
color: color.brand,
fontSize: font.mini,
marginBottom: 0,
},
};

View File

@ -4,7 +4,7 @@ import { noop } from 'lodash';
import radium from 'radium';
import avatar from '../../../../assets/images/avatar.svg';
import componentStyles from './styles';
import GradientButton from '../../buttons/GradientButton';
import Button from '../../buttons/Button';
import InputFieldWithIcon from '../fields/InputFieldWithIcon';
import paths from '../../../router/paths';
import validatePresence from '../validators/validate_presence';
@ -172,11 +172,12 @@ class LoginForm extends Component {
<Link style={forgotPasswordStyles} to={paths.FORGOT_PASSWORD}>Forgot Password?</Link>
</div>
</div>
<GradientButton
<Button
onClick={onFormSubmit}
style={submitButtonStyles}
text="Login"
type="submit"
variant="gradient"
/>
</form>
);

View File

@ -1,6 +1,6 @@
import React, { Component, PropTypes } from 'react';
import componentStyles from './styles';
import GradientButton from '../../buttons/GradientButton';
import Button from '../../buttons/Button';
class LogoutForm extends Component {
static propTypes = {
@ -36,11 +36,12 @@ class LogoutForm extends Component {
<p style={usernameStyles}>{user.username}</p>
<p style={subtextStyles}>Are you sure you want to log out?</p>
</div>
<GradientButton
<Button
onClick={onFormSubmit}
style={submitButtonStyles}
text="Logout"
type="submit"
variant="gradient"
/>
</form>
);

View File

@ -1,6 +1,6 @@
import React, { Component, PropTypes } from 'react';
import componentStyles from './styles';
import GradientButton from '../../buttons/GradientButton';
import Button from '../../buttons/Button';
import InputFieldWithIcon from '../fields/InputFieldWithIcon';
import validatePresence from '../validators/validate_presence';
import validateEquality from '../validators/validate_equality';
@ -104,9 +104,9 @@ class ResetPasswordForm extends Component {
render () {
const { errors } = this.state;
const {
buttonStyles,
formStyles,
inputStyles,
submitButtonStyles,
} = componentStyles;
const { onFormSubmit, onInputChange } = this;
@ -131,11 +131,12 @@ class ResetPasswordForm extends Component {
style={inputStyles}
type="password"
/>
<GradientButton
<Button
onClick={onFormSubmit}
style={submitButtonStyles}
style={buttonStyles}
text="Reset Password"
type="submit"
variant="gradient"
/>
</form>
);

View File

@ -1,4 +1,11 @@
import Styles from '../../../styles';
const { padding } = Styles;
export default {
buttonStyles: {
marginTop: padding.base,
},
formStyles: {
width: '100%',
},

View File

@ -0,0 +1,58 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import { noop } from 'lodash';
import componentStyles from './styles';
class Dropdown extends Component {
static propTypes = {
containerStyles: PropTypes.object,
fieldName: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.shape({
text: PropTypes.string,
value: PropTypes.string,
})),
onSelect: PropTypes.func,
};
static defaultProps = {
onSelect: noop,
};
onOptionClick = (evt) => {
const { target: { value } } = evt;
const { fieldName, onSelect } = this.props;
onSelect({
[fieldName]: value,
});
return false;
}
renderOption = (option) => {
const { value, text } = option;
const { optionWrapperStyles } = componentStyles;
return (
<option key={value} style={optionWrapperStyles} value={value}>
{text}
</option>
);
}
render () {
const { containerStyles, options } = this.props;
const { onOptionClick, renderOption } = this;
const { selectWrapperStyles } = componentStyles;
return (
<select style={[selectWrapperStyles, containerStyles]} onChange={onOptionClick}>
{options.map(option => {
return renderOption(option);
})}
</select>
);
}
}
export default radium(Dropdown);

View File

@ -0,0 +1 @@
export default from './Dropdown';

View File

@ -0,0 +1,18 @@
import Styles from '../../../../styles';
const { color, padding } = Styles;
export default {
optionWrapperStyles: {
color: color.textMedium,
cursor: 'pointer',
padding: padding.half,
':hover': {
background: '#F9F0FF',
color: color.textUltradark,
},
},
selectWrapperStyles: {
position: 'relative',
},
};

View File

@ -5,6 +5,7 @@ import componentStyles from './styles';
class InputField extends Component {
static propTypes = {
autofocus: PropTypes.bool,
defaultValue: PropTypes.string,
error: PropTypes.string,
inputWrapperStyles: PropTypes.object,
inputOptions: PropTypes.object,
@ -19,6 +20,7 @@ class InputField extends Component {
static defaultProps = {
autofocus: false,
defaultValue: '',
inputWrapperStyles: {},
inputOptions: {},
label: null,
@ -29,7 +31,10 @@ class InputField extends Component {
constructor (props) {
super(props);
this.state = { value: null };
const { defaultValue } = props;
this.state = { value: defaultValue };
}
componentDidMount () {
@ -83,6 +88,7 @@ class InputField extends Component {
style={[inputStyles(type, value), inputErrorStyles(error), style]}
type={type}
{...inputOptions}
value={value}
/>
</div>
);
@ -94,12 +100,12 @@ class InputField extends Component {
<input
name={name}
onChange={onInputChange}
className="input-with-icon"
placeholder={placeholder}
ref={(r) => { this.input = r; }}
style={[inputStyles(type, value), inputErrorStyles(error), style]}
type={type}
{...inputOptions}
value={value}
/>
</div>
);

View File

@ -6,6 +6,7 @@ import InputField from '../InputField';
class InputFieldWithIcon extends InputField {
static propTypes = {
autofocus: PropTypes.bool,
defaultValue: PropTypes.string,
error: PropTypes.string,
iconName: PropTypes.string,
name: PropTypes.string,
@ -44,8 +45,9 @@ class InputFieldWithIcon extends InputField {
ref={(r) => { this.input = r; }}
style={[inputStyles(value, type), inputErrorStyles(error), style]}
type={type}
value={value}
/>
<i className={iconName} style={[iconStyles(value), iconErrorStyles(error), style]} />
{iconName && <i className={iconName} style={[iconStyles(value), iconErrorStyles(error), style]} />}
</div>
);
}

View File

@ -1,7 +1,7 @@
import React, { Component, PropTypes } from 'react';
import radium from 'radium';
import componentStyles from './styles';
import GradientButton from '../../../buttons/GradientButton';
import Button from '../../../buttons/Button';
import InputField from '../../fields/InputField';
import validatePresence from '../../validators/validate_presence';
@ -294,7 +294,7 @@ class SaveQueryForm extends Component {
return (
<form style={runQuerySectionStyles} onSubmit={onFormSubmit(RUN)}>
<span style={runQueryTipStyles}>&#8984; + Enter</span>
<GradientButton
<Button
style={buttonStyles}
text="Run Query"
type="submit"
@ -345,17 +345,16 @@ class SaveQueryForm extends Component {
{renderMoreOptionsCtaSection()}
{renderMoreOptionsFormFields()}
<div style={buttonWrapperStyles}>
<GradientButton
<Button
onClick={onFormSubmit(SAVE)}
style={buttonInvertStyles}
text="Save Query Only"
variant="inverse"
/>
<GradientButton
onClick={onFormSubmit(RUN_AND_SAVE)}
style={buttonStyles}
<Button
text="Run & Save Query"
type="submit"
style={buttonStyles}
/>
</div>
</form>

View File

@ -19,10 +19,6 @@ const formInput = {
};
const buttonStyles = {
backgroundImage: 'none',
backgroundColor: color.brandDark,
boxShadow: '0 3px 0 #C38DEC',
fontSize: font.medium,
letterSpacing: '1px',
paddingTop: padding.xSmall,
paddingBottom: padding.xSmall,
@ -30,17 +26,8 @@ const buttonStyles = {
};
export default {
buttonStyles: {
...buttonStyles,
},
buttonStyles,
buttonInvertStyles: {
...buttonStyles,
backgroundColor: color.white,
borderColor: color.brandLight,
borderStyle: 'solid',
borderWidth: '1px',
boxShadow: '0 3px 0 #9651CA',
color: color.brandDark,
marginRight: padding.half,
},
buttonWrapperStyles: {

View File

@ -9,6 +9,10 @@ class Base {
this.bearerToken = local.getItem('auth_token');
}
endpoint (pathname) {
return this.baseURL + pathname;
}
setBearerToken (bearerToken) {
this.bearerToken = bearerToken;
}
@ -17,6 +21,10 @@ class Base {
return this._authenticatedRequest('GET', endpoint, {}, overrideHeaders);
}
authenticatedPatch (endpoint, body = {}, overrideHeaders = {}) {
return this._authenticatedRequest('PATCH', endpoint, body, overrideHeaders);
}
authenticatedPost (endpoint, body = {}, overrideHeaders = {}) {
return this._authenticatedRequest('POST', endpoint, body, overrideHeaders);
}

View File

@ -4,4 +4,5 @@ export default {
LOGOUT: '/v1/kolide/logout',
ME: '/v1/kolide/me',
RESET_PASSWORD: '/v1/kolide/reset_password',
USERS: '/v1/kolide/users',
};

View File

@ -9,6 +9,13 @@ class Kolide extends Base {
return this.post(forgotPasswordEndpoint, JSON.stringify({ email }));
}
getUsers = () => {
const { USERS } = endpoints;
return this.authenticatedGet(this.endpoint(USERS))
.then(response => { return response.users; });
}
loginUser ({ username, password }) {
const { LOGIN } = endpoints;
const loginEndpoint = this.baseURL + LOGIN;
@ -36,6 +43,13 @@ class Kolide extends Base {
return this.post(resetPasswordEndpoint, JSON.stringify(formData));
}
updateUser = (user, formData) => {
const { USERS } = endpoints;
const updateUserEndpoint = `${this.baseURL}${USERS}/${user.id}`;
return this.authenticatedPatch(updateUserEndpoint, JSON.stringify(formData));
}
}
export default new Kolide();

View File

@ -6,10 +6,12 @@ const {
invalidForgotPasswordRequest,
invalidResetPasswordRequest,
validForgotPasswordRequest,
validGetUsersRequest,
validLoginRequest,
validLogoutRequest,
validMeRequest,
validResetPasswordRequest,
validUpdateUserRequest,
validUser,
} = mocks;
@ -20,6 +22,21 @@ describe('Kolide - API client', () => {
});
});
describe('#getUsers', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'valid-bearer-token';
const request = validGetUsersRequest();
Kolide.getUsers(bearerToken)
.then((users) => {
expect(users).toEqual([validUser]);
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
});
describe('#me', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'ABC123';
@ -137,4 +154,18 @@ describe('Kolide - API client', () => {
});
});
});
describe('#updateUser', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const formData = { enabled: false };
const request = validUpdateUserRequest(validUser, formData);
Kolide.updateUser(validUser, formData)
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
});
});

View File

@ -2,17 +2,19 @@ import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { StyleRoot } from 'radium';
import componentStyles from './styles';
import FlashMessage from '../../components/FlashMessage';
import SidePanel from '../../components/SidePanel';
export class CoreLayout extends Component {
static propTypes = {
children: PropTypes.node,
dispatch: PropTypes.func,
notifications: PropTypes.object,
user: PropTypes.object,
};
render () {
const { children, user } = this.props;
const { children, dispatch, notifications, user } = this.props;
const { wrapperStyles } = componentStyles;
if (!user) return false;
@ -26,6 +28,7 @@ export class CoreLayout extends Component {
user={user}
/>
<div style={wrapperStyles}>
<FlashMessage notification={notifications} dispatch={dispatch} />
{children}
</div>
</StyleRoot>
@ -35,9 +38,9 @@ export class CoreLayout extends Component {
const mapStateToProps = (state) => {
const { user } = state.auth;
const { notifications } = state;
return { user };
return { user, notifications };
};
export default connect(mapStateToProps)(CoreLayout);

View File

@ -0,0 +1,139 @@
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';
class UserBlock extends Component {
static propTypes = {
onSelect: PropTypes.func,
user: PropTypes.object,
};
static userActionOptions = (user) => {
const userEnableAction = user.enabled
? { text: 'Disable Account', value: 'disable_account' }
: { text: 'Enable Account', value: 'enable_account' };
const userPromotionAction = user.admin
? { text: 'Demote User', value: 'demote_user' }
: { text: 'Promote User', value: 'promote_user' };
return [
{ text: 'Actions...', value: '' },
userEnableAction,
userPromotionAction,
{ text: 'Require Password Reset', value: 'reset_password' },
{ text: 'Modify Details', value: 'modify_details' },
];
};
constructor (props) {
super(props);
this.state = {
isEdit: false,
};
}
onToggleEditing = (evt) => {
evt.preventDefault();
const { isEdit } = this.state;
this.setState({
isEdit: !isEdit,
});
return false;
}
onEditUserFormSubmit = (updatedUser) => {
const { user, onSelect } = this.props;
const formData = {
user_actions: 'edit_user',
updated_user: updatedUser,
};
this.setState({
isEdit: false,
});
return onSelect(user, formData);
}
onUserActionSelect = (formData) => {
const { onSelect, user } = this.props;
if (formData.user_actions === 'modify_details') {
this.setState({
isEdit: true,
});
return false;
}
return onSelect(user, formData);
}
render () {
const {
avatarStyles,
nameStyles,
userDetailsStyles,
userEmailStyles,
userHeaderStyles,
userLabelStyles,
usernameStyles,
userPositionStyles,
userStatusStyles,
userStatusWrapperStyles,
userWrapperStyles,
} = componentStyles;
const { user } = this.props;
const {
admin,
email,
enabled,
name,
position,
username,
} = user;
const userLabel = admin ? 'Admin' : 'User';
const activeLabel = enabled ? 'Active' : 'Disabled';
const userActionOptions = UserBlock.userActionOptions(user);
const { isEdit } = this.state;
const { onEditUserFormSubmit, onToggleEditing } = this;
if (isEdit) {
return <EditUserForm onCancel={onToggleEditing} onSubmit={onEditUserFormSubmit} user={user} />;
}
return (
<div style={userWrapperStyles}>
<div style={userHeaderStyles}>
<span style={nameStyles}>{name}</span>
</div>
<div style={userDetailsStyles}>
<Avatar user={user} style={avatarStyles} />
<div style={userStatusWrapperStyles}>
<span style={userLabelStyles}>{userLabel}</span>
<span style={userStatusStyles(enabled)}>{activeLabel}</span>
<div style={{ clear: 'both' }} />
</div>
<p style={usernameStyles}>{username}</p>
<p style={userPositionStyles}>{position}</p>
<p style={userEmailStyles}>{email}</p>
<Dropdown
fieldName="user_actions"
options={userActionOptions}
initialOption={{ text: 'Actions...' }}
onSelect={this.onUserActionSelect}
/>
</div>
</div>
);
}
}
export default radium(UserBlock);

View File

@ -0,0 +1 @@
export default from './UserBlock';

View File

@ -0,0 +1,75 @@
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,
height: '51px',
marginBottom: padding.half,
textAlign: 'center',
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,
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',
},
};

View File

@ -0,0 +1,170 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import componentStyles from './styles';
import entityGetter from '../../../redux/entityGetter';
import Button from '../../../components/buttons/Button';
import InviteUserForm from '../../../components/forms/InviteUserForm';
import Modal from '../../../components/Modal';
import userActions from '../../../redux/nodes/entities/users/actions';
import UserBlock from './UserBlock';
import { renderFlash } from '../../../redux/nodes/notifications/actions';
class UserManagementPage extends Component {
static propTypes = {
dispatch: PropTypes.func,
users: PropTypes.arrayOf(PropTypes.object),
};
constructor (props) {
super(props);
this.state = {
showInviteUserModal: false,
};
}
componentWillMount () {
const { dispatch, users } = this.props;
const { load } = userActions;
if (!users.length) dispatch(load());
return false;
}
onUserActionSelect = (user, formData) => {
const { dispatch } = this.props;
const { update } = userActions;
if (formData.user_actions) {
switch (formData.user_actions) {
case 'demote_user':
return dispatch(update(user, { admin: false }))
.then(() => {
return dispatch(renderFlash('success', 'User demoted', update(user, { admin: true })));
});
case 'disable_account':
return dispatch(userActions.update(user, { enabled: false }))
.then(() => {
return dispatch(renderFlash('success', 'User account disabled', update(user, { enabled: true })));
});
case 'enable_account':
return dispatch(update(user, { enabled: true }))
.then(() => {
return dispatch(renderFlash('success', 'User account enabled', update(user, { enabled: false })));
});
case 'promote_user':
return dispatch(update(user, { admin: true }))
.then(() => {
return dispatch(renderFlash('success', 'User promoted to admin', update(user, { admin: false })));
});
case 'reset_password':
return dispatch(update(user, { force_password_reset: true }))
.then(() => {
return dispatch(renderFlash('success', 'User forced to reset password', update(user, { force_password_reset: false })));
});
case 'edit_user':
return dispatch(update(user, formData.updated_user))
.then(() => {
return dispatch(renderFlash('success', 'User updated', update(user, user)));
});
default:
return false;
}
}
return false;
}
onInviteUserSubmit = (formData) => {
console.log('user invited', formData);
return this.toggleInviteUserModal();
}
onInviteCancel = (evt) => {
evt.preventDefault();
return this.toggleInviteUserModal();
}
toggleInviteUserModal = () => {
const { showInviteUserModal } = this.state;
this.setState({
showInviteUserModal: !showInviteUserModal,
});
return false;
}
renderUserBlock = (user) => {
const { onUserActionSelect } = this;
return (
<UserBlock
key={user.email}
onSelect={onUserActionSelect}
user={user}
/>
);
}
renderModal = () => {
const { showInviteUserModal } = this.state;
const { onInviteCancel, onInviteUserSubmit, toggleInviteUserModal } = this;
if (!showInviteUserModal) return false;
return (
<Modal
title="Invite new user"
onExit={toggleInviteUserModal}
>
<InviteUserForm
onCancel={onInviteCancel}
onSubmit={onInviteUserSubmit}
/>
</Modal>
);
};
render () {
const {
addUserButtonStyles,
addUserWrapperStyles,
containerStyles,
numUsersStyles,
usersWrapperStyles,
} = componentStyles;
const { toggleInviteUserModal } = this;
const { users } = this.props;
return (
<div style={containerStyles}>
<span style={numUsersStyles}>Listing {users.length} users</span>
<div style={addUserWrapperStyles}>
<Button
onClick={toggleInviteUserModal}
style={addUserButtonStyles}
text="Add User"
/>
</div>
<div style={usersWrapperStyles}>
{users.map(user => {
return this.renderUserBlock(user);
})}
</div>
{this.renderModal()}
</div>
);
}
}
const mapStateToProps = (state) => {
const { entities: users } = entityGetter(state).get('users');
return { users };
};
export default connect(mapStateToProps)(UserManagementPage);

View File

@ -0,0 +1 @@
export default from './UserManagementPage';

View File

@ -0,0 +1,38 @@
import Styles from '../../../styles';
const { color, font, padding } = Styles;
export default {
addUserButtonStyles: {
height: '38px',
letterSpacing: 'normal',
marginTop: 0,
marginLeft: padding.half,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
width: '145px',
},
addUserWrapperStyles: {
float: 'right',
},
containerStyles: {
backgroundColor: color.white,
minHeight: '100px',
paddingBottom: '190px',
paddingRight: padding.most,
paddingTop: padding.base,
},
numUsersStyles: {
borderBottom: '1px solid #EFF0F4',
color: color.textMedium,
display: 'inline-block',
fontSize: font.large,
marginLeft: padding.most,
paddingBottom: padding.half,
width: '260px',
},
usersWrapperStyles: {
},
};

View File

@ -6,11 +6,13 @@ import debounce from '../../utilities/debounce';
import { resetPassword } from '../../redux/nodes/components/ResetPasswordPage/actions';
import ResetPasswordForm from '../../components/forms/ResetPasswordForm';
import StackedWhiteBoxes from '../../components/StackedWhiteBoxes';
import userActions from '../../redux/nodes/entities/users/actions';
export class ResetPasswordPage extends Component {
static propTypes = {
dispatch: PropTypes.func,
token: PropTypes.string,
user: PropTypes.object,
};
static defaultProps = {
@ -18,15 +20,18 @@ export class ResetPasswordPage extends Component {
};
componentWillMount () {
const { dispatch, token } = this.props;
const { dispatch, token, user } = this.props;
if (!token) return dispatch(push('/login'));
if (!user && !token) return dispatch(push('/login'));
return false;
}
onSubmit = debounce((formData) => {
const { dispatch, token } = this.props;
const { dispatch, token, user } = this.props;
if (user) return this.updateUser(formData);
const resetPasswordData = {
...formData,
password_reset_token: token,
@ -38,6 +43,15 @@ export class ResetPasswordPage extends Component {
});
})
updateUser = (formData) => {
const { dispatch, user } = this.props;
const { new_password: password } = formData;
const passwordUpdateParams = { password };
return dispatch(userActions.update(user, passwordUpdateParams))
.then(() => { return dispatch(push('/')); });
}
render () {
const { onSubmit } = this;
@ -56,10 +70,12 @@ const mapStateToProps = (state, ownProps) => {
const { query = {} } = ownProps.location || {};
const { token } = query;
const { ResetPasswordPage: componentState } = state.components;
const { user } = state.auth;
return {
...componentState,
token,
user,
};
};

View File

@ -11,7 +11,7 @@ describe('ResetPasswordPage - component', () => {
expect(page.find('ResetPasswordForm').length).toEqual(1);
});
it('Redirects to the login page when there is no token', () => {
it('Redirects to the login page when there is no token or user', () => {
const { connectedComponent, reduxMockStore } = testHelpers;
const redirectToLoginAction = {
type: '@@router/CALL_HISTORY_METHOD',
@ -21,6 +21,7 @@ describe('ResetPasswordPage - component', () => {
},
};
const store = {
auth: {},
components: {
ResetPasswordPage: {
loading: false,

View File

@ -0,0 +1,7 @@
import stateEntityGetter from 'react-entity-getter';
const pathToEntities = (entityName) => {
return `entities[${entityName}].data`;
};
export default stateEntityGetter(pathToEntities);

View File

@ -0,0 +1,144 @@
import { noop } from 'lodash';
import { normalize, arrayOf } from 'normalizr';
const initialState = {
loading: false,
errors: {},
data: {},
};
const reduxConfig = ({
entityName,
loadFunc,
parseFunc = noop,
schema,
updateFunc,
}) => {
const actionTypes = {
LOAD_FAILURE: `${entityName}_LOAD_FAILURE`,
LOAD_REQUEST: `${entityName}_LOAD_REQUEST`,
LOAD_SUCCESS: `${entityName}_LOAD_SUCCESS`,
UPDATE_FAILURE: `${entityName}_UPDATE_FAILURE`,
UPDATE_REQUEST: `${entityName}_UPDATE_REQUEST`,
UPDATE_SUCCESS: `${entityName}_UPDATE_SUCCESS`,
};
const loadFailure = (errors) => {
return {
type: actionTypes.LOAD_FAILURE,
payload: { errors },
};
};
const loadRequest = { type: actionTypes.LOAD_REQUEST };
const loadSuccess = (data) => {
return {
type: actionTypes.LOAD_SUCCESS,
payload: { data },
};
};
const updateFailure = (errors) => {
return {
type: actionTypes.UPDATE_FAILURE,
payload: { errors },
};
};
const updateRequest = { type: actionTypes.UPDATE_REQUEST };
const updateSuccess = (data) => {
return {
type: actionTypes.UPDATE_SUCCESS,
payload: { data },
};
};
const parsedResponse = (responseArray) => {
return responseArray.map(response => {
return parseFunc(response);
});
};
const load = (...args) => {
return (dispatch) => {
dispatch(loadRequest);
return loadFunc(...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);
return updateFunc(...args)
.then(response => {
if (!response) return {};
const { entities } = normalize(parsedResponse([response]), arrayOf(schema));
return dispatch(updateSuccess(entities));
})
.catch(response => {
const { errors } = response;
dispatch(updateFailure(errors));
throw response;
});
};
};
const actions = {
load,
update,
};
const reducer = (state = initialState, { type, payload }) => {
switch (type) {
case actionTypes.UPDATE_REQUEST:
case actionTypes.LOAD_REQUEST:
return {
...state,
loading: true,
};
case actionTypes.UPDATE_SUCCESS:
case actionTypes.LOAD_SUCCESS:
return {
...state,
loading: false,
data: {
...state.data,
...payload.data[entityName],
},
};
case actionTypes.UPDATE_FAILURE:
case actionTypes.LOAD_FAILURE:
return {
...state,
loading: false,
errors: {
...payload.errors,
},
};
default:
return state;
}
};
return {
actions,
reducer,
};
};
export default reduxConfig;

View File

@ -0,0 +1,113 @@
import expect, { createSpy, restoreSpies } from 'expect';
import reduxConfig from './reduxConfig';
import { reduxMockStore } from '../../../../test/helpers';
import schemas from './schemas';
const store = { entities: { users: {} } };
const user = { id: 1, email: 'hi@thegnar.co' };
describe('reduxConfig', () => {
afterEach(restoreSpies);
describe('dispatching the load action', () => {
describe('successful load call', () => {
const mockStore = reduxMockStore(store);
const loadFunc = createSpy().andCall(() => {
return Promise.resolve([user]);
});
const config = reduxConfig({
entityName: 'users',
loadFunc,
schema: schemas.USERS,
});
const { actions, reducer } = config;
it('calls the loadFunc', () => {
mockStore.dispatch(actions.load());
expect(loadFunc).toHaveBeenCalled();
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.load());
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 load call', () => {
const mockStore = reduxMockStore(store);
const errors = { base: 'Unable to load users' };
const loadFunc = createSpy().andCall(() => {
return Promise.reject({ errors });
});
const config = reduxConfig({
entityName: 'users',
loadFunc,
schema: schemas.USERS,
});
const { actions, reducer } = config;
it('calls the loadFunc', () => {
mockStore.dispatch(actions.load());
expect(loadFunc).toHaveBeenCalled();
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.load());
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 loadFailureAction = {
type: 'users_LOAD_FAILURE',
payload: {
errors,
},
};
const initialState = {
loading: false,
entities: {},
errors: {},
};
const newState = reducer(initialState, loadFailureAction);
expect(newState.errors).toEqual(errors);
});
});
});
});

View File

@ -0,0 +1,7 @@
import { Schema } from 'normalizr';
const usersSchema = new Schema('users');
export default {
USERS: usersSchema,
};

View File

@ -0,0 +1,6 @@
import { combineReducers } from 'redux';
import users from './users/reducer';
export default combineReducers({
users,
});

View File

@ -0,0 +1,3 @@
import config from './config';
export default config.actions;

View File

@ -0,0 +1,23 @@
import md5 from 'js-md5';
import Kolide from '../../../../kolide';
import reduxConfig from '../base/reduxConfig';
import schemas from '../base/schemas';
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,
};
},
schema: USERS,
updateFunc: Kolide.updateUser,
});

View File

@ -0,0 +1,3 @@
import config from './config';
export default config.reducer;

View File

@ -0,0 +1,15 @@
export const RENDER_FLASH = 'RENDER_FLASH';
export const HIDE_FLASH = 'HIDE_FLASH';
export const renderFlash = (alertType, message, undoAction) => {
return {
type: RENDER_FLASH,
payload: {
alertType,
message,
undoAction,
},
};
};
export const hideFlash = { type: HIDE_FLASH };

View File

@ -0,0 +1,28 @@
import { LOCATION_CHANGE } from 'react-router-redux';
import { RENDER_FLASH, HIDE_FLASH } from './actions';
export const initialState = {
alertType: null,
isVisible: false,
message: null,
undoAction: null,
};
export default (state = initialState, { type, payload }) => {
switch (type) {
case RENDER_FLASH:
return {
alertType: payload.alertType,
isVisible: true,
message: payload.message,
undoAction: payload.undoAction,
};
case HIDE_FLASH:
case LOCATION_CHANGE:
return {
...initialState,
};
default:
return state;
}
};

View File

@ -0,0 +1,45 @@
import expect from 'expect';
import { LOCATION_CHANGE } from 'react-router-redux';
import reducer, { initialState } from './reducer';
import {
hideFlash,
renderFlash,
} from './actions';
describe('Notifications - reducer', () => {
it('Updates state with notification info when RENDER_FLASH is dispatched', () => {
const undoAction = { type: 'UNDO' };
const newState = reducer(initialState, renderFlash('success', 'You did it!', undoAction));
expect(newState).toEqual({
alertType: 'success',
isVisible: true,
message: 'You did it!',
undoAction,
});
});
it('Updates state to hide notifications when HIDE_FLASH is dispatched', () => {
const stateWithFlashDisplayed = reducer(initialState, renderFlash('success', 'You did it!'));
const newState = reducer(stateWithFlashDisplayed, hideFlash);
expect(newState).toEqual({
alertType: null,
isVisible: false,
message: null,
undoAction: null,
});
});
it('Updates state to hide notifications during location change', () => {
const stateWithFlashDisplayed = reducer(initialState, renderFlash('success', 'You did it!'));
const newState = reducer(stateWithFlashDisplayed, { type: LOCATION_CHANGE });
expect(newState).toEqual({
alertType: null,
isVisible: false,
message: null,
undoAction: null,
});
});
});

View File

@ -3,10 +3,14 @@ import { routerReducer } from 'react-router-redux';
import app from './nodes/app/reducer';
import auth from './nodes/auth/reducer';
import components from './nodes/components/reducer';
import entities from './nodes/entities/reducer';
import notifications from './nodes/notifications/reducer';
export default combineReducers({
app,
auth,
components,
entities,
notifications,
routing: routerReducer,
});

View File

@ -1,9 +1,10 @@
import React from 'react';
import { browserHistory, IndexRoute, Route, Router } from 'react-router';
import { Provider } from 'react-redux';
import radium from 'radium';
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';
import AuthenticatedRoutes from '../components/AuthenticatedRoutes';
@ -22,26 +23,27 @@ const history = syncHistoryWithStore(browserHistory, store);
const routes = (
<Provider store={store}>
<Router history={history}>
<Route path="/" component={radium(App)}>
<Route path="login" component={radium(LoginRoutes)}>
<Route path="forgot" component={radium(ForgotPasswordPage)} />
<Route path="reset" component={radium(ResetPasswordPage)} />
</Route>
<Route component={AuthenticatedRoutes}>
<Route path="logout" component={radium(LogoutPage)} />
</Route>
<Route component={AuthenticatedRoutes}>
<Route component={radium(CoreLayout)}>
<IndexRoute component={radium(HomePage)} />
<Route path="admin" component={AuthenticatedAdminRoutes}>
<IndexRoute component={radium(AdminDashboardPage)} />
</Route>
<Route path="queries" component={radium(QueryPageWrapper)}>
<Route path="new" component={radium(NewQueryPage)} />
<StyleRoot>
<Route path="/" component={radium(App)}>
<Route path="login" component={radium(LoginRoutes)}>
<Route path="forgot" component={radium(ForgotPasswordPage)} />
<Route path="reset" component={radium(ResetPasswordPage)} />
</Route>
<Route component={AuthenticatedRoutes}>
<Route path="logout" component={radium(LogoutPage)} />
<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)}>
<Route path="new" component={radium(NewQueryPage)} />
</Route>
</Route>
</Route>
</Route>
</Route>
</StyleRoot>
</Router>
</Provider>
);

View File

@ -4,4 +4,5 @@ export default {
HOME: '/',
LOGIN: '/login',
LOGOUT: '/logout',
RESET_PASSWORD: '/reset_password',
};

View File

@ -5,5 +5,6 @@ export default {
},
shadow: {
blur: '0 0 30px 0 rgba(0,0,0,0.30)',
slight: '0 2px 8px 0 rgba(0,0,0,0.12)',
},
};

View File

@ -2,6 +2,7 @@ import { pxToRem } from './helpers';
export default {
xSmall: pxToRem(10),
mini: pxToRem(12),
small: pxToRem(14),
medium: pxToRem(16),
base: pxToRem(18),

View File

@ -12,6 +12,16 @@ export const validUser = {
gravatarURL: 'https://www.gravatar.com/avatar/7157f4758f8423b59aaee869d919f6b9',
};
export const validGetUsersRequest = (bearerToken) => {
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.get('/api/v1/kolide/users')
.reply(200, { users: [validUser] });
};
export const validLoginRequest = () => {
return nock('http://localhost:8080')
.post('/api/v1/kolide/login')
@ -68,14 +78,22 @@ export const invalidResetPasswordRequest = (password, token, error) => {
.reply(422, { error });
};
export const validUpdateUserRequest = (user, formData) => {
return nock('http://localhost:8080')
.patch(`/api/v1/kolide/users/${user.id}`, JSON.stringify(formData))
.reply(200, validUser);
};
export default {
invalidForgotPasswordRequest,
invalidResetPasswordRequest,
validForgotPasswordRequest,
validGetUsersRequest,
validLoginRequest,
validLogoutRequest,
validMeRequest,
validResetPasswordRequest,
validUpdateUserRequest,
validUser,
};

View File

@ -36,6 +36,7 @@
"lodash": "^4.3.0",
"nock": "^8.0.0",
"node-sass": "^3.10.0",
"normalizr": "^2.2.1",
"postcss-functions": "^2.1.0",
"postcss-loader": "^0.8.0",
"precss": "^1.4.0",
@ -46,6 +47,7 @@
"react-ace": "^3.6.0",
"react-addons-css-transition-group": "^15.3.2",
"react-dom": "^15.3.2",
"react-entity-getter": "0.0.2",
"react-redux": "^4.4.5",
"react-router": "^2.7.0",
"react-router-redux": "^4.0.5",