mirror of
https://github.com/wassname/talk.git
synced 2026-07-26 13:37:38 +08:00
Merge branch 'master' into i18n-refactor
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
-3
@@ -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;
|
||||
}
|
||||
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
-5
@@ -1,8 +1,3 @@
|
||||
.message {
|
||||
background: #D8D8D8;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.editNameInput {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
+5
-7
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
const intersection = require('lodash/intersection');
|
||||
const check = (user, roles) => {
|
||||
return intersection(roles, user.roles).length > 0;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
check
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
<%= body %>
|
||||
@@ -1 +1 @@
|
||||
<%= body %>
|
||||
<%= body.replace(/\n/g, '<br />') %>
|
||||
|
||||
+45
-17
@@ -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 () {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user