diff --git a/app.js b/app.js index f5243f81a..16840f965 100644 --- a/app.js +++ b/app.js @@ -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) => { diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index e233f3112..29971e294 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -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); diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 9c9d5a571..c5e6fc620 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -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}); diff --git a/client/coral-admin/src/components/ActionsMenu.css b/client/coral-admin/src/components/ActionsMenu.css new file mode 100644 index 000000000..1d2f91f74 --- /dev/null +++ b/client/coral-admin/src/components/ActionsMenu.css @@ -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); + } +} + diff --git a/client/coral-admin/src/components/ActionsMenu.js b/client/coral-admin/src/components/ActionsMenu.js new file mode 100644 index 000000000..eb173eb81 --- /dev/null +++ b/client/coral-admin/src/components/ActionsMenu.js @@ -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 ( +
+ + + {this.props.children} + +
+ ); + } +} + +ActionsMenu.propTypes = { + icon: PropTypes.string, +}; + +export default ActionsMenu; diff --git a/client/coral-admin/src/components/ActionsMenuItem.js b/client/coral-admin/src/components/ActionsMenuItem.js new file mode 100644 index 000000000..82dab96f0 --- /dev/null +++ b/client/coral-admin/src/components/ActionsMenuItem.js @@ -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) => + ; + +export default ActionsMenuItem; diff --git a/client/coral-admin/src/components/App.js b/client/coral-admin/src/components/App.js index 3c6e88a14..1a15c72c3 100644 --- a/client/coral-admin/src/components/App.js +++ b/client/coral-admin/src/components/App.js @@ -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 ( - - - +
+ + +
); } } diff --git a/client/coral-admin/src/components/ToastContainer.css b/client/coral-admin/src/components/ToastContainer.css new file mode 100644 index 000000000..319872ceb --- /dev/null +++ b/client/coral-admin/src/components/ToastContainer.css @@ -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; + } +} diff --git a/client/coral-admin/src/components/ToastContainer.js b/client/coral-admin/src/components/ToastContainer.js new file mode 100644 index 000000000..a751d2714 --- /dev/null +++ b/client/coral-admin/src/components/ToastContainer.js @@ -0,0 +1,7 @@ +import './ToastContainer.css'; +import {defaultProps} from 'recompose'; +import {ToastContainer} from 'react-toastify'; + +export default defaultProps({ + autoClose: 5000, +})(ToastContainer); diff --git a/client/coral-admin/src/components/ui/Drawer.js b/client/coral-admin/src/components/ui/Drawer.js index 5ac55045c..2fcb5aafe 100644 --- a/client/coral-admin/src/components/ui/Drawer.js +++ b/client/coral-admin/src/components/ui/Drawer.js @@ -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}) => ( - { !restricted ? + { auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
( activeClassName={styles.active}> {lang.t('configure.dashboard')} - - {lang.t('configure.moderate')} - - + {lang.t('configure.moderate')} + + ) + } + {lang.t('configure.stories')} + to="/admin/community" + activeClassName={styles.active}> {lang.t('configure.community')} - - {lang.t('configure.configure')} - + { + can(auth.user, 'UPDATE_CONFIG') && + ( + + {lang.t('configure.configure')} + + ) + } Sign Out {`v${process.env.VERSION}`} diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index 6418efb10..f7ba83491 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -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 +}) => (
- { - !restricted ?
- - - {lang.t('configure.dashboard')} - - - {lang.t('configure.moderate')} - - - {lang.t('configure.stories')} - - - {lang.t('configure.community')} - - - {lang.t('configure.configure')} - - + { + auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? + + + {lang.t('configure.dashboard')} + + { + can(auth.user, 'MODERATE_COMMENTS') && ( + + {lang.t('configure.moderate')} + + ) + } + + {lang.t('configure.stories')} + + + {lang.t('configure.community')} + + { + can(auth.user, 'UPDATE_CONFIG') && ( + + {lang.t('configure.configure')} + + ) + } + + : + null + }
  • @@ -66,16 +82,13 @@ const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false
- : - null - }
); 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); diff --git a/client/coral-admin/src/components/ui/Layout.js b/client/coral-admin/src/components/ui/Layout.js index 6bf9661b7..11432e570 100644 --- a/client/coral-admin/src/components/ui/Layout.js +++ b/client/coral-admin/src/components/ui/Layout.js @@ -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}) => (
diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index 14672146e..b960d95a2 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -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'; diff --git a/client/coral-admin/src/containers/Community/CommunityContainer.js b/client/coral-admin/src/containers/Community/CommunityContainer.js index 899958e17..7c0051477 100644 --- a/client/coral-admin/src/containers/Community/CommunityContainer.js +++ b/client/coral-admin/src/containers/Community/CommunityContainer.js @@ -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} />
); @@ -165,5 +165,5 @@ export default compose( modUserFlaggedQuery, banUser, setUserStatus, - suspendUser + rejectUsername )(CommunityContainer); diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js index 44c06eed4..5ee8e7f92 100644 --- a/client/coral-admin/src/containers/Community/Table.js +++ b/client/coral-admin/src/containers/Community/Table.js @@ -65,6 +65,7 @@ class Table extends Component { label={lang.t('community.role')} onChange={(role) => this.onRoleChange(row.id, role)}> + diff --git a/client/coral-admin/src/containers/Community/components/ActionButton.js b/client/coral-admin/src/containers/Community/components/ActionButton.js index 6352480fd..ad5346ac5 100644 --- a/client/coral-admin/src/containers/Community/components/ActionButton.js +++ b/client/coral-admin/src/containers/Community/components/ActionButton.js @@ -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'; diff --git a/client/coral-admin/src/components/BanUserButton.css b/client/coral-admin/src/containers/Community/components/BanUserButton.css similarity index 100% rename from client/coral-admin/src/components/BanUserButton.css rename to client/coral-admin/src/containers/Community/components/BanUserButton.css diff --git a/client/coral-admin/src/components/BanUserButton.js b/client/coral-admin/src/containers/Community/components/BanUserButton.js similarity index 100% rename from client/coral-admin/src/components/BanUserButton.js rename to client/coral-admin/src/containers/Community/components/BanUserButton.js diff --git a/client/coral-admin/src/containers/Community/components/SuspendUserDialog.js b/client/coral-admin/src/containers/Community/components/SuspendUserDialog.js index 20e221c48..b717d1719 100644 --- a/client/coral-admin/src/containers/Community/components/SuspendUserDialog.js +++ b/client/coral-admin/src/containers/Community/components/SuspendUserDialog.js @@ -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')}>
{lang.t(stages[stage].title, lang.t('suspenduser.username'))}
diff --git a/client/coral-admin/src/containers/Configure/Configure.css b/client/coral-admin/src/containers/Configure/Configure.css index 04ef96dd2..3d5750693 100644 --- a/client/coral-admin/src/containers/Configure/Configure.css +++ b/client/coral-admin/src/containers/Configure/Configure.css @@ -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; diff --git a/client/coral-admin/src/containers/Configure/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js index e041edfe3..d22a19997 100644 --- a/client/coral-admin/src/containers/Configure/Configure.js +++ b/client/coral-admin/src/containers/Configure/Configure.js @@ -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

