Merge branch 'master' into i18n-refactor

This commit is contained in:
gaba
2017-05-19 12:44:34 -07:00
73 changed files with 1430 additions and 304 deletions
+2 -4
View File
@@ -20,8 +20,7 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) =>
return dispatch(checkLoginFailure('not logged in'));
}
dispatch(handleAuthToken(token));
const isAdmin = !!user.roles.filter((i) => i === 'ADMIN').length;
dispatch(checkLoginSuccess(user, isAdmin));
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
if (error.translation_key === 'login_maximum_exceeded') {
@@ -86,8 +85,7 @@ export const checkLogin = () => (dispatch) => {
return dispatch(checkLoginFailure('not logged in'));
}
const isAdmin = !!user.roles.filter((i) => i === 'ADMIN').length;
dispatch(checkLoginSuccess(user, isAdmin));
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
console.error(error);
@@ -7,6 +7,12 @@ export const singleView = () => ({type: actions.SINGLE_VIEW});
export const showBanUserDialog = (user, commentId, commentStatus, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, commentStatus, showRejectedNote});
export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog});
// Suspend User Dialog
export const showSuspendUserDialog = (userId, username, commentId, commentStatus) =>
({type: actions.SHOW_SUSPEND_USER_DIALOG, userId, username, commentId, commentStatus});
export const hideSuspendUserDialog = () => ({type: actions.HIDE_SUSPEND_USER_DIALOG});
// hide shortcuts note
export const hideShortcutsNote = () => {
try {
@@ -0,0 +1,53 @@
.button {
-webkit-transform: scale(.8);
transform: scale(.8);
margin: 0;
}
.root {
color: black;
> :global(.mdl-menu__container) {
margin-left: 10px;
> :global(.mdl-menu__outline) {
box-shadow: none;
}
}
}
.buttonOpen {
box-shadow: none;
color: white;
background-color: #616161;
}
.arrowIcon {
margin-left: 6px;
margin-right: 0;
vertical-align: middle;
margin-right: 0;
font-size: 14px;
}
.menu {
padding: 0;
}
.menuItem {
background-color: #2a2a2a;
color: white;
&:first-child {
margin-bottom: 1px;
border-radius: 2px 2px 0px 0px;
}
&:last-child {
border-radius: 0px 0px 2px 2px;
}
&:hover, &:active, &:focus {
background-color: #767676;
}
&[disabled], &[disabled]:hover, &[disabled]:focus, &[disabled]:active {
background-color: #262626;
color: rgba(255, 255, 255, 0.5);
}
}
@@ -0,0 +1,64 @@
import React, {PropTypes} from 'react';
import {Button, Icon} from 'coral-ui';
import {Menu} from 'react-mdl';
import cn from 'classnames';
import {findDOMNode} from 'react-dom';
import styles from './ActionsMenu.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
const lang = new I18n(translations);
let count = 0;
class ActionsMenu extends React.Component {
id = `actions-dropdown-${count++}`;
menu = null;
state = {open: false};
timeout = null;
componentWillUnmount() {
clearTimeout(this.timeout);
}
handleRef = (ref) => {
this.menu = ref ? findDOMNode(ref).parentNode : null;
}
syncOpenState = () => {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.setState({open: this.menu.className.indexOf('is-visible') >= 0});
}, 150);
};
render() {
return (
<div className={styles.root} onBlur={this.syncOpenState} >
<Button
cStyle='actions'
className={cn(styles.button, {[styles.buttonOpen]: this.state.open})}
disabled={false}
id={this.id}
onClick={this.syncOpenState}
icon={this.props.icon}
raised>
{lang.t('modqueue.actions')}
<Icon
name={this.state.open ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
className={styles.arrowIcon}
/>
</Button>
<Menu target={this.id} className={styles.menu} ref={this.handleRef}>
{this.props.children}
</Menu>
</div>
);
}
}
ActionsMenu.propTypes = {
icon: PropTypes.string,
};
export default ActionsMenu;
@@ -0,0 +1,9 @@
import React from 'react';
import cn from 'classnames';
import {MenuItem} from 'react-mdl';
import styles from './ActionsMenu.css';
const ActionsMenuItem = (props) =>
<MenuItem className={cn(styles.menuItem, props.className)} {...props} />;
export default ActionsMenuItem;
+5 -5
View File
@@ -1,16 +1,16 @@
import React from 'react';
import {Provider} from 'react-redux';
import ToastContainer from './ToastContainer';
import 'material-design-lite';
import store from 'services/store';
import AppRouter from '../AppRouter';
export default class App extends React.Component {
render () {
return (
<Provider store={store}>
<AppRouter store={store} />
</Provider>
<div>
<ToastContainer />
<AppRouter />
</div>
);
}
}
@@ -0,0 +1,226 @@
@keyframes :global(bounceInRight) {
from, 60%, 75%, 90%, to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); }
from {
opacity: 0;
transform: translate3d(3000px, 0, 0); }
60% {
opacity: 1;
transform: translate3d(-25px, 0, 0); }
75% {
transform: translate3d(10px, 0, 0); }
90% {
transform: translate3d(-5px, 0, 0); }
to {
transform: none; } }
@keyframes :global(bounceOutRight) {
20% {
opacity: 1;
transform: translate3d(-20px, 0, 0); }
to {
opacity: 0;
transform: translate3d(2000px, 0, 0); } }
@keyframes :global(bounceInLeft) {
from, 60%, 75%, 90%, to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); }
0% {
opacity: 0;
transform: translate3d(-3000px, 0, 0); }
60% {
opacity: 1;
transform: translate3d(25px, 0, 0); }
75% {
transform: translate3d(-10px, 0, 0); }
90% {
transform: translate3d(5px, 0, 0); }
to {
transform: none; } }
@keyframes :global(bounceOutLeft) {
20% {
opacity: 1;
transform: translate3d(20px, 0, 0); }
to {
opacity: 0;
transform: translate3d(-2000px, 0, 0); } }
@keyframes :global(bounceInUp) {
from, 60%, 75%, 90%, to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); }
from {
opacity: 0;
transform: translate3d(0, 3000px, 0); }
60% {
opacity: 1;
transform: translate3d(0, -20px, 0); }
75% {
transform: translate3d(0, 10px, 0); }
90% {
transform: translate3d(0, -5px, 0); }
to {
transform: translate3d(0, 0, 0); } }
@keyframes :global(bounceOutUp) {
20% {
transform: translate3d(0, -10px, 0); }
40%, 45% {
opacity: 1;
transform: translate3d(0, 20px, 0); }
to {
opacity: 0;
transform: translate3d(0, -2000px, 0); } }
@keyframes :global(bounceInDown) {
from, 60%, 75%, 90%, to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); }
0% {
opacity: 0;
transform: translate3d(0, -3000px, 0); }
60% {
opacity: 1;
transform: translate3d(0, 25px, 0); }
75% {
transform: translate3d(0, -10px, 0); }
90% {
transform: translate3d(0, 5px, 0); }
to {
transform: none; } }
@keyframes :global(bounceOutDown) {
20% {
transform: translate3d(0, 10px, 0); }
40%, 45% {
opacity: 1;
transform: translate3d(0, -20px, 0); }
to {
opacity: 0;
transform: translate3d(0, 2000px, 0); } }
@keyframes :global(track-progress) {
0% {
width: 100%; }
100% {
width: 0; } }
:global {
.bounceOutRight, .toast-exit--top-right, .toast-exit--bottom-right {
animation-name: bounceOutRight; }
.bounceInRight, .toast-enter--top-right, .toast-enter--bottom-right {
animation-name: bounceInRight; }
.bounceInLeft, .toast-enter--top-left, .toast-enter--bottom-left {
animation-name: bounceInLeft; }
.bounceOutLeft, .toast-exit--top-left, .toast-exit--bottom-left {
animation-name: bounceOutLeft; }
.bounceInUp, .toast-enter--bottom-center {
animation-name: bounceInUp; }
.bounceOutUp, .toast-exit--top-center {
animation-name: bounceOutUp; }
.bounceInDown, .toast-enter--top-center {
animation-name: bounceInDown; }
.bounceOutDown, .toast-exit--bottom-center {
animation-name: bounceOutDown; }
.animated {
animation-duration: 0.75s;
animation-fill-mode: both; }
.toastify {
z-index: 999;
position: fixed;
padding: 4px;
width: 350px;
max-width: 98%;
color: #999;
box-sizing: border-box; }
.toastify--top-left {
top: 1em;
left: 1em; }
.toastify--top-center {
top: 1em;
left: 50%;
margin-left: -175px; }
.toastify--top-right {
top: 1em;
right: 2em; }
.toastify--bottom-left {
bottom: 1em;
left: 1em; }
.toastify--bottom-center {
bottom: 1em;
left: 50%;
margin-left: -175px; }
.toastify--bottom-right {
bottom: 1em;
right: 2em; }
.toastify__img {
float: left;
margin-right: 8px;
vertical-align: middle; }
.toastify__close {
position: absolute;
top: 18px;
left: 12px;
width: 20px;
height: 16px;
padding: 0;
text-align: center;
text-decoration: none;
color: white;
font-weight: bold;
font-size: 14px;
background: transparent;
outline: none;
border: none;
cursor: pointer;
opacity: 0.8;
transition: .3s ease; }
.toastify__close:hover, .toastify__close:focus {
opacity: 1;
}
.toastify-content {
position: relative;
width: 100%;
margin-bottom: 12px;
padding: 18px 24px 20px 48px;
box-sizing: border-box;
background: #404040;
border-radius: 2px;
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1), 0 3px 20px 0 rgba(0, 0, 0, 0.05); }
.toastify-content--info {
background: #2488cb; }
.toastify-content--success {
background: #008577; }
.toastify-content--warning {
background: #ef6c2b; }
.toastify-content--error {
background: #ef342b; }
.toastify__body {
color: white;
font-size: 15px;
font-weight: 400;
}
.toastify__progress {
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 4px;
z-index: 999;
opacity: 0.8;
border-radius: 2px;
animation: track-progress linear 1;
background-color: white;
}
}
@@ -0,0 +1,7 @@
import './ToastContainer.css';
import {defaultProps} from 'recompose';
import {ToastContainer} from 'react-toastify';
export default defaultProps({
autoClose: 5000,
})(ToastContainer);
+28 -18
View File
@@ -3,10 +3,11 @@ import {Navigation, Drawer} from 'react-mdl';
import {IndexLink, Link} from 'react-router';
import styles from './Drawer.css';
import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
const CoralDrawer = ({handleLogout, restricted = false}) => (
const CoralDrawer = ({handleLogout, auth}) => (
<Drawer className={styles.header}>
{ !restricted ?
{ auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
<div>
<Navigation className={styles.nav}>
<IndexLink
@@ -15,28 +16,37 @@ const CoralDrawer = ({handleLogout, restricted = false}) => (
activeClassName={styles.active}>
{t('configure.dashboard')}
</IndexLink>
<Link
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
</Link>
<Link className={styles.navLink}
to="/admin/stories"
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
</Link>
)
}
<Link className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{t('configure.stories')}
</Link>
<Link className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
to="/admin/community"
activeClassName={styles.active}>
{t('configure.community')}
</Link>
<Link
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{t('configure.configure')}
</Link>
{
can(auth.user, 'UPDATE_CONFIG') &&
(
<Link
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{t('configure.configure')}
</Link>
)
}
<a onClick={handleLogout}>Sign Out</a>
<span>{`v${process.env.VERSION}`}</span>
</Navigation>
+58 -45
View File
@@ -4,50 +4,66 @@ import {Link, IndexLink} from 'react-router';
import styles from './Header.css';
import t from 'coral-framework/services/i18n';
import {Logo} from './Logo';
import {can} from 'coral-framework/services/perms';
const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false}) => (
const CoralHeader = ({
handleLogout,
showShortcuts = () => {},
auth
}) => (
<Header className={styles.header}>
<Logo className={styles.logo} />
{
!restricted ?
<div>
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{t('configure.dashboard')}
</IndexLink>
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
</Link>
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{t('configure.stories')}
</Link>
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{t('configure.community')}
</Link>
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{t('configure.configure')}
</Link>
</Navigation>
{
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{t('configure.dashboard')}
</IndexLink>
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
</Link>
)
}
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{t('configure.stories')}
</Link>
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{t('configure.community')}
</Link>
{
can(auth.user, 'UPDATE_CONFIG') && (
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{t('configure.configure')}
</Link>
)
}
</Navigation>
:
null
}
<div className={styles.rightPanel}>
<ul>
<li className={styles.settings}>
@@ -65,16 +81,13 @@ const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false
</ul>
</div>
</div>
:
null
}
</Header>
);
CoralHeader.propTypes = {
auth: PropTypes.object,
showShortcuts: PropTypes.func,
handleLogout: PropTypes.func.isRequired,
restricted: PropTypes.bool // hide elemnts from a user that's logged out
handleLogout: PropTypes.func.isRequired
};
export default CoralHeader;
@@ -4,12 +4,16 @@ import Header from './Header';
import Drawer from './Drawer';
import styles from './Layout.css';
const Layout = ({children, handleLogout = () => {}, toggleShortcutModal, restricted = false, ...props}) => (
const Layout = ({
children,
handleLogout = () => {},
toggleShortcutModal,
restricted = false,
...props}) => (
<LayoutMDL fixedDrawer>
<Header
handleLogout={handleLogout}
showShortcuts={toggleShortcutModal}
restricted={restricted}
{...props} />
<Drawer handleLogout={handleLogout} restricted={restricted} {...props} />
<div className={styles.layout}>
@@ -3,5 +3,7 @@ export const SINGLE_VIEW = 'SINGLE_VIEW';
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
export const SHOW_SUSPEND_USER_DIALOG = 'SHOW_SUSPEND_USER_DIALOG';
export const HIDE_SUSPEND_USER_DIALOG = 'HIDE_SUSPEND_USER_DIALOG';
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
@@ -3,7 +3,7 @@ import {connect} from 'react-redux';
import {compose} from 'react-apollo';
import {modUserFlaggedQuery} from 'coral-admin/src/graphql/queries';
import {banUser, setUserStatus, suspendUser} from 'coral-admin/src/graphql/mutations';
import {banUser, setUserStatus, rejectUsername} from 'coral-admin/src/graphql/mutations';
import {
fetchAccounts,
@@ -113,7 +113,7 @@ class CommunityContainer extends Component {
error={data.error}
showBanUserDialog={props.showBanUserDialog}
approveUser={props.approveUser}
suspendUser={props.suspendUser}
rejectUsername={props.rejectUsername}
showSuspendUserDialog={props.showSuspendUserDialog}
/>
<BanUserDialog
@@ -126,7 +126,7 @@ class CommunityContainer extends Component {
open={community.suspendDialog}
handleClose={props.hideSuspendUserDialog}
user={community.user}
suspendUser={props.suspendUser}
rejectUsername={props.rejectUsername}
/>
</div>
);
@@ -165,5 +165,5 @@ export default compose(
modUserFlaggedQuery,
banUser,
setUserStatus,
suspendUser
rejectUsername
)(CommunityContainer);
@@ -63,6 +63,7 @@ class Table extends Component {
label={t('community.role')}
onChange={(role) => this.onRoleChange(row.id, role)}>
<Option value={''}>.</Option>
<Option value={'STAFF'}>{t('community.staff')}</Option>
<Option value={'MODERATOR'}>{t('community.moderator')}</Option>
<Option value={'ADMIN'}>{t('community.admin')}</Option>
</SelectField>
@@ -1,6 +1,6 @@
import React from 'react';
import styles from '../Community.css';
import BanUserButton from '../../../components/BanUserButton';
import BanUserButton from './BanUserButton';
import {Button} from 'coral-ui';
import {menuActionsMap} from '../../../containers/ModerationQueue/helpers/moderationQueueActionsMap';
@@ -7,16 +7,16 @@ import t from 'coral-framework/services/i18n';
const stages = [
{
title: 'suspenduser.title_0',
description: 'suspenduser.description_0',
title: 'suspenduser.title_reject',
description: 'suspenduser.description_reject',
options: {
'j': 'suspenduser.no_cancel',
'k': 'suspenduser.yes_suspend'
}
},
{
title: 'suspenduser.title_1',
description: 'suspenduser.description_1',
title: 'suspenduser.title_notify',
description: 'suspenduser.description_notify',
options: {
'j': 'bandialog.cancel',
'k': 'suspenduser.send'
@@ -31,11 +31,11 @@ class SuspendUserDialog extends Component {
static propTypes = {
stage: PropTypes.number,
handleClose: PropTypes.func.isRequired,
suspendUser: PropTypes.func.isRequired
rejectUsername: PropTypes.func.isRequired
}
componentDidMount() {
this.setState({email: t('suspenduser.email'), about: t('suspenduser.username')});
this.setState({email: t('suspenduser.email_message_reject'), about: t('suspenduser.username')});
}
/*
@@ -43,13 +43,13 @@ class SuspendUserDialog extends Component {
* handles the possible actions for that dialog.
*/
onActionClick = (stage, menuOption) => () => {
const {suspendUser, user} = this.props;
const {rejectUsername, user} = this.props;
const {stage} = this.state;
const cancel = this.props.handleClose;
const next = () => this.setState({stage: stage + 1});
const suspend = () => {
suspendUser({userId: user.user.id, message: this.state.email})
rejectUsername({id: user.user.id, message: this.state.email})
.then(() => {
this.props.handleClose();
});
@@ -76,7 +76,7 @@ class SuspendUserDialog extends Component {
open={open}
onClose={handleClose}
onCancel={handleClose}
title={t('suspenduser.title')}>
title={t('suspenduser.suspend_user')}>
<div className={styles.title}>
{t(stages[stage].title, t('suspenduser.username'))}
</div>
@@ -14,6 +14,7 @@ import StreamSettings from './StreamSettings';
import ModerationSettings from './ModerationSettings';
import TechSettings from './TechSettings';
import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
class Configure extends Component {
constructor (props) {
@@ -117,6 +118,11 @@ class Configure extends Component {
render () {
const {activeSection} = this.state;
const section = this.getSection(activeSection);
const {auth: {user}} = this.props;
if (!can(user, 'UPDATE_CONFIG')) {
return <p>You must be an administrator to access config settings. Please find the nearest Admin and ask them to level you up!</p>;
}
const showSave = Object.keys(this.state.errors).reduce(
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
@@ -171,6 +177,7 @@ class Configure extends Component {
}
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
settings: state.settings.toJS()
});
export default connect(mapStateToProps)(Configure);
@@ -7,6 +7,7 @@ import {logout} from 'coral-framework/actions/auth';
import {FullLoading} from '../components/FullLoading';
import {toggleModal as toggleShortcutModal} from '../actions/moderation';
import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth';
import {can} from 'coral-framework/services/perms';
class LayoutContainer extends Component {
componentWillMount() {
@@ -17,7 +18,7 @@ class LayoutContainer extends Component {
}
render() {
const {
isAdmin,
user,
loggedIn,
loadingUser,
loginError,
@@ -33,7 +34,7 @@ class LayoutContainer extends Component {
if (loadingUser) {
return <FullLoading />;
}
if (!isAdmin) {
if (!loggedIn) {
return (
<AdminLogin
loginMaxExceeded={loginMaxExceeded}
@@ -45,7 +46,7 @@ class LayoutContainer extends Component {
/>
);
}
if (isAdmin && loggedIn) {
if (can(user, 'ACCESS_ADMIN') && loggedIn) {
return (
<Layout
handleLogout={handleLogout}
@@ -53,6 +54,12 @@ class LayoutContainer extends Component {
{...this.props}
/>
);
} else if (loggedIn) {
return (
<Layout {...this.props}>
<p>This page is for team use only. Please contact an administrator if you want to join this team.</p>
</Layout>
);
}
return <FullLoading />;
}
@@ -1,12 +1,16 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose} from 'react-apollo';
import * as notification from 'coral-admin/src/services/notification';
import key from 'keymaster';
import isEqual from 'lodash/isEqual';
import styles from './components/styles.css';
import translations from 'coral-admin/src/translations';
import I18n from 'coral-framework/modules/i18n/i18n';
import {modQueueQuery, getQueueCounts} from '../../graphql/queries';
import {banUser, setCommentStatus} from '../../graphql/mutations';
import {banUser, setCommentStatus, suspendUser} from '../../graphql/mutations';
import {fetchSettings} from 'actions/settings';
import {updateAssets} from 'actions/assets';
@@ -15,13 +19,16 @@ import {
singleView,
showBanUserDialog,
hideBanUserDialog,
showSuspendUserDialog,
hideSuspendUserDialog,
hideShortcutsNote,
viewUserDetail,
hideUserDetail
} from 'actions/moderation';
import {Spinner} from 'coral-ui';
import BanUserDialog from '../../components/BanUserDialog';
import BanUserDialog from './components/BanUserDialog';
import SuspendUserDialog from './components/SuspendUserDialog';
import ModerationQueue from './ModerationQueue';
import ModerationMenu from './components/ModerationMenu';
import ModerationHeader from './components/ModerationHeader';
@@ -29,6 +36,8 @@ import NotFoundAsset from './components/NotFoundAsset';
import ModerationKeysModal from '../../components/ModerationKeysModal';
import UserDetail from './UserDetail';
const lang = new I18n(translations);
class ModerationContainer extends Component {
state = {
selectedIndex: 0,
@@ -91,6 +100,33 @@ class ModerationContainer extends Component {
this.props.modQueueResort(sort);
}
suspendUser = async (args) => {
this.props.hideSuspendUserDialog();
try {
const result = await this.props.suspendUser(args);
if (result.data.suspendUser.errors) {
throw result.data.suspendUser.errors;
}
notification.success(
lang.t('suspenduser.notify_suspend_until',
this.props.moderation.suspendUserDialog.username,
lang.timeago(args.until)),
);
const {commentStatus, commentId} = this.props.moderation.suspendUserDialog;
if (commentStatus !== 'REJECTED') {
return this.props.rejectComment({commentId})
.then((result) => {
if (result.data.setCommentStatus.errors) {
throw result.data.setCommentStatus.errors;
}
});
}
}
catch(err) {
notification.showMutationErrors(err);
}
};
componentWillUnmount() {
key.unbind('s');
key.unbind('shift+/');
@@ -184,12 +220,14 @@ class ModerationContainer extends Component {
bannedWords={settings.wordlist.banned}
suspectWords={settings.wordlist.suspect}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={props.loadMore}
assetId={providedAssetId}
sort={this.state.sort}
commentCount={activeTabCount}
currentUserId={this.props.auth.user.id}
viewUserDetail={viewUserDetail}
hideUserDetail={hideUserDetail}
/>
@@ -203,6 +241,14 @@ class ModerationContainer extends Component {
showRejectedNote={moderation.showRejectedNote}
rejectComment={props.rejectComment}
/>
<SuspendUserDialog
open={moderation.suspendUserDialog.show}
username={moderation.suspendUserDialog.username}
userId={moderation.suspendUserDialog.userId}
organizationName={data.settings.organizationName}
onCancel={props.hideSuspendUserDialog}
onPerform={this.suspendUser}
/>
<ModerationKeysModal
hideShortcutsNote={props.hideShortcutsNote}
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
@@ -221,26 +267,32 @@ class ModerationContainer extends Component {
const mapStateToProps = (state) => ({
moderation: state.moderation.toJS(),
settings: state.settings.toJS(),
auth: state.auth.toJS(),
assets: state.assets.get('assets')
});
const mapDispatchToProps = (dispatch) => ({
toggleModal: (toggle) => dispatch(toggleModal(toggle)),
onClose: () => dispatch(toggleModal(false)),
singleView: () => dispatch(singleView()),
updateAssets: (assets) => dispatch(updateAssets(assets)),
fetchSettings: () => dispatch(fetchSettings()),
viewUserDetail: (id) => dispatch(viewUserDetail(id)),
hideUserDetail: () => dispatch(hideUserDetail()),
showBanUserDialog: (user, commentId, commentStatus, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, commentStatus, showRejectedNote)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
hideShortcutsNote: () => dispatch(hideShortcutsNote()),
...bindActionCreators({
toggleModal,
singleView,
updateAssets,
fetchSettings,
showBanUserDialog,
hideShortcutsNote,
showSuspendUserDialog,
hideSuspendUserDialog,
viewUserDetail,
hideUserDetail,
}, dispatch),
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
setCommentStatus,
getQueueCounts,
banUser,
suspendUser,
modQueueQuery,
banUser
)(ModerationContainer);
@@ -16,6 +16,7 @@ class ModerationQueue extends React.Component {
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
comments: PropTypes.array.isRequired
@@ -62,9 +63,11 @@ class ModerationQueue extends React.Component {
viewUserDetail={viewUserDetail}
actions={actionsMap[status]}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
currentUserId={this.props.currentUserId}
/>;
})
: <EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
@@ -152,13 +152,14 @@ input.error{
.cancel {
margin-right: 10px;
width: 47%;
width: 48%;
}
.ban {
width: 47%;
width: 48%;
}
.buttons {
margin: 20px 0;
margin: 20px;
text-align: center;
}
@@ -3,7 +3,6 @@ import {Dialog} from 'coral-ui';
import styles from './BanUserDialog.css';
import Button from 'coral-ui/components/Button';
import t from 'coral-framework/services/i18n';
const onBanClick = (userId, commentId, commentStatus, handleBanUser, rejectComment, handleClose) => (e) => {
@@ -10,7 +10,8 @@ import Highlighter from 'react-highlight-words';
import Slot from 'coral-framework/components/Slot';
import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import BanUserButton from 'coral-admin/src/components/BanUserButton';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
const linkify = new Linkify();
@@ -60,16 +61,19 @@ const Comment = ({
<span className={styles.created}>
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
</span>
<BanUserButton
user={comment.user}
onClick={() =>
props.showBanUserDialog(
comment.user,
comment.id,
comment.status,
comment.status !== 'REJECTED'
)}
/>
{props.currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
<CommentType type={commentType} />
</div>
{comment.user.status === 'banned'
@@ -155,6 +159,9 @@ Comment.propTypes = {
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
currentUserId: PropTypes.string.isRequired,
comment: PropTypes.shape({
body: PropTypes.string.isRequired,
action_summaries: PropTypes.array,
@@ -0,0 +1,90 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 400px;
top: 50%;
transform: translateY(-50%);
padding: 20px;
border-radius: 4px;
}
.header {
color: black;
font-size: 1.5em;
font-weight: 500;
margin: 0 0 8px 0;
}
.close {
display: block;
position: absolute;
top: 24px;
right: 20px;
}
.closeButton {
userSelect: none;
outline: none;
border: none;
touchAction: manipulation;
&::-moz-focus-inner: {
border: 0;
}
background: 0;
padding: 0;
font-size: 24px;
line-height: 14px;
cursor: pointer;
color: #363636;
&:hover {
color: #6b6b6b;
}
}
.legend {
padding: 0;
font-weight: bold;
}
div.radioGroup {
margin-top: 6px;
}
label.radioGroup {
&:global(.is-checked) > :global(.mdl-radio__outer-circle),
> :global(.mdl-radio__outer-circle) {
border-color: #212121;
}
> :global(.mdl-radio__inner-circle) {
background: #212121;
}
> :global(.mdl-radio__label) {
font-size: 14px;
line-height: 20px;
}
}
.messageInput {
border-radius: 3px;
width: 100%;
padding: 10px;
font-size: 14px;
box-sizing: border-box;
}
.cancel {
margin-right: 5px;
}
.perform {
min-width: 90px;
}
.buttons {
margin-top: 8px;
margin-bottom: 6px;
text-align: right;
}
@@ -0,0 +1,165 @@
import React, {PropTypes} from 'react';
import {Dialog} from 'coral-ui';
import {RadioGroup, Radio} from 'react-mdl';
import styles from './SuspendUserDialog.css';
import Button from 'coral-ui/components/Button';
import I18n from 'coral-framework/modules/i18n/i18n';
import {dateAdd} from 'coral-framework/utils';
import translations from '../../../translations';
const lang = new I18n(translations);
const initialState = {step: 0, duration: '3'};
function durationsToDate(hours) {
// Add 1 minute more to help `timeago.js` to display the correct duration.
return dateAdd(new Date(), 'minute', hours * 60 + 1);
}
class SuspendUserDialog extends React.Component {
state = initialState;
componentWillReceiveProps(next) {
if (this.props.open && !next.open) {
this.setState(initialState);
}
}
handleDurationChange = (event) => {
this.setState({duration: event.target.value});
}
handleMessageChange = (event) => {
this.setState({message: event.target.value});
}
goToStep1 = () => {
this.setState({
step: 1,
message: lang.t(
'suspenduser.email_message_suspend',
this.props.username,
this.props.organizationName,
lang.timeago(durationsToDate(this.state.duration)),
),
});
}
handlePerform = () => {
this.props.onPerform({
id: this.props.userId,
message: this.state.message,
// Add 1 minute more to help `timeago.js` to display the correct duration.
until: durationsToDate(this.state.duration),
});
};
renderStep0() {
const {onCancel, username} = this.props;
const {duration} = this.state;
return (
<section>
<h1 className={styles.header}>
{lang.t('suspenduser.title_suspend')}
</h1>
<p className={styles.description}>
{lang.t('suspenduser.description_suspend', username)}
</p>
<fieldset>
<legend className={styles.legend}>{lang.t('suspenduser.select_duration')}</legend>
<RadioGroup
name='status filter'
value={duration}
childContainer='div'
onChange={this.handleDurationChange}
className={styles.radioGroup}
>
<Radio value='1'>{lang.t('suspenduser.one_hour')}</Radio>
<Radio value='3'>{lang.t('suspenduser.hours', 3)}</Radio>
<Radio value='24'>{lang.t('suspenduser.hours', 24)}</Radio>
<Radio value='168'>{lang.t('suspenduser.days', 7)}</Radio>
</RadioGroup>
</fieldset>
<div className={styles.buttons}>
<Button cStyle="white" className={styles.cancel} onClick={onCancel} raised>
{lang.t('suspenduser.cancel')}
</Button>
<Button cStyle="black" className={styles.perform} onClick={this.goToStep1} raised>
{lang.t('suspenduser.suspend_user')}
</Button>
</div>
</section>
);
}
renderStep1() {
const {onCancel, username} = this.props;
const {message} = this.state;
return (
<section>
<h1 className={styles.header}>
{lang.t('suspenduser.title_notify')}
</h1>
<p className={styles.description}>
{lang.t('suspenduser.description_notify', username)}
</p>
<fieldset>
<legend className={styles.legend}>{lang.t('suspenduser.write_message')}</legend>
<textarea
rows={5}
className={styles.messageInput}
value={message}
onChange={this.handleMessageChange} />
</fieldset>
<div className={styles.buttons}>
<Button cStyle="white" className={styles.cancel} onClick={onCancel} raised>
{lang.t('suspenduser.cancel')}
</Button>
<Button
cStyle="black"
className={styles.perform}
onClick={this.handlePerform}
disabled={this.state.message.length === 0}
raised
>
{lang.t('suspenduser.send')}
</Button>
</div>
</section>
);
}
render() {
const {open, onCancel} = this.props;
const {step} = this.state;
return (
<Dialog
className={styles.dialog}
onCancel={onCancel}
open={open}
>
<div className={styles.close}>
<button aria-label="Close" onClick={onCancel} className={styles.closeButton}>×</button>
</div>
{step === 0 && this.renderStep0()}
{step === 1 && this.renderStep1()}
</Dialog>
);
}
}
SuspendUserDialog.propTypes = {
open: PropTypes.bool.isRequired,
onCancel: PropTypes.func.isRequired,
onPerform: PropTypes.func.isRequired,
username: PropTypes.string,
userId: PropTypes.string,
organizationName: PropTypes.string,
};
export default SuspendUserDialog;
@@ -2,6 +2,7 @@ import {graphql} from 'react-apollo';
import SET_USER_STATUS from './setUserStatus.graphql';
import SET_COMMENT_STATUS from './setCommentStatus.graphql';
import SUSPEND_USER from './suspendUser.graphql';
import REJECT_USERNAME from './rejectUsername.graphql';
export const banUser = graphql(SET_USER_STATUS, {
props: ({mutate}) => ({
@@ -32,11 +33,22 @@ export const setUserStatus = graphql(SET_USER_STATUS, {
export const suspendUser = graphql(SUSPEND_USER, {
props: ({mutate}) => ({
suspendUser: ({userId, message}) => {
suspendUser: (input) => {
return mutate({
variables: {
userId,
message
input,
},
});
}
})
});
export const rejectUsername = graphql(REJECT_USERNAME, {
props: ({mutate}) => ({
rejectUsername: (input) => {
return mutate({
variables: {
input,
},
refetchQueries: ['Users']
});
@@ -0,0 +1,7 @@
mutation rejectUsername($input: RejectUsernameInput!) {
rejectUsername(input: $input) {
errors {
translation_key
}
}
}
@@ -1,5 +1,5 @@
mutation suspendUser($userId: ID!, $message: String) {
suspendUser(id: $userId, message: $message) {
mutation suspendUser($input: SuspendUserInput!) {
suspendUser(input: $input) {
errors {
translation_key
}
@@ -62,4 +62,7 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
asset_id: $asset_id,
statuses: [NONE, PREMOD]
})
settings {
organizationName
}
}
-2
View File
@@ -4,7 +4,6 @@ import * as actions from '../constants/auth';
const initialState = Map({
loggedIn: false,
user: null,
isAdmin: false,
loginError: null,
loginMaxExceeded: false,
passwordRequestSuccess: null
@@ -24,7 +23,6 @@ export default function auth (state = initialState, action) {
return state
.set('loggedIn', true)
.set('loadingUser', false)
.set('isAdmin', action.isAdmin)
.set('user', action.user);
case actions.LOGOUT:
return initialState;
+25 -4
View File
@@ -1,15 +1,22 @@
import {Map} from 'immutable';
import {fromJS, Map} from 'immutable';
import * as actions from '../constants/moderation';
const initialState = Map({
const initialState = fromJS({
singleView: false,
modalOpen: false,
user: Map({}),
user: {},
commentId: null,
commentStatus: null,
userDetailId: null,
banDialog: false,
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show'
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
suspendUserDialog: {
show: false,
userId: null,
username: '',
commentId: null,
commentStatus: '',
},
});
export default function moderation (state = initialState, action) {
@@ -27,6 +34,20 @@ export default function moderation (state = initialState, action) {
showRejectedNote: action.showRejectedNote,
banDialog: true
});
case actions.SHOW_SUSPEND_USER_DIALOG:
return state
.mergeDeep({
suspendUserDialog: {
show: true,
userId: action.userId,
username: action.username,
commentId: action.commentId,
commentStatus: action.commentStatus,
}
});
case actions.HIDE_SUSPEND_USER_DIALOG:
return state
.setIn(['suspendUserDialog', 'show'], false);
case actions.SET_ACTIVE_TAB:
return state
.set('activeTab', action.activeTab);
@@ -0,0 +1,28 @@
import translations from 'coral-admin/src/translations';
import I18n from 'coral-framework/modules/i18n/i18n';
import {toast} from 'react-toastify';
const lang = new I18n(translations);
export function success(msg) {
return toast(msg, {type: 'success'});
}
export function error(msg) {
return toast(msg, {type: 'error'});
}
export function info(msg) {
return toast(msg, {type: 'info'});
}
export function showMutationErrors(err) {
const errors = Array.isArray(err) ? err : [err];
errors.forEach((err) => {
console.error(err);
toast(
err.translation_key ? lang.t(`errors.${err.translation_key}`) : err,
{type: 'error'}
);
});
}
@@ -1,5 +1,6 @@
import React from 'react';
import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
import {TabBar, Tab, TabContent, Button} from 'coral-ui';
@@ -7,7 +8,6 @@ import Stream from '../containers/Stream';
import Count from 'coral-plugin-comment-count/CommentCount';
import UserBox from 'coral-sign-in/components/UserBox';
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
export default class Embed extends React.Component {
@@ -36,7 +36,7 @@ export default class Embed extends React.Component {
render () {
const {activeTab, logout, viewAllComments, commentId} = this.props;
const {asset: {totalCommentCount}} = this.props.root;
const {loggedIn, isAdmin, user} = this.props.auth;
const {loggedIn, user} = this.props.auth;
const userBox = <UserBox user={user} onLogout={logout} onShowProfile={this.handleShowProfile}/>;
@@ -45,8 +45,8 @@ export default class Embed extends React.Component {
<div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={totalCommentCount}/></Tab>
<Tab>{t('framework.my_profile')}</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
<Tab>{t('myProfile')}</Tab>
<Tab restricted={!can(user, 'UPDATE_CONFIG')}>Configure Stream</Tab>
</TabBar>
{
commentId &&
@@ -66,10 +66,8 @@ export default class Embed extends React.Component {
<ProfileContainer />
</TabContent>
<TabContent show={activeTab === 'config'}>
<RestrictedContent restricted={!loggedIn}>
{ loggedIn ? userBox : null }
<ConfigureStreamContainer />
</RestrictedContent>
{ loggedIn ? userBox : null }
<ConfigureStreamContainer />
</TabContent>
</div>
</div>
@@ -9,10 +9,16 @@ import {ModerationLink} from 'coral-plugin-moderation';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import SuspendedAccount from 'coral-framework/components/SuspendedAccount';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
import SuspendedAccount from './SuspendedAccount';
import RestrictedMessageBox
from 'coral-framework/components/RestrictedMessageBox';
import {can} from 'coral-framework/services/perms';
import ChangeUsernameContainer
from 'coral-sign-in/containers/ChangeUsernameContainer';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations';
const lang = new I18n(translations);
class Stream extends React.Component {
setActiveReplyBox = (reactKey) => {
@@ -37,7 +43,7 @@ class Stream extends React.Component {
removeCommentTag,
pluginProps,
ignoreUser,
auth: {loggedIn, isAdmin, user},
auth: {loggedIn, user},
commentCountCache,
editName
} = this.props;
@@ -49,6 +55,7 @@ class Stream extends React.Component {
: comment;
const banned = user && user.status === 'BANNED';
const temporarilySuspended = user && user.suspension.until && new Date(user.suspension.until) > new Date();
const hasOlderComments = !!(asset &&
asset.lastComment &&
@@ -73,32 +80,38 @@ class Stream extends React.Component {
content={asset.settings.questionBoxContent}
enable={asset.settings.questionBoxEnable}
/>
<RestrictedContent
restricted={banned}
restrictedComp={
<SuspendedAccount
canEditName={user && user.canEditName}
editName={editName}
{!banned && temporarilySuspended &&
<RestrictedMessageBox>
{
lang.t('temporarilySuspended',
this.props.root.settings.organizationName,
lang.timeago(user.suspension.until),
)
}
</RestrictedMessageBox>
}
{banned &&
<SuspendedAccount
canEditName={user && user.canEditName}
editName={editName}
/>
}
{loggedIn && !banned && !temporarilySuspended &&
<CommentBox
addNotification={this.props.addNotification}
postComment={this.props.postComment}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
setCommentCountCache={this.props.setCommentCountCache}
commentCountCache={commentCountCache}
assetId={asset.id}
premod={asset.settings.moderation}
isReply={false}
authorId={user.id}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
/>
}
>
{user
? <CommentBox
addNotification={this.props.addNotification}
postComment={this.props.postComment}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
setCommentCountCache={this.props.setCommentCountCache}
commentCountCache={commentCountCache}
assetId={asset.id}
premod={asset.settings.moderation}
isReply={false}
authorId={user.id}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
/>
: null}
</RestrictedContent>
}
</div>
: <p>{asset.settings.closedMessage}</p>}
{!loggedIn &&
@@ -112,7 +125,7 @@ class Stream extends React.Component {
{loggedIn &&
user &&
<ChangeUsernameContainer loggedIn={loggedIn} user={user} />}
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={can(user, 'MODERATE_COMMENTS')} />}
{/* the highlightedComment is isolated after the user followed a permalink */}
{highlightedComment
@@ -1,8 +1,3 @@
.message {
background: #D8D8D8;
padding: 25px;
}
.editNameInput {
margin-top: 10px;
margin-bottom: 10px;
@@ -1,11 +1,9 @@
import React, {Component, PropTypes} from 'react';
import t from 'coral-framework/services/i18n';
import styles from './RestrictedContent.css';
import styles from './SuspendAccount.css';
import {Button} from 'coral-ui';
import validate from '../helpers/validate';
import validate from 'coral-framework/helpers/validate';
import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox';
class SuspendedAccount extends Component {
@@ -39,7 +37,7 @@ class SuspendedAccount extends Component {
const {canEditName} = this.props;
const {username, alert} = this.state;
return <div className={styles.message}>
return <RestrictedMessageBox>
<span>{
canEditName ?
t('framework.edit_name.msg')
@@ -73,7 +71,7 @@ class SuspendedAccount extends Component {
</Button>
</div> : null
}
</div>;
</RestrictedMessageBox>;
}
}
@@ -212,6 +212,9 @@ const fragments = {
id
}
}
settings {
organizationName
}
...${getDefinitionName(Comment.fragments.root)}
}
${Comment.fragments.root}
+6 -5
View File
@@ -185,7 +185,7 @@ export const fetchSignUpFacebook = () => (dispatch) => {
);
};
export const facebookCallback = (err, data) => (dispatch) => {
export const facebookCallback = (err, data) => (dispatch, getState) => {
if (err) {
dispatch(signInFacebookFailure(err));
return;
@@ -194,8 +194,10 @@ export const facebookCallback = (err, data) => (dispatch) => {
dispatch(handleAuthToken(data.token));
dispatch(signInFacebookSuccess(data.user));
dispatch(hideSignInDialog());
dispatch(showCreateUsernameDialog());
dispatch(hideSignInDialog());
const {user: {canEditName, status}} = getState().auth.toJS();
if (canEditName && status !== 'BANNED') {
dispatch(showCreateUsernameDialog());
}
} catch (err) {
dispatch(signInFacebookFailure(err));
return;
@@ -292,8 +294,7 @@ export const checkLogin = () => (dispatch) => {
throw new Error('Not logged in');
}
const isAdmin = !!result.user.roles.filter((i) => i === 'ADMIN').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
dispatch(checkLoginSuccess(result.user));
})
.catch((error) => {
console.error(error);
@@ -1,11 +1,11 @@
import React from 'react';
import styles from './RestrictedContent.css';
import t from 'coral-framework/services/i18n';
import RestrictedMessageBox from './RestrictedMessageBox';
export default ({children, restricted, message = t('framework.content_not_available'), restrictedComp}) => {
if (restricted) {
return restrictedComp ? restrictedComp : messageBox(message);
return restrictedComp ? restrictedComp : <RestrictedMessageBox message={message} />;
} else {
return (
<div>
@@ -14,5 +14,3 @@ export default ({children, restricted, message = t('framework.content_not_availa
);
}
};
const messageBox = (message) => <div className={styles.message}>{message}</div>;
@@ -0,0 +1,6 @@
.message {
background: #D8D8D8;
padding: 25px;
margin-bottom: 8px;
}
@@ -0,0 +1,4 @@
import React from 'react';
import styles from './RestrictedMessageBox.css';
export default ({children}) => <div className={styles.message}>{children}</div>;
+3
View File
@@ -52,6 +52,7 @@ en:
people: People
role: "Select role..."
select-status: "Select status..."
staff: "Staff"
status: Status
username_and_email: "Username and Email"
yes_ban_user: "Yes Ban User"
@@ -306,8 +307,10 @@ en:
description_1: "Suspending this user will temporarily disable their account and hide all of their comments on the site."
email: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment like or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns."
email_subject: "Your account has been suspended"
email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns."
no_cancel: "No cancel"
send: Send
suspend_user: "Suspend User"
title: "Suspend a user"
title_0: "We noticed you rejected a username"
title_1: "Notify the user of their temporary suspension"
+1 -5
View File
@@ -4,7 +4,6 @@ import * as actions from '../constants/auth';
const initialState = Map({
isLoading: false,
loggedIn: false,
isAdmin: false,
user: null,
showSignInDialog: false,
showCreateUsernameDialog: false,
@@ -76,12 +75,10 @@ export default function auth (state = initialState, action) {
return state
.set('checkedInitialLogin', true)
.set('loggedIn', true)
.set('isAdmin', action.isAdmin)
.set('user', purge(action.user));
case actions.FETCH_SIGNIN_SUCCESS:
return state
.set('loggedIn', true)
.set('isAdmin', action.isAdmin)
.set('user', purge(action.user));
case actions.FETCH_SIGNIN_FAILURE:
return state
@@ -117,8 +114,7 @@ export default function auth (state = initialState, action) {
return state
.set('user', null)
.set('isLoading', false)
.set('loggedIn', false)
.set('isAdmin', false);
.set('loggedIn', false);
case actions.INVALID_FORM:
return state
.set('error', action.error);
+34
View File
@@ -0,0 +1,34 @@
import intersection from 'lodash/intersection';
const basicRoles = {
HAS_STAFF_TAG: ['ADMIN', 'MODERATOR', 'STAFF']
};
const queryRoles = {
UPDATE_CONFIG: ['ADMIN'],
ACCESS_ADMIN: ['ADMIN', 'MODERATOR'],
VIEW_USER_EMAILS: ['ADMIN']
};
const mutationRoles = {
CHANGE_ROLES: ['ADMIN'],
MODERATE_COMMENTS: ['ADMIN', 'MODERATOR']
};
const roles = {...basicRoles, ...queryRoles, ...mutationRoles};
export const can = (user, ...perms) => {
if (!user) {
return false;
}
return perms.every((perm) => {
const role = roles[perm];
if (typeof role === 'undefined') {
throw new Error(`${perm} is not a valid role`);
}
return intersection(role, user.roles).length > 0;
});
};
+30 -1
View File
@@ -64,10 +64,39 @@ export function separateDataAndRoot(
};
}
/**
* Taken from: http://stackoverflow.com/questions/1197928/how-to-add-30-minutes-to-a-javascript-date-object.
* Adds time to a date. Modelled after MySQL DATE_ADD function.
* Example: dateAdd(new Date(), 'minute', 30) //returns 30 minutes from now.
*
* @param date Date to start with
* @param interval One of: year, quarter, month, week, day, hour, minute, second
* @param units Number of units of the given interval to add.
*/
export function dateAdd(date, interval, units) {
let ret = new Date(date); // don't change original date
const checkRollover = () => {
if (ret.getDate() !== date.getDate()) {
ret.setDate(0);
}
};
switch(interval.toLowerCase()) {
case 'year' : ret.setFullYear(ret.getFullYear() + units); checkRollover(); break;
case 'quarter': ret.setMonth(ret.getMonth() + 3 * units); checkRollover(); break;
case 'month' : ret.setMonth(ret.getMonth() + units); checkRollover(); break;
case 'week' : ret.setDate(ret.getDate() + 7 * units); break;
case 'day' : ret.setDate(ret.getDate() + units); break;
case 'hour' : ret.setTime(ret.getTime() + units * 3600000); break;
case 'minute' : ret.setTime(ret.getTime() + units * 60000); break;
case 'second' : ret.setTime(ret.getTime() + units * 1000); break;
default : ret = undefined; break;
}
return ret;
}
export function mergeDocuments(documents) {
const main = typeof documents[0] === 'string' ? documents[0] : documents[0].loc.source.body;
const substitutions = documents.slice(1);
const literals = [main, ...substitutions.map(() => '\n')];
return gql.apply(null, [literals, ...substitutions]);
}
@@ -102,7 +102,7 @@ class ChangeUsernameContainer extends Component {
return (
<div>
<CreateUsernameDialog
open={auth.showCreateUsernameDialog && auth.user.canEditName}
open={auth.showCreateUsernameDialog}
handleClose={this.handleClose}
loggedIn={loggedIn}
handleSubmitUsername={this.handleSubmitUsername}
+23 -13
View File
@@ -25,12 +25,6 @@
letter-spacing: 0.7px;
font-weight: 400;
i {
margin-right: 13px;
font-size: 18px;
vertical-align: middle;
}
&:disabled {
background: #E0E0E0;
color: #4f5c67;
@@ -38,11 +32,22 @@
}
}
.icon {
margin-right: 13px;
font-size: 18px;
vertical-align: middle;
}
.type--black {
color: #E0E0E0;
color: white;
background: #212121;
}
.type--white {
color: #212121;
background: white;
}
.type--local {
background: #E0E0E0;
color: #212121;
@@ -163,7 +168,7 @@
cursor: not-allowed;
}
.type--ban {
.type--ban, .type--actions {
display: block;
color: #616161;
border: solid 1px rgba(97, 97, 97, 0.77);
@@ -179,11 +184,16 @@
font-size: 14px;
width: auto;
&:hover {
box-shadow: none;
color: white;
background-color: #616161;
}
&:hover {
box-shadow: none;
color: white;
background-color: #616161;
}
> .icon {
margin-right: 5px;
font-size: 14px;
}
}
.full {
+1 -1
View File
@@ -13,7 +13,7 @@ const Button = ({cStyle = 'local', children, className, raised = false, full = f
`}
{...props}
>
{icon && <Icon name={icon} />}
{icon && <Icon name={icon} className={styles.icon} />}
{children}
</button>
);
+7 -3
View File
@@ -4,6 +4,10 @@ const {
arrayJoinBy
} = require('./util');
const DataLoader = require('dataloader');
const {
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS,
SEARCH_OTHERS_COMMENTS
} = require('../../perms/constants');
const CommentModel = require('../../models/comment');
const UsersService = require('../../services/users');
@@ -230,7 +234,7 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a
// Only administrators can search for comments with statuses that are not
// `null`, or `'ACCEPTED'`.
if (user != null && user.hasRoles('ADMIN') && statuses) {
if (user != null && user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS) && statuses) {
comments = comments.where({
status: {
$in: statuses
@@ -253,7 +257,7 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a
}
// Only let an admin request any user or the current user request themself.
if (user && (user.hasRoles('ADMIN') || user.id === author_id) && author_id != null) {
if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === author_id) && author_id != null) {
comments = comments.where({author_id});
}
@@ -403,7 +407,7 @@ const genRecentComments = (_, ids) => {
*/
const genComments = ({user}, ids) => {
let comments;
if (user && user.hasRoles('ADMIN')) {
if (user && user.can(SEARCH_OTHERS_COMMENTS)) {
comments = CommentModel.find({
id: {
$in: ids
+2 -1
View File
@@ -2,6 +2,7 @@ const ActionModel = require('../../models/action');
const ActionsService = require('../../services/actions');
const UsersService = require('../../services/users');
const errors = require('../../errors');
const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
/**
* Creates an action on a item. If the item is a user flag, sets the user's status to
@@ -45,7 +46,7 @@ const deleteAction = ({user}, {id}) => {
};
module.exports = (context) => {
if (context.user && context.user.can('mutation:createAction', 'mutation:deleteAction')) {
if (context.user && context.user.can(CREATE_ACTION, DELETE_ACTION)) {
return {
Action: {
create: (action) => createAction(context, action),
+15 -8
View File
@@ -9,6 +9,13 @@ const KarmaService = require('../../services/karma');
const linkify = require('linkify-it')();
const Wordlist = require('../../services/wordlist');
const {
CREATE_COMMENT,
SET_COMMENT_STATUS,
ADD_COMMENT_TAG,
REMOVE_COMMENT_TAG,
EDIT_COMMENT
} = require('../../perms/constants');
/**
* adjustKarma will adjust the affected user's karma depending on the moderators
@@ -101,7 +108,7 @@ const createComment = async ({user, loaders: {Comments}, pubsub}, {body, asset_i
tags = tags.map((tag) => ({name: tag}));
// If admin or moderator, adding STAFF tag
if (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')) {
if (user.isStaff()) {
tags.push({name: 'STAFF'});
}
@@ -179,7 +186,7 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {},
if (wordlist.banned) {
return 'REJECTED';
}
if (settings.premodLinksEnable && linkify.test(body)) {
return 'PREMOD';
}
@@ -328,7 +335,7 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
const [wordlist, settings] = await filterNewComment(context, {asset_id, body});
// Determine the new status of the comment.
const status = await resolveNewCommentStatus(context, {asset_id, body}, wordlist, settings);
const status = await resolveNewCommentStatus(context, {asset_id, body}, wordlist, settings);
// Execute the edit.
await CommentsService.edit(id, context.user.id, {body, status});
@@ -347,23 +354,23 @@ module.exports = (context) => {
}
};
if (context.user && context.user.can('mutation:createComment')) {
if (context.user && context.user.can(CREATE_COMMENT)) {
mutators.Comment.create = (comment) => createPublicComment(context, comment);
}
if (context.user && context.user.can('mutation:setCommentStatus')) {
if (context.user && context.user.can(SET_COMMENT_STATUS)) {
mutators.Comment.setStatus = (action) => setStatus(context, action);
}
if (context.user && context.user.can('mutation:addCommentTag')) {
if (context.user && context.user.can(ADD_COMMENT_TAG)) {
mutators.Comment.addCommentTag = (action) => addCommentTag(context, action);
}
if (context.user && context.user.can('mutation:removeCommentTag')) {
if (context.user && context.user.can(REMOVE_COMMENT_TAG)) {
mutators.Comment.removeCommentTag = (action) => removeCommentTag(context, action);
}
if (context.user && context.user.can('mutation:editComment')) {
if (context.user && context.user.can(EDIT_COMMENT)) {
mutators.Comment.edit = (action) => edit(context, action);
}
+14 -4
View File
@@ -1,12 +1,17 @@
const errors = require('../../errors');
const UsersService = require('../../services/users');
const {SET_USER_STATUS, SUSPEND_USER, REJECT_USERNAME} = require('../../perms/constants');
const setUserStatus = ({user}, {id, status}) => {
return UsersService.setStatus(id, status);
};
const suspendUser = ({user}, {id, message}) => {
return UsersService.suspendUser(id, message);
const suspendUser = ({user}, {id, message, until}) => {
return UsersService.suspendUser(id, message, until);
};
const rejectUsername = ({user}, {id, message}) => {
return UsersService.rejectUsername(id, message);
};
const ignoreUser = ({user}, userToIgnore) => {
@@ -22,18 +27,23 @@ module.exports = (context) => {
User: {
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
suspendUser: () => Promise.reject(errors.ErrNotAuthorized),
rejectUsername: () => Promise.reject(errors.ErrNotAuthorized),
ignoreUser: (action) => ignoreUser(context, action),
stopIgnoringUser: (action) => stopIgnoringUser(context, action),
}
};
if (context.user && context.user.can('mutation:setUserStatus')) {
if (context.user && context.user.can(SET_USER_STATUS)) {
mutators.User.setUserStatus = (action) => setUserStatus(context, action);
}
if (context.user && context.user.can('mutation:suspendUser')) {
if (context.user && context.user.can(SUSPEND_USER)) {
mutators.User.suspendUser = (action) => suspendUser(context, action);
}
if (context.user && context.user.can(REJECT_USERNAME)) {
mutators.User.rejectUsername = (action) => rejectUsername(context, action);
}
return mutators;
};
+3 -1
View File
@@ -1,3 +1,5 @@
const {SEARCH_OTHER_USERS} = require('../../perms/constants');
const Action = {
__resolveType({action_type}) {
switch (action_type) {
@@ -11,7 +13,7 @@ const Action = {
// This will load the user for the specific action. We'll limit this to the
// admin users only or the current logged in user.
user({user_id}, _, {loaders: {Users}, user}) {
if (user && (user.hasRole('ADMIN') || user_id === user.id)) {
if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) {
return Users.getByID.load(user_id);
}
}
+3 -4
View File
@@ -25,14 +25,13 @@ const Comment = {
// TODO: remove
if (user && excludeIgnored) {
return Comments.countByParentIDPersonalized({id, excludeIgnored});
return Comments.countByParentIDPersonalized({id, excludeIgnored});
}
return Comments.countByParentID.load(id);
return Comments.countByParentID.load(id);
},
actions({id}, _, {user, loaders: {Actions}}) {
// Only return the actions if the user is not an admin.
if (user && user.hasRoles('ADMIN')) {
if (user && user.can('SEARCH_ACTIONS')) {
return Actions.getByID.load(id);
}
+5 -2
View File
@@ -20,8 +20,11 @@ const RootMutation = {
setUserStatus(_, {id, status}, {mutators: {User}}) {
return wrapResponse(null)(User.setUserStatus({id, status}));
},
suspendUser(_, {id, message}, {mutators: {User}}) {
return wrapResponse(null)(User.suspendUser({id, message}));
suspendUser(_, {input: {id, message, until}}, {mutators: {User}}) {
return wrapResponse(null)(User.suspendUser({id, message, until}));
},
rejectUsername(_, {input: {id, message}}, {mutators: {User}}) {
return wrapResponse(null)(User.rejectUsername({id, message}));
},
ignoreUser(_, {id}, {mutators: {User}}) {
return wrapResponse(null)(User.ignoreUser({id}));
+13 -6
View File
@@ -1,6 +1,13 @@
const {
SEARCH_ASSETS,
SEARCH_OTHERS_COMMENTS,
SEARCH_COMMENT_METRICS,
SEARCH_OTHER_USERS
} = require('../../perms/constants');
const RootQuery = {
assets(_, args, {loaders: {Assets}, user}) {
if (user == null || !user.hasRoles('ADMIN')) {
if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
}
@@ -22,7 +29,7 @@ const RootQuery = {
async comments(_, {query}, {user, loaders: {Comments, Actions}}) {
let {action_type} = query;
if (user != null && user.hasRoles('ADMIN') && action_type) {
if (user != null && user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
@@ -34,7 +41,7 @@ const RootQuery = {
},
async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) {
if (user == null || !user.hasRoles('ADMIN')) {
if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) {
return null;
}
@@ -48,7 +55,7 @@ const RootQuery = {
},
assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) {
if (user == null || !user.hasRoles('ADMIN')) {
if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
}
@@ -60,7 +67,7 @@ const RootQuery = {
},
commentMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Comments}}}) {
if (user == null || !user.hasRoles('ADMIN')) {
if (user == null || !user.can(SEARCH_COMMENT_METRICS)) {
return null;
}
@@ -89,7 +96,7 @@ const RootQuery = {
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
// so hide it in the event that we aren't an admin.
async users(_, {query}, {user, loaders: {Users, Actions}}) {
if (user == null || !user.hasRoles('ADMIN')) {
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
+4 -3
View File
@@ -1,4 +1,5 @@
const KarmaService = require('../../services/karma');
const {SEARCH_ACTIONS, SEARCH_OTHERS_COMMENTS, UPDATE_USER_ROLES} = require('../../perms/constants');
const User = {
action_summaries({id}, _, {loaders: {Actions}}) {
@@ -7,7 +8,7 @@ const User = {
actions({id}, _, {user, loaders: {Actions}}) {
// Only return the actions if the user is not an admin.
if (user && user.hasRoles('ADMIN')) {
if (user && user.can(SEARCH_ACTIONS)) {
return Actions.getByID.load(id);
}
@@ -23,7 +24,7 @@ const User = {
// If the user is not an admin, only return comment list for the owner of
// the comments.
if (user && (user.hasRoles('ADMIN') || user.id === id)) {
if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === id)) {
return Comments.getByQuery({author_id: id, sort: 'REVERSE_CHRONOLOGICAL'});
}
@@ -56,7 +57,7 @@ const User = {
roles({id, roles}, _, {user}) {
// If the user is not an admin, only return the current user's roles.
if (user && (user.hasRoles('ADMIN') || user.id === id)) {
if (user && (user.can(UPDATE_USER_ROLES) || user.id === id)) {
return roles;
}
+37 -2
View File
@@ -443,6 +443,7 @@ type Settings {
charCountEnable: Boolean
charCount: Int
organizationName: String
}
################################################################################
@@ -711,6 +712,29 @@ input CreateDontAgreeInput {
message: String
}
# Input for suspendUser mutation.
input SuspendUserInput {
# id of target user.
id: ID!
# message to be sent to the user.
message: String!
# target user will be suspended until this date.
until: Date!
}
# Input for rejectUsername mutation.
input RejectUsernameInput {
# id of target user.
id: ID!
# message to be sent to the user.
message: String!
}
# DeleteActionResponse is the response returned with possibly some errors
# relating to the delete action attempt.
type DeleteActionResponse implements Response {
@@ -735,6 +759,14 @@ type SuspendUserResponse implements Response {
errors: [UserError]
}
# RejectUsernameResponse is the response returned with possibly some errors
# relating to the reject username action attempt.
type RejectUsernameResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError]
}
# SetCommentStatusResponse is the response returned with possibly some errors
# relating to the delete action attempt.
type SetCommentStatusResponse implements Response {
@@ -807,8 +839,11 @@ type RootMutation {
# Sets User status. Requires the `ADMIN` role.
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
# Sets User status to BANNED and canEditName to true. It sends a message to the banned User. Requires the `ADMIN` role.
suspendUser(id: ID!, message: String): SuspendUserResponse
# Suspends a user. Requires the `ADMIN` role.
suspendUser(input: SuspendUserInput!): SuspendUserResponse
# Suspends a user. Requires the `ADMIN` role.
rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse
# Sets Comment status. Requires the `ADMIN` role.
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
+16 -44
View File
@@ -1,11 +1,14 @@
const mongoose = require('../services/mongoose');
const bcrypt = require('bcrypt');
const uuid = require('uuid');
const intersection = require('lodash/intersection');
const can = require('../perms');
// USER_ROLES is the array of roles that is permissible as a user role.
const USER_ROLES = [
'ADMIN',
'MODERATOR'
'MODERATOR',
'STAFF'
];
// USER_STATUS is the list of statuses that are permitted for the user status.
@@ -111,6 +114,14 @@ const UserSchema = new mongoose.Schema({
default: false
},
// User's suspension details.
suspension: {
until: {
type: Date,
default: null,
},
},
// User's settings
settings: {
bio: {
@@ -157,14 +168,10 @@ UserSchema.index({
});
/**
* Returns true if the user has all the roles specified.
* returns true if a commenter is staff
*/
UserSchema.method('hasRoles', function(...roles) {
return roles.every((role) => {
// TODO: remove toUpperCase() once we've migrated usage.
return this.roles.indexOf(role.toUpperCase()) >= 0;
});
UserSchema.method('isStaff', function () {
return intersection(['ADMIN', 'MODERATOR', 'STAFF'], this.roles).length !== 0;
});
/**
@@ -186,47 +193,12 @@ UserSchema.method('verifyPassword', function(password) {
});
});
/**
* All the graph operations that are available for a user.
* @type {Array}
*/
const USER_GRAPH_OPERATIONS = [
'mutation:createComment',
'mutation:createAction',
'mutation:deleteAction',
'mutation:editName',
'mutation:setUserStatus',
'mutation:suspendUser',
'mutation:setCommentStatus',
'mutation:addCommentTag',
'mutation:removeCommentTag',
'mutation:editComment'
];
/**
* Can returns true if the user is allowed to perform a specific graph
* operation.
*/
UserSchema.method('can', function(...actions) {
if (actions.some((action) => USER_GRAPH_OPERATIONS.indexOf(action) === -1)) {
throw new Error(`invalid actions: ${actions}`);
}
if (this.status === 'BANNED') {
return false;
}
if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:suspendUser' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
return false;
}
// {add,remove}CommentTag - requires admin and/or moderator role
const userCanModifyTags = (user) => ['ADMIN', 'MODERATOR'].some((r) => user.hasRoles(r));
if (actions.some((a) => ['mutation:removeCommentTag', 'mutation:addCommentTag'].includes(a)) && !userCanModifyTags(this)) {
return false;
}
return true;
return can(this, ...actions);
});
// Create the User model.
+1
View File
@@ -100,6 +100,7 @@
"prop-types": "^15.5.8",
"react-apollo": "^1.1.0",
"react-recaptcha": "^2.2.6",
"react-toastify": "^1.5.0",
"recompose": "^0.23.1",
"redis": "^2.7.1",
"resolve": "^1.3.2",
+25
View File
@@ -0,0 +1,25 @@
module.exports = {
// mutations
CREATE_COMMENT: 'CREATE_COMMENT',
CREATE_ACTION: 'CREATE_ACTION',
DELETE_ACTION: 'DELETE_ACTION',
EDIT_NAME: 'EDIT_NAME',
EDIT_COMMENT: 'EDIT_COMMENT',
REJECT_USERNAME: 'REJECT_USERNAME',
SET_USER_STATUS: 'SET_USER_STATUS',
SUSPEND_USER: 'SUSPEND_USER',
SET_COMMENT_STATUS: 'SET_COMMENT_STATUS',
ADD_COMMENT_TAG: 'ADD_COMMENT_TAG',
REMOVE_COMMENT_TAG: 'REMOVE_COMMENT_TAG',
UPDATE_USER_ROLES: 'UPDATE_USER_ROLES',
UPDATE_CONFIG: 'UPDATE_CONFIG',
// queries
SEARCH_ASSETS: 'SEARCH_ASSETS',
SEARCH_OTHER_USERS: 'SEARCH_OTHER_USERS',
SEARCH_ACTIONS: 'SEARCH_ACTIONS',
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS',
SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS',
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS'
};
+52
View File
@@ -0,0 +1,52 @@
const constants = require('./constants');
const root = require('./rootReducer');
const queries = require('./queryReducer');
const mutations = require('./mutationReducer');
const reducers = [
root,
queries,
mutations
];
// this will make 'reducer' a key in this array. hm.
const allPermissions = Object.keys(constants);
const findGrant = (user, perms) => {
return perms.every((perm) => {
for (let key in reducers) {
const reducer = reducers[key];
const grant = reducer(user, perm);
if (grant !== null && typeof grant !== 'undefined') {
return grant;
}
}
return false;
});
};
/**
* returns true, false, or null depending on whether the user has those permissions
* throws an error if you pass a permission that's not known to the system
* @param {User} user the user making the request for db operations
* @param {[type]} context [description]
* @param {String/Array} perms a string an array of strings which are the names of the permissions
* @return {Boolean}
*/
module.exports = (user, ...perms) => {
// make sure all the passed permissions are not typos
const missingPerms = perms.filter((perm) => {
return allPermissions.indexOf(perm) === -1;
});
if (missingPerms.length > 0) {
throw new Error(`${missingPerms.join(' ')} are not valid permissions.`);
}
return findGrant(user, perms);
};
+35
View File
@@ -0,0 +1,35 @@
const {check} = require('./utils');
const types = require('./constants');
module.exports = (user, perm) => {
switch (perm) {
case types.CREATE_COMMENT:
return true;
case types.CREATE_ACTION:
return true;
case types.DELETE_ACTION:
return true;
case types.EDIT_NAME:
return true;
case types.EDIT_COMMENT:
return true;
case types.UPDATE_USER_ROLES:
return check(user, ['ADMIN']);
case types.REJECT_USERNAME:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SET_USER_STATUS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUSPEND_USER:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SET_COMMENT_STATUS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.ADD_COMMENT_TAG:
return check(user, ['ADMIN', 'MODERATOR']);
case types.REMOVE_COMMENT_TAG:
return check(user, ['ADMIN', 'MODERATOR']);
case types.UPDATE_CONFIG:
return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
};
+21
View File
@@ -0,0 +1,21 @@
const {check} = require('./utils');
const types = require('./constants');
module.exports = (user, perm) => {
switch (perm) {
case types.SEARCH_ASSETS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_OTHER_USERS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_ACTIONS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_OTHERS_COMMENTS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_COMMENT_METRICS:
return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
};
+10
View File
@@ -0,0 +1,10 @@
module.exports = (user /* , perm*/) => {
// this runs before everything
if (
user.status === 'BANNED' ||
(user.suspension.until && user.suspension.until > new Date())
) {
return false;
}
};
+8
View File
@@ -0,0 +1,8 @@
const intersection = require('lodash/intersection');
const check = (user, roles) => {
return intersection(roles, user.roles).length > 0;
};
module.exports = {
check
};
-1
View File
@@ -1 +0,0 @@
<%= body %>
+1 -1
View File
@@ -1 +1 @@
<%= body %>
<%= body.replace(/\n/g, '<br />') %>
+45 -17
View File
@@ -389,13 +389,7 @@ module.exports = class UsersService {
return Promise.reject(new Error(`role ${role} is not supported`));
}
return UserModel.update({
id: id
}, {
$addToSet: {
roles: role
}
});
return UserModel.update({id}, {$set: {roles: [role]}});
}
/**
@@ -450,28 +444,64 @@ module.exports = class UsersService {
}
/**
* Suspend a user. It changes the status to BANNED and canEditName to True.
* @param {String} id id of a user
* @param {Function} done callback after the operation is complete
* Suspend a user until specified time.
* @param {String} id id of a user
* @param {String} message message to be send to the user
* @param {Date} until date until the suspension is valid.
*/
static suspendUser(id, message) {
static suspendUser(id, message, until) {
return UserModel.findOneAndUpdate(
{id}, {
$set: {
suspension: {
until,
},
}
})
.then((user) => {
if (message) {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'suspension', // needed to know which template to render!
locals: { // specifies the template locals.
body: message
},
subject: 'Your account has been suspended',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
return MailerService.sendSimple(options);
}
}
});
}
/**
* Reject username. It changes the status to BANNED and canEditName to True.
* @param {String} id id of a user
* @param {String} message message to be send to the user
* @param {Date} until date until the suspension is valid.
*/
static rejectUsername(id, message) {
return UserModel.findOneAndUpdate({
id
}, {
$set: {
status: 'BANNED',
canEditName: true
canEditName: true,
}
})
.then((user) => {
if (message) {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'suspension', // needed to know which template to render!
locals: { // specifies the template locals.
locals: { // specifies the template locals.
body: message
},
subject: 'Email Suspension',
@@ -480,8 +510,6 @@ module.exports = class UsersService {
};
return MailerService.sendSimple(options);
} else {
return Promise.reject(errors.ErrMissingEmail);
}
}
});
@@ -813,7 +841,7 @@ module.exports = class UsersService {
username: username,
lowercaseUsername: username.toLowerCase(),
canEditName: false,
status: 'PENDING'
status: 'PENDING',
}
})
.then((result) => {
@@ -44,6 +44,7 @@ describe('graph.mutations.addCommentTag', () => {
Object.entries({
'anonymous': undefined,
'regular commenter': new UserModel({}),
'staff': new UserModel({roles: ['STAFF']}),
'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'})
}).forEach(([ userDescription, user ]) => {
it(userDescription, async function () {
+24
View File
@@ -1480,6 +1480,10 @@ chai@^3.5.0:
deep-eql "^0.1.3"
type-detect "^1.0.0"
chain-function@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
@@ -2441,6 +2445,10 @@ doctypes@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9"
dom-helpers@^3.2.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a"
dom-serializer@0, dom-serializer@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82"
@@ -6862,6 +6870,22 @@ react-tagsinput@^3.14.0:
version "3.16.1"
resolved "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.16.1.tgz#dfb3bcbe5fc4430f60c145716c17cdc2613ce117"
react-toastify@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-1.5.0.tgz#e9857e0b5d640064e5ba6caf7a96bb1578273de7"
dependencies:
prop-types "^15.5.8"
react-transition-group "^1.1.2"
react-transition-group@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.1.3.tgz#5e02cf6e44a863314ff3c68a0c826c2d9d70b221"
dependencies:
chain-function "^1.0.0"
dom-helpers "^3.2.0"
prop-types "^15.5.6"
warning "^3.0.0"
react@^15.3.1, react@^15.4.2:
version "15.5.4"
resolved "https://registry.yarnpkg.com/react/-/react-15.5.4.tgz#fa83eb01506ab237cdc1c8c3b1cea8de012bf047"