Merge branch 'master' of github.com:coralproject/talk into coral-plugin-auth

This commit is contained in:
Belen Curcio
2017-05-22 08:58:33 -03:00
73 changed files with 1529 additions and 373 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
@@ -4,10 +4,11 @@ import {IndexLink, Link} from 'react-router';
import styles from './Drawer.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
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
@@ -16,28 +17,37 @@ const CoralDrawer = ({handleLogout, restricted = false}) => (
activeClassName={styles.active}>
{lang.t('configure.dashboard')}
</IndexLink>
<Link
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.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}>
{lang.t('configure.moderate')}
</Link>
)
}
<Link className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{lang.t('configure.stories')}
</Link>
<Link className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
</Link>
<Link
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{lang.t('configure.configure')}
</Link>
{
can(auth.user, 'UPDATE_CONFIG') &&
(
<Link
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{lang.t('configure.configure')}
</Link>
)
}
<a onClick={handleLogout}>Sign Out</a>
<span>{`v${process.env.VERSION}`}</span>
</Navigation>
+58 -45
View File
@@ -5,50 +5,66 @@ import styles from './Header.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
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}>
{lang.t('configure.dashboard')}
</IndexLink>
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</Link>
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{lang.t('configure.stories')}
</Link>
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
</Link>
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{lang.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}>
{lang.t('configure.dashboard')}
</IndexLink>
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</Link>
)
}
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{lang.t('configure.stories')}
</Link>
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
</Link>
{
can(auth.user, 'UPDATE_CONFIG') && (
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{lang.t('configure.configure')}
</Link>
)
}
</Navigation>
:
null
}
<div className={styles.rightPanel}>
<ul>
<li className={styles.settings}>
@@ -66,16 +82,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
};
const lang = new I18n(translations);
@@ -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);
@@ -65,6 +65,7 @@ class Table extends Component {
label={lang.t('community.role')}
onChange={(role) => this.onRoleChange(row.id, role)}>
<Option value={''}>.</Option>
<Option value={'STAFF'}>{lang.t('community.staff')}</Option>
<Option value={'MODERATOR'}>{lang.t('community.moderator')}</Option>
<Option value={'ADMIN'}>{lang.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';
@@ -10,16 +10,16 @@ const lang = new I18n(translations);
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'
@@ -34,11 +34,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: lang.t('suspenduser.email'), about: lang.t('suspenduser.username')});
this.setState({email: lang.t('suspenduser.email_message_reject'), about: lang.t('suspenduser.username')});
}
/*
@@ -46,13 +46,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();
});
@@ -79,7 +79,7 @@ class SuspendUserDialog extends Component {
open={open}
onClose={handleClose}
onCancel={handleClose}
title={lang.t('suspenduser.title')}>
title={lang.t('suspenduser.suspend_user')}>
<div className={styles.title}>
{lang.t(stages[stage].title, lang.t('suspenduser.username'))}
</div>
@@ -15,6 +15,7 @@ import translations from 'coral-admin/src/translations.json';
import StreamSettings from './StreamSettings';
import ModerationSettings from './ModerationSettings';
import TechSettings from './TechSettings';
import {can} from 'coral-framework/services/perms';
class Configure extends Component {
constructor (props) {
@@ -118,6 +119,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);
@@ -172,6 +178,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);
@@ -17,6 +17,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
@@ -63,9 +64,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>{lang.t('modqueue.emptyqueue')}</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;
}
@@ -5,7 +5,7 @@ import styles from './BanUserDialog.css';
import Button from 'coral-ui/components/Button';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
import translations from '../../../translations';
const lang = new I18n(translations);
const onBanClick = (userId, commentId, commentStatus, handleBanUser, rejectComment, handleClose) => (e) => {
@@ -11,7 +11,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();
@@ -66,16 +67,19 @@ const Comment = ({
lang.getLocale().replace('-', '_')
)}
</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'
@@ -161,6 +165,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'}
);
});
}
+33 -15
View File
@@ -1,7 +1,7 @@
{
"en": {
"errors": {
"NOT_AUTHORIZED": "Your username or password is not recognized by our system.",
"NOT_AUTHORIZED": "You are not authorized to perform this action.",
"LOGIN_MAXIMUM_EXCEEDED": "You have made too many unsuccessful password attempts. Please wait."
},
"community": {
@@ -10,6 +10,7 @@
"newsroom_role": "Newsroom Role",
"admin": "Administrator",
"moderator": "Moderator",
"staff": "Staff",
"role": "Select role...",
"no-results": "No users found with that user name or email address. They're hiding!",
"status": "Status",
@@ -143,19 +144,30 @@
"yes_ban_user": "Yes, Ban User"
},
"suspenduser": {
"title": "Suspend a user",
"title_0": "We noticed you rejected a username",
"description_0": "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
"title_1": "Notify the user of their temporary suspension",
"description_1": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
"title_suspend": "Suspend User",
"description_suspend": "You are suspending {0}. This comment will go to the Rejected queue, and {0} will not be allowed to like, report, reply or post until the suspension time is complete.",
"select_duration": "Select suspension duration",
"title_reject": "We noticed you rejected a username",
"description_reject": "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
"title_notify": "Notify the user of their temporary suspension",
"description_notify": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
"no_cancel": "No, cancel",
"yes_suspend": "Yes, suspend",
"send": "Send",
"bio": "bio",
"username": "username",
"email_subject": "Your account has been suspended",
"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.",
"write_message": "Write a message"
"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.",
"email_message_suspend": "Dear {0},\n\nIn accordance with {1}s community guidelines, your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation {2}.",
"write_message": "Write a message",
"one_hour": "1 hour",
"hours": "{0} hours",
"days": "{0} days",
"suspend_user": "Suspend User",
"cancel": "Cancel",
"error_email_message_empty": "You must specify an E-Mail message.",
"notify_suspend_until": "User {0} has been temporarily suspended. This suspension will automatically end {1}."
},
"dashboard": {
"next-update": "{0} minutes until next update.",
@@ -196,6 +208,7 @@
"newsroom_role": "Rol en la redacción",
"admin": "Administradora",
"moderator": "Moderadora",
"staff": "Miembro",
"role": "Seleccionar rol...",
"no-results": "No se encontraron usuarixs con ese nombre de usuario o e-mail.",
"status": "Estado",
@@ -220,19 +233,24 @@
"yes_ban_user": "Si, Suspendan el usuario"
},
"suspenduser": {
"title": "Suspendiendo un usuario",
"title_0": "Esta queriendo suspender un usuario?",
"description_0": "Le gustaria suspender a esta usuaria temporarianmente por su nombre de usuario? Si lo hace sus comentarios serán escondidos temporariamente hasta que puedan reescribir su nombre de usuario.",
"title_1": "Enviarle una nota al usuario sobre su cuenta suspendida",
"description_1": "Si suspende a este usuario, su cuenta va a ser deshabilitada y todos sus comentarios escondidos del sitio.",
"title_suspend": "Suspender Usuario",
"title_reject": "Esta queriendo suspender un usuario?",
"description_reject": "Le gustaria suspender a esta usuaria temporarianmente por su nombre de usuario? Si lo hace sus comentarios serán escondidos temporariamente hasta que puedan reescribir su nombre de usuario.",
"title_notify": "Enviarle una nota al usuario sobre su cuenta suspendida",
"description_notify": "Si suspende a este usuario, su cuenta va a ser deshabilitada y todos sus comentarios escondidos del sitio.",
"no_cancel": "No, cancelar",
"yes_suspend": "Si, suspender",
"send": "Enviar",
"username": "nombre de usuario",
"email_subject": "Su cuenta ha sido suspendida temporariamente",
"email": "Otra persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo a moderator@newsorg.com si tiene alguna pregunta o preocupación",
"email_message_reject": "Otra persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo a moderator@newsorg.com si tiene alguna pregunta o preocupación",
"write_message": "Escribir un mensaje",
"loading": "Cargando resultados"
"loading": "Cargando resultados",
"hour": "una hora",
"hours": "{0} horas",
"days": "{0} días",
"suspend_user": "Suspender",
"cancel": "Cancelar"
},
"modqueue": {
"all": "todos",
@@ -2,62 +2,60 @@ import React from 'react';
const lang = new I18n(translations);
import Stream from '../containers/Stream';
import Slot from 'coral-framework/components/Slot';
import {can} from 'coral-framework/services/perms';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations';
import {TabBar, Tab, TabContent, Button} from 'coral-ui';
import Count from 'coral-plugin-comment-count/CommentCount';
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
import ConfigureStreamContainer
from 'coral-configure/containers/ConfigureStreamContainer';
export default class Embed extends React.Component {
changeTab = (tab) => {
switch(tab) {
switch (tab) {
case 0:
this.props.setActiveTab('stream');
break;
case 1:
this.props.setActiveTab('profile');
// TODO: move data fetching to profile container.
// TODO: move data fetching to profile container.
this.props.data.refetch();
break;
case 2:
this.props.setActiveTab('config');
// TODO: move data fetching to config container.
// TODO: move data fetching to config container.
this.props.data.refetch();
break;
}
}
};
handleShowProfile = () => this.props.setActiveTab('profile');
render () {
render() {
const {activeTab, viewAllComments, commentId} = this.props;
const {asset: {totalCommentCount}} = this.props.root;
const {loggedIn, isAdmin} = this.props.auth;
const {user} = this.props.auth;
return (
<div>
<div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={totalCommentCount}/></Tab>
<Tab><Count count={totalCommentCount} /></Tab>
<Tab>{lang.t('myProfile')}</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
<Tab restricted={!can(user, 'UPDATE_CONFIG')}>Configure Stream</Tab>
</TabBar>
{
commentId &&
<Button
cStyle='darkGrey'
style={{float: 'right'}}
onClick={viewAllComments}
>
{lang.t('showAllComments')}
</Button>
}
<Slot fill="embed"/>
{commentId &&
<Button
cStyle="darkGrey"
style={{float: 'right'}}
onClick={viewAllComments}
>
{lang.t('showAllComments')}
</Button>}
<Slot fill="embed" />
<TabContent show={activeTab === 'stream'}>
<Stream data={this.props.data} root={this.props.root} />
</TabContent>
@@ -65,9 +63,7 @@ export default class Embed extends React.Component {
<ProfileContainer />
</TabContent>
<TabContent show={activeTab === 'config'}>
<RestrictedContent restricted={!loggedIn}>
<ConfigureStreamContainer />
</RestrictedContent>
<ConfigureStreamContainer />
</TabContent>
</div>
</div>
@@ -79,5 +75,5 @@ Embed.propTypes = {
data: React.PropTypes.shape({
loading: React.PropTypes.bool,
error: React.PropTypes.object
}).isRequired,
}).isRequired
};
@@ -1,15 +1,22 @@
import React, {PropTypes} from 'react';
import LoadMore from './LoadMore';
import NewCount from './NewCount';
import Comment from '../containers/Comment';
import SuspendedAccount from './SuspendedAccount';
import Slot from 'coral-framework/components/Slot';
import InfoBox from 'coral-plugin-infobox/InfoBox';
import {can} from 'coral-framework/services/perms';
import I18n from 'coral-framework/modules/i18n/i18n';
import {ModerationLink} from 'coral-plugin-moderation';
import translations from 'coral-framework/translations';
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 ChangeUsernameContainer
from 'coral-sign-in/containers/ChangeUsernameContainer';
const lang = new I18n(translations);
class Stream extends React.Component {
setActiveReplyBox = (reactKey) => {
@@ -34,7 +41,7 @@ class Stream extends React.Component {
removeCommentTag,
pluginProps,
ignoreUser,
auth: {loggedIn, isAdmin, user},
auth: {loggedIn, user},
commentCountCache,
editName
} = this.props;
@@ -46,6 +53,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 &&
@@ -56,18 +64,12 @@ class Stream extends React.Component {
? asset.comments[0].created_at
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
const commentIsIgnored = (comment) => {
return (
me &&
me.ignoredUsers &&
me.ignoredUsers.find((u) => u.id === comment.user.id)
);
return me && me.ignoredUsers && me.ignoredUsers.find((u) => u.id === comment.user.id);
};
return (
<div id="stream">
<Slot
fill="stream"
/>
<Slot fill="stream" />
{open
? <div id="commentBox">
@@ -79,36 +81,54 @@ 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 && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
{!loggedIn &&
<Button
id="coralSignInButton"
onClick={this.props.showSignInDialog}
full
>
Sign in to comment
</Button>}
{loggedIn &&
user &&
<ChangeUsernameContainer loggedIn={loggedIn} user={user} />}
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={can(user, 'MODERATE_COMMENTS')} />}
{/* the highlightedComment is isolated after the user followed a permalink */}
{highlightedComment
? <Comment
data={this.props.data}
@@ -144,38 +164,41 @@ class Stream extends React.Component {
setCommentCountCache={this.props.setCommentCountCache}
/>
<div className="embed__stream">
{comments.map((comment) => {
return commentIsIgnored(comment)
? <IgnoredCommentTombstone key={comment.id} />
: <Comment
data={this.props.data}
root={this.props.root}
disableReply={!open}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
commentIsIgnored={commentIsIgnored}
loadMore={loadMore}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
reactKey={comment.id}
comment={comment}
pluginProps={pluginProps}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={this.props.editComment}
/>;
})}
{comments.map(
(comment) => {
return (commentIsIgnored(comment)
? <IgnoredCommentTombstone key={comment.id} />
: <Comment
data={this.props.data}
root={this.props.root}
disableReply={!open}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
commentIsIgnored={commentIsIgnored}
loadMore={loadMore}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
reactKey={comment.id}
comment={comment}
pluginProps={pluginProps}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={this.props.editComment}
/>
);
}
)}
</div>
<LoadMore
topLevel={true}
@@ -204,7 +227,7 @@ Stream.propTypes = {
ignoreUser: React.PropTypes.func,
// edit a comment, passed (id, asset_id, { body })
editComment: React.PropTypes.func
editComment: React.PropTypes.func,
};
export default Stream;
@@ -1,8 +1,3 @@
.message {
background: #D8D8D8;
padding: 25px;
}
.editNameInput {
margin-top: 10px;
margin-bottom: 10px;
@@ -2,9 +2,10 @@ import React, {Component, PropTypes} from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations.json';
const lang = new I18n(translations);
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 {
@@ -38,7 +39,7 @@ class SuspendedAccount extends Component {
const {canEditName} = this.props;
const {username, alert} = this.state;
return <div className={styles.message}>
return <RestrictedMessageBox>
<span>{
canEditName ?
lang.t('editName.msg')
@@ -72,7 +73,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
@@ -187,7 +187,7 @@ export const fetchSignUpFacebook = () => (dispatch) => {
);
};
export const facebookCallback = (err, data) => (dispatch) => {
export const facebookCallback = (err, data) => (dispatch, getState) => {
if (err) {
dispatch(signInFacebookFailure(err));
return;
@@ -196,8 +196,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;
@@ -294,8 +296,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,13 +1,13 @@
import React from 'react';
import styles from './RestrictedContent.css';
import RestrictedMessageBox from './RestrictedMessageBox';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations.json';
const lang = new I18n(translations);
export default ({children, restricted, message = lang.t('contentNotAvailable'), restrictedComp}) => {
if (restricted) {
return restrictedComp ? restrictedComp : messageBox(message);
return restrictedComp ? restrictedComp : <RestrictedMessageBox message={message} />;
} else {
return (
<div>
@@ -17,4 +17,3 @@ export default ({children, restricted, message = lang.t('contentNotAvailable'),
}
};
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>;
+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;
});
};
+2
View File
@@ -7,6 +7,8 @@
"successNameUpdate": "Your username has been updated",
"contentNotAvailable": "This content is not available",
"bannedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Report, or write comments. Please contact us if you have any questions.",
"temporarilySuspended": "In accordance with {0}'s community guidlines, your account has been temporarily suspended. Please rejoin the conversation {1}.",
"editName": {
"msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. Please contact us if you have any questions.",
"label": "New Username",
+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]);
}
+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"