+
+ { isFetching && }
+ {
+ hasResults
+ ? commenters.map((commenter, index) => {
+ if (commenter.status === 'PENDING' && commenter.actions.length > 0) {
+ return ;
+ }
+ return null;
+ })
+ : {lang.t('community.no-flagged-accounts')}
+ }
+
+
+ );
+};
+
+export default FlaggedAccounts;
diff --git a/client/coral-admin/src/containers/Community/Community.js b/client/coral-admin/src/containers/Community/People.js
similarity index 94%
rename from client/coral-admin/src/containers/Community/Community.js
rename to client/coral-admin/src/containers/Community/People.js
index 08a962c34..b5701b5a4 100644
--- a/client/coral-admin/src/containers/Community/Community.js
+++ b/client/coral-admin/src/containers/Community/People.js
@@ -29,7 +29,7 @@ const tableHeaders = [
}
];
-const Community = ({isFetching, commenters, ...props}) => {
+const People = ({isFetching, commenters, ...props}) => {
const hasResults = !isFetching && !!commenters.length;
return (
@@ -58,7 +58,7 @@ const Community = ({isFetching, commenters, ...props}) => {
hasResults
?
:
{lang.t('community.no-results')}
@@ -73,4 +73,4 @@ const Community = ({isFetching, commenters, ...props}) => {
);
};
-export default Community;
+export default People;
diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js
index 480ce72bf..aacc9c82f 100644
--- a/client/coral-admin/src/containers/Community/Table.js
+++ b/client/coral-admin/src/containers/Community/Table.js
@@ -77,4 +77,4 @@ class Table extends Component {
}
}
-export default connect(state => ({commenters: state.community.get('commenters')}))(Table);
+export default connect(state => ({commenters: state.community.get('accounts')}))(Table);
diff --git a/client/coral-admin/src/containers/Community/components/ActionButton.js b/client/coral-admin/src/containers/Community/components/ActionButton.js
new file mode 100644
index 000000000..7ae8aa20b
--- /dev/null
+++ b/client/coral-admin/src/containers/Community/components/ActionButton.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import styles from '../Community.css';
+import BanUserButton from '../../../components/BanUserButton';
+import {FabButton} from 'coral-ui';
+import {menuActionsMap} from '../../../containers/ModerationQueue/helpers/moderationQueueActionsMap';
+
+const ActionButton = ({type = '', user, ...props}) => {
+ if (type === 'BAN') {
+ return
props.showBanUserDialog(user)} />;
+ }
+
+ return (
+ {
+ type === 'APPROVE' ? props.approveUser({userId: user.id}) : props.showSuspendUserDialog({user: user});
+ }}
+ />
+ );
+};
+
+export default ActionButton;
diff --git a/client/coral-admin/src/containers/Community/components/BanUserDialog.css b/client/coral-admin/src/containers/Community/components/BanUserDialog.css
new file mode 100644
index 000000000..a46b9da32
--- /dev/null
+++ b/client/coral-admin/src/containers/Community/components/BanUserDialog.css
@@ -0,0 +1,164 @@
+.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: 500px;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 184px;
+ padding: 20px;
+
+ h2 {
+ color: black;
+ font-size: 1.76em;
+ font-weight: 500;
+ margin: 0;
+ }
+
+ h3 {
+ color: black;
+ font-size: 1.4em;
+ font-weight: 500;
+ margin: 0;
+ }
+}
+
+.textField {
+ margin-top: 15px;
+}
+
+.textField label {
+ font-size: 1.08em;
+ font-weight: bold;
+ margin-bottom: 5px;
+}
+
+.textField input {
+ width: 100%;
+ display: block;
+ border: none;
+ outline: none;
+ border: 1px solid rgba(0,0,0,.12);
+ padding: 10px 6px;
+ box-sizing: border-box;
+ border-radius: 2px;
+ margin: 5px auto;
+}
+
+.footer {
+ margin: 20px auto 10px;
+ text-align: center;
+}
+
+.footer span {
+ display: block;
+ margin-bottom: 5px;
+}
+
+.footer a {
+ color: #2c69b6;
+ cursor: pointer;
+ margin: 0 5px;
+}
+
+.socialConnections {
+ margin-bottom: 20px;
+}
+
+.signInButton {
+ margin-top: 10px;
+}
+
+.close {
+ font-size: 20px;
+ line-height: 14px;
+ top: 10px;
+ right: 10px;
+ position: absolute;
+ display: block;
+ font-weight: bold;
+ color: #363636;
+ cursor: pointer;
+}
+
+.close:hover {
+ color: #6b6b6b;
+}
+
+input.error{
+ border: solid 2px #f44336;
+}
+
+.errorMsg, .hint {
+ color: grey;
+ font-weight: 600;
+ padding: 3px 0 16px;
+}
+
+.alert {
+ padding: 10px;
+ margin-bottom: 20px;
+ border-radius: 2px;
+}
+
+.alert--success {
+ border: solid 1px #1ec00e;
+ background: #cbf1b8;
+ color: #006900;
+}
+
+.alert--error {
+ background: #FFEBEE;
+ color: #B71C1C;
+}
+
+.userBox a {
+ color: #2c69b6;
+ cursor: pointer;
+ margin: 0px;
+}
+
+.attention {
+ display: inline-block;
+ width: 15px;
+ height: 15px;
+ background: #B71C1C;
+ color: #FFEBEE;
+ font-weight: bolder;
+ padding: 4px;
+ vertical-align: middle;
+ border-radius: 20px;
+ box-sizing: border-box;
+ font-size: 9px;
+ line-height: 7px;
+ text-align: center;
+ margin-right: 5px;
+}
+
+.action {
+ margin-top: 15px;
+}
+
+.passwordRequestSuccess {
+ border: 1px solid green;
+ background-color: lightgreen;
+ padding: 10px;
+}
+
+.passwordRequestFailure {
+ border: 1px solid orange;
+ background-color: 1px solid coral;
+ padding: 10px;
+}
+
+.cancel {
+ margin-right: 10px;
+ width: 47%;
+}
+
+.ban {
+ width: 47%;
+}
+
+.buttons {
+ margin: 20px 0;
+}
diff --git a/client/coral-admin/src/containers/Community/components/BanUserDialog.js b/client/coral-admin/src/containers/Community/components/BanUserDialog.js
new file mode 100644
index 000000000..25169ffee
--- /dev/null
+++ b/client/coral-admin/src/containers/Community/components/BanUserDialog.js
@@ -0,0 +1,53 @@
+import React, {PropTypes} from 'react';
+import {Dialog} from 'coral-ui';
+import styles from './BanUserDialog.css';
+
+import Button from 'coral-ui/components/Button';
+
+import I18n from 'coral-framework/modules/i18n/i18n';
+import translations from '../../../translations';
+const lang = new I18n(translations);
+
+const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
+
+ ×
+
+
+
{lang.t('community.ban_user')}
+
+
+
{lang.t('community.are_you_sure', user.username)}
+ {lang.t('community.note')}
+
+
+
+ {lang.t('community.cancel')}
+
+ {
+ handleBanUser({userId: user.id}).then(() => {
+ handleClose();
+ });
+ }}
+ raised>
+ {lang.t('community.yes_ban_user')}
+
+
+
+
+);
+
+BanUserDialog.propTypes = {
+ handleBanUser: PropTypes.func.isRequired,
+ handleClose: PropTypes.func.isRequired,
+ user: PropTypes.object.isRequired,
+};
+
+export default BanUserDialog;
diff --git a/client/coral-admin/src/containers/Community/components/CommunityMenu.js b/client/coral-admin/src/containers/Community/components/CommunityMenu.js
new file mode 100644
index 000000000..9a51798ec
--- /dev/null
+++ b/client/coral-admin/src/containers/Community/components/CommunityMenu.js
@@ -0,0 +1,31 @@
+import React from 'react';
+
+import styles from './styles.css';
+
+import I18n from 'coral-framework/modules/i18n/i18n';
+import translations from 'coral-admin/src/translations.json';
+
+import {Link} from 'react-router';
+
+const lang = new I18n(translations);
+
+const CommunityMenu = () => {
+ const flaggedPath = '/admin/community/flagged';
+ const peoplePath = '/admin/community/people';
+ return (
+
+
+
+
+ {lang.t('community.flaggedaccounts')}
+
+
+ {lang.t('community.people')}
+
+
+
+
+ );
+};
+
+export default CommunityMenu;
diff --git a/client/coral-admin/src/components/SuspendUserModal.css b/client/coral-admin/src/containers/Community/components/SuspendUserDialog.css
similarity index 100%
rename from client/coral-admin/src/components/SuspendUserModal.css
rename to client/coral-admin/src/containers/Community/components/SuspendUserDialog.css
diff --git a/client/coral-admin/src/containers/Community/components/SuspendUserDialog.js b/client/coral-admin/src/containers/Community/components/SuspendUserDialog.js
new file mode 100644
index 000000000..5749a47a5
--- /dev/null
+++ b/client/coral-admin/src/containers/Community/components/SuspendUserDialog.js
@@ -0,0 +1,115 @@
+import React, {Component, PropTypes} from 'react';
+
+import {Dialog, Button} from 'coral-ui';
+import styles from './SuspendUserDialog.css';
+
+import I18n from 'coral-framework/modules/i18n/i18n';
+import translations from '../../../translations.json';
+
+const lang = new I18n(translations);
+
+const stages = [
+ {
+ title: 'suspenduser.title_0',
+ description: 'suspenduser.description_0',
+ options: {
+ 'j': 'suspenduser.no_cancel',
+ 'k': 'suspenduser.yes_suspend'
+ }
+ },
+ {
+ title: 'suspenduser.title_1',
+ description: 'suspenduser.description_1',
+ options: {
+ 'j': 'bandialog.cancel',
+ 'k': 'suspenduser.send'
+ }
+ }
+];
+
+class SuspendUserDialog extends Component {
+
+ state = {email: '', stage: 0}
+
+ static propTypes = {
+ stage: PropTypes.number,
+ handleClose: PropTypes.func.isRequired,
+ suspendUser: PropTypes.func.isRequired
+ }
+
+ componentDidMount() {
+ this.setState({email: lang.t('suspenduser.email'), about: lang.t('suspenduser.username')});
+ }
+
+ /*
+ * When an admin clicks to suspend a user a dialog is shown, this function
+ * handles the possible actions for that dialog.
+ */
+ onActionClick = (stage, menuOption) => () => {
+ const {suspendUser, user} = this.props;
+ const {stage} = this.state;
+
+ const cancel = this.props.onClose;
+ const next = () => this.setState({stage: stage + 1});
+ const suspend = () => {
+ suspendUser({userId: user.user.id})
+ .then(() => {
+ this.props.handleClose();
+ });
+ };
+
+ const suspendModalActions = [
+ [ cancel, next ],
+ [ cancel, suspend ]
+ ];
+ return suspendModalActions[stage][menuOption]();
+ }
+
+ onEmailChange = (e) => {
+ this.setState({email: e.target.value});
+ }
+
+ render () {
+ const {open, handleClose} = this.props;
+ const {stage} = this.state;
+
+ return
+
+ {lang.t(stages[stage].title, lang.t('suspenduser.username'))}
+
+
+
+ {lang.t(stages[stage].description, lang.t('suspenduser.username'))}
+
+ {
+ stage === 1 &&
+
+
{lang.t('suspenduser.write_message')}
+
+
+
+ }
+
+ {Object.keys(stages[stage].options).map((key, i) => (
+
+ {lang.t(stages[stage].options[key], lang.t('suspenduser.username'))}
+
+ ))}
+
+
+ ;
+ }
+}
+
+export default SuspendUserDialog;
diff --git a/client/coral-admin/src/containers/Community/components/User.js b/client/coral-admin/src/containers/Community/components/User.js
new file mode 100644
index 000000000..c0442362d
--- /dev/null
+++ b/client/coral-admin/src/containers/Community/components/User.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import styles from '../Community.css';
+
+import ActionButton from './ActionButton';
+
+import I18n from 'coral-framework/modules/i18n/i18n';
+import translations from '../../../translations.json';
+
+const lang = new I18n(translations);
+
+// Render a single user for the list
+const User = props => {
+ const {user, modActionButtons} = props;
+ let userStatus = user.status;
+
+ // Do not display unless the user status is 'pending' or 'banned'.
+ // This means that they have already been reviewed and approved.
+ return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
+
+
+
+
+ flag Flags({ user.actions.length }) :
+ { user.action_summaries.map(
+ (action, i ) => {
+ return
+ {lang.t(`community.${action.reason}`)} ({action.count})
+ ;
+ }
+ )}
+
+
+ {user.actions.map(
+ (action, i) => {
+ return
+ {action.reason}
+ {/* action.user.username */}
+ ;
+ }
+ )}
+
+
+
+ {modActionButtons.map(
+ (action, i) => {
+ return
;
+ }
+ )}
+
+
+
+ ;
+};
+
+export default User;
diff --git a/client/coral-admin/src/containers/Community/components/styles.css b/client/coral-admin/src/containers/Community/components/styles.css
new file mode 100644
index 000000000..e2fd31911
--- /dev/null
+++ b/client/coral-admin/src/containers/Community/components/styles.css
@@ -0,0 +1,336 @@
+@custom-media --big-viewport (min-width: 780px);
+
+.listContainer {
+ max-width: 860px;
+ margin: 0 auto;
+}
+
+.tabBar {
+ background-color: rgba(44, 44, 44, 0.89);
+ z-index: 5;
+}
+
+.tab {
+ flex: 1;
+ color: white;
+ text-transform: capitalize;
+ font-weight: 500;
+ font-size: 15px;
+ letter-spacing: 1px;
+ transition: border-bottom 200ms;
+}
+
+.active {
+ color: white;
+ box-sizing: border-box;
+ border-bottom: solid 5px #F36451;
+}
+
+.active > span {
+ color: white;
+}
+
+.active:after {
+ background: transparent !important;
+}
+
+.showShortcuts {
+ position: absolute;
+ right: 130px;
+ display: flex;
+ align-items: center;
+ font-size: 13px;
+
+span {
+ margin-left: 7px;
+}
+}
+
+@media (--big-viewport) {
+ .tab {
+ flex: none;
+ }
+}
+
+.notFound {
+ position: relative;
+ margin: 20px auto;
+ text-align: center;
+ padding: 68px 45px;
+ vertical-align: middle;
+ min-width: 500px;
+
+ a {
+ color: rgb(244, 126, 107);
+ font-weight: 500;
+
+ &.goToStreams {
+ position: absolute;
+ right: 10px;
+ bottom: 10px;
+ }
+ }
+}
+
+.header {
+ background-color: #2c2c2c;
+ color: white;
+ margin-bottom: -1px;
+
+ .settingsButton {
+ i {
+ vertical-align: middle;
+ margin-left: 10px;
+ margin-top: -4px;
+ }
+ }
+
+ .moderateAsset {
+ a {
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1;
+ color: white;
+ text-transform: capitalize;
+ font-weight: 500;
+ font-size: 15px;
+ letter-spacing: 1px;
+ transition: opacity 200ms;
+ opacity: 1;
+
+ &:hover {
+ opacity: .8;
+ cursor: pointer;
+ }
+
+ &:first-child {
+ text-align: left;
+ }
+
+ &:nth-child(2) {
+ text-align: center;
+ }
+
+ &:last-child {
+ text-align: right;
+ }
+ }
+ }
+}
+
+
+@custom-media --big-viewport (min-width: 780px);
+
+.list {
+ padding: 8px 0;
+ list-style: none;
+ display: block;
+
+ &.singleView .listItem {
+ display: none;
+ }
+
+ &.singleView .listItem.activeItem {
+ display: block;
+ height: 100%;
+ font-size: 1.5em;
+ line-height: 1.5em;
+ border: none;
+
+ .actions {
+ position: fixed;
+ bottom: 60px;
+ left: 25%;
+ margin: 0 auto;
+ display: flex;
+ justify-content: space-around;
+ width: 50%;
+ margin: 0;
+ }
+
+ .actionButton {
+ transform: scale(1.4);
+ }
+ }
+}
+
+.listItem {
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 16px;
+ width: 100%;
+ max-width: 660px;
+ min-width: 400px;
+ margin: 0 auto;
+ padding: 16px 14px;
+ position: relative;
+ transition: box-shadow 200ms;
+ margin-top: 0;
+
+
+ &:hover {
+ box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .context {
+ a {
+ color: #f36451;
+ text-decoration: underline;
+ float: right;
+ }
+ }
+
+ .sideActions {
+ position: absolute;
+ right: 0;
+ height: 100%;
+ top: 0;
+ padding: 40px 18px;
+ box-sizing: border-box;
+ }
+
+ .itemHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ .author {
+ min-width: 230px;
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .itemBody {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .avatar {
+ margin-right: 16px;
+ height: 40px;
+ width: 40px;
+ border-radius: 50%;
+ background-color: #757575;
+ font-size: 40px;
+ color: #fff;
+ }
+
+ .created {
+ color: #666;
+ font-size: 13px;
+ margin-left: 40px;
+ }
+
+ .actionButton {
+ transform: scale(.8);
+ margin: 0;
+ }
+
+ .body {
+ margin-top: 0px;
+ flex: 1;
+ font-size: 0.88em;
+ color: black;
+ max-width: 500px;
+ word-wrap: break-word;
+ }
+
+ .flagged {
+ color: rgba(255, 0, 0, .5);
+ padding-top: 15px;
+ padding-left: 10px;
+ }
+
+ .flagCount{
+ font-size: 12px;
+ color: #d32f2f;
+ }
+
+}
+
+.empty {
+ color: #444;
+ margin-top: 50px;
+ text-align: center;
+}
+
+
+@media (--big-viewport) {
+ .listItem {
+ border: 1px solid #e0e0e0;
+ margin-bottom: 30px;
+
+ &:last-child {
+ border-bottom: 1px solid #e0e0e0;
+ }
+
+ &.activeItem {
+ border: 2px solid #333;
+ }
+ }
+
+}
+
+.hasLinks {
+ color: #f00;
+ text-align: right;
+ display: flex;
+ align-items: center;
+
+ i {
+ margin-right: 5px;
+ }
+}
+
+.banned {
+ color: #f00;
+ text-align: left;
+ display: flex;
+ align-items: center;
+
+ i {
+ margin-right: 5px;
+ }
+}
+
+.ban {
+ display: block;
+ text-align: center;
+ margin-top: 5px;
+}
+
+.Comment {
+ .moderateArticle {
+ font-size: 12px;
+ a {
+ display: inline-block;
+ color: #679af3;
+ text-decoration: none;
+ font-size: 1em;
+ font-weight: 400;
+ letter-spacing: .5px;
+ font-size: 12px;
+ margin-left: 10px;
+
+ &:hover {
+ text-decoration: underline;
+ opacity: .9;
+ cursor: pointer;
+ }
+ }
+ }
+}
+
+.flagBox {
+ max-width: 480px;
+ border-top: 1px solid rgba(66, 66, 66, 0.12);
+ h3 {
+ font-size: 14px;
+ margin: 0;
+ font-weight: 500;
+ }
+}
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js
index c5a11b642..8d9f6b7e0 100644
--- a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js
+++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js
@@ -10,9 +10,9 @@ const ModerationHeader = props => (
props.asset ?
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/styles.css b/client/coral-admin/src/containers/ModerationQueue/components/styles.css
index 59f47faad..627cde5e4 100644
--- a/client/coral-admin/src/containers/ModerationQueue/components/styles.css
+++ b/client/coral-admin/src/containers/ModerationQueue/components/styles.css
@@ -84,11 +84,10 @@ span {
margin-bottom: -1px;
.settingsButton {
- i {
- vertical-align: middle;
- margin-left: 10px;
- margin-top: -4px;
- }
+ vertical-align: middle;
+ margin-left: 10px;
+ margin-top: -4px;
+ font-size: 16px;
}
.moderateAsset {
@@ -114,7 +113,15 @@ span {
}
&:nth-child(2) {
- text-align: center;
+ span {
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ max-width: 344px;
+ display: inline-block;
+ vertical-align: top;
+ }
}
&:last-child {
diff --git a/client/coral-admin/src/graphql/mutations/index.js b/client/coral-admin/src/graphql/mutations/index.js
index fe3a1faf9..d74f4dea8 100644
--- a/client/coral-admin/src/graphql/mutations/index.js
+++ b/client/coral-admin/src/graphql/mutations/index.js
@@ -1,6 +1,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';
export const banUser = graphql(SET_USER_STATUS, {
props: ({mutate}) => ({
@@ -9,11 +10,39 @@ export const banUser = graphql(SET_USER_STATUS, {
variables: {
userId,
status: 'BANNED'
- }
+ },
+ refetchQueries: ['Users']
});
}}),
});
+export const setUserStatus = graphql(SET_USER_STATUS, {
+ props: ({mutate}) => ({
+ approveUser: ({userId}) => {
+ return mutate({
+ variables: {
+ userId,
+ status: 'APPROVED'
+ },
+ refetchQueries: ['Users']
+ });
+ }
+ })
+});
+
+export const suspendUser = graphql(SUSPEND_USER, {
+ props: ({mutate}) => ({
+ suspendUser: ({userId}) => {
+ return mutate({
+ variables: {
+ userId
+ },
+ refetchQueries: ['Users']
+ });
+ }
+ })
+});
+
export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
props: ({mutate}) => ({
acceptComment: ({commentId}) => {
diff --git a/client/coral-admin/src/graphql/mutations/suspendUser.graphql b/client/coral-admin/src/graphql/mutations/suspendUser.graphql
new file mode 100644
index 000000000..f34d93370
--- /dev/null
+++ b/client/coral-admin/src/graphql/mutations/suspendUser.graphql
@@ -0,0 +1,7 @@
+mutation suspendUser($userId: ID!) {
+ suspendUser(id: $userId) {
+ errors {
+ translation_key
+ }
+ }
+}
diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js
index c4a30ad44..e911402b0 100644
--- a/client/coral-admin/src/graphql/queries/index.js
+++ b/client/coral-admin/src/graphql/queries/index.js
@@ -1,6 +1,7 @@
import {graphql} from 'react-apollo';
import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
+import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql';
import METRICS from './metricsQuery.graphql';
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
@@ -30,6 +31,8 @@ export const getMetrics = graphql(METRICS, {
}
});
+export const modUserFlaggedQuery = graphql(MOD_USER_FLAGGED_QUERY);
+
export const modQueueResort = (id, fetchMore) => (sort) => {
return fetchMore({
query: MOD_QUEUE_QUERY,
diff --git a/client/coral-admin/src/graphql/queries/modUserFlaggedQuery.graphql b/client/coral-admin/src/graphql/queries/modUserFlaggedQuery.graphql
new file mode 100644
index 000000000..1e6a6e53b
--- /dev/null
+++ b/client/coral-admin/src/graphql/queries/modUserFlaggedQuery.graphql
@@ -0,0 +1,22 @@
+query Users ($n: ACTION_TYPE) {
+ users (query:{action_type: $n}){
+ id
+ username
+ status
+ roles
+ actions{
+ id
+ created_at
+ ... on FlagAction {
+ reason
+ }
+ user {
+ username
+ }
+ }
+ action_summaries {
+ count
+ reason
+ }
+ }
+}
diff --git a/client/coral-admin/src/reducers/community.js b/client/coral-admin/src/reducers/community.js
index 367e67b2a..d051ae0ff 100644
--- a/client/coral-admin/src/reducers/community.js
+++ b/client/coral-admin/src/reducers/community.js
@@ -6,58 +6,88 @@ import {
FETCH_COMMENTERS_SUCCESS,
SORT_UPDATE,
SET_ROLE,
- SET_COMMENTER_STATUS
+ SET_COMMENTER_STATUS,
+ SHOW_BANUSER_DIALOG,
+ HIDE_BANUSER_DIALOG,
+ SHOW_SUSPENDUSER_DIALOG,
+ HIDE_SUSPENDUSER_DIALOG
} from '../constants/community';
const initialState = Map({
community: Map(),
- isFetching: false,
- error: '',
- commenters: [],
- field: 'created_at',
- asc: false,
- totalPages: 0,
- page: 0
+ isFetchingPeople: false,
+ errorPeople: '',
+ accounts: [],
+ fieldPeople: 'created_at',
+ ascPeople: false,
+ totalPagesPeople: 0,
+ pagePeople: 0,
+ user: Map({}),
+ banDialog: false,
+ suspendDialog: false
});
export default function community (state = initialState, action) {
switch (action.type) {
case FETCH_COMMENTERS_REQUEST :
return state
- .set('isFetching', true);
+ .set('isFetchingPeople', true);
case FETCH_COMMENTERS_FAILURE :
return state
- .set('isFetching', false)
- .set('error', action.error);
+ .set('isFetchingPeople', false)
+ .set('errorPeople', action.error);
+
case FETCH_COMMENTERS_SUCCESS : {
- const {commenters, type, ...rest} = action; // eslint-disable-line
+ const {accounts, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line
return state
.merge({
- isFetching: false,
- error: '',
+ isFetchingPeople: false,
+ errorPeople: '',
+ pagePeople: page,
+ countPeople: count,
+ limitPeople: limit,
+ totalPagesPeople: totalPages,
...rest
})
- .set('commenters', commenters); // Sets to normal array
+ .set('accounts', accounts); // Sets to normal array
}
case SET_ROLE : {
- const commenters = state.get('commenters');
+ const commenters = state.get('accounts');
const idx = commenters.findIndex(el => el.id === action.id);
commenters[idx].roles[0] = action.role;
- return state.set('commenters', commenters.map(id => id));
+ return state.set('accounts', commenters.map(id => id));
}
case SET_COMMENTER_STATUS: {
- const commenters = state.get('commenters');
+ const commenters = state.get('accounts');
const idx = commenters.findIndex(el => el.id === action.id);
commenters[idx].status = action.status;
- return state.set('commenters', commenters.map(id => id));
+ return state.set('accounts', commenters.map(id => id));
}
case SORT_UPDATE :
return state
- .set('field', action.sort.field)
- .set('asc', !state.get('asc'));
+ .set('fieldPeople', action.sort.field)
+ .set('ascPeople', !state.get('ascPeople'));
+ case HIDE_BANUSER_DIALOG:
+ return state
+ .set('banDialog', false);
+ case SHOW_BANUSER_DIALOG:
+ return state
+ .merge({
+ user: Map(action.user),
+ banDialog: true
+ });
+ case HIDE_SUSPENDUSER_DIALOG:
+ return state
+ .set('suspendDialog', false);
+ case SHOW_SUSPENDUSER_DIALOG:
+ return state
+ .merge({
+ user: Map(action.user),
+ suspendDialog: true
+ });
default :
return state;
}
diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json
index ac9131685..321ace66b 100644
--- a/client/coral-admin/src/translations.json
+++ b/client/coral-admin/src/translations.json
@@ -16,7 +16,20 @@
"active": "Active",
"banned": "Banned",
"banned-user": "Banned User",
- "loading": "Loading results"
+ "loading": "Loading results",
+ "flaggedaccounts": "Flagged Usernames",
+ "people": "People",
+ "no-flagged-accounts": "The Account Flags queue is currently empty.",
+ "I don't like this username": "I don't like this username",
+ "This user is impersonating": "Impersonation",
+ "This looks like an ad/marketing": "Spam/Ads",
+ "This username is offensive": "Offensive",
+ "Other": "Other",
+ "ban_user": "Ban User?",
+ "are_you_sure": "Are you sure you would like to ban {0}?",
+ "note": "Note: Banning this user will not let them edit, comment or remove anything.",
+ "cancel": "Cancel",
+ "yes_ban_user": "Yes, Ban User"
},
"modqueue": {
"likes": "likes",
@@ -103,7 +116,8 @@
"yes_ban_user": "Yes, Ban User"
},
"suspenduser": {
- "title_0": "We noticed you rejected a {0}",
+ "title": "Suspend a user",
+ "title_0": "We noticed you rejected a username",
"description_0": "Would you like to temporarily ban this user becuase 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.",
@@ -113,7 +127,7 @@
"bio": "bio",
"username": "username",
"email_subject": "Your account has been suspended",
- "email": "Another member of the community recently flagged your {0} for review. Because of its content your {0} was rejected. This means you can no longer comment, like, or flag content until you rewrite your {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.",
+ "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 {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.",
"write_message": "Write a message"
},
"dashboard": {
@@ -157,7 +171,34 @@
"active": "Activa",
"banned": "Suspendido",
"banned-user": "Usuario Suspendido",
- "loading": "Cargando resultados"
+ "loading": "Cargando resultados",
+ "flaggedaccounts": "Nombres de Usuario Reportados",
+ "people": "Gente",
+ "no-flagged-accounts": "No hay ninguna cuenta reportada.",
+ "I don't like this username": "No me gusta ese nombre de usuario",
+ "This user is impersonating": "Suplantación",
+ "This looks like an ad/marketing": "Spam/Propaganda",
+ "This username is offensive": "Ofensivo",
+ "Other": "Otros",
+ "ban_user": "Quieres suspender el Usuario?",
+ "are_you_sure": "Estas segura que quieres suspender a {0}?",
+ "note": "Nota: Suspender a este usuario no le va a permitir borrar ni editar ni comentar.",
+ "cancel": "Cancelar",
+ "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.",
+ "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",
+ "write_message": "Escribir un mensaje"
},
"modqueue": {
"likes": "gustos",
diff --git a/client/coral-plugin-flags/FlagComment.js b/client/coral-plugin-flags/FlagComment.js
index 24fde6b0f..3f3051f28 100644
--- a/client/coral-plugin-flags/FlagComment.js
+++ b/client/coral-plugin-flags/FlagComment.js
@@ -23,14 +23,14 @@ const getPopupMenu = [
{val: 'This comment is offensive', text: lang.t('comment-offensive')},
{val: 'This looks like an ad/marketing', text: lang.t('marketing')},
{val: 'I don\'t agree with this comment', text: lang.t('no-agree-comment')},
- {val: 'other', text: lang.t('other')}
+ {val: 'Other', text: lang.t('other')}
]
: [
{val: 'This username is offensive', text: lang.t('username-offensive')},
{val: 'I don\'t like this username', text: lang.t('no-like-username')},
{val: 'This user is impersonating', text: lang.t('user-impersonating')},
{val: 'This looks like an ad/marketing', text: lang.t('marketing')},
- {val: 'other', text: lang.t('other')}
+ {val: 'Other', text: lang.t('other')}
];
return {
header: lang.t('step-2-header'),
diff --git a/client/coral-ui/components/Icon.js b/client/coral-ui/components/Icon.js
index 65bf52f72..11432c753 100644
--- a/client/coral-ui/components/Icon.js
+++ b/client/coral-ui/components/Icon.js
@@ -1,7 +1,7 @@
import React from 'react';
import {Icon as IconMDL} from 'react-mdl';
-const Icon = ({className, name}) => (
+const Icon = ({className = '', name}) => (
);
diff --git a/graph/loaders/users.js b/graph/loaders/users.js
index 90c661f71..145b0dfdb 100644
--- a/graph/loaders/users.js
+++ b/graph/loaders/users.js
@@ -3,11 +3,51 @@ const DataLoader = require('dataloader');
const util = require('./util');
const UsersService = require('../../services/users');
+const UserModel = require('../../models/user');
const genUserByIDs = (context, ids) => UsersService
.findByIdArray(ids)
.then(util.singleJoinBy(ids, 'id'));
+/**
+ * Retrieves users based on the passed in query that is filtered by the
+ * current used passed in via the context.
+ * @param {Object} context graph context
+ * @param {Object} query query terms to apply to the users query
+ */
+const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
+
+ let users = UserModel.find();
+
+ if (ids) {
+ users = users.find({
+ id: {
+ $in: ids
+ }
+ });
+ }
+
+ if (cursor) {
+ if (sort === 'REVERSE_CHRONOLOGICAL') {
+ users = users.where({
+ created_at: {
+ $lt: cursor
+ }
+ });
+ } else {
+ users = users.where({
+ created_at: {
+ $gt: cursor
+ }
+ });
+ }
+ }
+
+ return users
+ .sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1})
+ .limit(limit);
+};
+
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
@@ -15,6 +55,7 @@ const genUserByIDs = (context, ids) => UsersService
*/
module.exports = (context) => ({
Users: {
+ getByQuery: (query) => getUsersByQuery(context, query),
getByID: new DataLoader((ids) => genUserByIDs(context, ids))
}
});
diff --git a/graph/mutators/user.js b/graph/mutators/user.js
index 3f87c1fb5..6f94ae9b1 100644
--- a/graph/mutators/user.js
+++ b/graph/mutators/user.js
@@ -6,18 +6,26 @@ const setUserStatus = ({user}, {id, status}) => {
.then(res => res);
};
-module.exports = (context) => {
- if (context.user && context.user.can('mutation:setUserStatus')) {
- return {
- User: {
- setUserStatus: (action) => setUserStatus(context, action)
- }
- };
- }
+const suspendUser = ({user}, {id}) => {
+ return UsersService.suspendUser(id)
+ .then(res => res);
+};
- return {
+module.exports = (context) => {
+ let mutators = {
User: {
- setUserStatus: () => Promise.reject(errors.ErrNotAuthorized)
+ setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
+ suspendUser: () => Promise.reject(errors.ErrNotAuthorized)
}
};
+
+ if (context.user && context.user.can('mutation:setUserStatus')) {
+ mutators.User.setUserStatus = (action) => setUserStatus(context, action);
+ }
+
+ if (context.user && context.user.can('mutation:suspendUser')) {
+ mutators.User.suspendUser = (action) => suspendUser(context, action);
+ }
+
+ return mutators;
};
diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js
index dc540b202..f819cc586 100644
--- a/graph/resolvers/root_mutation.js
+++ b/graph/resolvers/root_mutation.js
@@ -47,6 +47,9 @@ const RootMutation = {
setUserStatus(_, {id, status}, {mutators: {User}}) {
return wrapResponse(null)(User.setUserStatus({id, status}));
},
+ suspendUser(_, {id}, {mutators: {User}}) {
+ return wrapResponse(null)(User.suspendUser({id}));
+ },
setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
return wrapResponse(null)(Comment.setCommentStatus({id, status}));
},
diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js
index bdcdcef78..d577ddddb 100644
--- a/graph/resolvers/root_query.js
+++ b/graph/resolvers/root_query.js
@@ -82,6 +82,28 @@ 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.
+ users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) {
+
+ if (user == null || !user.hasRoles('ADMIN')) {
+ return null;
+ }
+
+ const query = {limit, cursor, sort};
+
+ if (action_type) {
+ return Actions.getByTypes({action_type, item_type: 'USERS'})
+ .then((ids) => {
+
+ // Perform the query using the available resolver.
+ return Users.getByQuery({ids, limit, cursor, sort});
+ });
+ }
+
+ return Users.getByQuery(query);
}
};
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index 6cb4cb046..29000c84d 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -31,7 +31,7 @@ type User {
username: String!
# Action summaries against the user.
- action_summaries: [ActionSummary]
+ action_summaries: [FlagActionSummary]
# Actions completed on the parent.
actions: [Action]
@@ -45,6 +45,9 @@ type User {
# returns all comments based on a query.
comments(query: CommentsQuery): [Comment]
+ # returns all users based on a query.
+ users(query: UsersQuery): [User]
+
# returns user status
status: USER_STATUS
}
@@ -60,6 +63,20 @@ type Tag {
created_at: Date!
}
+# UsersQuery allows the ability to query users by a specific fields.
+input UsersQuery {
+ action_type: ACTION_TYPE
+
+ # Limit the number of results to be returned.
+ limit: Int = 10
+
+ # Skip results from the last created_at timestamp.
+ cursor: Date
+
+ # Sort the results by created_at.
+ sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
+}
+
################################################################################
## Comments
################################################################################
@@ -501,6 +518,9 @@ type RootQuery {
# role.
me: User
+ # Users returned based on a query.
+ users(query: UsersQuery): [User]
+
# Asset metrics related to user actions are saturated into the assets
# returned.
assetMetrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Asset!]
@@ -634,6 +654,14 @@ type SetUserStatusResponse implements Response {
errors: [UserError]
}
+# SuspendUserResponse is the response returned with possibly some errors
+# relating to the suspend action attempt.
+type SuspendUserResponse 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 {
@@ -646,14 +674,14 @@ type SetCommentStatusResponse implements Response {
type AddCommentTagResponse implements Response {
# An array of errors relating to the mutation that occured.
comment: Comment
- errors: [UserError]
+ errors: [UserError]
}
# Response to removeCommentTag mutation
type RemoveCommentTagResponse implements Response {
# An array of errors relating to the mutation that occured.
comment: Comment
- errors: [UserError]
+ errors: [UserError]
}
# All mutations for the application are defined on this object.
@@ -677,6 +705,9 @@ 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. Requires the `ADMIN` role.
+ suspendUser(id: ID!): SuspendUserResponse
+
# Sets Comment status. Requires the `ADMIN` role.
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
diff --git a/models/user.js b/models/user.js
index 2b65bd300..ea4195b8a 100644
--- a/models/user.js
+++ b/models/user.js
@@ -156,9 +156,10 @@ const USER_GRAPH_OPERATIONS = [
'mutation:deleteAction',
'mutation:editName',
'mutation:setUserStatus',
+ 'mutation:suspendUser',
'mutation:setCommentStatus',
'mutation:addCommentTag',
- 'mutation:removeCommentTag',
+ 'mutation:removeCommentTag'
];
/**
@@ -174,7 +175,7 @@ UserSchema.method('can', function(...actions) {
return false;
}
- if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
+ if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:suspendUser' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
return false;
}
diff --git a/services/users.js b/services/users.js
index b58776a75..90ca2fae2 100644
--- a/services/users.js
+++ b/services/users.js
@@ -378,6 +378,22 @@ module.exports = class UsersService {
});
}
+ /**
+ * Suspend a user. It changes the status to BANNED and canEditName to True.
+ * @param {String} id id of a user
+ * @param {Function} done callback after the operation is complete
+ */
+ static suspendUser(id) {
+ return UserModel.update({
+ id
+ }, {
+ $set: {
+ status: 'BANNED',
+ canEditName: true
+ }
+ });
+ }
+
/**
* Finds a user with the id.
* @param {String} id user id (uuid)
diff --git a/test/graph/mutations/addCommentTag.js b/test/graph/mutations/addCommentTag.js
index 14a746705..0835e81bc 100644
--- a/test/graph/mutations/addCommentTag.js
+++ b/test/graph/mutations/addCommentTag.js
@@ -54,7 +54,7 @@ describe('graph.mutations.addCommentTag', () => {
}
expect(response.errors).to.be.empty;
expect(response.data.addCommentTag.errors).to.deep.equal([{'translation_key':'NOT_AUTHORIZED'}]);
- expect(response.data.addCommentTag.comment).to.be.null;
+ expect(response.data.addCommentTag.comment).to.be.null;
});
});
});