mirror of
https://github.com/wassname/talk.git
synced 2026-06-30 06:14:52 +08:00
merge master
This commit is contained in:
@@ -10,6 +10,7 @@ const enabled = require('debug').enabled;
|
||||
const errors = require('./errors');
|
||||
const {createGraphOptions} = require('./graph');
|
||||
const apollo = require('graphql-server-express');
|
||||
const accepts = require('accepts');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -31,8 +32,39 @@ app.use(helmet({
|
||||
frameguard: false
|
||||
}));
|
||||
app.use(bodyParser.json());
|
||||
|
||||
//==============================================================================
|
||||
// STATIC FILES
|
||||
//==============================================================================
|
||||
|
||||
// If the application is in production mode, then add gzip rewriting for the
|
||||
// content.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.get('*.js', (req, res, next) => {
|
||||
const accept = accepts(req);
|
||||
if (accept.encoding(['gzip']) === 'gzip') {
|
||||
|
||||
// Adjsut the headers on the request by adding a content type header
|
||||
// because express won't be able to detect the mime-type with the .gz
|
||||
// extension and we need to decalre support for the gzip encoding.
|
||||
res.set('Content-Type', 'application/javascript');
|
||||
res.set('Content-Encoding', 'gzip');
|
||||
|
||||
// Rewrite the url so that the gzip version will be served instead.
|
||||
req.url = `${req.url}.gz`;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.use('/client', express.static(path.join(__dirname, 'dist')));
|
||||
app.use('/public', express.static(path.join(__dirname, 'public')));
|
||||
|
||||
//==============================================================================
|
||||
// VIEW CONFIGURATION
|
||||
//==============================================================================
|
||||
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
@@ -69,9 +101,11 @@ app.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions));
|
||||
if (app.get('env') !== 'production') {
|
||||
|
||||
// Interactive graphiql interface.
|
||||
app.use('/api/v1/graph/iql', apollo.graphiqlExpress({
|
||||
endpointURL: '/api/v1/graph/ql'
|
||||
}));
|
||||
app.use('/api/v1/graph/iql', (req, res) => {
|
||||
res.render('graphiql', {
|
||||
endpointURL: '/api/v1/graph/ql'
|
||||
});
|
||||
});
|
||||
|
||||
// GraphQL documention.
|
||||
app.get('/admin/docs', (req, res) => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -18,3 +24,6 @@ export const hideShortcutsNote = () => {
|
||||
|
||||
return {type: actions.HIDE_SHORTCUTS_NOTE};
|
||||
};
|
||||
|
||||
export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId});
|
||||
export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL});
|
||||
|
||||
@@ -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);
|
||||
@@ -4,10 +4,11 @@ import {IndexLink, Link} from 'react-router';
|
||||
import styles from './Drawer.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
|
||||
const CoralDrawer = ({handleLogout, restricted = false}) => (
|
||||
const CoralDrawer = ({handleLogout, auth}) => (
|
||||
<Drawer className={styles.header}>
|
||||
{ !restricted ?
|
||||
{ auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
|
||||
<div>
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
@@ -16,28 +17,37 @@ const CoralDrawer = ({handleLogout, restricted = false}) => (
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
<Link
|
||||
className={styles.navLink}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.moderate')}
|
||||
</Link>
|
||||
<Link className={styles.navLink}
|
||||
to="/admin/stories"
|
||||
{
|
||||
can(auth.user, 'MODERATE_COMMENTS') && (
|
||||
<Link
|
||||
className={styles.navLink}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.moderate')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
<Link className={styles.navLink}
|
||||
to="/admin/stories"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.stories')}
|
||||
</Link>
|
||||
<Link className={styles.navLink}
|
||||
to="/admin/community"
|
||||
activeClassName={styles.active}>
|
||||
to="/admin/community"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.community')}
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.navLink}
|
||||
to="/admin/configure"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.configure')}
|
||||
</Link>
|
||||
{
|
||||
can(auth.user, 'UPDATE_CONFIG') &&
|
||||
(
|
||||
<Link
|
||||
className={styles.navLink}
|
||||
to="/admin/configure"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.configure')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
<a onClick={handleLogout}>Sign Out</a>
|
||||
<span>{`v${process.env.VERSION}`}</span>
|
||||
</Navigation>
|
||||
|
||||
@@ -5,50 +5,66 @@ import styles from './Header.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import {Logo} from './Logo';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
|
||||
const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false}) => (
|
||||
const CoralHeader = ({
|
||||
handleLogout,
|
||||
showShortcuts = () => {},
|
||||
auth
|
||||
}) => (
|
||||
<Header className={styles.header}>
|
||||
<Logo className={styles.logo} />
|
||||
{
|
||||
!restricted ?
|
||||
<div>
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
id='dashboardNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
<Link
|
||||
id='moderateNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.moderate')}
|
||||
</Link>
|
||||
<Link
|
||||
id='streamsNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/stories"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.stories')}
|
||||
</Link>
|
||||
<Link
|
||||
id='communityNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/community"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.community')}
|
||||
</Link>
|
||||
<Link
|
||||
id='configureNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/configure"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.configure')}
|
||||
</Link>
|
||||
</Navigation>
|
||||
{
|
||||
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
id='dashboardNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
{
|
||||
can(auth.user, 'MODERATE_COMMENTS') && (
|
||||
<Link
|
||||
id='moderateNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.moderate')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
<Link
|
||||
id='streamsNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/stories"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.stories')}
|
||||
</Link>
|
||||
<Link
|
||||
id='communityNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/community"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.community')}
|
||||
</Link>
|
||||
{
|
||||
can(auth.user, 'UPDATE_CONFIG') && (
|
||||
<Link
|
||||
id='configureNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/configure"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.configure')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</Navigation>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className={styles.rightPanel}>
|
||||
<ul>
|
||||
<li className={styles.settings}>
|
||||
@@ -66,16 +82,13 @@ const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</Header>
|
||||
);
|
||||
|
||||
CoralHeader.propTypes = {
|
||||
auth: PropTypes.object,
|
||||
showShortcuts: PropTypes.func,
|
||||
handleLogout: PropTypes.func.isRequired,
|
||||
restricted: PropTypes.bool // hide elemnts from a user that's logged out
|
||||
handleLogout: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
@@ -4,12 +4,16 @@ import Header from './Header';
|
||||
import Drawer from './Drawer';
|
||||
import styles from './Layout.css';
|
||||
|
||||
const Layout = ({children, handleLogout = () => {}, toggleShortcutModal, restricted = false, ...props}) => (
|
||||
const Layout = ({
|
||||
children,
|
||||
handleLogout = () => {},
|
||||
toggleShortcutModal,
|
||||
restricted = false,
|
||||
...props}) => (
|
||||
<LayoutMDL fixedDrawer>
|
||||
<Header
|
||||
handleLogout={handleLogout}
|
||||
showShortcuts={toggleShortcutModal}
|
||||
restricted={restricted}
|
||||
{...props} />
|
||||
<Drawer handleLogout={handleLogout} restricted={restricted} {...props} />
|
||||
<div className={styles.layout}>
|
||||
|
||||
@@ -3,3 +3,7 @@ export const SINGLE_VIEW = 'SINGLE_VIEW';
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
|
||||
export const SHOW_SUSPEND_USER_DIALOG = 'SHOW_SUSPEND_USER_DIALOG';
|
||||
export const HIDE_SUSPEND_USER_DIALOG = 'HIDE_SUSPEND_USER_DIALOG';
|
||||
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
|
||||
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
|
||||
|
||||
@@ -3,7 +3,7 @@ import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
|
||||
import {modUserFlaggedQuery} from 'coral-admin/src/graphql/queries';
|
||||
import {banUser, setUserStatus, suspendUser} from 'coral-admin/src/graphql/mutations';
|
||||
import {banUser, setUserStatus, rejectUsername} from 'coral-admin/src/graphql/mutations';
|
||||
|
||||
import {
|
||||
fetchAccounts,
|
||||
@@ -113,7 +113,7 @@ class CommunityContainer extends Component {
|
||||
error={data.error}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
rejectUsername={props.rejectUsername}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
/>
|
||||
<BanUserDialog
|
||||
@@ -126,7 +126,7 @@ class CommunityContainer extends Component {
|
||||
open={community.suspendDialog}
|
||||
handleClose={props.hideSuspendUserDialog}
|
||||
user={community.user}
|
||||
suspendUser={props.suspendUser}
|
||||
rejectUsername={props.rejectUsername}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -165,5 +165,5 @@ export default compose(
|
||||
modUserFlaggedQuery,
|
||||
banUser,
|
||||
setUserStatus,
|
||||
suspendUser
|
||||
rejectUsername
|
||||
)(CommunityContainer);
|
||||
|
||||
@@ -65,6 +65,7 @@ class Table extends Component {
|
||||
label={lang.t('community.role')}
|
||||
onChange={(role) => this.onRoleChange(row.id, role)}>
|
||||
<Option value={''}>.</Option>
|
||||
<Option value={'STAFF'}>{lang.t('community.staff')}</Option>
|
||||
<Option value={'MODERATOR'}>{lang.t('community.moderator')}</Option>
|
||||
<Option value={'ADMIN'}>{lang.t('community.admin')}</Option>
|
||||
</SelectField>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import styles from '../Community.css';
|
||||
import BanUserButton from '../../../components/BanUserButton';
|
||||
import BanUserButton from './BanUserButton';
|
||||
import {Button} from 'coral-ui';
|
||||
import {menuActionsMap} from '../../../containers/ModerationQueue/helpers/moderationQueueActionsMap';
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@ const lang = new I18n(translations);
|
||||
|
||||
const stages = [
|
||||
{
|
||||
title: 'suspenduser.title_0',
|
||||
description: 'suspenduser.description_0',
|
||||
title: 'suspenduser.title_reject',
|
||||
description: 'suspenduser.description_reject',
|
||||
options: {
|
||||
'j': 'suspenduser.no_cancel',
|
||||
'k': 'suspenduser.yes_suspend'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'suspenduser.title_1',
|
||||
description: 'suspenduser.description_1',
|
||||
title: 'suspenduser.title_notify',
|
||||
description: 'suspenduser.description_notify',
|
||||
options: {
|
||||
'j': 'bandialog.cancel',
|
||||
'k': 'suspenduser.send'
|
||||
@@ -34,11 +34,11 @@ class SuspendUserDialog extends Component {
|
||||
static propTypes = {
|
||||
stage: PropTypes.number,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired
|
||||
rejectUsername: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({email: lang.t('suspenduser.email'), about: lang.t('suspenduser.username')});
|
||||
this.setState({email: lang.t('suspenduser.email_message_reject'), about: lang.t('suspenduser.username')});
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -46,13 +46,13 @@ class SuspendUserDialog extends Component {
|
||||
* handles the possible actions for that dialog.
|
||||
*/
|
||||
onActionClick = (stage, menuOption) => () => {
|
||||
const {suspendUser, user} = this.props;
|
||||
const {rejectUsername, user} = this.props;
|
||||
const {stage} = this.state;
|
||||
|
||||
const cancel = this.props.handleClose;
|
||||
const next = () => this.setState({stage: stage + 1});
|
||||
const suspend = () => {
|
||||
suspendUser({userId: user.user.id, message: this.state.email})
|
||||
rejectUsername({id: user.user.id, message: this.state.email})
|
||||
.then(() => {
|
||||
this.props.handleClose();
|
||||
});
|
||||
@@ -79,7 +79,7 @@ class SuspendUserDialog extends Component {
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={lang.t('suspenduser.title')}>
|
||||
title={lang.t('suspenduser.suspend_user')}>
|
||||
<div className={styles.title}>
|
||||
{lang.t(stages[stage].title, lang.t('suspenduser.username'))}
|
||||
</div>
|
||||
|
||||
@@ -96,24 +96,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.inlineTextfield {
|
||||
border-color: #ccc;
|
||||
border-style: solid;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
text-align: center;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.inlineTextfield:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.charCountTexfield {
|
||||
width: 4em;
|
||||
padding: 0px;
|
||||
border-color: #ccc;
|
||||
border-style: solid;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.charCountTexfieldEnabled {
|
||||
border-color: #00796b;
|
||||
}
|
||||
|
||||
.charCountTexfield:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.changedSave {
|
||||
background-color: #00796B;
|
||||
color: white;
|
||||
|
||||
@@ -15,6 +15,7 @@ import translations from 'coral-admin/src/translations.json';
|
||||
import StreamSettings from './StreamSettings';
|
||||
import ModerationSettings from './ModerationSettings';
|
||||
import TechSettings from './TechSettings';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
|
||||
class Configure extends Component {
|
||||
constructor (props) {
|
||||
@@ -118,6 +119,11 @@ class Configure extends Component {
|
||||
render () {
|
||||
const {activeSection} = this.state;
|
||||
const section = this.getSection(activeSection);
|
||||
const {auth: {user}} = this.props;
|
||||
|
||||
if (!can(user, 'UPDATE_CONFIG')) {
|
||||
return <p>You must be an administrator to access config settings. Please find the nearest Admin and ask them to level you up!</p>;
|
||||
}
|
||||
|
||||
const showSave = Object.keys(this.state.errors).reduce(
|
||||
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
|
||||
@@ -172,6 +178,7 @@ class Configure extends Component {
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
auth: state.auth.toJS(),
|
||||
settings: state.settings.toJS()
|
||||
});
|
||||
export default connect(mapStateToProps)(Configure);
|
||||
|
||||
@@ -27,6 +27,12 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
|
||||
const on = styles.enabledSetting;
|
||||
const off = styles.disabledSetting;
|
||||
|
||||
const onChangeEditCommentWindowLength = (e) => {
|
||||
const value = e.target.value;
|
||||
const valueAsNumber = parseFloat(value);
|
||||
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
|
||||
updateSettings({editCommentWindowLength: milliseconds || value});
|
||||
};
|
||||
return (
|
||||
<div className={styles.Configure}>
|
||||
<Card className={`${styles.configSetting} ${settings.requireEmailConfirmation ? on : off}`}>
|
||||
@@ -72,6 +78,27 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
|
||||
bannedWords={settings.wordlist.banned}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
onChangeWordlist={onChangeWordlist} />
|
||||
|
||||
{/* Edit Comment Timeframe */}
|
||||
<Card className={styles.configSetting}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.edit-comment-timeframe-heading')}</div>
|
||||
<p>
|
||||
{lang.t('configure.edit-comment-timeframe-text-pre')}
|
||||
|
||||
<input
|
||||
style={{width: '3em'}}
|
||||
className={styles.inlineTextfield}
|
||||
type="number"
|
||||
min="0"
|
||||
onChange={onChangeEditCommentWindowLength}
|
||||
placeholder="30"
|
||||
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
|
||||
pattern='[0-9]+([\.][0-9]*)?'
|
||||
/>
|
||||
|
||||
{lang.t('configure.edit-comment-timeframe-text-post')}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
|
||||
<span>{lang.t('configure.comment-count-text-pre')}</span>
|
||||
<input type='text'
|
||||
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
|
||||
className={`${styles.inlineTextfield} ${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
|
||||
htmlFor='charCount'
|
||||
onChange={updateCharCount(updateSettings, settingsError)}
|
||||
value={settings.charCount}
|
||||
|
||||
@@ -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,24 +1,42 @@
|
||||
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';
|
||||
import {toggleModal, singleView, showBanUserDialog, hideBanUserDialog, hideShortcutsNote} from 'actions/moderation';
|
||||
import {
|
||||
toggleModal,
|
||||
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';
|
||||
import NotFoundAsset from './components/NotFoundAsset';
|
||||
import ModerationKeysModal from '../../components/ModerationKeysModal';
|
||||
import UserDetail from './UserDetail';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
class ModerationContainer extends Component {
|
||||
state = {
|
||||
@@ -82,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+/');
|
||||
@@ -111,7 +156,7 @@ class ModerationContainer extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const {data, moderation, settings, assets, onClose, ...props} = this.props;
|
||||
const {data, moderation, settings, assets, onClose, viewUserDetail, hideUserDetail, ...props} = this.props;
|
||||
const providedAssetId = this.props.params.id;
|
||||
const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
|
||||
|
||||
@@ -175,12 +220,16 @@ 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}
|
||||
/>
|
||||
<BanUserDialog
|
||||
open={moderation.banDialog}
|
||||
@@ -192,11 +241,24 @@ class ModerationContainer extends Component {
|
||||
showRejectedNote={moderation.showRejectedNote}
|
||||
rejectComment={props.rejectComment}
|
||||
/>
|
||||
<ModerationKeysModal
|
||||
<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}
|
||||
open={moderation.modalOpen}
|
||||
onClose={onClose}/>
|
||||
{moderation.userDetailId && (
|
||||
<UserDetail
|
||||
id={moderation.userDetailId}
|
||||
hideUserDetail={hideUserDetail} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -205,24 +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()),
|
||||
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);
|
||||
|
||||
@@ -12,10 +12,12 @@ const lang = new I18n(translations);
|
||||
class ModerationQueue extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
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
|
||||
@@ -33,7 +35,17 @@ class ModerationQueue extends React.Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const {comments, selectedIndex, commentCount, singleView, loadMore, activeTab, sort, ...props} = this.props;
|
||||
const {
|
||||
comments,
|
||||
selectedIndex,
|
||||
commentCount,
|
||||
singleView,
|
||||
loadMore,
|
||||
activeTab,
|
||||
sort,
|
||||
viewUserDetail,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
|
||||
@@ -49,11 +61,14 @@ class ModerationQueue extends React.Component {
|
||||
selected={i === selectedIndex}
|
||||
suspectWords={props.suspectWords}
|
||||
bannedWords={props.bannedWords}
|
||||
viewUserDetail={viewUserDetail}
|
||||
actions={actionsMap[status]}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment}
|
||||
currentAsset={props.currentAsset}
|
||||
currentUserId={this.props.currentUserId}
|
||||
/>;
|
||||
})
|
||||
: <EmptyCard>{lang.t('modqueue.emptyqueue')}</EmptyCard>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.copyButton {
|
||||
float: right;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
.memberSince {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.small {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
|
||||
.stat {
|
||||
margin: 0 4px 12px;
|
||||
}
|
||||
|
||||
.stat:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat p:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.profileEmail {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button, Drawer} from 'coral-ui';
|
||||
import styles from './UserDetail.css';
|
||||
import {compose} from 'react-apollo';
|
||||
import {getUserDetail} from 'coral-admin/src/graphql/queries';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
|
||||
class UserDetail extends React.Component {
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
hideUserDetail: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
copyPermalink = () => {
|
||||
this.profile.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (e) {
|
||||
|
||||
/* nothing */
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {data, hideUserDetail} = this.props;
|
||||
|
||||
if (!('user' in data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {user, totalComments, rejectedComments} = data;
|
||||
const localProfile = user.profiles.find((p) => p.provider === 'local');
|
||||
let profile;
|
||||
if (localProfile) {
|
||||
profile = localProfile.id;
|
||||
}
|
||||
|
||||
let rejectedPercent = rejectedComments / totalComments;
|
||||
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
|
||||
|
||||
// if totalComments is 0, you're dividing by zero, which is naughty
|
||||
rejectedPercent = 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer handleClickOutside={hideUserDetail}>
|
||||
<h3>{user.username}</h3>
|
||||
<Button className={styles.copyButton} onClick={this.copyPermalink}>Copy</Button>
|
||||
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
|
||||
<Slot fill="userProfile" user={user} />
|
||||
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
|
||||
<hr/>
|
||||
<p>
|
||||
<strong>Account summary</strong>
|
||||
<br/><small className={styles.small}>Data represents the last six months of activity</small>
|
||||
</p>
|
||||
<div className={styles.stats}>
|
||||
<div className={styles.stat}>
|
||||
<p>Total Comments</p>
|
||||
<p>{totalComments}</p>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<p>Reject Rate</p>
|
||||
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
getUserDetail
|
||||
)(UserDetail);
|
||||
+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
-1
@@ -5,7 +5,7 @@ import styles from './BanUserDialog.css';
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations';
|
||||
import translations from '../../../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const onBanClick = (userId, commentId, commentStatus, handleBanUser, rejectComment, handleClose) => (e) => {
|
||||
@@ -11,7 +11,8 @@ import Highlighter from 'react-highlight-words';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import {getActionSummary} from 'coral-framework/utils';
|
||||
import ActionButton from 'coral-admin/src/components/ActionButton';
|
||||
import BanUserButton from 'coral-admin/src/components/BanUserButton';
|
||||
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
|
||||
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
@@ -22,6 +23,7 @@ const lang = new I18n(translations);
|
||||
const Comment = ({
|
||||
actions = [],
|
||||
comment,
|
||||
viewUserDetail,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
...props
|
||||
@@ -56,7 +58,7 @@ const Comment = ({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>
|
||||
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
|
||||
{comment.user.name}
|
||||
</span>
|
||||
<span className={styles.created}>
|
||||
@@ -65,16 +67,19 @@ const Comment = ({
|
||||
lang.getLocale().replace('-', '_')
|
||||
)}
|
||||
</span>
|
||||
<BanUserButton
|
||||
user={comment.user}
|
||||
onClick={() =>
|
||||
props.showBanUserDialog(
|
||||
comment.user,
|
||||
comment.id,
|
||||
comment.status,
|
||||
comment.status !== 'REJECTED'
|
||||
)}
|
||||
/>
|
||||
{props.currentUserId !== comment.user.id &&
|
||||
<ActionsMenu icon="not_interested">
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}>
|
||||
Suspend User</ActionsMenuItem>
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
|
||||
Ban User
|
||||
</ActionsMenuItem>
|
||||
</ActionsMenu>
|
||||
}
|
||||
<CommentType type={commentType} />
|
||||
</div>
|
||||
{comment.user.status === 'banned'
|
||||
@@ -154,19 +159,24 @@ const Comment = ({
|
||||
};
|
||||
|
||||
Comment.propTypes = {
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
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,
|
||||
actions: PropTypes.array,
|
||||
created_at: PropTypes.string.isRequired,
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
status: PropTypes.string
|
||||
}),
|
||||
}).isRequired,
|
||||
asset: PropTypes.shape({
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
|
||||
@@ -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;
|
||||
@@ -424,6 +424,17 @@ span {
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 0, 0, .1);
|
||||
}
|
||||
}
|
||||
|
||||
.external {
|
||||
font-size: .7em;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
|
||||
import MOD_QUEUE_LOAD_MORE from './loadMore.graphql';
|
||||
import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql';
|
||||
import METRICS from './metricsQuery.graphql';
|
||||
import USER_DETAIL from './userDetail.graphql';
|
||||
import GET_QUEUE_COUNTS from './getQueueCounts.graphql';
|
||||
|
||||
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
|
||||
@@ -95,6 +96,14 @@ export const modQueueResort = (id, fetchMore) => (sort) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserDetail = graphql(USER_DETAIL, {
|
||||
options: ({id}) => {
|
||||
return {
|
||||
variables: {author_id: id}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const getQueueCounts = graphql(GET_QUEUE_COUNTS, {
|
||||
options: ({params: {id = null}}) => {
|
||||
return {
|
||||
|
||||
@@ -62,4 +62,7 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
|
||||
asset_id: $asset_id,
|
||||
statuses: [NONE, PREMOD]
|
||||
})
|
||||
settings {
|
||||
organizationName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
query UserDetail ($author_id: ID!) {
|
||||
user(id: $author_id) {
|
||||
id
|
||||
username
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
}
|
||||
totalComments: commentCount(query: {author_id: $author_id})
|
||||
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
|
||||
}
|
||||
@@ -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,14 +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) {
|
||||
@@ -26,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);
|
||||
@@ -38,6 +60,10 @@ export default function moderation (state = initialState, action) {
|
||||
case actions.HIDE_SHORTCUTS_NOTE:
|
||||
return state
|
||||
.set('shortcutsNoteVisible', 'hide');
|
||||
case actions.VIEW_USER_DETAIL:
|
||||
return state.set('userDetailId', action.userId);
|
||||
case actions.HIDE_USER_DETAIL:
|
||||
return state.set('userDetailId', null);
|
||||
default :
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"en": {
|
||||
"errors": {
|
||||
"NOT_AUTHORIZED": "Your username or password is not recognized by our system.",
|
||||
"NOT_AUTHORIZED": "You are not authorized to perform this action.",
|
||||
"LOGIN_MAXIMUM_EXCEEDED": "You have made too many unsuccessful password attempts. Please wait."
|
||||
},
|
||||
"community": {
|
||||
@@ -10,6 +10,7 @@
|
||||
"newsroom_role": "Newsroom Role",
|
||||
"admin": "Administrator",
|
||||
"moderator": "Moderator",
|
||||
"staff": "Staff",
|
||||
"role": "Select role...",
|
||||
"no-results": "No users found with that user name or email address. They're hiding!",
|
||||
"status": "Status",
|
||||
@@ -106,6 +107,9 @@
|
||||
"include-text": "Include your text here.",
|
||||
"enable-premod-links": "Pre-Moderate Comments Containing Links",
|
||||
"enable-premod-links-text": "Moderators must approve any comment containing a link before its published.",
|
||||
"edit-comment-timeframe-heading": "Edit Comment Timeframe",
|
||||
"edit-comment-timeframe-text-pre": "Commenters will have",
|
||||
"edit-comment-timeframe-text-post": "seconds to edit their comments.",
|
||||
"comment-settings": "Settings",
|
||||
"embed-comment-stream": "Embed Stream",
|
||||
"banned-word-text": "Comments which contain these words or phrases (not case-sensitive) will be automatically removed from the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
|
||||
@@ -140,19 +144,30 @@
|
||||
"yes_ban_user": "Yes, Ban User"
|
||||
},
|
||||
"suspenduser": {
|
||||
"title": "Suspend a user",
|
||||
"title_0": "We noticed you rejected a username",
|
||||
"description_0": "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
|
||||
"title_1": "Notify the user of their temporary suspension",
|
||||
"description_1": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
|
||||
"title_suspend": "Suspend User",
|
||||
"description_suspend": "You are suspending {0}. This comment will go to the Rejected queue, and {0} will not be allowed to like, report, reply or post until the suspension time is complete.",
|
||||
"select_duration": "Select suspension duration",
|
||||
"title_reject": "We noticed you rejected a username",
|
||||
"description_reject": "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
|
||||
|
||||
"title_notify": "Notify the user of their temporary suspension",
|
||||
"description_notify": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
|
||||
"no_cancel": "No, cancel",
|
||||
"yes_suspend": "Yes, suspend",
|
||||
"send": "Send",
|
||||
"bio": "bio",
|
||||
"username": "username",
|
||||
"email_subject": "Your account has been suspended",
|
||||
"email": "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns.",
|
||||
"write_message": "Write a message"
|
||||
"email_message_reject": "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns.",
|
||||
"email_message_suspend": "Dear {0},\n\nIn accordance with {1}’s community guidelines, your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation {2}.",
|
||||
"write_message": "Write a message",
|
||||
"one_hour": "1 hour",
|
||||
"hours": "{0} hours",
|
||||
"days": "{0} days",
|
||||
"suspend_user": "Suspend User",
|
||||
"cancel": "Cancel",
|
||||
"error_email_message_empty": "You must specify an E-Mail message.",
|
||||
"notify_suspend_until": "User {0} has been temporarily suspended. This suspension will automatically end {1}."
|
||||
},
|
||||
"dashboard": {
|
||||
"next-update": "{0} minutes until next update.",
|
||||
@@ -193,6 +208,7 @@
|
||||
"newsroom_role": "Rol en la redacción",
|
||||
"admin": "Administradora",
|
||||
"moderator": "Moderadora",
|
||||
"staff": "Miembro",
|
||||
"role": "Seleccionar rol...",
|
||||
"no-results": "No se encontraron usuarixs con ese nombre de usuario o e-mail.",
|
||||
"status": "Estado",
|
||||
@@ -217,19 +233,24 @@
|
||||
"yes_ban_user": "Si, Suspendan el usuario"
|
||||
},
|
||||
"suspenduser": {
|
||||
"title": "Suspendiendo un usuario",
|
||||
"title_0": "Esta queriendo suspender un usuario?",
|
||||
"description_0": "Le gustaria suspender a esta usuaria temporarianmente por su nombre de usuario? Si lo hace sus comentarios serán escondidos temporariamente hasta que puedan reescribir su nombre de usuario.",
|
||||
"title_1": "Enviarle una nota al usuario sobre su cuenta suspendida",
|
||||
"description_1": "Si suspende a este usuario, su cuenta va a ser deshabilitada y todos sus comentarios escondidos del sitio.",
|
||||
"title_suspend": "Suspender Usuario",
|
||||
"title_reject": "Esta queriendo suspender un usuario?",
|
||||
"description_reject": "Le gustaria suspender a esta usuaria temporarianmente por su nombre de usuario? Si lo hace sus comentarios serán escondidos temporariamente hasta que puedan reescribir su nombre de usuario.",
|
||||
"title_notify": "Enviarle una nota al usuario sobre su cuenta suspendida",
|
||||
"description_notify": "Si suspende a este usuario, su cuenta va a ser deshabilitada y todos sus comentarios escondidos del sitio.",
|
||||
"no_cancel": "No, cancelar",
|
||||
"yes_suspend": "Si, suspender",
|
||||
"send": "Enviar",
|
||||
"username": "nombre de usuario",
|
||||
"email_subject": "Su cuenta ha sido suspendida temporariamente",
|
||||
"email": "Otra persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo a moderator@newsorg.com si tiene alguna pregunta o preocupación",
|
||||
"email_message_reject": "Otra persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo a moderator@newsorg.com si tiene alguna pregunta o preocupación",
|
||||
"write_message": "Escribir un mensaje",
|
||||
"loading": "Cargando resultados"
|
||||
"loading": "Cargando resultados",
|
||||
"hour": "una hora",
|
||||
"hours": "{0} horas",
|
||||
"days": "{0} días",
|
||||
"suspend_user": "Suspender",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"modqueue": {
|
||||
"all": "todos",
|
||||
@@ -294,6 +315,9 @@
|
||||
"embed-comment-stream": "Colocar Hilo de Comentarios",
|
||||
"enable-premod-links": "Pre-Moderar Commentarios que contienen Enlaces",
|
||||
"enable-premod-links-text": "Los y las Moderadoras deben aprobar cualquier comentario que contengan links antes de su publicación.",
|
||||
"edit-comment-timeframe-heading": "Editar Tiempo de Comentario",
|
||||
"edit-comment-timeframe-text-pre": "Los comentaristas tendrán",
|
||||
"edit-comment-timeframe-text-post": "segundos para editar sus comentarios.",
|
||||
"wordlist": "Palabras Suspendidas y Sospechosas",
|
||||
"banned-word-text": "Comentarios que contengan estas palabras o frases, no separadas por comas y en mayusculas o minusuculas, serán automaticamente marcadas para separar los comentarios publicados.",
|
||||
"suspect-word-text": "Comentarios que contengan estas palabras o frases, considerando mayusculas y minusculas, serán automaticamente destacadas en los comentarios publicados. Escribir una palabra y apretar Enter o Tabulador para agergarla. Opcionalmente pegar una lista separada por coma.",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<div style="margin-left:auto; margin-right:auto; width:500px">
|
||||
<h1>Lorem ipsum</h1>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut lobortis sollicitudin eros a ornare. Curabitur dignissim vestibulum massa non rhoncus. Cras laoreet ante vel nunc hendrerit, ac imperdiet neque egestas. Suspendisse aliquet iaculis fermentum. Pellentesque interdum nec elit sed tincidunt. Donec volutpat, tellus posuere laoreet consequat, mi lacus laoreet massa, sed vehicula mauris velit non lectus. Integer non enim nec neque congue faucibus porttitor sit amet dui.</p>
|
||||
<p>Nunc pharetra orci id diam feugiat, vitae rutrum magna efficitur. Morbi porttitor blandit lorem, et facilisis tellus luctus at. Morbi tincidunt eget nisl id placerat. Nullam consectetur quam vel mauris lacinia, non consectetur est faucibus. Duis cursus auctor nulla nec sagittis. Aenean sem erat, ultrices a hendrerit consectetur, accumsan non lorem. Integer ac neque sed magna sodales vulputate at quis neque. Praesent eget ornare lacus. Donec ultricies, dolor eget commodo faucibus, arcu velit ullamcorper tellus, in cursus tellus elit sed urna. Suspendisse in consequat magna. Duis vel ullamcorper tortor, vel cursus libero. Proin et nisi luctus ligula faucibus luctus. Morbi pulvinar, justo ac feugiat elementum, libero tellus congue justo, pharetra ultrices felis felis id leo. Integer mattis quam tempus libero porta, ac pretium ligula elementum.</p>
|
||||
<div id='coralStreamEmbed'></div>
|
||||
<script type='text/javascript' src='/client/js/lib/pym.v1.min.js'></script>
|
||||
<script>
|
||||
var pymParent = new pym.Parent('coralStreamEmbed', 'index.html', {title: 'comments'});
|
||||
pymParent.onMessage('height', function(height) {document.querySelector('#coralStreamEmbed iframe').height = height + 'px'})</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -25,7 +25,7 @@ import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
|
||||
import {getEditableUntilDate} from './util';
|
||||
import styles from './Comment.css';
|
||||
|
||||
const isStaff = (tags) => !!tags.filter((i) => i.tag.name === 'STAFF').length;
|
||||
const isStaff = (tags) => tags.some((i) => i.tag.name === 'STAFF');
|
||||
|
||||
// hold actions links (e.g. Reply) along the comment footer
|
||||
const ActionButton = ({children}) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
import {can} from 'coral-framework/services/perms';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
import {TabBar, Tab, TabContent, Button} from 'coral-ui';
|
||||
@@ -9,7 +10,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 {
|
||||
@@ -38,7 +38,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}/>;
|
||||
|
||||
@@ -48,7 +48,7 @@ export default class Embed extends React.Component {
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab}>
|
||||
<Tab><Count count={totalCommentCount}/></Tab>
|
||||
<Tab>{lang.t('myProfile')}</Tab>
|
||||
<Tab restricted={!isAdmin}>Configure Stream</Tab>
|
||||
<Tab restricted={!can(user, 'UPDATE_CONFIG')}>Configure Stream</Tab>
|
||||
</TabBar>
|
||||
{
|
||||
commentId &&
|
||||
@@ -68,10 +68,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) => {
|
||||
@@ -25,7 +31,7 @@ class Stream extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
root: {asset, asset: {comments}, comment, myIgnoredUsers},
|
||||
root: {asset, asset: {comments}, comment, me},
|
||||
postComment,
|
||||
addNotification,
|
||||
postFlag,
|
||||
@@ -37,7 +43,7 @@ class Stream extends React.Component {
|
||||
removeTag,
|
||||
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 &&
|
||||
@@ -58,8 +65,9 @@ class Stream extends React.Component {
|
||||
const firstCommentDate = asset.comments[0]
|
||||
? asset.comments[0].created_at
|
||||
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
|
||||
const commentIsIgnored = (comment) =>
|
||||
myIgnoredUsers && myIgnoredUsers.includes(comment.user.id);
|
||||
const commentIsIgnored = (comment) => {
|
||||
return me && me.ignoredUsers && me.ignoredUsers.find((u) => u.id === comment.user.id);
|
||||
};
|
||||
return (
|
||||
<div id="stream">
|
||||
{open
|
||||
@@ -72,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 &&
|
||||
@@ -111,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
|
||||
@@ -150,8 +164,8 @@ class Stream extends React.Component {
|
||||
/>
|
||||
<div className="embed__stream">
|
||||
{comments.map(
|
||||
(comment) =>
|
||||
(commentIsIgnored(comment)
|
||||
(comment) => {
|
||||
return (commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone key={comment.id} />
|
||||
: <Comment
|
||||
data={this.props.data}
|
||||
@@ -180,7 +194,9 @@ class Stream extends React.Component {
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
/>)
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<LoadMore
|
||||
|
||||
-5
@@ -1,8 +1,3 @@
|
||||
.message {
|
||||
background: #D8D8D8;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.editNameInput {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
+5
-4
@@ -2,9 +2,10 @@ import React, {Component, PropTypes} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
import styles from './RestrictedContent.css';
|
||||
import styles from './SuspendAccount.css';
|
||||
import {Button} from 'coral-ui';
|
||||
import validate from '../helpers/validate';
|
||||
import validate from 'coral-framework/helpers/validate';
|
||||
import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox';
|
||||
|
||||
class SuspendedAccount extends Component {
|
||||
|
||||
@@ -38,7 +39,7 @@ class SuspendedAccount extends Component {
|
||||
const {canEditName} = this.props;
|
||||
const {username, alert} = this.state;
|
||||
|
||||
return <div className={styles.message}>
|
||||
return <RestrictedMessageBox>
|
||||
<span>{
|
||||
canEditName ?
|
||||
lang.t('editName.msg')
|
||||
@@ -72,7 +73,7 @@ class SuspendedAccount extends Component {
|
||||
</Button>
|
||||
</div> : null
|
||||
}
|
||||
</div>;
|
||||
</RestrictedMessageBox>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,10 @@ class StreamContainer extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.previousTab) {
|
||||
this.props.data.refetch();
|
||||
this.props.data.refetch()
|
||||
.then(({data: {asset: {commentCount}}}) => {
|
||||
return this.props.setCommentCountCache(commentCount);
|
||||
});
|
||||
}
|
||||
this.countPoll = setInterval(() => {
|
||||
this.getCounts(this.props.data.variables);
|
||||
@@ -205,6 +208,12 @@ const fragments = {
|
||||
}
|
||||
me {
|
||||
status
|
||||
ignoredUsers {
|
||||
id
|
||||
}
|
||||
}
|
||||
settings {
|
||||
organizationName
|
||||
}
|
||||
...${getDefinitionName(Comment.fragments.root)}
|
||||
}
|
||||
|
||||
@@ -109,19 +109,40 @@ const extension = {
|
||||
`,
|
||||
},
|
||||
mutations: {
|
||||
IgnoreUser: () => ({
|
||||
|
||||
// TODO: don't rely on refetching.
|
||||
refetchQueries: [
|
||||
'EmbedQuery', 'EmbedStreamProfileQuery',
|
||||
],
|
||||
IgnoreUser: ({variables}) => ({
|
||||
updateQueries: {
|
||||
EmbedQuery: (previousData, {mutationResult}) => {
|
||||
const ignoredUserId = variables.id;
|
||||
const response = mutationResult.data.ignoreUser;
|
||||
if (ignoredUserId && !response.errors) {
|
||||
const updated = update(previousData, {me: {ignoredUsers: {$push: [{
|
||||
id: ignoredUserId,
|
||||
__typename: 'User',
|
||||
}]}}});
|
||||
return updated;
|
||||
}
|
||||
return previousData;
|
||||
}
|
||||
}
|
||||
}),
|
||||
StopIgnoringUser: () => ({
|
||||
StopIgnoringUser: ({variables}) => ({
|
||||
updateQueries: {
|
||||
EmbedStreamProfileQuery: (previousData, {mutationResult}) => {
|
||||
const noLongerIgnoredUserId = variables.id;
|
||||
const response = mutationResult.data.stopIgnoringUser;
|
||||
if (noLongerIgnoredUserId && !response.errors) {
|
||||
|
||||
// TODO: don't rely on refetching.
|
||||
refetchQueries: [
|
||||
'EmbedQuery', 'EmbedStreamProfileQuery',
|
||||
],
|
||||
// remove noLongerIgnoredUserId from ignoredUsers
|
||||
const updated = update(previousData, {me: {ignoredUsers: {
|
||||
$apply: (ignoredUsers) => {
|
||||
return ignoredUsers.filter((u) => u.id !== noLongerIgnoredUserId);
|
||||
}
|
||||
}}});
|
||||
return updated;
|
||||
}
|
||||
return previousData;
|
||||
}
|
||||
}
|
||||
}),
|
||||
PostComment: ({
|
||||
variables: {comment: {asset_id, body, parent_id, tags = []}},
|
||||
@@ -195,7 +216,13 @@ const extension = {
|
||||
variables: {id, edit},
|
||||
}) => ({
|
||||
updateQueries: {
|
||||
EmbedQuery: (previousData, {mutationResult: {data: {editComment: {comment: {status}}}}}) => {
|
||||
EmbedQuery: (previousData, {mutationResult: {data: {editComment: {comment, errors}}}}) => {
|
||||
|
||||
// @TODO (kiwi) revisit after streamlining error handling
|
||||
if (errors && errors.length) {
|
||||
return previousData;
|
||||
}
|
||||
const {status} = comment;
|
||||
const updateCommentWithEdit = (comment, edit) => {
|
||||
const {body} = edit;
|
||||
const editedComment = update(comment, {
|
||||
|
||||
@@ -187,7 +187,7 @@ export const fetchSignUpFacebook = () => (dispatch) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const facebookCallback = (err, data) => (dispatch) => {
|
||||
export const facebookCallback = (err, data) => (dispatch, getState) => {
|
||||
if (err) {
|
||||
dispatch(signInFacebookFailure(err));
|
||||
return;
|
||||
@@ -196,8 +196,10 @@ export const facebookCallback = (err, data) => (dispatch) => {
|
||||
dispatch(handleAuthToken(data.token));
|
||||
dispatch(signInFacebookSuccess(data.user));
|
||||
dispatch(hideSignInDialog());
|
||||
dispatch(showCreateUsernameDialog());
|
||||
dispatch(hideSignInDialog());
|
||||
const {user: {canEditName, status}} = getState().auth.toJS();
|
||||
if (canEditName && status !== 'BANNED') {
|
||||
dispatch(showCreateUsernameDialog());
|
||||
}
|
||||
} catch (err) {
|
||||
dispatch(signInFacebookFailure(err));
|
||||
return;
|
||||
@@ -294,8 +296,7 @@ export const checkLogin = () => (dispatch) => {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
|
||||
const isAdmin = !!result.user.roles.filter((i) => i === 'ADMIN').length;
|
||||
dispatch(checkLoginSuccess(result.user, isAdmin));
|
||||
dispatch(checkLoginSuccess(result.user));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import styles from './RestrictedContent.css';
|
||||
|
||||
import RestrictedMessageBox from './RestrictedMessageBox';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({children, restricted, message = lang.t('contentNotAvailable'), restrictedComp}) => {
|
||||
if (restricted) {
|
||||
return restrictedComp ? restrictedComp : messageBox(message);
|
||||
return restrictedComp ? restrictedComp : <RestrictedMessageBox message={message} />;
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
@@ -17,4 +17,3 @@ export default ({children, restricted, message = lang.t('contentNotAvailable'),
|
||||
}
|
||||
};
|
||||
|
||||
const messageBox = (message) => <div className={styles.message}>{message}</div>;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.message {
|
||||
background: #D8D8D8;
|
||||
padding: 25px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
import styles from './RestrictedMessageBox.css';
|
||||
|
||||
export default ({children}) => <div className={styles.message}>{children}</div>;
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Map, Set} from 'immutable';
|
||||
import {Map} from 'immutable';
|
||||
import * as authActions from '../constants/auth';
|
||||
import * as actions from '../constants/user';
|
||||
import * as assetActions from '../constants/assets';
|
||||
@@ -9,7 +9,6 @@ const initialState = Map({
|
||||
settings: {},
|
||||
myComments: [],
|
||||
myAssets: [], // the assets from which myComments (above) originated
|
||||
ignoredUsers: Set(),
|
||||
});
|
||||
|
||||
const purge = (user) => {
|
||||
@@ -39,14 +38,6 @@ export default function user (state = initialState, action) {
|
||||
return state.set('myAssets', action.assets);
|
||||
case actions.LOGOUT_SUCCESS:
|
||||
return initialState;
|
||||
case 'APOLLO_MUTATION_RESULT':
|
||||
switch (action.operationName) {
|
||||
case 'ignoreUser':
|
||||
return state.updateIn(['ignoredUsers'], (i) => i.add(action.variables.id));
|
||||
case 'stopIgnoringUser':
|
||||
return state.updateIn(['ignoredUsers'], (i) => i.delete(action.variables.id));
|
||||
}
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -7,6 +7,8 @@
|
||||
"successNameUpdate": "Your username has been updated",
|
||||
"contentNotAvailable": "This content is not available",
|
||||
"bannedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Report, or write comments. Please contact us if you have any questions.",
|
||||
|
||||
"temporarilySuspended": "In accordance with {0}'s community guidlines, your account has been temporarily suspended. Please rejoin the conversation {1}.",
|
||||
"editName": {
|
||||
"msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. Please contact us if you have any questions.",
|
||||
"label": "New Username",
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.drawer {
|
||||
max-width: 700px;
|
||||
min-width: 400px;
|
||||
padding: 20px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: white;
|
||||
transition: transform 500ms ease-in-out;
|
||||
box-shadow: -3px 0px 4px 0px rgba(0,0,0,0.15);
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
left: -40px;
|
||||
background-color: white;
|
||||
border-radius: 4px 0 0 4px;
|
||||
box-sizing: border-box;
|
||||
font-size: 32px;
|
||||
top: 60px;
|
||||
box-shadow: -1px 3px 4px 0px rgba(0,0,0,0.15);
|
||||
text-align: center;
|
||||
padding-top: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './Drawer.css';
|
||||
import onClickOutside from 'react-onclickoutside';
|
||||
|
||||
const Drawer = ({children, handleClickOutside}) => {
|
||||
return (
|
||||
<div className={styles.drawer}>
|
||||
<div className={styles.closeButton} onClick={handleClickOutside}>×</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Drawer.propTypes = {
|
||||
active: PropTypes.bool,
|
||||
handleClickOutside: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default onClickOutside(Drawer);
|
||||
@@ -23,3 +23,4 @@ export {default as Select} from './components/Select';
|
||||
export {default as Option} from './components/Option';
|
||||
export {default as SnackBar} from './components/SnackBar';
|
||||
export {default as TextArea} from './components/TextArea';
|
||||
export {default as Drawer} from './components/Drawer';
|
||||
|
||||
Vendored
-2
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
@@ -120,7 +124,7 @@ const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgn
|
||||
const ignoredUsers = freshUser.ignoresUsers;
|
||||
query.author_id = {$nin: ignoredUsers};
|
||||
}
|
||||
|
||||
|
||||
return CommentModel.where(query).count();
|
||||
};
|
||||
|
||||
@@ -191,7 +195,7 @@ const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) =>
|
||||
* @return {Promise} resolves to the counts of the comments from the
|
||||
* query
|
||||
*/
|
||||
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) => {
|
||||
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id}) => {
|
||||
let query = CommentModel.find();
|
||||
|
||||
if (ids) {
|
||||
@@ -210,6 +214,10 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) =
|
||||
query = query.where({parent_id});
|
||||
}
|
||||
|
||||
if (author_id) {
|
||||
query = query.where({author_id});
|
||||
}
|
||||
|
||||
return CommentModel
|
||||
.find(query)
|
||||
.count();
|
||||
@@ -226,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
|
||||
@@ -249,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});
|
||||
}
|
||||
|
||||
@@ -399,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
|
||||
|
||||
@@ -15,7 +15,7 @@ const genUserByIDs = (context, ids) => UsersService
|
||||
* @param {Object} context graph context
|
||||
* @param {Object} query query terms to apply to the users query
|
||||
*/
|
||||
const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
|
||||
const getUsersByQuery = ({user}, {ids, limit, cursor, statuses = null, sort}) => {
|
||||
|
||||
let users = UserModel.find();
|
||||
|
||||
@@ -27,6 +27,14 @@ const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (statuses != null) {
|
||||
users = users.where({
|
||||
status: {
|
||||
$in: statuses
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
if (sort === 'REVERSE_CHRONOLOGICAL') {
|
||||
users = users.where({
|
||||
|
||||
@@ -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),
|
||||
|
||||
+109
-5
@@ -1,11 +1,19 @@
|
||||
const errors = require('../../errors');
|
||||
|
||||
const ActionModel = require('../../models/action');
|
||||
const AssetsService = require('../../services/assets');
|
||||
const ActionsService = require('../../services/actions');
|
||||
const TagsService = require('../../services/tags');
|
||||
const CommentsService = require('../../services/comments');
|
||||
const KarmaService = require('../../services/karma');
|
||||
const linkify = require('linkify-it')();
|
||||
const Wordlist = require('../../services/wordlist');
|
||||
const {
|
||||
CREATE_COMMENT,
|
||||
SET_COMMENT_STATUS,
|
||||
ADD_COMMENT_TAG,
|
||||
EDIT_COMMENT
|
||||
} = require('../../perms/constants');
|
||||
|
||||
const debug = require('debug')('talk:graph:mutators:tags');
|
||||
const plugins = require('../../services/plugins');
|
||||
@@ -47,7 +55,7 @@ const resolveTagsForComment = async ({user, loaders: {Tags}}, {asset_id, tags =
|
||||
}
|
||||
|
||||
// Add the staff tag for comments created as a staff member.
|
||||
if (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')) {
|
||||
if (user.can(ADD_COMMENT_TAG)) {
|
||||
tags.push(TagsService.newTagLink(user, {
|
||||
name: 'STAFF',
|
||||
item_type
|
||||
@@ -57,6 +65,82 @@ const resolveTagsForComment = async ({user, loaders: {Tags}}, {asset_id, tags =
|
||||
return tags;
|
||||
};
|
||||
|
||||
/**
|
||||
* adjustKarma will adjust the affected user's karma depending on the moderators
|
||||
* action.
|
||||
*/
|
||||
const adjustKarma = (Comments, id, status) => async () => {
|
||||
try {
|
||||
|
||||
// Use the dataloader to get the comment that was just moderated and
|
||||
// get the flag user's id's so we can adjust their karma too.
|
||||
let [
|
||||
comment,
|
||||
flagUserIDs
|
||||
] = await Promise.all([
|
||||
|
||||
// Load the comment that was just made/updated by the setCommentStatus
|
||||
// operation.
|
||||
Comments.get.load(id),
|
||||
|
||||
// Find all the flag actions that were referenced by this comment
|
||||
// at this point in time.
|
||||
ActionModel.find({
|
||||
item_id: id,
|
||||
item_type: 'COMMENTS',
|
||||
action_type: 'FLAG'
|
||||
}).then((actions) => {
|
||||
|
||||
// This is to ensure that this is always an array.
|
||||
if (!actions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return actions.map(({user_id}) => user_id);
|
||||
})
|
||||
]);
|
||||
|
||||
debug(`Comment[${id}] by User[${comment.author_id}] was Status[${status}]`);
|
||||
|
||||
switch (status) {
|
||||
case 'REJECTED':
|
||||
|
||||
// Reduce the user's karma.
|
||||
debug(`CommentUser[${comment.author_id}] had their karma reduced`);
|
||||
|
||||
// Decrease the flag user's karma, the moderator disagreed with this
|
||||
// action.
|
||||
debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma increased`);
|
||||
await Promise.all([
|
||||
KarmaService.modifyUser(comment.author_id, -1, 'comment'),
|
||||
KarmaService.modifyUser(flagUserIDs, 1, 'flag', true)
|
||||
]);
|
||||
|
||||
break;
|
||||
|
||||
case 'ACCEPTED':
|
||||
|
||||
// Increase the user's karma.
|
||||
debug(`CommentUser[${comment.author_id}] had their karma increased`);
|
||||
|
||||
// Increase the flag user's karma, the moderator agreed with this
|
||||
// action.
|
||||
debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma reduced`);
|
||||
await Promise.all([
|
||||
KarmaService.modifyUser(comment.author_id, 1, 'comment'),
|
||||
KarmaService.modifyUser(flagUserIDs, -1, 'flag', true)
|
||||
]);
|
||||
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new comment.
|
||||
* @param {Object} user the user performing the request
|
||||
@@ -132,6 +216,7 @@ const filterNewComment = (context, {body, asset_id}) => {
|
||||
* @return {Promise} resolves to the comment's status
|
||||
*/
|
||||
const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, settings = {}) => {
|
||||
let {user} = context;
|
||||
|
||||
// Check to see if the body is too short, if it is, then complain about it!
|
||||
if (body.length < 2) {
|
||||
@@ -169,6 +254,22 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {},
|
||||
return 'REJECTED';
|
||||
}
|
||||
|
||||
if (user && user.metadata) {
|
||||
|
||||
// If the user is not a reliable commenter (passed the unreliability
|
||||
// threshold by having too many rejected comments) then we can change the
|
||||
// status of the comment to `PREMOD`, therefore pushing the user's comments
|
||||
// away from the public eye until a moderator can manage them. This of
|
||||
// course can only be applied if the comment's current status is `NONE`,
|
||||
// we don't want to interfere if the comment was rejected.
|
||||
if (KarmaService.isReliable('comment', user.metadata.trust) === false) {
|
||||
|
||||
// Update the response from the comment creation to add the PREMOD so that
|
||||
// that user's UI will reflect the fact that their comment is in pre-mod.
|
||||
return 'PREMOD';
|
||||
}
|
||||
}
|
||||
|
||||
return moderation === 'PRE' ? 'PREMOD' : 'NONE';
|
||||
};
|
||||
|
||||
@@ -225,7 +326,6 @@ const createPublicComment = async (context, commentInput) => {
|
||||
* @param {String} id identifier of the comment (uuid)
|
||||
* @param {String} status the new status of the comment
|
||||
*/
|
||||
|
||||
const setStatus = async ({user, loaders: {Comments}}, {id, status}) => {
|
||||
let comment = await CommentsService.pushStatus(id, status, user ? user.id : null);
|
||||
|
||||
@@ -242,6 +342,10 @@ const setStatus = async ({user, loaders: {Comments}}, {id, status}) => {
|
||||
|
||||
Comments.countByAssetID.clear(comment.asset_id);
|
||||
|
||||
// postSetCommentStatus will use the arguments from the mutation and
|
||||
// adjust the affected user's karma in the next tick.
|
||||
process.nextTick(adjustKarma(Comments, id, status));
|
||||
|
||||
return comment;
|
||||
};
|
||||
|
||||
@@ -274,15 +378,15 @@ 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:editComment')) {
|
||||
if (context.user && context.user.can(EDIT_COMMENT)) {
|
||||
mutators.Comment.edit = (action) => edit(context, action);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const TagsService = require('../../services/tags');
|
||||
const errors = require('../../errors');
|
||||
const {ADD_COMMENT_TAG, REMOVE_COMMENT_TAG} = require('../../perms/constants');
|
||||
|
||||
/**
|
||||
* Modifies the targeted model with the specified operation to add/remove a tag.
|
||||
@@ -26,11 +27,11 @@ module.exports = (context) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (context.user && context.user.can('mutation:addTag')) {
|
||||
if (context.user && context.user.can(ADD_COMMENT_TAG)) {
|
||||
mutators.Tag.add = (tag) => modify(context, TagsService.add, tag);
|
||||
}
|
||||
|
||||
if (context.user && context.user.can('mutation:removeTag')) {
|
||||
if (context.user && context.user.can(REMOVE_COMMENT_TAG)) {
|
||||
mutators.Tag.remove = (tag) => modify(context, TagsService.remove, tag);
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,7 @@ const Comment = {
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -50,10 +49,12 @@ const Comment = {
|
||||
asset({asset_id}, _, {loaders: {Assets}}) {
|
||||
return Assets.getByID.load(asset_id);
|
||||
},
|
||||
editing(comment) {
|
||||
async editing(comment, _, {loaders: {Settings}}) {
|
||||
const settings = await Settings.load();
|
||||
const editableUntil = new Date(Number(comment.created_at) + settings.editCommentWindowLength);
|
||||
return {
|
||||
edited: comment.edited,
|
||||
editableUntil: comment.editableUntil
|
||||
editableUntil: editableUntil
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,8 +19,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;
|
||||
}
|
||||
|
||||
@@ -19,38 +26,36 @@ const RootQuery = {
|
||||
|
||||
// This endpoint is used for loading moderation queues, so hide it in the
|
||||
// event that we aren't an admin.
|
||||
async comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored}}, {user, loaders: {Comments, Actions}}) {
|
||||
let query = {statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored};
|
||||
async comments(_, {query}, {user, loaders: {Comments, Actions}}) {
|
||||
let {action_type} = query;
|
||||
|
||||
if (user != null && user.hasRoles('ADMIN') && action_type) {
|
||||
let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
|
||||
|
||||
// Perform the query using the available resolver.
|
||||
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored});
|
||||
if (user != null && user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
|
||||
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
|
||||
}
|
||||
|
||||
return Comments.getByQuery(query);
|
||||
},
|
||||
|
||||
comment(_, {id}, {loaders: {Comments}}) {
|
||||
return Comments.get.load(id);
|
||||
},
|
||||
async commentCount(_, {query: {action_type, statuses, asset_id, parent_id}}, {user, loaders: {Actions, Comments}}) {
|
||||
if (user == null || !user.hasRoles('ADMIN')) {
|
||||
|
||||
async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) {
|
||||
if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action_type) {
|
||||
let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
|
||||
const {action_type} = query;
|
||||
|
||||
// Perform the query using the available resolver.
|
||||
return Comments.getCountByQuery({ids, statuses, asset_id, parent_id});
|
||||
if (action_type) {
|
||||
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
|
||||
}
|
||||
|
||||
return Comments.getCountByQuery({statuses, asset_id, parent_id});
|
||||
return Comments.getCountByQuery(query);
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -62,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;
|
||||
}
|
||||
|
||||
@@ -79,21 +84,27 @@ const RootQuery = {
|
||||
return user;
|
||||
},
|
||||
|
||||
// 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: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) {
|
||||
|
||||
if (user == null || !user.hasRoles('ADMIN')) {
|
||||
// this returns an arbitrary user
|
||||
user(_, {id}, {user, loaders: {Users}}) {
|
||||
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = {limit, cursor, sort};
|
||||
return Users.getByID.load(id);
|
||||
},
|
||||
|
||||
// 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.can(SEARCH_OTHER_USERS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {action_type} = query;
|
||||
|
||||
if (action_type) {
|
||||
let ids = await Actions.getByTypes({action_type, item_type: 'USERS'});
|
||||
|
||||
// Perform the query using the available resolver.
|
||||
return Users.getByQuery({ids, limit, cursor, sort}).find({status: 'PENDING'});
|
||||
query.ids = await Actions.getByTypes({action_type, item_type: 'USERS'});
|
||||
query.statuses = ['PENDING'];
|
||||
}
|
||||
|
||||
return Users.getByQuery(query);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const {SEARCH_OTHER_USERS} = require('../../perms/constants');
|
||||
|
||||
const TagLink = {
|
||||
assigned_by({assigned_by}, _, {user, loaders: {Users}}) {
|
||||
if (user && user.hasRoles('ADMIN') && assigned_by != null) {
|
||||
if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) {
|
||||
return Users.getByID.load(assigned_by);
|
||||
}
|
||||
}
|
||||
|
||||
+35
-4
@@ -1,4 +1,12 @@
|
||||
const {decorateWithTags} = require('./util');
|
||||
const KarmaService = require('../../services/karma');
|
||||
const {
|
||||
SEARCH_ACTIONS,
|
||||
SEARCH_OTHER_USERS,
|
||||
SEARCH_OTHERS_COMMENTS,
|
||||
UPDATE_USER_ROLES,
|
||||
SEARCH_COMMENT_METRICS
|
||||
} = require('../../perms/constants');
|
||||
|
||||
const User = {
|
||||
action_summaries({id}, _, {loaders: {Actions}}) {
|
||||
@@ -7,26 +15,42 @@ 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);
|
||||
}
|
||||
|
||||
},
|
||||
created_at({roles, created_at}, _, {user}) {
|
||||
if (user && user.can(SEARCH_OTHER_USERS)) {
|
||||
return created_at;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
comments({id}, _, {loaders: {Comments}, 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'});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
profiles({profiles}, _, {user}) {
|
||||
|
||||
// if the user is not an admin, do not return the profiles
|
||||
if (user && user.can(SEARCH_OTHER_USERS)) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
ignoredUsers({id}, args, {user, loaders: {Users}}) {
|
||||
|
||||
// Only allow a logged in user that is either the current user or is a staff
|
||||
// member to access the ignoredUsers of a given user.
|
||||
if (!user || ((user.id !== id) && !(user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')))) {
|
||||
if (!user || ((user.id !== id) && !user.can(SEARCH_OTHER_USERS))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -40,11 +64,18 @@ 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;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
// Extract the reliability from the user metadata if they have permission.
|
||||
reliable(user, _, {user: requestingUser}) {
|
||||
if (requestingUser && requestingUser.can(SEARCH_COMMENT_METRICS)) {
|
||||
return KarmaService.model(user);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const {ADD_COMMENT_TAG} = require('../../perms/constants');
|
||||
|
||||
/**
|
||||
* Decorates the typeResolver with the tags field.
|
||||
*/
|
||||
const decorateWithTags = (typeResolver) => {
|
||||
typeResolver.tags = ({tags = []}, _, {user}) => {
|
||||
if (user && (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR'))) {
|
||||
if (user && user.can(ADD_COMMENT_TAG)) {
|
||||
return tags;
|
||||
}
|
||||
|
||||
|
||||
+10
-11
@@ -1,6 +1,7 @@
|
||||
const {SubscriptionManager} = require('graphql-subscriptions');
|
||||
const {SubscriptionServer} = require('subscriptions-transport-ws');
|
||||
const _ = require('lodash');
|
||||
const debug = require('debug')('talk:graph:subscriptions');
|
||||
|
||||
const pubsub = require('./pubsub');
|
||||
const schema = require('./schema');
|
||||
@@ -9,24 +10,22 @@ const plugins = require('../services/plugins');
|
||||
|
||||
const {deserializeUser} = require('../services/subscriptions');
|
||||
|
||||
// Core setup functions
|
||||
let setupFunctions = {
|
||||
commentAdded: (options, args) => ({
|
||||
commentAdded: {
|
||||
filter: (comment) => comment.asset_id === args.asset_id
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin support requires that we merge in existing setupFunctions with our new
|
||||
* plugin based ones. This allows plugins to extend existing setupFunctions as well
|
||||
* as provide new ones.
|
||||
*/
|
||||
setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {setupFunctions}) => {
|
||||
const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plugin, setupFunctions}) => {
|
||||
debug(`added plugin '${plugin.name}'`);
|
||||
|
||||
return _.merge(acc, setupFunctions);
|
||||
}, setupFunctions);
|
||||
}, {
|
||||
commentAdded: (options, args) => ({
|
||||
commentAdded: {
|
||||
filter: (comment) => comment.asset_id === args.asset_id
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* This creates a new subscription manager.
|
||||
|
||||
+79
-2
@@ -5,6 +5,22 @@
|
||||
# Date represented as an ISO8601 string.
|
||||
scalar Date
|
||||
|
||||
################################################################################
|
||||
## Reliability
|
||||
################################################################################
|
||||
|
||||
# Reliability defines how a given user should be considered reliable for their
|
||||
# comment or flag activity.
|
||||
type Reliability {
|
||||
|
||||
# flagger will be `true` when the flagger is reliable, `false` if not, or
|
||||
# `null` if the reliability cannot be determined.
|
||||
flagger: Boolean
|
||||
|
||||
# commenter will be `true` when the commenter is reliable, `false` if not, or
|
||||
# `null` if the reliability cannot be determined.
|
||||
commenter: Boolean
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Users
|
||||
@@ -20,6 +36,14 @@ enum USER_ROLES {
|
||||
MODERATOR
|
||||
}
|
||||
|
||||
type UserProfile {
|
||||
# the id is an identifier for the user profile (email, facebook id, etc)
|
||||
id: String!
|
||||
|
||||
# name of the provider attached to the authentication mode
|
||||
provider: String!
|
||||
}
|
||||
|
||||
# Any person who can author comments, create actions, and view comments on a
|
||||
# stream.
|
||||
type User {
|
||||
@@ -30,6 +54,9 @@ type User {
|
||||
# Username of a user.
|
||||
username: String!
|
||||
|
||||
# creation date of user
|
||||
created_at: String!
|
||||
|
||||
# Action summaries against the user.
|
||||
action_summaries: [ActionSummary!]!
|
||||
|
||||
@@ -39,6 +66,9 @@ type User {
|
||||
# the current roles of the user.
|
||||
roles: [USER_ROLES!]
|
||||
|
||||
# the current profiles of the user.
|
||||
profiles: [UserProfile]
|
||||
|
||||
# the tags on the user
|
||||
tags: [TagLink!]
|
||||
|
||||
@@ -51,6 +81,11 @@ type User {
|
||||
# returns all comments based on a query.
|
||||
comments(query: CommentsQuery): [Comment!]
|
||||
|
||||
# reliable is the reference to a given user's Reliability. If the requesting
|
||||
# user does not have permission to access the reliability, null will be
|
||||
# returned.
|
||||
reliable: Reliability
|
||||
|
||||
# returns user status
|
||||
status: USER_STATUS
|
||||
}
|
||||
@@ -196,6 +231,10 @@ input CommentCountQuery {
|
||||
# type.
|
||||
action_type: ACTION_TYPE
|
||||
|
||||
# author_id allows the querying of comment counts based on the author of the
|
||||
# comments.
|
||||
author_id: ID
|
||||
|
||||
# Filter by a specific tag name.
|
||||
tag: [String]
|
||||
}
|
||||
@@ -441,6 +480,7 @@ type Settings {
|
||||
charCountEnable: Boolean
|
||||
charCount: Int
|
||||
|
||||
organizationName: String
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -589,6 +629,9 @@ type RootQuery {
|
||||
# Users returned based on a query.
|
||||
users(query: UsersQuery): [User]
|
||||
|
||||
# a single User by id
|
||||
user(id: ID!): User
|
||||
|
||||
# Asset metrics related to user actions are saturated into the assets
|
||||
# returned. Parameters `from` and `to` are related to the action created_at field.
|
||||
assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
|
||||
@@ -714,6 +757,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 {
|
||||
@@ -738,6 +804,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 {
|
||||
@@ -820,8 +894,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
|
||||
|
||||
@@ -4,8 +4,6 @@ const TagLinkSchema = require('./schema/tag_link');
|
||||
const uuid = require('uuid');
|
||||
const COMMENT_STATUS = require('./enum/comment_status');
|
||||
|
||||
const EDIT_WINDOW_MS = 30 * 1000; // 30 seconds
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment Status.
|
||||
* @type {Schema}
|
||||
@@ -97,12 +95,7 @@ CommentSchema.virtual('edited').get(function() {
|
||||
return this.body_history.length > 1;
|
||||
});
|
||||
|
||||
CommentSchema.virtual('editableUntil').get(function() {
|
||||
return new Date(Number(this.created_at) + EDIT_WINDOW_MS);
|
||||
});
|
||||
|
||||
// Comment model.
|
||||
const Comment = mongoose.model('Comment', CommentSchema);
|
||||
|
||||
module.exports = Comment;
|
||||
module.exports.EDIT_WINDOW_MS = EDIT_WINDOW_MS;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module.exports = [
|
||||
'ADMIN',
|
||||
'MODERATOR'
|
||||
'MODERATOR',
|
||||
'STAFF'
|
||||
];
|
||||
|
||||
@@ -86,6 +86,13 @@ const SettingSchema = new Schema({
|
||||
default: ['localhost']
|
||||
}
|
||||
},
|
||||
|
||||
// Length of time (in milliseconds) after a comment is posted that it can still be edited by the author
|
||||
editCommentWindowLength: {
|
||||
type: Number,
|
||||
min: [0, 'Edit Comment Window length must be greater than zero'],
|
||||
default: 30 * 1000,
|
||||
},
|
||||
tags: [TagSchema]
|
||||
}, {
|
||||
timestamps: {
|
||||
|
||||
+14
-37
@@ -3,6 +3,8 @@ const bcrypt = require('bcrypt');
|
||||
const Schema = mongoose.Schema;
|
||||
const uuid = require('uuid');
|
||||
const TagLinkSchema = require('./schema/tag_link');
|
||||
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 = require('./enum/user_roles');
|
||||
@@ -105,6 +107,14 @@ const UserSchema = new Schema({
|
||||
default: false
|
||||
},
|
||||
|
||||
// User's suspension details.
|
||||
suspension: {
|
||||
until: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
// User's settings
|
||||
settings: {
|
||||
bio: {
|
||||
@@ -154,14 +164,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;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -183,41 +189,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:addTag',
|
||||
'mutation:removeTag',
|
||||
'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;
|
||||
}
|
||||
|
||||
return true;
|
||||
return can(this, ...actions);
|
||||
});
|
||||
|
||||
// Create the User model.
|
||||
|
||||
+4
-1
@@ -50,6 +50,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/coralproject/talk#readme",
|
||||
"dependencies": {
|
||||
"accepts": "^1.3.3",
|
||||
"app-module-path": "^2.2.0",
|
||||
"bcrypt": "^1.0.2",
|
||||
"body-parser": "^1.17.1",
|
||||
@@ -99,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",
|
||||
@@ -131,6 +133,7 @@
|
||||
"chai-as-promised": "^6.0.0",
|
||||
"chai-http": "^3.0.0",
|
||||
"common-tags": "^1.4.0",
|
||||
"compression-webpack-plugin": "^0.4.0",
|
||||
"copy-webpack-plugin": "^4.0.0",
|
||||
"css-loader": "^0.27.3",
|
||||
"dialog-polyfill": "^0.4.4",
|
||||
@@ -176,7 +179,7 @@
|
||||
"react-linkify": "^0.1.3",
|
||||
"react-mdl": "^1.7.2",
|
||||
"react-mdl-selectfield": "^0.2.0",
|
||||
"react-onclickoutside": "^5.7.1",
|
||||
"react-onclickoutside": "^5.11.1",
|
||||
"react-redux": "^4.4.5",
|
||||
"react-router": "^3.0.0",
|
||||
"react-tagsinput": "^3.14.0",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
+12
-2
@@ -8,8 +8,18 @@ const router = express.Router();
|
||||
router.use('/api/v1', require('./api'));
|
||||
router.use('/admin', require('./admin'));
|
||||
router.use('/embed', require('./embed'));
|
||||
router.get('/embed.js', (req, res) => res.sendFile(path.join(__dirname, '../dist/embed.js')));
|
||||
router.get('/embed.js.map', (req, res) => res.sendFile(path.join(__dirname, '../dist/embed.js.map')));
|
||||
|
||||
/**
|
||||
* Serves a file based on a relative path.
|
||||
*/
|
||||
const serveFile = (filename) => (req, res) => res.sendFile(path.join(__dirname, filename));
|
||||
|
||||
/**
|
||||
* Serves the embed javascript files.
|
||||
*/
|
||||
router.get('/embed.js', serveFile('../dist/embed.js'));
|
||||
router.get('/embed.js.gz', serveFile('../dist/embed.js.gz'));
|
||||
router.get('/embed.js.map', serveFile('../dist/embed.js.map'));
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
router.use('/assets', require('./assets'));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const CommentModel = require('../models/comment');
|
||||
const EDIT_WINDOW_MS = CommentModel.EDIT_WINDOW_MS;
|
||||
|
||||
const ActionModel = require('../models/action');
|
||||
const ActionsService = require('./actions');
|
||||
const SettingsService = require('./settings');
|
||||
|
||||
const errors = require('../errors');
|
||||
|
||||
@@ -53,8 +53,10 @@ module.exports = class CommentsService {
|
||||
|
||||
// Establish the edit window (if it exists) and add the condition to the
|
||||
// original query.
|
||||
const lastEditableCommentCreatedAt = new Date((new Date()).getTime() - EDIT_WINDOW_MS);
|
||||
let lastEditableCommentCreatedAt;
|
||||
if (!ignoreEditWindow) {
|
||||
const {editCommentWindowLength: editWindowMs} = await SettingsService.retrieve();
|
||||
lastEditableCommentCreatedAt = new Date((new Date()).getTime() - editWindowMs);
|
||||
query.created_at = {
|
||||
$gt: lastEditableCommentCreatedAt,
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<%= body %>
|
||||
@@ -1 +1 @@
|
||||
<%= body %>
|
||||
<%= body.replace(/\n/g, '<br />') %>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
const debug = require('debug')('talk:trust');
|
||||
const UserModel = require('../models/user');
|
||||
|
||||
/**
|
||||
* This will create an object with the property name of the action type as the
|
||||
* key and an object as it's value. This will contain a RELIABLE, and UNRELIABLE
|
||||
* property with the number of karma points associated with their particular
|
||||
* state.
|
||||
*
|
||||
* If only the RELIABLE variable is provided, then it will also be used as the
|
||||
* UNRELIABLE variable.
|
||||
*
|
||||
* The form of the environment variable is:
|
||||
*
|
||||
* <name>:<RELIABLE>,<UNRELIABLE>;<name>:<RELIABLE>,<UNRELIABLE>;...
|
||||
*
|
||||
* The default used is:
|
||||
*
|
||||
* comment:1,1;flag:-1,-1
|
||||
*/
|
||||
const parseThresholds = (thresholds) => thresholds
|
||||
.split(';')
|
||||
.filter((threshold) => threshold && threshold.length > 0)
|
||||
.reduce((acc, threshold) => {
|
||||
const thresholds = threshold.split(':');
|
||||
if (thresholds.length < 2) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
let [name, values] = thresholds;
|
||||
let [RELIABLE, UNRELIABLE] = values.split(',').map((value) => parseInt(value));
|
||||
|
||||
if (!(name in acc)) {
|
||||
acc[name] = {};
|
||||
}
|
||||
|
||||
if (isNaN(UNRELIABLE) && !isNaN(RELIABLE)) {
|
||||
acc[name].RELIABLE = RELIABLE;
|
||||
acc[name].UNRELIABLE = RELIABLE;
|
||||
} else {
|
||||
if (!isNaN(UNRELIABLE)) {
|
||||
acc[name].UNRELIABLE = UNRELIABLE;
|
||||
}
|
||||
|
||||
if (!isNaN(RELIABLE)) {
|
||||
acc[name].RELIABLE = RELIABLE;
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {
|
||||
comment: {
|
||||
RELIABLE: -1,
|
||||
UNRELIABLE: -1
|
||||
},
|
||||
flag: {
|
||||
RELIABLE: -1,
|
||||
UNRELIABLE: -1
|
||||
}
|
||||
});
|
||||
|
||||
const THRESHOLDS = parseThresholds(process.env.TRUST_THRESHOLDS || '');
|
||||
|
||||
debug('using thresholds: ', THRESHOLDS);
|
||||
|
||||
/**
|
||||
* KarmaModel represents the checkable properties of a user and wrapps the
|
||||
* KarmaService function `isReliable` to work flexibly with the graph.
|
||||
*/
|
||||
class KarmaModel {
|
||||
constructor(model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
get flagger() {
|
||||
return KarmaService.isReliable('flag', this.model);
|
||||
}
|
||||
|
||||
get commenter() {
|
||||
return KarmaService.isReliable('comment', this.model);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KarmaService provides interfaces for editing a user's karma.
|
||||
*/
|
||||
class KarmaService {
|
||||
|
||||
/**
|
||||
* Model returns a KarmaModel based on the passed in user.
|
||||
*/
|
||||
static model(user) {
|
||||
if (user === null || !user.metadata || !user.metadata.trust) {
|
||||
return new KarmaModel({});
|
||||
}
|
||||
|
||||
return new KarmaModel(user.metadata.trust);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects the reliability of a property and returns it if known.
|
||||
* @param {String} name - name of the property
|
||||
* @param {Object} trust - object possibly containing the propertys
|
||||
*/
|
||||
static isReliable(name, trust) {
|
||||
if (trust && trust[name]) {
|
||||
if (trust[name].karma > THRESHOLDS[name].RELIABLE) {
|
||||
return true;
|
||||
} else if (trust[name].karma < THRESHOLDS[name].UNRELIABLE) {
|
||||
return false;
|
||||
}
|
||||
} else if (THRESHOLDS[name].RELIABLE < 0) {
|
||||
return true;
|
||||
} else if (THRESHOLDS[name].UNRELIABLE > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* modifyUserKarma updates the user to adjust their karma, for either the `type`
|
||||
* of 'comment' or 'flag'. If `multi` is true, then it assumes that `id` is an
|
||||
* array of id's.
|
||||
*/
|
||||
static async modifyUser(id, direction = 1, type = 'comment', multi = false) {
|
||||
const key = `metadata.trust.${type}.karma`;
|
||||
|
||||
let update = {
|
||||
$inc: {
|
||||
[key]: direction
|
||||
}
|
||||
};
|
||||
|
||||
if (multi) {
|
||||
|
||||
// If it was in multi-mode but there was no user's to adjust, bail.
|
||||
if (id.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return UserModel.update({
|
||||
id: {
|
||||
$in: id
|
||||
}
|
||||
}, update, {
|
||||
multi: true
|
||||
});
|
||||
}
|
||||
|
||||
return UserModel.update({id}, update);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = KarmaService;
|
||||
+2
-1
@@ -4,6 +4,7 @@ const UserModel = require('../models/user');
|
||||
|
||||
const AssetsService = require('./assets');
|
||||
const SettingsService = require('./settings');
|
||||
const {ADD_COMMENT_TAG} = require('../perms/constants');
|
||||
|
||||
const errors = require('../errors');
|
||||
|
||||
@@ -114,7 +115,7 @@ class TagsService {
|
||||
|
||||
// Only admin/moderators can modify unique tags, these are tags that are not
|
||||
// in the global list.
|
||||
if (!(user.hasRoles('ADMIN') || user.hasRoles('MODERATOR'))) {
|
||||
if (!user.can(ADD_COMMENT_TAG)) {
|
||||
throw errors.ErrNotAuthorized;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user