From c90c177b7c9ebec6f20aad70923bd7d34dcc46a4 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 27 Jun 2017 20:06:27 +0700 Subject: [PATCH 1/5] Live update on stream when banned user, suspended user or rejected username --- .../src/components/Comment.js | 14 ++- .../src/components/EditableCommentContent.js | 9 +- .../src/containers/Embed.js | 102 ++++++++++++++++++ .../src/containers/Stream.js | 4 +- client/coral-embed/src/index.js | 12 ++- client/coral-framework/reducers/auth.js | 14 +++ client/coral-plugin-commentbox/CommentBox.js | 7 ++ client/coral-plugin-flags/FlagButton.js | 2 + graph/context.js | 3 +- graph/index.js | 3 +- graph/mutators/action.js | 4 +- graph/mutators/comment.js | 28 ++--- graph/mutators/user.js | 24 +++-- graph/resolvers/subscription.js | 9 ++ graph/resolvers/user.js | 8 ++ graph/subscriptions.js | 48 ++++++++- graph/typeDefs.graphql | 23 ++++ locales/en.yml | 3 + locales/es.yml | 3 + perms/constants.js | 4 + perms/queryReducer.js | 2 + perms/subscriptionReducer.js | 6 ++ plugin-api/beta/client/hocs/withReaction.js | 4 +- .../coral-plugin-like/client/LikeButton.js | 2 + .../coral-plugin-love/client/LoveButton.js | 2 + .../client/RespectButton.js | 2 + routes/api/users/index.js | 15 ++- {graph => services}/pubsub.js | 2 +- services/users.js | 97 +++++++++-------- test/server/graph/context.js | 27 +++-- 30 files changed, 375 insertions(+), 108 deletions(-) rename {graph => services}/pubsub.js (69%) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 754f44558..86149b178 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -196,8 +196,16 @@ export default class Comment extends React.Component { editComment: React.PropTypes.func, } + editComment = () => { + return this.props.editComment(this.props.comment.id, this.props.asset.id); + } + onClickEdit (e) { e.preventDefault(); + if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { + this.props.addNotification('error', t('error.NOT_AUTHORIZED')); + return; + } this.setState({isEditing: true}); } @@ -240,7 +248,8 @@ export default class Comment extends React.Component { } if (can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { this.props.setActiveReplyBox(this.props.comment.id); - return; + } else { + this.props.addNotification('error', t('error.NOT_AUTHORIZED')); } return; } @@ -469,7 +478,7 @@ export default class Comment extends React.Component { { this.state.isEditing ? { + if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) { + this.props.addNotification('error', t('error.NOT_AUTHORIZED')); + return; + } + const {editComment, addNotification, stopEditing} = this.props; if (typeof editComment !== 'function') {return;} let response; diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index 68f76ccc5..2c41b0886 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -12,6 +12,8 @@ import {getDefinitionName} from 'coral-framework/utils'; import {withQuery} from 'coral-framework/hocs'; import Embed from '../components/Embed'; import Stream from './Stream'; +import {addNotification} from 'coral-framework/actions/notification'; +import t from 'coral-framework/services/i18n'; import {setActiveTab} from '../actions/embed'; import {viewAllComments} from '../actions/stream'; @@ -20,12 +22,72 @@ const {logout, checkLogin} = authActions; const {fetchAssetSuccess} = assetActions; class EmbedContainer extends React.Component { + subscriptions = []; + + subscribeToUpdates(props = this.props) { + if (props.auth.loggedIn) { + let sub = props.data.subscribeToMore({ + document: USER_BANNED_SUBSCRIPTION, + variables: { + user_id: props.auth.user.id, + }, + updateQuery: () => { + addNotification('info', t('your_account_has_been_banned')); + }, + }); + + this.subscriptions.push(sub); + + sub = props.data.subscribeToMore({ + document: USER_SUSPENDED_SUBSCRIPTION, + variables: { + user_id: props.auth.user.id, + }, + updateQuery: () => { + addNotification('info', t('your_account_has_been_suspended')); + }, + }); + + this.subscriptions.push(sub); + + sub = props.data.subscribeToMore({ + document: USERNAME_REJECTED_SUBSCRIPTION, + variables: { + user_id: props.auth.user.id, + }, + updateQuery: () => { + addNotification('info', t('your_username_has_been_rejected')); + }, + }); + + this.subscriptions.push(sub); + } + } + + unsubscribe() { + this.subscriptions.forEach((unsubscribe) => unsubscribe()); + this.subscriptions = []; + } + + resubscribe(props) { + this.unsubscribe(); + this.subscribeToUpdates(props); + } + + componentDidMount() { + this.subscribeToUpdates(); + } + + componentWillUnmount() { + this.unsubscribe(); + } componentWillReceiveProps(nextProps) { if (this.props.auth.loggedIn !== nextProps.auth.loggedIn) { // Refetch after login/logout. this.props.data.refetch(); + this.resubscribe(nextProps); } const {fetchAssetSuccess} = this.props; @@ -52,6 +114,45 @@ class EmbedContainer extends React.Component { } } +const USER_BANNED_SUBSCRIPTION = gql` + subscription UserBanned($user_id: ID!) { + userBanned(user_id: $user_id){ + id + status + canEditName + suspension { + until + } + } + } +`; + +const USER_SUSPENDED_SUBSCRIPTION = gql` + subscription UserSuspended($user_id: ID!) { + userSuspended(user_id: $user_id){ + id + status + canEditName + suspension { + until + } + } + } +`; + +const USERNAME_REJECTED_SUBSCRIPTION = gql` + subscription UsernameRejected($user_id: ID!) { + usernameRejected(user_id: $user_id){ + id + status + canEditName + suspension { + until + } + } + } +`; + const EMBED_QUERY = gql` query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) { asset(id: $assetId, url: $assetUrl) { @@ -95,6 +196,7 @@ const mapDispatchToProps = (dispatch) => setActiveTab, viewAllComments, fetchAssetSuccess, + addNotification, }, dispatch ); diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index f6e1fd9ec..d66ad38d4 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -173,7 +173,7 @@ const commentFragment = gql` `; const COMMENTS_ADDED_SUBSCRIPTION = gql` - subscription onCommentAdded($assetId: ID!, $excludeIgnored: Boolean){ + subscription CommentAdded($assetId: ID!, $excludeIgnored: Boolean){ commentAdded(asset_id: $assetId){ parent { id @@ -185,7 +185,7 @@ const COMMENTS_ADDED_SUBSCRIPTION = gql` `; const COMMENTS_EDITED_SUBSCRIPTION = gql` - subscription onCommentEdited($assetId: ID!){ + subscription CommentEdited($assetId: ID!){ commentEdited(asset_id: $assetId){ id body diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 5b42fa541..82eea8ef7 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -28,6 +28,7 @@ const snackbarStyles = { // This function should return value of window.Coral const Coral = {}; const Talk = (Coral.Talk = {}); +let notificationTimeout = null; // build the URL to load in the pym iframe function buildStreamIframeUrl(talkBaseUrl, query) { @@ -110,14 +111,15 @@ function configurePymParent(pymParent, opts) { snackbar.className = `coral-notif-${type}`; snackbar.textContent = text; - setTimeout(() => { + clearTimeout(notificationTimeout); + notificationTimeout = setTimeout(() => { snackbar.style.transform = 'translate(-50%, 0)'; snackbar.style.opacity = 1; - }, 0); - setTimeout(() => { - snackbar.style.opacity = 0; - }, 5000); + notificationTimeout = setTimeout(() => { + snackbar.style.opacity = 0; + }, 7000); + }, 0); }); // Helps child show notifications at the right scrollTop diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index 5a05de28e..7ab12cf53 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -151,6 +151,20 @@ export default function auth (state = initialState, action) { case actions.SET_REDIRECT_URI: return state .set('redirectUri', action.uri); + case 'APOLLO_SUBSCRIPTION_RESULT': + if (action.operationName === 'UserBanned' && state.getIn(['user', 'id']) === action.variables.user_id) { + return state + .mergeIn(['user'], action.result.data.userBanned); + } + if (action.operationName === 'UserSuspended' && state.getIn(['user', 'id']) === action.variables.user_id) { + return state + .mergeIn(['user'], action.result.data.userSuspended); + } + if (action.operationName === 'UsernameRejected' && state.getIn(['user', 'id']) === action.variables.user_id) { + return state + .mergeIn(['user'], action.result.data.usernameRejected); + } + return state; default : return state; } diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index 5105667ed..c05f7bda0 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -1,6 +1,7 @@ import React, {PropTypes} from 'react'; import t from 'coral-framework/services/i18n'; +import {can} from 'coral-framework/services/perms'; import Slot from 'coral-framework/components/Slot'; import {connect} from 'react-redux'; @@ -43,8 +44,14 @@ class CommentBox extends React.Component { assetId, parentId, addNotification, + currentUser, } = this.props; + if (!can(currentUser, 'INTERACT_WITH_COMMUNITY')) { + addNotification('error', t('error.NOT_AUTHORIZED')); + return; + } + let comment = { asset_id: assetId, parent_id: parentId, diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js index e8a17f201..38b1fd594 100644 --- a/client/coral-plugin-flags/FlagButton.js +++ b/client/coral-plugin-flags/FlagButton.js @@ -39,6 +39,8 @@ export default class FlagButton extends Component { } else { this.setState({showMenu: true}); } + } else { + this.props.addNotification('error', t('error.NOT_AUTHORIZED')); } } diff --git a/graph/context.js b/graph/context.js index 66086dc89..d2306cbc8 100644 --- a/graph/context.js +++ b/graph/context.js @@ -3,6 +3,7 @@ const mutators = require('./mutators'); const uuid = require('uuid'); const plugins = require('../services/plugins'); +const pubsub = require('../services/pubsub'); const debug = require('debug')('talk:graph:context'); /** @@ -32,7 +33,7 @@ const decorateContextPlugins = (context, contextPlugins) => contextPlugins.reduc * Stores the request context. */ class Context { - constructor({user = null}, pubsub) { + constructor({user = null}) { // Generate a new context id for the request. this.id = uuid.v4(); diff --git a/graph/index.js b/graph/index.js index 4e99714d9..7495c0605 100644 --- a/graph/index.js +++ b/graph/index.js @@ -1,6 +1,5 @@ const schema = require('./schema'); const Context = require('./context'); -const pubsub = require('./pubsub'); const {createSubscriptionManager} = require('./subscriptions'); module.exports = { @@ -11,7 +10,7 @@ module.exports = { // Load in the new context here, this'll create the loaders + mutators for // the lifespan of this request. - context: new Context(req, pubsub) + context: new Context(req) }), createSubscriptionManager }; diff --git a/graph/mutators/action.js b/graph/mutators/action.js index 19db33183..d62d3b4c5 100644 --- a/graph/mutators/action.js +++ b/graph/mutators/action.js @@ -16,7 +16,7 @@ const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants'); const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id, item_type, action_type, group_id, metadata = {}}) => { let comment; - if (pubsub && item_type === 'COMMENTS') { + if (item_type === 'COMMENTS') { comment = await Comments.get.load(item_id); if (!comment) { throw new Error('Comment not found'); @@ -38,7 +38,7 @@ const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id, await UsersService.setStatus(item_id, 'PENDING'); } - if (pubsub && comment) { + if (comment) { pubsub.publish('commentFlagged', comment); } diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index df6ed71ce..f32172a4d 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -177,11 +177,8 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu } Comments.countByAssetID.incr(asset_id); - if (pubsub) { - - // Publish the newly added comment via the subscription. - pubsub.publish('commentAdded', comment); - } + // Publish the newly added comment via the subscription. + pubsub.publish('commentAdded', comment); } return comment; @@ -346,17 +343,14 @@ const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => { // adjust the affected user's karma in the next tick. process.nextTick(adjustKarma(Comments, id, status)); - if (pubsub) { + if (status === 'ACCEPTED') { - if (status === 'ACCEPTED') { + // Publish the comment status change via the subscription. + pubsub.publish('commentAccepted', comment); + } else if (status === 'REJECTED') { - // Publish the comment status change via the subscription. - pubsub.publish('commentAccepted', comment); - } else if (status === 'REJECTED') { - - // Publish the comment status change via the subscription. - pubsub.publish('commentRejected', comment); - } + // Publish the comment status change via the subscription. + pubsub.publish('commentRejected', comment); } return comment; @@ -379,11 +373,9 @@ const edit = async (context, {id, asset_id, edit: {body}}) => { // Execute the edit. const comment = await CommentsService.edit(id, context.user.id, {body, status}); - if (context.pubsub) { + // Publish the edited comment via the subscription. + context.pubsub.publish('commentEdited', comment); - // Publish the edited comment via the subscription. - context.pubsub.publish('commentEdited', comment); - } return comment; }; diff --git a/graph/mutators/user.js b/graph/mutators/user.js index cf0106a2c..448ac750a 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -2,16 +2,28 @@ const errors = require('../../errors'); const UsersService = require('../../services/users'); const {SET_USER_STATUS, SUSPEND_USER, REJECT_USERNAME} = require('../../perms/constants'); -const setUserStatus = ({user}, {id, status}) => { - return UsersService.setStatus(id, status); +const setUserStatus = async ({user, pubsub}, {id, status}) => { + const result = await UsersService.setStatus(id, status); + if (result && result.status === 'BANNED') { + pubsub.publish('userBanned', result); + } + return result; }; -const suspendUser = ({user}, {id, message, until}) => { - return UsersService.suspendUser(id, message, until); +const suspendUser = async ({user, pubsub}, {id, message, until}) => { + const result = await UsersService.suspendUser(id, message, until); + if (result) { + pubsub.publish('userSuspended', result); + } + return result; }; -const rejectUsername = ({user}, {id, message}) => { - return UsersService.rejectUsername(id, message); +const rejectUsername = async ({user, pubsub}, {id, message}) => { + const result = await UsersService.rejectUsername(id, message); + if (result) { + pubsub.publish('usernameRejected', result); + } + return result; }; const ignoreUser = ({user}, userToIgnore) => { diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js index 1f26d5ff3..f6da9fb54 100644 --- a/graph/resolvers/subscription.js +++ b/graph/resolvers/subscription.js @@ -14,6 +14,15 @@ const Subscription = { commentFlagged(comment) { return comment; }, + userBanned(user) { + return user; + }, + userSuspended(user) { + return user; + }, + usernameRejected(user) { + return user; + }, }; module.exports = Subscription; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index 035af22cb..2878d6561 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -6,6 +6,7 @@ const { SEARCH_OTHERS_COMMENTS, UPDATE_USER_ROLES, SEARCH_COMMENT_METRICS, + VIEW_SUSPENSION_INFO, LIST_OWN_TOKENS } = require('../../perms/constants'); @@ -84,6 +85,13 @@ const User = { if (requestingUser && requestingUser.can(SEARCH_COMMENT_METRICS)) { return KarmaService.model(user); } + }, + + suspension({id, suspension}, _, {user}) { + if (user.id !== id && !user.can(VIEW_SUSPENSION_INFO)) { + return null; + } + return suspension; } }; diff --git a/graph/subscriptions.js b/graph/subscriptions.js index ec2d5b786..ce1eb1a09 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -3,7 +3,7 @@ const {SubscriptionServer} = require('subscriptions-transport-ws'); const _ = require('lodash'); const debug = require('debug')('talk:graph:subscriptions'); -const pubsub = require('./pubsub'); +const pubsub = require('../services/pubsub'); const schema = require('./schema'); const Context = require('./context'); const plugins = require('../services/plugins'); @@ -21,6 +21,9 @@ const { SUBSCRIBE_COMMENT_FLAGGED, SUBSCRIBE_ALL_COMMENT_EDITED, SUBSCRIBE_ALL_COMMENT_ADDED, + SUBSCRIBE_ALL_USER_SUSPENDED, + SUBSCRIBE_ALL_USER_BANNED, + SUBSCRIBE_ALL_USERNAME_REJECTED, } = require('../perms/constants'); /** @@ -83,6 +86,45 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu } }, }), + userSuspended: (options, args) => ({ + userSuspended: { + filter: (user, context) => { + if ( + !context.user + || args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_SUSPENDED) + ) { + return false; + } + return !args.user_id || user.id === args.user_id; + } + }, + }), + userBanned: (options, args) => ({ + userBanned: { + filter: (user, context) => { + if ( + !context.user + || args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_BANNED) + ) { + return false; + } + return !args.user_id || user.id === args.user_id; + } + }, + }), + usernameRejected: (options, args) => ({ + usernameRejected: { + filter: (user, context) => { + if ( + !context.user + || args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USERNAME_REJECTED) + ) { + return false; + } + return !args.user_id || user.id === args.user_id; + } + }, + }), }); /** @@ -117,10 +159,10 @@ const createSubscriptionManager = (server) => new SubscriptionServer({ } catch (e) { console.error(e); - return new Context({}, pubsub); + return new Context({}); } - return new Context(req, pubsub); + return new Context(req); }; return baseParams; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 22f4a668f..249be8d13 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -61,6 +61,10 @@ type UserProfile { provider: String! } +type SuspensionInfo { + until: Date +} + # Any person who can author comments, create actions, and view comments on a # stream. type User { @@ -108,6 +112,10 @@ type User { # returns user status status: USER_STATUS + + # returns suspension info. Only available to Admins and Moderators + # or on own logged in User. + suspension: SuspensionInfo } # UsersQuery allows the ability to query users by a specific fields. @@ -1031,6 +1039,21 @@ type Subscription { # Get an update whenever a comment has been rejected. # Requires the `ADMIN` or `MODERATOR` role. commentRejected(asset_id: ID): Comment + + # Get an update whenever a user has been suspended. + # `user_id` must match id of current user except for + # users with the `ADMIN` or `MODERATOR` role. + userSuspended(user_id: ID): User + + # Get an update whenever a user has been banned. + # `user_id` must match id of current user except for + # users with the `ADMIN` or `MODERATOR` role. + userBanned(user_id: ID): User + + # Get an update whenever a username has been rejected. + # `user_id` must match id of current user except for + # users with the `ADMIN` or `MODERATOR` role. + usernameRejected(user_id: ID): User } ################################################################################ diff --git a/locales/en.yml b/locales/en.yml index 944902e15..0051a92f2 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1,4 +1,7 @@ en: + your_account_has_been_suspended: Your account has been temporarily suspended. + your_account_has_been_banned: Your account has been banned. + your_username_has_been_rejected: Your account has been suspended because your username has been deemed inappropriate. To restore your account please enter a new username. bandialog: are_you_sure: "Are you sure you would like to ban {0}?" ban_user: "Ban User?" diff --git a/locales/es.yml b/locales/es.yml index 58bc7a132..98e2dbc0c 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -1,4 +1,7 @@ es: + your_account_has_been_suspended: Su cuenta ha sido temporalmente suspendida. + your_account_has_been_banned: Su cuenta ha sido suspendida. + your_username_has_been_rejected: Su cuenta ha sido suspendida porque tu nombre de usuario ha sido considerado no apropiado para el espacio. Para recuperar la cuenta, por favor ingresar un nuevo nombre de usuario. bandialog: are_you_sure: "¿Estás segura que quieres suspender a {0}?" ban_user: "¿Quieres suspender el Usuario?" diff --git a/perms/constants.js b/perms/constants.js index abfd16c6f..383f36daf 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -26,6 +26,7 @@ module.exports = { SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS', LIST_OWN_TOKENS: 'LIST_OWN_TOKENS', SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY', + VIEW_SUSPENSION_INFO: 'VIEW_SUSPENSION_INFO', // subscriptions SUBSCRIBE_COMMENT_ACCEPTED: 'SUBSCRIBE_COMMENT_ACCEPTED', @@ -33,4 +34,7 @@ module.exports = { SUBSCRIBE_COMMENT_FLAGGED: 'SUBSCRIBE_COMMENT_FLAGGED', SUBSCRIBE_ALL_COMMENT_ADDED: 'SUBSCRIBE_ALL_COMMENT_ADDED', SUBSCRIBE_ALL_COMMENT_EDITED: 'SUBSCRIBE_ALL_COMMENT_EDITED', + SUBSCRIBE_ALL_USER_SUSPENDED: 'SUBSCRIBE_ALL_USER_SUSPENDED', + SUBSCRIBE_ALL_USER_BANNED: 'SUBSCRIBE_ALL_USER_BANNED', + SUBSCRIBE_ALL_USERNAME_REJECTED: 'SUBSCRIBE_ALL_USERNAME_REJECTED', }; diff --git a/perms/queryReducer.js b/perms/queryReducer.js index ed84f2265..0b76c225a 100644 --- a/perms/queryReducer.js +++ b/perms/queryReducer.js @@ -19,6 +19,8 @@ module.exports = (user, perm) => { return check(user, ['ADMIN']); case types.SEARCH_COMMENT_STATUS_HISTORY: return check(user, ['ADMIN', 'MODERATOR']); + case types.VIEW_SUSPENSION_INFO: + return check(user, ['ADMIN', 'MODERATOR']); default: break; } diff --git a/perms/subscriptionReducer.js b/perms/subscriptionReducer.js index 0b06902cf..e0f73526a 100644 --- a/perms/subscriptionReducer.js +++ b/perms/subscriptionReducer.js @@ -13,6 +13,12 @@ module.exports = (user, perm) => { return check(user, ['ADMIN', 'MODERATOR']); case types.SUBSCRIBE_ALL_COMMENT_ADDED: return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_ALL_USER_SUSPENDED: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_ALL_USER_BANNED: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_ALL_USERNAME_REJECTED: + return check(user, ['ADMIN', 'MODERATOR']); default: break; } diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js index 1ed777996..72de7f5c4 100644 --- a/plugin-api/beta/client/hocs/withReaction.js +++ b/plugin-api/beta/client/hocs/withReaction.js @@ -8,6 +8,7 @@ import {compose, gql} from 'react-apollo'; import withFragments from 'coral-framework/hocs/withFragments'; import withMutation from 'coral-framework/hocs/withMutation'; import {showSignInDialog} from 'coral-framework/actions/auth'; +import {addNotification} from 'coral-framework/actions/notification'; import {capitalize} from 'coral-framework/helpers/strings'; import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; import * as PropTypes from 'prop-types'; @@ -248,6 +249,7 @@ export default (reaction) => (WrappedComponent) => { return (WrappedComponent) => { }); const mapDispatchToProps = (dispatch) => - bindActionCreators({showSignInDialog}, dispatch); + bindActionCreators({showSignInDialog, addNotification}, dispatch); const enhance = compose( withFragments({ diff --git a/plugins/coral-plugin-like/client/LikeButton.js b/plugins/coral-plugin-like/client/LikeButton.js index 68a516c3f..7d17ef056 100644 --- a/plugins/coral-plugin-like/client/LikeButton.js +++ b/plugins/coral-plugin-like/client/LikeButton.js @@ -13,6 +13,7 @@ class LikeButton extends React.Component { postReaction, deleteReaction, showSignInDialog, + addNotification, alreadyReacted, user, } = this.props; @@ -25,6 +26,7 @@ class LikeButton extends React.Component { // If the current user is suspended, do nothing. if (!can(user, 'INTERACT_WITH_COMMUNITY')) { + addNotification('error', t('error.NOT_AUTHORIZED')); return; } diff --git a/plugins/coral-plugin-love/client/LoveButton.js b/plugins/coral-plugin-love/client/LoveButton.js index 0fc7bd7ca..7450ad668 100644 --- a/plugins/coral-plugin-love/client/LoveButton.js +++ b/plugins/coral-plugin-love/client/LoveButton.js @@ -13,6 +13,7 @@ class LoveButton extends React.Component { postReaction, deleteReaction, showSignInDialog, + addNotification, alreadyReacted, user, } = this.props; @@ -25,6 +26,7 @@ class LoveButton extends React.Component { // If the current user is suspended, do nothing. if (!can(user, 'INTERACT_WITH_COMMUNITY')) { + addNotification('error', t('error.NOT_AUTHORIZED')); return; } diff --git a/plugins/coral-plugin-respect/client/RespectButton.js b/plugins/coral-plugin-respect/client/RespectButton.js index bde8c9669..9bc556b67 100644 --- a/plugins/coral-plugin-respect/client/RespectButton.js +++ b/plugins/coral-plugin-respect/client/RespectButton.js @@ -14,6 +14,7 @@ class RespectButton extends React.Component { deleteReaction, showSignInDialog, alreadyReacted, + addNotification, user, } = this.props; @@ -25,6 +26,7 @@ class RespectButton extends React.Component { // If the current user is suspended, do nothing. if (!can(user, 'INTERACT_WITH_COMMUNITY')) { + addNotification('error', t('error.NOT_AUTHORIZED')); return; } diff --git a/routes/api/users/index.js b/routes/api/users/index.js index 10d72fe3a..47ff46853 100644 --- a/routes/api/users/index.js +++ b/routes/api/users/index.js @@ -1,8 +1,8 @@ const express = require('express'); const router = express.Router(); const UsersService = require('../../../services/users'); -const CommentsService = require('../../../services/comments'); const mailer = require('../../../services/mailer'); +const pubsub = require('../../../services/pubsub'); const errors = require('../../../errors'); const authorization = require('../../../middleware/authorization'); const i18n = require('../../../services/i18n'); @@ -53,11 +53,16 @@ router.post('/:user_id/role', authorization.needed('ADMIN'), (req, res, next) => router.post('/:user_id/status', authorization.needed('ADMIN'), (req, res, next) => { UsersService .setStatus(req.params.user_id, req.body.status) - .then((status) => { - res.status(201).json(status); + .then((user) => { - if (status === 'BANNED' && req.body.comment_id) { - return CommentsService.pushStatus(req.body.comment_id, 'rejected', req.params.user_id); + // TODO: current updating status behavior is weird. + if (user) { + if (user.status === 'BANNED') { + pubsub.publish('userBanned', user); + } + res.status(201).json(user.status); + } else { + res.status(500).json(); } }) .catch(next); diff --git a/graph/pubsub.js b/services/pubsub.js similarity index 69% rename from graph/pubsub.js rename to services/pubsub.js index 704f83725..a42ffb314 100644 --- a/graph/pubsub.js +++ b/services/pubsub.js @@ -1,5 +1,5 @@ const {RedisPubSub} = require('graphql-redis-subscriptions'); -const {connectionOptions} = require('../services/redis'); +const {connectionOptions} = require('./redis'); module.exports = new RedisPubSub({connection: connectionOptions}); diff --git a/services/users.js b/services/users.js index 78ec57b4c..dea208c8e 100644 --- a/services/users.js +++ b/services/users.js @@ -435,7 +435,10 @@ module.exports = class UsersService { return Promise.reject(new Error(`status ${status} is not supported`)); } - return UserModel.update({ + // TODO: current updating status behavior is weird. + // once a user has been `APPROVED` its status cannot be + // changed anymore. + return UserModel.findOneAndUpdate({ id, status: { $ne: 'APPROVED' @@ -444,6 +447,8 @@ module.exports = class UsersService { $set: { status } + }, { + new: true, }); } @@ -453,34 +458,37 @@ module.exports = class UsersService { * @param {String} message message to be send to the user * @param {Date} until date until the suspension is valid. */ - static suspendUser(id, message, until) { - return UserModel.findOneAndUpdate( + static async suspendUser(id, message, until) { + const user = await UserModel.findOneAndUpdate( {id}, { $set: { suspension: { until, }, } - }) - .then((user) => { - if (message) { - let localProfile = user.profiles.find((profile) => profile.provider === 'local'); - if (localProfile) { - const options = - { - template: 'suspension', // needed to know which template to render! - locals: { // specifies the template locals. - body: message - }, - subject: 'Your account has been suspended', - to: localProfile.id // This only works if the user has registered via e-mail. - // We may want a standard way to access a user's e-mail address in the future - }; - - return MailerService.sendSimple(options); - } - } + }, { + new: true, }); + + if (message) { + let localProfile = user.profiles.find((profile) => profile.provider === 'local'); + if (localProfile) { + const options = + { + template: 'suspension', // needed to know which template to render! + locals: { // specifies the template locals. + body: message + }, + subject: 'Your account has been suspended', + to: localProfile.id // This only works if the user has registered via e-mail. + // We may want a standard way to access a user's e-mail address in the future + }; + + await MailerService.sendSimple(options); + } + } + + return user; } /** @@ -489,34 +497,37 @@ module.exports = class UsersService { * @param {String} message message to be send to the user * @param {Date} until date until the suspension is valid. */ - static rejectUsername(id, message) { - return UserModel.findOneAndUpdate({ + static async rejectUsername(id, message) { + const user = await UserModel.findOneAndUpdate({ id }, { $set: { status: 'BANNED', canEditName: true, } - }) - .then((user) => { - if (message) { - let localProfile = user.profiles.find((profile) => profile.provider === 'local'); - if (localProfile) { - const options = - { - template: 'suspension', // needed to know which template to render! - locals: { // specifies the template locals. - body: message - }, - subject: 'Email Suspension', - to: localProfile.id // This only works if the user has registered via e-mail. - // We may want a standard way to access a user's e-mail address in the future - }; - - return MailerService.sendSimple(options); - } - } + }, { + new: true, }); + + if (message) { + let localProfile = user.profiles.find((profile) => profile.provider === 'local'); + if (localProfile) { + const options = + { + template: 'suspension', // needed to know which template to render! + locals: { // specifies the template locals. + body: message + }, + subject: 'Email Suspension', + to: localProfile.id // This only works if the user has registered via e-mail. + // We may want a standard way to access a user's e-mail address in the future + }; + + await MailerService.sendSimple(options); + } + } + + return user; } /** diff --git a/test/server/graph/context.js b/test/server/graph/context.js index b01917289..7d30042e4 100644 --- a/test/server/graph/context.js +++ b/test/server/graph/context.js @@ -3,14 +3,16 @@ const expect = require('chai').expect; const User = require('../../../models/user'); const Context = require('../../../graph/context'); const errors = require('../../../errors'); +const SettingsService = require('../../../services/settings'); describe('graph.Context', () => { + beforeEach(() => SettingsService.init()); describe('#constructor: with a user', () => { let c; beforeEach(() => { - c = new Context({user: new User({id: '1'})}); + c = new Context({user: new User({id: '1', roles: ['ADMIN']})}); }); it('creates a context with a user', (done) => { @@ -21,15 +23,10 @@ describe('graph.Context', () => { }); it('does have access to mutators', () => { - return c.mutators.Action.create({ - item_id: '1', - item_type: 'COMMENTS', - action_type: 'LIKE' - }) - .then((action) => { - expect(action).to.have.property('item_id', '1'); - expect(action).to.have.property('item_type', 'COMMENTS'); - expect(action).to.have.property('action_type', 'LIKE'); + return c.mutators.Tag.add({ + item_type: 'USERS', + id: '1', + name: 'Tag', }); }); }); @@ -48,13 +45,13 @@ describe('graph.Context', () => { }); it('does not have access to mutators', () => { - return c.mutators.Action.create({ - item_id: '1', + return c.mutators.Tag.add({ item_type: 'COMMENTS', - action_type: 'LIKE' + id: '1', + name: 'Tag', }) - .then((action) => { - expect(action).to.be.null; + .then(() => { + throw new Error('should not reach this point'); }) .catch((err) => { expect(err).to.be.equal(errors.ErrNotAuthorized); From 175dd30d274cbb35ccec451494f5396fd7d3d215 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 27 Jun 2017 21:40:48 +0700 Subject: [PATCH 2/5] Keep comment box when user is degraded (live) --- .../src/components/Stream.js | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index c912d1a72..457880a94 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -57,7 +57,10 @@ class Stream extends React.Component { constructor(props) { super(props); - this.state = resetCursors(this.state, props); + this.state = { + ...resetCursors(this.state, props), + keepCommentBox: false, + }; } componentWillReceiveProps(next) { @@ -68,6 +71,12 @@ class Stream extends React.Component { this.setState(resetCursors); return; } + + // Keep comment box when user was live suspended, banned, ... + if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) { + this.setState({keepCommentBox: true}); + } + if ( prevComments && nextComments && nextComments.nodes.length < prevComments.nodes.length @@ -133,6 +142,10 @@ class Stream extends React.Component { return view; } + userIsDegraged({auth: {user}} = this.props) { + return !can(user, 'INTERACT_WITH_COMMUNITY'); + } + render() { const { commentClassNames, @@ -150,6 +163,7 @@ class Stream extends React.Component { pluginProps, editName } = this.props; + const {keepCommentBox} = this.state; const view = this.getVisibleComments(); const open = asset.closedAt === null; @@ -171,6 +185,8 @@ class Stream extends React.Component { me.ignoredUsers.find((u) => u.id === comment.user.id) ); }; + + const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox); return (
@@ -199,10 +215,7 @@ class Stream extends React.Component { editName={editName} currentUsername={user.username} />} - {loggedIn && - !banned && - !temporarilySuspended && - !highlightedComment && + {showCommentBox && Date: Tue, 27 Jun 2017 21:51:19 +0700 Subject: [PATCH 3/5] Fix edit --- client/coral-embed-stream/src/components/Comment.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 86149b178..2178e6c44 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -196,8 +196,8 @@ export default class Comment extends React.Component { editComment: React.PropTypes.func, } - editComment = () => { - return this.props.editComment(this.props.comment.id, this.props.asset.id); + editComment = (...args) => { + return this.props.editComment(this.props.comment.id, this.props.asset.id, ...args); } onClickEdit (e) { From b67ac375ba68283da4f160931a472a2177823043 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 27 Jun 2017 22:14:06 +0700 Subject: [PATCH 4/5] Pass current user to CommentBox --- client/coral-embed-stream/src/components/Comment.js | 2 +- client/coral-embed-stream/src/components/Stream.js | 2 +- client/coral-plugin-commentbox/CommentBox.js | 6 +++--- client/coral-plugin-replies/ReplyBox.js | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 2178e6c44..bfa629047 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -557,8 +557,8 @@ export default class Comment extends React.Component { setActiveReplyBox={setActiveReplyBox} parentId={parentId || comment.id} addNotification={addNotification} - authorId={currentUser.id} postComment={postComment} + currentUser={currentUser} assetId={asset.id} /> : null} diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 457880a94..165b3ce4c 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -224,7 +224,7 @@ class Stream extends React.Component { assetId={asset.id} premod={asset.settings.moderation} isReply={false} - authorId={user.id} + currentUser={user} charCountEnable={asset.settings.charCountEnable} maxCharCount={asset.settings.charCount} />} diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index c05f7bda0..efa1d27ed 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -133,7 +133,7 @@ class CommentBox extends React.Component { handleChange = (e) => this.setState({body: e.target.value}); render () { - const {styles, isReply, authorId, maxCharCount} = this.props; + const {styles, isReply, currentUser, maxCharCount} = this.props; let {cancelButtonClicked} = this.props; if (isReply && typeof cancelButtonClicked !== 'function') { @@ -152,7 +152,7 @@ class CommentBox extends React.Component { charCountEnable={this.props.charCountEnable} bodyPlaceholder={t('comment.comment')} bodyInputId={isReply ? 'replyText' : 'commentText'} - saveComment={authorId && this.postComment} + saveComment={currentUser && this.postComment} buttonContainerStart={ From b134d4099cf5ea892c000fb22e913dc98a4ea534 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Wed, 28 Jun 2017 00:13:47 +0700 Subject: [PATCH 5/5] More concise and functional subscribing --- .../src/containers/Embed.js | 35 +++++++------------ .../src/containers/Stream.js | 23 ++++++------ 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index 2c41b0886..bf05caf29 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -26,41 +26,32 @@ class EmbedContainer extends React.Component { subscribeToUpdates(props = this.props) { if (props.auth.loggedIn) { - let sub = props.data.subscribeToMore({ + const newSubscriptions = [{ document: USER_BANNED_SUBSCRIPTION, - variables: { - user_id: props.auth.user.id, - }, updateQuery: () => { addNotification('info', t('your_account_has_been_banned')); }, - }); - - this.subscriptions.push(sub); - - sub = props.data.subscribeToMore({ + }, + { document: USER_SUSPENDED_SUBSCRIPTION, - variables: { - user_id: props.auth.user.id, - }, updateQuery: () => { addNotification('info', t('your_account_has_been_suspended')); }, - }); - - this.subscriptions.push(sub); - - sub = props.data.subscribeToMore({ + }, + { document: USERNAME_REJECTED_SUBSCRIPTION, - variables: { - user_id: props.auth.user.id, - }, updateQuery: () => { addNotification('info', t('your_username_has_been_rejected')); }, - }); + }]; - this.subscriptions.push(sub); + this.subscriptions = newSubscriptions.map((s) => props.data.subscribeToMore({ + document: s.document, + variables: { + user_id: props.auth.user.id, + }, + updateQuery: s.updateQuery, + })); } } diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index d66ad38d4..3491fc846 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -30,11 +30,8 @@ class StreamContainer extends React.Component { subscriptions = []; subscribeToUpdates() { - const sub1 = this.props.data.subscribeToMore({ + const newSubscriptions = [{ document: COMMENTS_EDITED_SUBSCRIPTION, - variables: { - assetId: this.props.root.asset.id, - }, updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => { // Ignore mutations from me. @@ -52,13 +49,9 @@ class StreamContainer extends React.Component { return removeCommentFromEmbedQuery(prev, commentEdited.id); } }, - }); - - const sub2 = this.props.data.subscribeToMore({ + }, + { document: COMMENTS_ADDED_SUBSCRIPTION, - variables: { - assetId: this.props.root.asset.id, - }, updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => { // Ignore mutations from me. @@ -81,9 +74,15 @@ class StreamContainer extends React.Component { return insertCommentIntoEmbedQuery(prev, commentAdded); } - }); + }]; - this.subscriptions.push(sub1, sub2); + this.subscriptions = newSubscriptions.map((s) => this.props.data.subscribeToMore({ + document: s.document, + variables: { + assetId: this.props.root.asset.id, + }, + updateQuery: s.updateQuery, + })); } unsubscribe() {