mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 08:55:24 +00:00
parent
a03347489c
commit
6ebc460b66
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
};
|
||||
|
43
frontend/components/FlashMessage/FlashMessage.jsx
Normal file
43
frontend/components/FlashMessage/FlashMessage.jsx
Normal 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);
|
1
frontend/components/FlashMessage/index.js
Normal file
1
frontend/components/FlashMessage/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './FlashMessage';
|
26
frontend/components/FlashMessage/styles.js
Normal file
26
frontend/components/FlashMessage/styles.js
Normal 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: {},
|
||||
};
|
39
frontend/components/Modal/Modal.jsx
Normal file
39
frontend/components/Modal/Modal.jsx
Normal 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);
|
1
frontend/components/Modal/index.js
Normal file
1
frontend/components/Modal/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './Modal';
|
43
frontend/components/Modal/styles.js
Normal file
43
frontend/components/Modal/styles.js
Normal 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%',
|
||||
},
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
},
|
||||
|
@ -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);
|
1
frontend/components/buttons/Button/index.js
Normal file
1
frontend/components/buttons/Button/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './Button';
|
67
frontend/components/buttons/Button/styles.js
Normal file
67
frontend/components/buttons/Button/styles.js
Normal 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%',
|
||||
},
|
||||
};
|
@ -1 +0,0 @@
|
||||
export default from './GradientButton';
|
@ -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',
|
||||
},
|
||||
};
|
143
frontend/components/forms/Admin/EditUserForm/EditUserForm.jsx
Normal file
143
frontend/components/forms/Admin/EditUserForm/EditUserForm.jsx
Normal 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);
|
1
frontend/components/forms/Admin/EditUserForm/index.js
Normal file
1
frontend/components/forms/Admin/EditUserForm/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './EditUserForm';
|
@ -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>
|
||||
|
142
frontend/components/forms/InviteUserForm/InviteUserForm.jsx
Normal file
142
frontend/components/forms/InviteUserForm/InviteUserForm.jsx
Normal 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);
|
1
frontend/components/forms/InviteUserForm/index.js
Normal file
1
frontend/components/forms/InviteUserForm/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './InviteUserForm';
|
28
frontend/components/forms/InviteUserForm/styles.js
Normal file
28
frontend/components/forms/InviteUserForm/styles.js
Normal 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,
|
||||
},
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -1,4 +1,11 @@
|
||||
import Styles from '../../../styles';
|
||||
|
||||
const { padding } = Styles;
|
||||
|
||||
export default {
|
||||
buttonStyles: {
|
||||
marginTop: padding.base,
|
||||
},
|
||||
formStyles: {
|
||||
width: '100%',
|
||||
},
|
||||
|
58
frontend/components/forms/fields/Dropdown/Dropdown.jsx
Normal file
58
frontend/components/forms/fields/Dropdown/Dropdown.jsx
Normal 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);
|
1
frontend/components/forms/fields/Dropdown/index.js
Normal file
1
frontend/components/forms/fields/Dropdown/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './Dropdown';
|
18
frontend/components/forms/fields/Dropdown/styles.js
Normal file
18
frontend/components/forms/fields/Dropdown/styles.js
Normal 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',
|
||||
},
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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}>⌘ + 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>
|
||||
|
@ -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: {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -4,4 +4,5 @@ export default {
|
||||
LOGOUT: '/v1/kolide/logout',
|
||||
ME: '/v1/kolide/me',
|
||||
RESET_PASSWORD: '/v1/kolide/reset_password',
|
||||
USERS: '/v1/kolide/users',
|
||||
};
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
||||
|
139
frontend/pages/Admin/UserManagementPage/UserBlock/UserBlock.jsx
Normal file
139
frontend/pages/Admin/UserManagementPage/UserBlock/UserBlock.jsx
Normal 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);
|
@ -0,0 +1 @@
|
||||
export default from './UserBlock';
|
75
frontend/pages/Admin/UserManagementPage/UserBlock/styles.js
Normal file
75
frontend/pages/Admin/UserManagementPage/UserBlock/styles.js
Normal 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',
|
||||
},
|
||||
};
|
170
frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx
Normal file
170
frontend/pages/Admin/UserManagementPage/UserManagementPage.jsx
Normal 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);
|
||||
|
1
frontend/pages/Admin/UserManagementPage/index.js
Normal file
1
frontend/pages/Admin/UserManagementPage/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './UserManagementPage';
|
38
frontend/pages/Admin/UserManagementPage/styles.js
Normal file
38
frontend/pages/Admin/UserManagementPage/styles.js
Normal 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: {
|
||||
},
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
7
frontend/redux/entityGetter.js
Normal file
7
frontend/redux/entityGetter.js
Normal file
@ -0,0 +1,7 @@
|
||||
import stateEntityGetter from 'react-entity-getter';
|
||||
|
||||
const pathToEntities = (entityName) => {
|
||||
return `entities[${entityName}].data`;
|
||||
};
|
||||
|
||||
export default stateEntityGetter(pathToEntities);
|
144
frontend/redux/nodes/entities/base/reduxConfig.js
Normal file
144
frontend/redux/nodes/entities/base/reduxConfig.js
Normal 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;
|
113
frontend/redux/nodes/entities/base/reduxConfig.tests.js
Normal file
113
frontend/redux/nodes/entities/base/reduxConfig.tests.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
7
frontend/redux/nodes/entities/base/schemas.js
Normal file
7
frontend/redux/nodes/entities/base/schemas.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { Schema } from 'normalizr';
|
||||
|
||||
const usersSchema = new Schema('users');
|
||||
|
||||
export default {
|
||||
USERS: usersSchema,
|
||||
};
|
6
frontend/redux/nodes/entities/reducer.js
Normal file
6
frontend/redux/nodes/entities/reducer.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import users from './users/reducer';
|
||||
|
||||
export default combineReducers({
|
||||
users,
|
||||
});
|
3
frontend/redux/nodes/entities/users/actions.js
Normal file
3
frontend/redux/nodes/entities/users/actions.js
Normal file
@ -0,0 +1,3 @@
|
||||
import config from './config';
|
||||
|
||||
export default config.actions;
|
23
frontend/redux/nodes/entities/users/config.js
Normal file
23
frontend/redux/nodes/entities/users/config.js
Normal 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,
|
||||
});
|
3
frontend/redux/nodes/entities/users/reducer.js
Normal file
3
frontend/redux/nodes/entities/users/reducer.js
Normal file
@ -0,0 +1,3 @@
|
||||
import config from './config';
|
||||
|
||||
export default config.reducer;
|
15
frontend/redux/nodes/notifications/actions.js
Normal file
15
frontend/redux/nodes/notifications/actions.js
Normal 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 };
|
28
frontend/redux/nodes/notifications/reducer.js
Normal file
28
frontend/redux/nodes/notifications/reducer.js
Normal 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;
|
||||
}
|
||||
};
|
45
frontend/redux/nodes/notifications/reducer.tests.js
Normal file
45
frontend/redux/nodes/notifications/reducer.tests.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -4,4 +4,5 @@ export default {
|
||||
HOME: '/',
|
||||
LOGIN: '/login',
|
||||
LOGOUT: '/logout',
|
||||
RESET_PASSWORD: '/reset_password',
|
||||
};
|
||||
|
@ -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)',
|
||||
},
|
||||
};
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user