diff --git a/bin/cli-users b/bin/cli-users index 743119a0a..3e8bc7741 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -105,7 +105,8 @@ function printUserAsTable(user) { Suspension: user.suspended ? `Until ${user.status.suspension.until}` : false, - } + }, + { 'Always premod comments': user.alwaysPremod } ); console.log(table.toString()); diff --git a/client/coral-admin/src/actions/alwaysPremodUserDialog.js b/client/coral-admin/src/actions/alwaysPremodUserDialog.js new file mode 100644 index 000000000..5c5cfe7b8 --- /dev/null +++ b/client/coral-admin/src/actions/alwaysPremodUserDialog.js @@ -0,0 +1,14 @@ +import { + SHOW_ALWAYS_PREMOD_USER_DIALOG, + HIDE_ALWAYS_PREMOD_USER_DIALOG, +} from '../constants/alwaysPremodUserDialog.js'; + +export const showAlwaysPremodUserDialog = ({ userId, username }) => ({ + type: SHOW_ALWAYS_PREMOD_USER_DIALOG, + userId, + username, +}); + +export const hideAlwaysPremodUserDialog = () => ({ + type: HIDE_ALWAYS_PREMOD_USER_DIALOG, +}); diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index c7c7cf2a3..f3bd6301e 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -9,6 +9,8 @@ import { SET_SEARCH_VALUE, SHOW_BANUSER_DIALOG, HIDE_BANUSER_DIALOG, + SHOW_ALWAYS_PREMOD_USER_DIALOG, + HIDE_ALWAYS_PREMOD_USER_DIALOG, SHOW_REJECT_USERNAME_DIALOG, HIDE_REJECT_USERNAME_DIALOG, SET_INDICATOR_TRACK, @@ -61,6 +63,15 @@ export const setSearchValue = value => ({ export const showBanUserDialog = user => ({ type: SHOW_BANUSER_DIALOG, user }); export const hideBanUserDialog = () => ({ type: HIDE_BANUSER_DIALOG }); +// Always premod User Dialog +export const showAlwaysPremodUserDialog = user => ({ + type: SHOW_ALWAYS_PREMOD_USER_DIALOG, + user, +}); +export const hideAlwaysPremodUserDialog = () => ({ + type: HIDE_ALWAYS_PREMOD_USER_DIALOG, +}); + // Reject Username Dialog export const showRejectUsernameDialog = user => ({ type: SHOW_REJECT_USERNAME_DIALOG, diff --git a/client/coral-admin/src/components/AlwaysPremodUserDialog.css b/client/coral-admin/src/components/AlwaysPremodUserDialog.css new file mode 100644 index 000000000..5bf1beeb7 --- /dev/null +++ b/client/coral-admin/src/components/AlwaysPremodUserDialog.css @@ -0,0 +1,29 @@ +.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; +} + +.subheader { + color: black; + font-size: 1.3em; + font-weight: 500; + margin: 0 0 8px 0; +} + +.buttons { + margin-top: 8px; + margin-bottom: 6px; + text-align: right; +} diff --git a/client/coral-admin/src/components/AlwaysPremodUserDialog.js b/client/coral-admin/src/components/AlwaysPremodUserDialog.js new file mode 100644 index 000000000..7d01d6259 --- /dev/null +++ b/client/coral-admin/src/components/AlwaysPremodUserDialog.js @@ -0,0 +1,68 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; +import { Dialog } from 'coral-ui'; +import styles from './AlwaysPremodUserDialog.css'; + +import Button from 'coral-ui/components/Button'; +import t from 'coral-framework/services/i18n'; + +class AlwaysPremodUserDialog extends React.Component { + handlePerform = () => { + this.props.onPerform(); + }; + + render() { + const { open, onCancel, username, info } = this.props; + return ( + + + × + +
+

+ {t('alwayspremoddialog.always_premod_user')} +

+

+ {t('alwayspremoddialog.are_you_sure', username)} +

+

{info}

+
+ + +
+
+
+ ); + } +} + +AlwaysPremodUserDialog.propTypes = { + open: PropTypes.bool, + onPerform: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + username: PropTypes.string, + info: PropTypes.string, +}; + +export default AlwaysPremodUserDialog; diff --git a/client/coral-admin/src/components/UserDetail.css b/client/coral-admin/src/components/UserDetail.css index c35e4776f..eb379c00f 100644 --- a/client/coral-admin/src/components/UserDetail.css +++ b/client/coral-admin/src/components/UserDetail.css @@ -137,6 +137,12 @@ display: inline-block; } +.actionsMenuAlwaysPremod { + background-color: #F0B50B; + border-color: #F0B50B; + color: white; +} + .actionsMenuSuspended { background-color: #F29336; border-color: #F29336; diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index 9bde04f96..f6806915d 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -12,6 +12,7 @@ import { isUsernameRejected, isUsernameChanged, isBanned, + isAlwaysPremod, getKarma, } from 'coral-framework/utils/user'; @@ -45,6 +46,7 @@ function getUserStatusArray(user) { const statusMap = { suspended: isSuspended, banned: isBanned, + alwaysPremod: isAlwaysPremod, usernameRejected: isUsernameRejected, usernameChanged: isUsernameChanged, }; @@ -68,6 +70,12 @@ class UserDetail extends React.Component { username: this.props.root.user.username, }); + showAlwaysPremodUserDialog = () => + this.props.showAlwaysPremodUserDialog({ + userId: this.props.root.user.id, + username: this.props.root.user.username, + }); + showRejectUsernameDialog = () => this.props.showRejectUsernameDialog({ userId: this.props.root.user.id, @@ -107,6 +115,8 @@ class UserDetail extends React.Component { return t('user_detail.suspended'); case 'banned': return t('user_detail.banned'); + case 'alwaysPremod': + return t('user_detail.always_premoded'); case 'usernameRejected': return ( @@ -153,6 +163,7 @@ class UserDetail extends React.Component { toggleSelectAll, unbanUser, unsuspendUser, + removeAlwaysPremodUser, modal, acceptComment, rejectComment, @@ -169,6 +180,7 @@ class UserDetail extends React.Component { const banned = isBanned(user); const suspended = isSuspended(user); + const alwaysPremod = isAlwaysPremod(user); const usernameRejected = isUsernameRejected(user); const usernameChanged = isUsernameChanged(user); @@ -200,6 +212,7 @@ class UserDetail extends React.Component { { [styles.actionsMenuSuspended]: suspended, [styles.actionsMenuBanned]: banned, + [styles.actionsMenuAlwaysPremod]: alwaysPremod, }, 'talk-admin-user-detail-actions-button' )} @@ -231,6 +244,21 @@ class UserDetail extends React.Component { )} + {alwaysPremod ? ( + removeAlwaysPremodUser({ id: user.id })} + > + {t('user_detail.remove_always_premod')} + + ) : ( + + {t('user_detail.always_premod')} + + )} + {usernameChanged && ( {t('user_detail.username_needs_approval')} @@ -476,8 +504,10 @@ UserDetail.propTypes = { showRejectUsernameDialog: PropTypes.func, showSuspendUserDialog: PropTypes.func, showBanUserDialog: PropTypes.func, + showAlwaysPremodUserDialog: PropTypes.func, unbanUser: PropTypes.func.isRequired, unsuspendUser: PropTypes.func.isRequired, + removeAlwaysPremodUser: PropTypes.func.isRequired, modal: PropTypes.bool, rejectUsername: PropTypes.func.isRequired, }; diff --git a/client/coral-admin/src/components/UserHistory.js b/client/coral-admin/src/components/UserHistory.js index 3a3472c86..fb797c1cd 100644 --- a/client/coral-admin/src/components/UserHistory.js +++ b/client/coral-admin/src/components/UserHistory.js @@ -48,6 +48,10 @@ const buildActionResponse = (typename, created_at, until, status) => { return status ? t('user_history.user_banned') : t('user_history.ban_removed'); + case 'AlwaysPremodStatusHistory': + return status + ? t('user_history.user_always_premoded') + : t('user_history.always_premod_removed'); case 'SuspensionStatusHistory': return until ? t('user_history.suspended', readableDuration(created_at, until)) diff --git a/client/coral-admin/src/constants/alwaysPremodUserDialog.js b/client/coral-admin/src/constants/alwaysPremodUserDialog.js new file mode 100644 index 000000000..0728bbc43 --- /dev/null +++ b/client/coral-admin/src/constants/alwaysPremodUserDialog.js @@ -0,0 +1,2 @@ +export const SHOW_ALWAYS_PREMOD_USER_DIALOG = 'SHOW_ALWAYS_PREMOD_USER_DIALOG'; +export const HIDE_ALWAYS_PREMOD_USER_DIALOG = 'HIDE_ALWAYS_PREMOD_USER_DIALOG'; diff --git a/client/coral-admin/src/constants/community.js b/client/coral-admin/src/constants/community.js index 1ffe3f8d6..b47ec0455 100644 --- a/client/coral-admin/src/constants/community.js +++ b/client/coral-admin/src/constants/community.js @@ -14,6 +14,9 @@ export const FETCH_FLAGGED_COMMENTERS_FAILURE = `${prefix}_FETCH_FLAGGED_COMMENT export const SHOW_BANUSER_DIALOG = `${prefix}_SHOW_BANUSER_DIALOG`; export const HIDE_BANUSER_DIALOG = `${prefix}_HIDE_BANUSER_DIALOG`; +export const SHOW_ALWAYS_PREMOD_USER_DIALOG = `${prefix}_SHOW_ALWAYS_PREMOD_USER_DIALOG`; +export const HIDE_ALWAYS_PREMOD_USER_DIALOG = `${prefix}_HIDE_ALWAYS_PREMOD_USER_DIALOG`; + export const SHOW_REJECT_USERNAME_DIALOG = `${prefix}_SHOW_REJECT_USERNAME_DIALOG`; export const HIDE_REJECT_USERNAME_DIALOG = `${prefix}_HIDE_REJECT_USERNAME_DIALOG`; diff --git a/client/coral-admin/src/containers/AlwaysPremodUserDialog.js b/client/coral-admin/src/containers/AlwaysPremodUserDialog.js new file mode 100644 index 000000000..e6e195810 --- /dev/null +++ b/client/coral-admin/src/containers/AlwaysPremodUserDialog.js @@ -0,0 +1,66 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import AlwaysPremodUserDialog from '../components/AlwaysPremodUserDialog'; +import { hideAlwaysPremodUserDialog } from '../actions/alwaysPremodUserDialog'; +import { withAlwaysPremodUser } from 'coral-framework/graphql/mutations'; +import { compose } from 'react-apollo'; +import t from 'coral-framework/services/i18n'; + +class AlwaysPremodUserDialogContainer extends Component { + alwaysPremodUser = async () => { + const { userId, alwaysPremodUser, hideAlwaysPremodUserDialog } = this.props; + await alwaysPremodUser({ id: userId }); + hideAlwaysPremodUserDialog(); + }; + + getInfo() { + let note = t('alwayspremoddialog.note_always_premod_user'); + return t('alwayspremoddialog.note', note); + } + + render() { + return ( + + ); + } +} + +AlwaysPremodUserDialogContainer.propTypes = { + alwaysPremodUser: PropTypes.func.isRequired, + hideAlwaysPremodUserDialog: PropTypes.func, + open: PropTypes.bool, + username: PropTypes.string, +}; + +const mapStateToProps = ({ + alwaysPremodUserDialog: { open, userId, username }, +}) => ({ + open, + userId, + username, +}); + +const mapDispatchToProps = dispatch => ({ + ...bindActionCreators( + { + hideAlwaysPremodUserDialog, + }, + dispatch + ), +}); + +export default compose( + connect( + mapStateToProps, + mapDispatchToProps + ), + withAlwaysPremodUser +)(AlwaysPremodUserDialogContainer); diff --git a/client/coral-admin/src/containers/Layout.js b/client/coral-admin/src/containers/Layout.js index 22c624b7d..10e4666f8 100644 --- a/client/coral-admin/src/containers/Layout.js +++ b/client/coral-admin/src/containers/Layout.js @@ -5,6 +5,7 @@ import Layout from '../components/Layout'; import Login from '../containers/Login'; import { FullLoading } from '../components/FullLoading'; import BanUserDialog from './BanUserDialog'; +import AlwaysPremodUserDialog from './AlwaysPremodUserDialog'; import SuspendUserDialog from './SuspendUserDialog'; import RejectUsernameDialog from './RejectUsernameDialog'; import { toggleModal as toggleShortcutModal } from '../actions/moderation'; @@ -40,6 +41,7 @@ class LayoutContainer extends React.Component { toggleShortcutModal={toggleShortcutModal} currentUser={this.props.currentUser} > + diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js index e9ffea4d1..7053f6c0b 100644 --- a/client/coral-admin/src/containers/UserDetail.js +++ b/client/coral-admin/src/containers/UserDetail.js @@ -22,12 +22,14 @@ import { withSetCommentStatus, withUnbanUser, withUnsuspendUser, + withRemoveAlwaysPremodUser, withRejectUsername, withPostFlag, } from 'coral-framework/graphql/mutations'; import UserDetailComment from './UserDetailComment'; import update from 'immutability-helper'; import { showBanUserDialog } from 'actions/banUserDialog'; +import { showAlwaysPremodUserDialog } from 'actions/alwaysPremodUserDialog'; import { showSuspendUserDialog } from 'actions/suspendUserDialog'; import { showRejectUsernameDialog } from 'actions/rejectUsernameDialog'; @@ -153,6 +155,7 @@ UserDetailContainer.propTypes = { selectedCommentIds: PropTypes.array, unbanUser: PropTypes.func.isRequired, unsuspendUser: PropTypes.func.isRequired, + removeAlwaysPremodUser: PropTypes.func.isRequired, rejectUsername: PropTypes.func.isRequired, userId: PropTypes.string, }; @@ -218,6 +221,17 @@ export const withUserDetailQuery = withQuery( created_at } } + alwaysPremod { + status + history { + status + assigned_by { + id + username + } + created_at + } + }, username { status history { @@ -280,6 +294,7 @@ const mapDispatchToProps = dispatch => ({ ...bindActionCreators( { showBanUserDialog, + showAlwaysPremodUserDialog, showSuspendUserDialog, showRejectUsernameDialog, changeTab, @@ -302,6 +317,7 @@ export default compose( withSetCommentStatus, withUnbanUser, withUnsuspendUser, + withRemoveAlwaysPremodUser, withRejectUsername, withPostFlag, withRouter diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 9fca9f497..11eea3d18 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -10,6 +10,9 @@ const userStatusFragment = gql` banned { status } + alwaysPremod { + status + } suspension { until } @@ -125,6 +128,64 @@ export default { }); }, }), + AlwaysPremodUser: ({ + variables: { + input: { id }, + }, + }) => ({ + update: proxy => { + const fragmentId = `User_${id}`; + const data = proxy.readFragment({ + fragment: userStatusFragment, + id: fragmentId, + }); + + const updated = update(data, { + state: { + status: { + alwaysPremod: { + status: { $set: true }, + }, + }, + }, + }); + + proxy.writeFragment({ + fragment: userStatusFragment, + id: fragmentId, + data: updated, + }); + }, + }), + RemoveAlwaysPremodUser: ({ + variables: { + input: { id }, + }, + }) => ({ + update: proxy => { + const fragmentId = `User_${id}`; + const data = proxy.readFragment({ + fragment: userStatusFragment, + id: fragmentId, + }); + + const updated = update(data, { + state: { + status: { + alwaysPremod: { + status: { $set: false }, + }, + }, + }, + }); + + proxy.writeFragment({ + fragment: userStatusFragment, + id: fragmentId, + data: updated, + }); + }, + }), BanUser: ({ variables: { input: { id }, @@ -154,6 +215,23 @@ export default { }); }, }), + SetUserAlwaysPremodStatus: ({ variables: { status, id } }) => ({ + updateQueries: { + TalkAdmin_Community: prev => { + if (!status) { + return prev; + } + const updated = update(prev, { + users: { + nodes: { + $apply: nodes => nodes.filter(node => node.id !== id), + }, + }, + }); + return updated; + }, + }, + }), UnbanUser: ({ variables: { input: { id }, diff --git a/client/coral-admin/src/reducers/alwaysPremodUserDialog.js b/client/coral-admin/src/reducers/alwaysPremodUserDialog.js new file mode 100644 index 000000000..a5da4c54b --- /dev/null +++ b/client/coral-admin/src/reducers/alwaysPremodUserDialog.js @@ -0,0 +1,29 @@ +import { + SHOW_ALWAYS_PREMOD_USER_DIALOG, + HIDE_ALWAYS_PREMOD_USER_DIALOG, +} from '../constants/alwaysPremodUserDialog'; + +const initialState = { + open: false, + userId: null, + username: '', +}; + +export default function alwaysPremodUserDialog(state = initialState, action) { + switch (action.type) { + case SHOW_ALWAYS_PREMOD_USER_DIALOG: + return { + ...state, + open: true, + userId: action.userId, + username: action.username, + }; + case HIDE_ALWAYS_PREMOD_USER_DIALOG: + return { + ...state, + open: false, + }; + default: + return state; + } +} diff --git a/client/coral-admin/src/reducers/index.js b/client/coral-admin/src/reducers/index.js index e806f5d11..cded51943 100644 --- a/client/coral-admin/src/reducers/index.js +++ b/client/coral-admin/src/reducers/index.js @@ -5,6 +5,7 @@ import moderation from './moderation'; import install from './install'; import banUserDialog from './banUserDialog'; import suspendUserDialog from './suspendUserDialog'; +import alwaysPremodUserDialog from './alwaysPremodUserDialog'; import rejectUsernameDialog from './rejectUsernameDialog'; import userDetail from './userDetail'; import ui from './ui'; @@ -14,6 +15,7 @@ export default { banUserDialog, configure, suspendUserDialog, + alwaysPremodUserDialog, userDetail, stories, community, diff --git a/client/coral-admin/src/reducers/ui.js b/client/coral-admin/src/reducers/ui.js index dcb9ddbe0..263ae4ddd 100644 --- a/client/coral-admin/src/reducers/ui.js +++ b/client/coral-admin/src/reducers/ui.js @@ -6,6 +6,10 @@ import { SHOW_SUSPEND_USER_DIALOG, HIDE_SUSPEND_USER_DIALOG, } from '../constants/suspendUserDialog'; +import { + SHOW_ALWAYS_PREMOD_USER_DIALOG, + HIDE_ALWAYS_PREMOD_USER_DIALOG, +} from '../constants/alwaysPremodUserDialog'; const initialState = { modal: false, @@ -23,6 +27,11 @@ export default function config(state = initialState, action) { ...state, modal: true, }; + case SHOW_ALWAYS_PREMOD_USER_DIALOG: + return { + ...state, + modal: true, + }; case HIDE_BAN_USER_DIALOG: return { ...state, @@ -33,6 +42,11 @@ export default function config(state = initialState, action) { ...state, modal: false, }; + case HIDE_ALWAYS_PREMOD_USER_DIALOG: + return { + ...state, + modal: false, + }; default: return state; } diff --git a/client/coral-admin/src/routes/Community/components/People.css b/client/coral-admin/src/routes/Community/components/People.css index aea321cd7..4b7cfd4a8 100644 --- a/client/coral-admin/src/routes/Community/components/People.css +++ b/client/coral-admin/src/routes/Community/components/People.css @@ -130,6 +130,12 @@ th.header:nth-child(2), th.header:nth-child(3) { font-size: 0.98em; } +.actionsMenuAlwaysPremod { + background-color: #F0B50B; + border-color: #F0B50B; + color: white; +} + .actionsMenuSuspended { background-color: #F29336; border-color: #F29336; diff --git a/client/coral-admin/src/routes/Community/components/People.js b/client/coral-admin/src/routes/Community/components/People.js index e40b6cca4..cbcb74bc9 100644 --- a/client/coral-admin/src/routes/Community/components/People.js +++ b/client/coral-admin/src/routes/Community/components/People.js @@ -8,7 +8,11 @@ import LoadMore from '../../../components/LoadMore'; import PropTypes from 'prop-types'; import ActionsMenu from 'coral-admin/src/components/ActionsMenu'; import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem'; -import { isSuspended, isBanned } from 'coral-framework/utils/user'; +import { + isSuspended, + isBanned, + isAlwaysPremod, +} from 'coral-framework/utils/user'; import { RadioGroup, Radio } from 'react-mdl'; import moment from 'moment'; import { @@ -43,6 +47,8 @@ class People extends React.Component { return 'Banned'; } else if (isSuspended(user)) { return 'Suspended'; + } else if (isAlwaysPremod(user)) { + return 'Always premoderated'; } return ''; }; @@ -55,6 +61,10 @@ class People extends React.Component { this.props.unbanUser(input); }; + removeAlwaysPremodUser = input => { + this.props.removeAlwaysPremodUser(input); + }; + showBanUserDialog = input => { this.props.showBanUserDialog(input); }; @@ -63,6 +73,10 @@ class People extends React.Component { this.props.showSuspendUserDialog(input); }; + showAlwaysPremodUserDialog = input => { + this.props.showAlwaysPremodUserDialog(input); + }; + render() { const { onFilterChange, @@ -109,6 +123,7 @@ class People extends React.Component { {t('community.active')} {t('community.suspended')} {t('community.banned')} + {t('community.always_premod')}
{t('community.filter_role')} @@ -188,6 +203,9 @@ class People extends React.Component { user ), [styles.actionsMenuBanned]: isBanned(user), + [styles.actionsMenuAlwaysPremod]: isAlwaysPremod( + user + ), }, 'talk-admin-user-detail-actions-button' )} @@ -232,6 +250,27 @@ class People extends React.Component { {t('modqueue.ban_user_actions')} )} + + {isAlwaysPremod(user) ? ( + + this.removeAlwaysPremodUser({ id: user.id }) + } + > + Remove Always Premoderate + + ) : ( + + this.showAlwaysPremodUserDialog({ + userId: user.id, + username: user.username, + }) + } + > + {t('modqueue.always_premod_user_actions')} + + )} @@ -294,8 +333,10 @@ People.propTypes = { viewUserDetail: PropTypes.func.isRequired, unbanUser: PropTypes.func.isRequired, unsuspendUser: PropTypes.func.isRequired, + removeAlwaysPremodUser: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func, showBanUserDialog: PropTypes.func, + showAlwaysPremodUserDialog: PropTypes.func, loadMore: PropTypes.func.isRequired, }; diff --git a/client/coral-admin/src/routes/Community/containers/People.js b/client/coral-admin/src/routes/Community/containers/People.js index 7f47795b4..57fb7fa8e 100644 --- a/client/coral-admin/src/routes/Community/containers/People.js +++ b/client/coral-admin/src/routes/Community/containers/People.js @@ -8,9 +8,11 @@ import { withUnbanUser, withUnsuspendUser, withSetUserRole, + withRemoveAlwaysPremodUser, } from 'coral-framework/graphql/mutations'; import { showBanUserDialog } from 'actions/banUserDialog'; import { showSuspendUserDialog } from 'actions/suspendUserDialog'; +import { showAlwaysPremodUserDialog } from 'actions/alwaysPremodUserDialog'; import { viewUserDetail } from '../../../actions/userDetail'; import { appendNewNodes } from 'plugin-api/beta/client/utils'; import update from 'immutability-helper'; @@ -30,6 +32,7 @@ class PeopleContainer extends React.PureComponent { active: { suspended: false, banned: false }, suspended: { suspended: true }, banned: { banned: true }, + alwaysPremod: { alwaysPremod: true }, }; onFilterChange = filter => e => @@ -40,7 +43,6 @@ class PeopleContainer extends React.PureComponent { getFilterState = () => { const { role, status } = this.state; - return { status: this.statusToQuery[status] || null, role: role || null, @@ -106,8 +108,10 @@ class PeopleContainer extends React.PureComponent { setUserRole={this.props.setUserRole} showSuspendUserDialog={this.props.showSuspendUserDialog} showBanUserDialog={this.props.showBanUserDialog} + showAlwaysPremodUserDialog={this.props.showAlwaysPremodUserDialog} unbanUser={this.props.unbanUser} unsuspendUser={this.props.unsuspendUser} + removeAlwaysPremodUser={this.props.removeAlwaysPremodUser} data={this.props.data} root={this.props.root} users={this.props.root.users} @@ -122,9 +126,11 @@ PeopleContainer.propTypes = { setUserRole: PropTypes.func.isRequired, unbanUser: PropTypes.func.isRequired, unsuspendUser: PropTypes.func.isRequired, + removeAlwaysPremodUser: PropTypes.func.isRequired, viewUserDetail: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func, showBanUserDialog: PropTypes.func, + showAlwaysPremodUserDialog: PropTypes.func, data: PropTypes.object, root: PropTypes.object, }; @@ -153,6 +159,9 @@ const LOAD_MORE_QUERY = gql` banned { status } + alwaysPremod { + status + } suspension { until } @@ -187,6 +196,9 @@ const FILTER_QUERY = gql` banned { status } + alwaysPremod { + status + } suspension { until } @@ -203,6 +215,7 @@ const mapDispatchToProps = dispatch => viewUserDetail, showSuspendUserDialog, showBanUserDialog, + showAlwaysPremodUserDialog, }, dispatch ); @@ -215,6 +228,7 @@ export default compose( withSetUserRole, withUnsuspendUser, withUnbanUser, + withRemoveAlwaysPremodUser, withQuery( gql` query TalkAdmin_Community_People { @@ -236,6 +250,9 @@ export default compose( banned { status } + alwaysPremod { + status + } suspension { until } diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index 836def259..ef7c361a5 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -175,6 +175,9 @@ const USER_BANNED_SUBSCRIPTION = gql` banned { status } + alwaysPremod { + status + } suspension { until } @@ -196,6 +199,9 @@ const USER_SUSPENDED_SUBSCRIPTION = gql` banned { status } + alwaysPremod { + status + } suspension { until } @@ -217,6 +223,9 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql` banned { status } + alwaysPremod { + status + } suspension { until } @@ -253,6 +262,9 @@ const EMBED_QUERY = gql` banned { status } + alwaysPremod { + status + } suspension { until } diff --git a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js index 1f32fcedf..a379e6dbd 100644 --- a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js @@ -398,6 +398,9 @@ const fragments = { banned { status } + alwaysPremod { + status + } suspension { until } diff --git a/client/coral-framework/graphql/fragments.js b/client/coral-framework/graphql/fragments.js index 778214b8b..976776933 100644 --- a/client/coral-framework/graphql/fragments.js +++ b/client/coral-framework/graphql/fragments.js @@ -3,6 +3,7 @@ import { createDefaultResponseFragments } from '../utils'; // fragments defined here are automatically registered. export default { ...createDefaultResponseFragments( + 'AlwaysPremodUserResponse', 'BanUsersResponse', 'ChangeUsernameResponse', 'CloseAssetResponse', @@ -14,6 +15,7 @@ export default { 'IgnoreUserResponse', 'ModifyTagResponse', 'PostFlagResponse', + 'RemoveAlwaysPremodUserResponse', 'SetCommentStatusResponse', 'SetUsernameResponse', 'SetUsernameStatusResponse', diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index 8191c2cd0..78c044270 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -412,6 +412,48 @@ export const withSetUsername = withMutation( } ); +export const withAlwaysPremodUser = withMutation( + gql` + mutation AlwaysPremodUser($input: AlwaysPremodUserInput!) { + alwaysPremodUser(input: $input) { + ...AlwaysPremodUserResponse + } + } + `, + { + props: ({ mutate }) => ({ + alwaysPremodUser: input => { + return mutate({ + variables: { + input, + }, + }); + }, + }), + } +); + +export const withRemoveAlwaysPremodUser = withMutation( + gql` + mutation RemoveAlwaysPremodUser($input: RemoveAlwaysPremodUserInput!) { + removeAlwaysPremodUser(input: $input) { + ...RemoveAlwaysPremodUserResponse + } + } + `, + { + props: ({ mutate }) => ({ + removeAlwaysPremodUser: input => { + return mutate({ + variables: { + input, + }, + }); + }, + }), + } +); + export const withBanUser = withMutation( gql` mutation BanUser($input: BanUserInput!) { diff --git a/client/coral-framework/utils/user.js b/client/coral-framework/utils/user.js index cdc0884d5..174815bfb 100644 --- a/client/coral-framework/utils/user.js +++ b/client/coral-framework/utils/user.js @@ -35,6 +35,15 @@ export const isBanned = user => { return get(user, 'state.status.banned.status'); }; +/** + * isAlwaysPremod + * retrieves boolean based on the user premod status + */ + +export const isAlwaysPremod = user => { + return get(user, 'state.status.alwaysPremod.status'); +}; + /** * isUsernameRejected * retrieves boolean based on the username status diff --git a/graph/loaders/users.js b/graph/loaders/users.js index 11d35ac95..cc91af340 100644 --- a/graph/loaders/users.js +++ b/graph/loaders/users.js @@ -11,7 +11,7 @@ const mergeState = (query, state) => { } if (status) { - const { username, banned, suspended } = status; + const { username, banned, suspended, alwaysPremod } = status; if (typeof username !== 'undefined' && username && username.length > 0) { query.merge({ @@ -27,6 +27,12 @@ const mergeState = (query, state) => { }); } + if (typeof alwaysPremod !== 'undefined' && alwaysPremod !== null) { + query.merge({ + 'status.alwaysPremod.status': alwaysPremod, + }); + } + if (typeof suspended !== 'undefined' && suspended !== null) { if (suspended) { query.merge({ diff --git a/graph/mutators/user.js b/graph/mutators/user.js index fa6a18ee5..3732ee0de 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -10,6 +10,7 @@ const { SET_USERNAME, SET_USER_USERNAME_STATUS, SET_USER_BAN_STATUS, + SET_USER_ALWAYS_PREMOD_STATUS, SET_USER_SUSPENSION_STATUS, UPDATE_USER_ROLES, DELETE_OTHER_USER, @@ -32,6 +33,13 @@ const setUserBanStatus = async (ctx, id, status = false, message = null) => { } }; +const setUserAlwaysPremodStatus = async (ctx, id, status = false) => { + const user = await Users.setAlwaysPremodStatus(id, status, ctx.user.id); + if (user.alwaysPremod) { + ctx.pubsub.publish('userAlwaysPremod', user); + } +}; + const setUserSuspensionStatus = async ( ctx, id, @@ -220,6 +228,7 @@ module.exports = ctx => { setRole: () => Promise.reject(new ErrNotAuthorized()), setUserBanStatus: () => Promise.reject(new ErrNotAuthorized()), setUserSuspensionStatus: () => Promise.reject(new ErrNotAuthorized()), + setUserAlwaysPremodStatus: () => Promise.reject(new ErrNotAuthorized()), setUserUsernameStatus: () => Promise.reject(new ErrNotAuthorized()), setUsername: () => Promise.reject(new ErrNotAuthorized()), stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()), @@ -256,6 +265,11 @@ module.exports = ctx => { setUserBanStatus(ctx, id, status, message); } + if (ctx.user.can(SET_USER_ALWAYS_PREMOD_STATUS)) { + mutators.User.setUserAlwaysPremodStatus = (id, status) => + setUserAlwaysPremodStatus(ctx, id, status); + } + if (ctx.user.can(SET_USER_SUSPENSION_STATUS)) { mutators.User.setUserSuspensionStatus = (id, until, message) => setUserSuspensionStatus(ctx, id, until, message); diff --git a/graph/resolvers/always_premod_status_history.js b/graph/resolvers/always_premod_status_history.js new file mode 100644 index 000000000..9fccae1d7 --- /dev/null +++ b/graph/resolvers/always_premod_status_history.js @@ -0,0 +1,7 @@ +const { decorateUserField } = require('./util'); + +const AlwaysPremodStatusHistory = {}; + +decorateUserField(AlwaysPremodStatusHistory, 'assigned_by'); + +module.exports = AlwaysPremodStatusHistory; diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index 4bc9d9624..7dc9d24c8 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -3,6 +3,7 @@ const debug = require('debug')('talk:graph:resolvers'); const Action = require('./action'); const ActionSummary = require('./action_summary'); +const AlwaysPremodStatusHistory = require('./always_premod_status_history'); const Asset = require('./asset'); const AssetActionSummary = require('./asset_action_summary'); const BannedStatusHistory = require('./banned_status_history'); @@ -37,6 +38,7 @@ const plugins = require('../../services/plugins'); let resolvers = { Action, ActionSummary, + AlwaysPremodStatusHistory, Asset, AssetActionSummary, BannedStatusHistory, diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index d2c2965e0..6572cdca2 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -74,6 +74,16 @@ const RootMutation = { unbanUser: async (obj, { input: { id } }, { mutators: { User } }) => { await User.setUserBanStatus(id, false); }, + alwaysPremodUser: async (obj, { input: { id } }, { mutators: { User } }) => { + await User.setUserAlwaysPremodStatus(id, true); + }, + removeAlwaysPremodUser: async ( + obj, + { input: { id } }, + { mutators: { User } } + ) => { + await User.setUserAlwaysPremodStatus(id, false); + }, ignoreUser: async (_, { id }, { mutators: { User } }) => { await User.ignoreUser({ id }); }, diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index ebbf8203d..d1f2ca5a5 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -142,6 +142,10 @@ input UserStatusInput { # suspended will restrict the returned users to only those that are, or are not # suspended. If not provided, no filtering will be performed. suspended: Boolean + + # alwaysPremod will restrict the returned users to only those that are, or are not + # marked as always premoderate. If not provided, no filtering will be performed. + alwaysPremod: Boolean } type UsernameStatusHistory { @@ -166,6 +170,17 @@ type BannedStatus { history: [BannedStatusHistory!] } +type AlwaysPremodStatusHistory { + status: Boolean! + assigned_by: User + created_at: Date! +} + +type AlwaysPremodStatus { + status: Boolean! + history: [AlwaysPremodStatusHistory!] +} + type SuspensionStatusHistory { until: Date assigned_by: User @@ -184,6 +199,9 @@ type UserStatus { # banned is the bool that determines if the user is banned or not. banned: BannedStatus! + # alwaysPremod is the bool that determines if the user comments are always pushed to the premod queue or not + alwaysPremod: AlwaysPremodStatus! + # suspension is the date that the user is suspended until. suspension: SuspensionStatus! } @@ -1446,6 +1464,22 @@ type UnbanUserResponse implements Response { errors: [UserError!] } +input AlwaysPremodUserInput { + id: ID! +} + +input RemoveAlwaysPremodUserInput { + id: ID! +} + +type AlwaysPremodUserResponse implements Response { + errors: [UserError!] +} + +type RemoveAlwaysPremodUserResponse implements Response { + errors: [UserError!] +} + input SuspendUserInput { id: ID! message: String! @@ -1545,6 +1579,14 @@ type RootMutation { # Mutation is restricted. unsuspendUser(input: UnsuspendUserInput!): UnsuspendUserResponse + # Sets the always premod status on a given user. Requires the `MODERATOR` role. + # Mutation is restricted. + alwaysPremodUser(input: AlwaysPremodUserInput!): AlwaysPremodUserResponse + + # Sets the always premod status on a given user. Requires the `MODERATOR` role. + # Mutation is restricted. + removeAlwaysPremodUser(input: RemoveAlwaysPremodUserInput!): RemoveAlwaysPremodUserResponse + # Sets the ban status on a given user. Requires the `MODERATOR` role. # Mutation is restricted. banUser(input: BanUserInput!): BanUsersResponse diff --git a/locales/en.yml b/locales/en.yml index ad831f5a3..dc5653ed1 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -3,6 +3,13 @@ en: sort_comments: 'Sort Comments' view_options: 'View Options' already_flagged_username: 'You have already flagged this username.' + alwayspremoddialog: + are_you_sure: 'Are you sure you would like to always premoderate {0}?' + always_premod_user: 'Always premoderate user?' + cancel: Cancel + note: 'Note: {0}' + note_always_premod_user: 'Always premoderating this user will place all of their comments in the Pre-Moderate queue.' + yes_always_premod_user: 'Yes, always premoderate user' bandialog: are_you_sure: 'Are you sure you would like to ban {0}?' ban_user: 'Ban User?' @@ -64,6 +71,7 @@ en: admin: Administrator ads_marketing: 'This looks like an ad/marketing' all: 'All' + always_premod: 'Always premoderated' are_you_sure: 'Are you sure you would like to ban {0}?' ban_user: 'Ban User?' banned: Banned @@ -382,6 +390,7 @@ en: actions: Actions all: all all_streams: 'All Streams' + always_premod_user_actions: 'Always Premod User' approve: Approve approved: Approved ban_user: Ban @@ -528,6 +537,8 @@ en: username_flags: 'flags for this username' user_detail: all: All + always_premod: 'Always Premod User' + always_premoded: 'Always premoderated' ban: 'Ban User' banned: Banned email: Email @@ -539,6 +550,7 @@ en: reject_rate: 'Reject Rate' reject_username: 'Reject Username' rejected: Rejected + remove_always_premod: 'Remove Always Premoderate' remove_ban: 'Remove Ban' remove_suspension: 'Remove Suspension' suspend: 'Suspend User' @@ -552,12 +564,14 @@ en: username_rejected: 'Username rejected' user_history: action: Action + always_premod_removed: 'Always premoderate removed' ban_removed: 'Ban removed' date: Date suspended: 'Suspended, {0}' suspension_removed: 'Suspension removed' system: System taken_by: 'Taken By' + user_always_premoded: 'User always premoderated' user_banned: 'User banned' username_status: 'Username {0}' user_impersonating: 'This user is impersonating' diff --git a/locales/es.yml b/locales/es.yml index cff505cbd..57a789510 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -3,6 +3,13 @@ es: sort_comments: 'Ordenar comentarios' view_options: 'Ver opciones' already_flagged_username: 'Usted ya reportó a este usuario.' + alwayspremoddialog: + are_you_sure: '¿Estás seguro que quieres pre-moderar a {0}?' + always_premod_user: '¿Pre-moderar al Usuario?' + cancel: Cancelar + note: 'Nota: {0}' + note_always_premod_user: 'Pre-moderar a un usuario pondrá todos sus comentarios en la fila de Pre-Moderación.' + yes_always_premod_user: 'Sí, pre-moderar al usuario.' bandialog: are_you_sure: '¿Estás seguro que quieres suspender a {0}?' ban_user: '¿Quieres suspender al Usuario?' @@ -62,6 +69,7 @@ es: admin: Administrator ads_marketing: 'Esto parece ser un publicidad/acción de comercialización' all: 'Todos' + always_premod: 'Pre-moderado' are_you_sure: '¿Estás seguro que quieres suspender a {0}?' ban_user: '¿Quieres suspender al Usuario?' banned: Suspendido @@ -355,6 +363,7 @@ es: actions: Acciones all: todos all_streams: 'Todos los Hilos' + always_premod_user_actions: 'Siempre Pre-moderar Usuario' approve: Aprobar approved: Aprobado ban_user: Bloquear @@ -486,12 +495,15 @@ es: username_flags: 'reportes para este nombre de usuario' user_detail: all: Todos + always_premod: 'Siempre pre-moderar usuario' + always_premoded: 'Siempre pre-moderado' ban: 'Bloquear usuario' banned: Baneado email: 'Correo electrónico' member_since: 'Miembro desde' reject_rate: 'Promedio de rechazo' rejected: Rechazado + remove_always_premod: 'Cancelar siempre pre-moderar' remove_ban: 'Cancelar bloqueo' remove_suspension: 'Cancelar suspensión' suspend: 'Suspender usuario' @@ -503,12 +515,14 @@ es: username_rejected: 'Usuario rechazado' user_history: action: Acción + always_premod_removed: 'Siempre pre-moderar cancelado' ban_removed: 'Bloqueo cancelado' date: Fecha suspended: 'Suspendido, {0}' suspension_removed: 'Suspensión cancelada' system: Sistema taken_by: 'Está en uso' + user_always_premoded: 'Usuario siempre pre-moderado' user_banned: 'Usuario bloqueado' username_status: 'Nombre de usuario {0}' user_impersonating: 'Este usuario suplanta a alguien' diff --git a/models/schema/user.js b/models/schema/user.js index 40dcd5ec8..d4dca3a1c 100644 --- a/models/schema/user.js +++ b/models/schema/user.js @@ -180,6 +180,30 @@ const User = new Schema( }, ], }, + + // alwaysPremod stores the current user premod status as well as the history of + // changes. + alwaysPremod: { + // Status stores the current user premod status. + status: { + type: Boolean, + required: true, + default: false, + index: true, + }, + history: [ + { + // Status stores the historical premod status. + status: Boolean, + + // assigned_by stores the user id of the user who assigned this status. + assigned_by: { type: String, default: null }, + + // created_at stores the date when this status was assigned. + created_at: { type: Date, default: Date.now }, + }, + ], + }, }, // IgnoresUsers is an array of user id's that the current user is ignoring. @@ -359,6 +383,27 @@ User.virtual('banned') }); }); +/** + * alwaysPremod returns true when the user is currently in always premod, and sets the alwaysPremod + * status locally. + */ +User.virtual('alwaysPremod') + .get(function() { + return this.status.alwaysPremod.status; + }) + .set(function(status) { + this.status.alwaysPremod.status = status; + + if (!this.status.alwaysPremod.history) { + this.status.alwaysPremod.history = []; + } + + this.status.alwaysPremod.history.push({ + status, + created_at: new Date(), + }); + }); + /** * suspended returns true when the user is currently suspended, and sets the * suspension status locally. diff --git a/perms/constants/mutation.js b/perms/constants/mutation.js index 2d4ae04cb..54b92e00b 100644 --- a/perms/constants/mutation.js +++ b/perms/constants/mutation.js @@ -7,6 +7,7 @@ module.exports = { EDIT_COMMENT: 'EDIT_COMMENT', SET_USER_USERNAME_STATUS: 'SET_USER_USERNAME_STATUS', SET_USER_BAN_STATUS: 'SET_USER_BAN_STATUS', + SET_USER_ALWAYS_PREMOD_STATUS: 'SET_USER_ALWAYS_PREMOD_STATUS', SET_USER_SUSPENSION_STATUS: 'SET_USER_SUSPENSION_STATUS', SET_COMMENT_STATUS: 'SET_COMMENT_STATUS', ADD_COMMENT_TAG: 'ADD_COMMENT_TAG', diff --git a/perms/reducers/mutation.js b/perms/reducers/mutation.js index 8133fcd47..93cb0ab24 100644 --- a/perms/reducers/mutation.js +++ b/perms/reducers/mutation.js @@ -47,6 +47,7 @@ module.exports = (user, perm) => { case types.SET_USER_USERNAME_STATUS: case types.SET_USER_BAN_STATUS: case types.SET_USER_SUSPENSION_STATUS: + case types.SET_USER_ALWAYS_PREMOD_STATUS: case types.UPDATE_ASSET_SETTINGS: case types.UPDATE_ASSET_STATUS: return check(user, ['ADMIN', 'MODERATOR']); diff --git a/services/moderation/phases/premod.js b/services/moderation/phases/premod.js index db1a3fd1b..36aba634e 100644 --- a/services/moderation/phases/premod.js +++ b/services/moderation/phases/premod.js @@ -9,9 +9,9 @@ module.exports = ( }, } ) => { - // If the settings say that we're in premod mode, then the comment is in - // premod status. - if (moderation === 'PRE') { + // If the settings say that we're in premod mode, or the user is flagged as + // always premod, then the comment is in premod status. + if (moderation === 'PRE' || ctx.user.status.alwaysPremod.status === true) { return { status: 'PREMOD', }; diff --git a/services/users.js b/services/users.js index 2ce0b7c15..e85ed67a2 100644 --- a/services/users.js +++ b/services/users.js @@ -207,6 +207,50 @@ class Users { return user; } + static async setAlwaysPremodStatus(id, status, assignedBy = null) { + let user = await User.findOneAndUpdate( + { + id, + 'status.alwaysPremod.status': { + $ne: status, + }, + }, + { + $set: { + 'status.alwaysPremod.status': status, + }, + $push: { + 'status.alwaysPremod.history': { + status, + assigned_by: assignedBy, + created_at: Date.now(), + }, + }, + }, + { + new: true, + runValidators: true, + } + ); + + if (!user) { + user = await User.findOne({ id }); + if (!user) { + throw new ErrNotFound(); + } + + if (user.status.alwaysPremod.status === status) { + return user; + } + + throw new Error( + 'always premod status change edit failed for an unknown reason' + ); + } + + return user; + } + static async setBanStatus(id, status, assignedBy = null, message) { let user = await User.findOneAndUpdate( {