You must be an administrator to access config settings. Please find the nearest Admin and ask them to level you up!

; + } 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); diff --git a/client/coral-admin/src/containers/Configure/ModerationSettings.js b/client/coral-admin/src/containers/Configure/ModerationSettings.js index 5b7286b98..c7df573e8 100644 --- a/client/coral-admin/src/containers/Configure/ModerationSettings.js +++ b/client/coral-admin/src/containers/Configure/ModerationSettings.js @@ -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 (
@@ -72,6 +78,27 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => { bannedWords={settings.wordlist.banned} suspectWords={settings.wordlist.suspect} onChangeWordlist={onChangeWordlist} /> + + {/* Edit Comment Timeframe */} + +
{lang.t('configure.edit-comment-timeframe-heading')}
+

+ {lang.t('configure.edit-comment-timeframe-text-pre')} +   + +   + {lang.t('configure.edit-comment-timeframe-text-post')} +

+
); }; diff --git a/client/coral-admin/src/containers/Configure/StreamSettings.js b/client/coral-admin/src/containers/Configure/StreamSettings.js index 646eb9d9e..341c47ae9 100644 --- a/client/coral-admin/src/containers/Configure/StreamSettings.js +++ b/client/coral-admin/src/containers/Configure/StreamSettings.js @@ -81,7 +81,7 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {

{lang.t('configure.comment-count-text-pre')} ; } - if (!isAdmin) { + if (!loggedIn) { return ( ); } - if (isAdmin && loggedIn) { + if (can(user, 'ACCESS_ADMIN') && loggedIn) { return ( ); + } else if (loggedIn) { + return ( + +

This page is for team use only. Please contact an administrator if you want to join this team.

+ + ); } return ; } diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 3d1c1dd7a..237059103 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -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} /> - + + {moderation.userDetailId && ( + + )}
); } @@ -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); diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js index c4bf0f85c..043173549 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js @@ -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 (
@@ -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} />; }) : {lang.t('modqueue.emptyqueue')} diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.css b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css new file mode 100644 index 000000000..e37971ada --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css @@ -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; +} diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js new file mode 100644 index 000000000..d9b85a060 --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js @@ -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 ( + +

{user.username}

+ + {profile && this.profile = ref} value={profile} />} + +

Member since {new Date(user.created_at).toLocaleString()}

+
+

+ Account summary +
Data represents the last six months of activity +

+
+
+

Total Comments

+

{totalComments}

+
+
+

Reject Rate

+

{`${(rejectedPercent).toFixed(1)}%`}

+
+
+
+ ); + } +} + +export default compose( + getUserDetail +)(UserDetail); diff --git a/client/coral-admin/src/components/BanUserDialog.css b/client/coral-admin/src/containers/ModerationQueue/components/BanUserDialog.css similarity index 97% rename from client/coral-admin/src/components/BanUserDialog.css rename to client/coral-admin/src/containers/ModerationQueue/components/BanUserDialog.css index a46b9da32..f13f0e6aa 100644 --- a/client/coral-admin/src/components/BanUserDialog.css +++ b/client/coral-admin/src/containers/ModerationQueue/components/BanUserDialog.css @@ -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; } diff --git a/client/coral-admin/src/components/BanUserDialog.js b/client/coral-admin/src/containers/ModerationQueue/components/BanUserDialog.js similarity index 97% rename from client/coral-admin/src/components/BanUserDialog.js rename to client/coral-admin/src/containers/ModerationQueue/components/BanUserDialog.js index b259012f3..87a5fe55a 100644 --- a/client/coral-admin/src/components/BanUserDialog.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/BanUserDialog.js @@ -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) => { diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index 0f31210c0..6246dd5b9 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -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 = ({
- + viewUserDetail(comment.user.id)}> {comment.user.name} @@ -65,16 +67,19 @@ const Comment = ({ lang.getLocale().replace('-', '_') )} - - props.showBanUserDialog( - comment.user, - comment.id, - comment.status, - comment.status !== 'REJECTED' - )} - /> + {props.currentUserId !== comment.user.id && + + props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}> + Suspend User + props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}> + Ban User + + + }
{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, diff --git a/client/coral-admin/src/containers/ModerationQueue/components/SuspendUserDialog.css b/client/coral-admin/src/containers/ModerationQueue/components/SuspendUserDialog.css new file mode 100644 index 000000000..1c96f509a --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/SuspendUserDialog.css @@ -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; +} diff --git a/client/coral-admin/src/containers/ModerationQueue/components/SuspendUserDialog.js b/client/coral-admin/src/containers/ModerationQueue/components/SuspendUserDialog.js new file mode 100644 index 000000000..f257bcfb9 --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/components/SuspendUserDialog.js @@ -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 ( +
+

+ {lang.t('suspenduser.title_suspend')} +

+

+ {lang.t('suspenduser.description_suspend', username)} +

+
+ {lang.t('suspenduser.select_duration')} + + {lang.t('suspenduser.one_hour')} + {lang.t('suspenduser.hours', 3)} + {lang.t('suspenduser.hours', 24)} + {lang.t('suspenduser.days', 7)} + +
+
+ + +
+
+ ); + } + + renderStep1() { + const {onCancel, username} = this.props; + const {message} = this.state; + return ( +
+

+ {lang.t('suspenduser.title_notify')} +

+

+ {lang.t('suspenduser.description_notify', username)} +

+
+ {lang.t('suspenduser.write_message')} +