Merge pull request #283 from coralproject/username-ban

Username banning flow
This commit is contained in:
Kim Gardner
2017-02-06 23:04:43 -05:00
committed by GitHub
36 changed files with 426 additions and 273 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ export const fetchModerationQueueComments = () => {
return Promise.all([
coralApi('/queue/comments/premod'),
coralApi('/queue/users/pending'),
coralApi('/queue/users/flagged'),
coralApi('/queue/comments/rejected'),
coralApi('/queue/comments/flagged')
])
@@ -46,7 +46,7 @@ export const fetchPendingUsersQueue = () => {
return dispatch => {
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return coralApi('/queue/users/pending')
return coralApi('/queue/users/flagged')
.then(addUsersCommentsActions.bind(this, dispatch));
};
};
+8
View File
@@ -21,3 +21,11 @@ export const sendNotificationEmail = (userId, subject, body) => {
.catch(error => dispatch({type: userTypes.USER_EMAIL_FAILURE, error}));
};
};
// let a user edit their username
export const enableUsernameEdit = (userId) => {
return (dispatch) => {
return coralApi(`/users/${userId}/username-enable`, {method: 'POST'})
.catch(error => dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error}));
};
};
@@ -3,7 +3,7 @@ import styles from './ModerationList.css';
import key from 'keymaster';
import Hammer from 'hammerjs';
import Comment from './Comment';
import UserAction from './UserAction';
import User from './User';
import SuspendUserModal from './SuspendUserModal';
// Each action has different meaning and configuration
@@ -138,7 +138,7 @@ export default class ModerationList extends React.Component {
if (menuOption === 'REJECTED') {
this.setState({suspendUserModal: action});
} else if (menuOption === 'ACCEPTED') {
this.props.userStatusUpdate('ACTIVE', action.item_id);
this.props.userStatusUpdate('APPROVED', action.item_id);
}
}
}
@@ -177,7 +177,7 @@ export default class ModerationList extends React.Component {
// If the item is an action...
const user = users[item.item_id];
modItem = user && <UserAction
modItem = user && <User
suspectWords={suspectWords}
action={item}
user={user}
@@ -36,7 +36,7 @@ class SuspendUserModal extends Component {
}
componentDidMount() {
const about = this.props.actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username');
const about = lang.t('suspenduser.username');
this.setState({email: lang.t('suspenduser.email', about)});
}
@@ -1,22 +1,16 @@
import React from 'react';
import Linkify from 'react-linkify';
import styles from './ModerationList.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
import {Icon} from 'react-mdl';
import Highlighter from 'react-highlight-words';
import ActionButton from './ActionButton';
const linkify = new Linkify();
// Render a single comment for the list
const UserAction = props => {
const User = props => {
const {action, user} = props;
let userStatus = user.status;
const links = user.settings.bio ? linkify.getMatches(user.settings.bio) : [];
// Do not display unless the user status is 'pending' or 'banned'.
// This means that they have already been reviewed and approved.
@@ -27,8 +21,6 @@ const UserAction = props => {
<span>{user.displayName}</span>
</div>
<div className={styles.sideActions}>
{links ?
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
<div className={`actions ${styles.actions}`}>
{props.modActions.map(
(action, i) =>
@@ -48,32 +40,12 @@ const UserAction = props => {
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
</div>
</div>
{
user.settings.bio &&
<div>
<div className={styles.itemBody}>
<div>{lang.t('user.user_bio')}:</div>
<span className={styles.body}>
<Linkify component='span' properties={{style: linkStyles}}>
<Highlighter
searchWords={props.suspectWords}
textToHighlight={user.settings.bio} />
</Linkify>
</span>
</div>
</div>
}
<div className={styles.flagCount}>
{`${action.count} ${action.action_type === 'flag_bio' ? lang.t('user.bio_flags') : lang.t('user.username_flags')}`}
</div>
</li>;
};
export default UserAction;
const linkStyles = {
backgroundColor: 'rgb(255, 219, 135)',
padding: '1px 2px'
};
export default User;
const lang = new I18n(translations);
@@ -2,4 +2,5 @@ export const UPDATE_STATUS_REQUEST = 'UPDATE_STATUS_REQUEST';
export const UPDATE_STATUS_SUCCESS = 'UPDATE_STATUS_SUCCESS';
export const UPDATE_STATUS_FAILURE = 'UPDATE_STATUS_FAILURE';
export const USER_EMAIL_FAILURE = 'USER_EMAIL_FAILURE';
export const USERNAME_ENABLE_FAILURE = 'USERNAME_ENABLE_FAILURE';
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
@@ -11,7 +11,7 @@ import {
fetchFlaggedQueue,
fetchModerationQueueComments,
} from 'actions/comments';
import {userStatusUpdate, sendNotificationEmail} from 'actions/users';
import {userStatusUpdate, sendNotificationEmail, enableUsernameEdit} from 'actions/users';
import {fetchSettings} from 'actions/settings';
import ModerationQueue from './ModerationQueue';
@@ -122,7 +122,8 @@ const mapDispatchToProps = dispatch => {
userStatusUpdate: (status, userId, commentId) => dispatch(userStatusUpdate(status, userId, commentId)).then(() => {
dispatch(fetchModerationQueueComments());
}),
suspendUser: (userId, subject, text) => dispatch(userStatusUpdate('suspended', userId))
suspendUser: (userId, subject, text) => dispatch(userStatusUpdate('BANNED', userId))
.then(() => dispatch(enableUsernameEdit(userId)))
.then(() => dispatch(sendNotificationEmail(userId, subject, text)))
.then(() => dispatch(fetchModerationQueueComments()))
,
+1 -8
View File
@@ -94,14 +94,7 @@ class Comment extends React.Component {
style={{marginLeft: depth * 30}}>
<hr aria-hidden={true} />
<AuthorName
author={comment.user}
addNotification={this.props.addNotification}
id={comment.id}
author_id={comment.user.id}
postAction={this.props.postAction}
showSignInDialog={this.props.showSignInDialog}
deleteAction={this.props.deleteAction}
currentUser={currentUser}/>
author={comment.user}/>
<PubDate created_at={comment.created_at} />
<Content body={comment.body} />
<div className="commentActionsLeft">
+17 -9
View File
@@ -11,6 +11,7 @@ const {fetchAssetSuccess} = assetActions;
import {queryStream} from 'coral-framework/graphql/queries';
import {postComment, postAction, deleteAction} from 'coral-framework/graphql/mutations';
import {editName} from 'coral-framework/actions/user';
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
import Stream from './Stream';
@@ -84,6 +85,8 @@ class Embed extends Component {
const openStream = closedAt === null;
const banned = user && user.status === 'BANNED';
const expandForLogin = showSignInDialog ? {
minHeight: document.body.scrollHeight + 200
} : {};
@@ -100,7 +103,7 @@ class Embed extends Component {
<Tab>Settings</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
</TabBar>
{loggedIn && <UserBox user={user} logout={this.props.logout} changeTab={this.changeTab} />}
{loggedIn && <UserBox user={user} logout={this.props.logout} changeTab={this.changeTab}/>}
<TabContent show={activeTab === 0}>
{
openStream
@@ -109,7 +112,12 @@ class Embed extends Component {
content={asset.settings.infoBoxContent}
enable={asset.settings.infoBoxEnable}
/>
<RestrictedContent restricted={false} restrictedComp={<SuspendedAccount />}>
<RestrictedContent restricted={banned} restrictedComp={
<SuspendedAccount
canEditName={user && user.canEditName}
editName={this.props.editName}
/>
}>
{
user
? <CommentBox
@@ -122,7 +130,6 @@ class Embed extends Component {
premod={asset.settings.moderation}
isReply={false}
currentUser={this.props.auth.user}
banned={false}
authorId={user.id}
charCount={asset.settings.charCountEnable && asset.settings.charCount} />
: null
@@ -148,28 +155,28 @@ class Embed extends Component {
clearNotification={this.props.clearNotification}
notification={{text: null}}
/>
</TabContent>
<TabContent show={activeTab === 1}>
</TabContent>
<TabContent show={activeTab === 1}>
<SettingsContainer
loggedIn={loggedIn}
userData={this.props.userData}
showSignInDialog={this.props.showSignInDialog}
/>
</TabContent>
<TabContent show={activeTab === 2}>
</TabContent>
<TabContent show={activeTab === 2}>
<RestrictedContent restricted={!loggedIn}>
<ConfigureStreamContainer
status={status}
onClick={this.toggleStatus}
/>
</RestrictedContent>
</TabContent>
</TabContent>
<Notification
notifLength={4500}
clearNotification={this.props.clearNotification}
notification={this.props.notification}
/>
</div>
</div>
</div>
);
}
@@ -193,6 +200,7 @@ const mapDispatchToProps = dispatch => ({
});
},
clearNotification: () => dispatch(clearNotification()),
editName: (displayName) => dispatch(editName(displayName)),
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
logout: () => dispatch(logout()),
dispatch: d => dispatch(d)
+4 -4
View File
@@ -15,15 +15,15 @@ export const hideCreateDisplayNameDialog = () => ({type: actions.HIDE_CREATEDISP
const createDisplayNameSuccess = () => ({type: actions.CREATEDISPLAYNAME_SUCCESS});
const createDisplayNameFailure = error => ({type: actions.CREATEDISPLAYNAME_FAILURE, error});
export const updateDisplayName = displayName => ({type: actions.UPDATE_DISPLAYNAME, displayName});
export const updateDisplayName = ({displayName}) => ({type: actions.UPDATE_DISPLAYNAME, displayName});
export const createDisplayName = (userId, formData) => dispatch => {
dispatch(createDisplayNameRequest());
coralApi(`/users/${userId}/displayname`, {method: 'POST', body: formData})
.then((user) => {
coralApi('/account/displayname', {method: 'PUT', body: formData})
.then(() => {
dispatch(createDisplayNameSuccess());
dispatch(hideCreateDisplayNameDialog());
dispatch(updateDisplayName(user));
dispatch(updateDisplayName(formData));
})
.catch(error => {
dispatch(createDisplayNameFailure(lang.t(`error.${error.message}`)));
+4 -12
View File
@@ -1,4 +1,3 @@
import * as actions from '../constants/user';
import {addNotification} from '../actions/notification';
import coralApi from '../helpers/response';
@@ -6,16 +5,9 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from './../translations';
const lang = new I18n(translations);
const saveBioRequest = () => ({type: actions.SAVE_BIO_REQUEST});
const saveBioSuccess = settings => ({type: actions.SAVE_BIO_SUCCESS, settings});
const saveBioFailure = error => ({type: actions.SAVE_BIO_FAILURE, error});
export const saveBio = (user_id, formData) => dispatch => {
dispatch(saveBioRequest());
coralApi('/account/settings', {method: 'PUT', body: formData})
export const editName = (displayName) => (dispatch) => {
return coralApi('/account/displayname', {method: 'PUT', body: {displayName}})
.then(() => {
dispatch(addNotification('success', lang.t('successBioUpdate')));
dispatch(saveBioSuccess(formData));
})
.catch(error => dispatch(saveBioFailure(error)));
dispatch(addNotification('success', lang.t('successNameUpdate')));
});
};
@@ -2,3 +2,13 @@
background: #D8D8D8;
padding: 25px;
}
.editNameInput {
margin-top: 10px;
margin-bottom: 10px;
}
.alert {
margin-top: 10px;
color: #B71C1C;
}
@@ -1,11 +1,79 @@
import React from 'react';
import React, {Component, PropTypes} from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations.json';
const lang = new I18n(translations);
import styles from './RestrictedContent.css';
import {Button} from 'coral-ui';
import validate from '../helpers/validate';
export default () => (
<div className={styles.message}>
<span>{lang.t('suspendedAccountMsg')}</span>
</div>
);
class SuspendedAccount extends Component {
static propTypes = {
canEditName: PropTypes.bool,
editName: PropTypes.func.isRequired
}
state = {
displayName: '',
alert: ''
}
onSubmitClick = (e) => {
const {editName} = this.props;
const {displayName} = this.state;
e.preventDefault();
if (validate.displayName(displayName)) {
editName(displayName)
.then(() => location.reload())
.catch((error) => {
this.setState({alert: lang.t(`error.${error.message}`)});
});
} else {
this.setState({alert: lang.t('editName.error')});
}
}
render () {
const {canEditName} = this.props;
const {displayName, alert} = this.state;
return <div className={styles.message}>
<span>{
canEditName ?
lang.t('editName.msg')
: lang.t('bannedAccountMsg')
}</span>
{
canEditName ?
<div>
<div className={styles.alert}>
{alert}
</div>
<label
htmlFor='displayName'
className="screen-reader-text"
aria-hidden={true}>
{lang.t('editName.label')}
</label>
<input
type='text'
className={styles.editNameInput}
value={displayName}
placeholder={lang.t('editName.label')}
id='displayName'
onChange={(e) => this.setState({displayName: e.target.value})}
rows={3}/><br/>
<Button
onClick={this.onSubmitClick}>
{
lang.t('editName.button')
}
</Button>
</div> : null
}
</div>;
}
}
export default SuspendedAccount;
+3 -3
View File
@@ -1,6 +1,6 @@
export const SAVE_BIO_REQUEST = 'SAVE_BIO_REQUEST';
export const SAVE_BIO_SUCCESS = 'SAVE_BIO_SUCCESS';
export const SAVE_BIO_FAILURE = 'SAVE_BIO_FAILURE';
export const EDIT_NAME_REQUEST = 'EDIT_NAME_REQUEST';
export const EDIT_NAME_SUCCESS = 'EDIT_NAME_SUCCESS';
export const EDIT_NAME_FAILURE = 'EDIT_NAME_FAILURE';
export const COMMENTS_BY_USER_REQUEST = 'COMMENTS_BY_USER_REQUEST';
export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS';
export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE';
@@ -6,9 +6,6 @@ fragment commentView on Comment {
user {
id
name: displayName
settings {
bio
}
}
actions {
type: action_type
@@ -11,6 +11,9 @@ function getQueryVariable(variable) {
return decodeURIComponent(pair[1]);
}
}
// If no query is included, return a default string for development
return 'http://dev.default.stream';
}
export const queryStream = graphql(STREAM_QUERY, {
+4 -3
View File
@@ -1,4 +1,4 @@
import {Map} from 'immutable';
import {Map, fromJS} from 'immutable';
import * as actions from '../constants/auth';
const initialState = Map({
@@ -21,7 +21,7 @@ const initialState = Map({
const purge = user => {
const {settings, profiles, ...userData} = user; // eslint-disable-line
return userData;
return fromJS(userData);
};
export default function auth (state = initialState, action) {
@@ -131,8 +131,9 @@ export default function auth (state = initialState, action) {
.set('passwordRequestFailure', 'There was an error sending your password reset email. Please try again soon!')
.set('passwordRequestSuccess', null);
case actions.UPDATE_DISPLAYNAME:
console.log('Action', action);
return state
.set('user', purge(action.displayName));
.setIn(['user', 'displayName'], action.displayName);
case actions.VERIFY_EMAIL_FAILURE:
return state
.set('emailVerificationFailure', true)
+10 -3
View File
@@ -1,9 +1,15 @@
{
"en": {
"successUpdateSettings": "The changes you have made have been applied to the comment stream on this article",
"successBioUpdate": "Your Bio has been updated",
"successNameUpdate": "Your display name has been updated",
"contentNotAvailable": "This content is not available",
"suspendedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Flag, or write comments. Please contact moderator@fakeurl.com for more information",
"bannedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Flag, or write comments. Please contact moderator@fakeurl.com for more information",
"editName": {
"msg": "Your account is currently suspended because your display name has been deemed inappropriate. To restore your account, please enter a new username. You may contact moderator@fakeurl.com for more information.",
"label": "New Display Name",
"button": "Submit",
"error": "Display names can contain letters, numbers and _ only"
},
"error": {
"emailNotVerified": "Email address {0} not verified.",
"email": "Not a valid E-Mail",
@@ -26,7 +32,8 @@
"successUpdateSettings": "La configuración de este articulo fue actualizada",
"successBioUpdate": "Tu bio fue actualizada",
"contentNotAvailable": "El contenido no se encuentra disponible",
"suspendedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes dar Like, Marcar o escribir commentarios. Por favor, contacta moderator@fakeurl for more information",
"bannedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes dar Like, Marcar o escribir commentarios. Por favor, contacta moderator@fakeurl for more information",
"editNameMsg": "",
"error": {
"emailNotVerified": "Dirección de correo electrónico {0} no verificada.",
"email": "No es un email válido",
+4 -19
View File
@@ -1,6 +1,4 @@
import React, {Component} from 'react';
import {Tooltip} from 'coral-ui';
import FlagBio from 'coral-plugin-flags/FlagBio';
const packagename = 'coral-plugin-author-name';
import styles from './styles.css';
@@ -24,24 +22,11 @@ export default class AuthorName extends Component {
render () {
const {author} = this.props;
const {showTooltip} = this.state;
return (
<div className={`${packagename}-text ${styles.container}`} onClick={this.handleClick} onMouseLeave={this.handleMouseLeave}>
<a className={`${styles.authorName} ${author.settings.bio ? styles.hasBio : ''}`}>
{author && author.name}
{author.settings.bio ? <span className={`${styles.arrowDown} ${showTooltip ? styles.arrowUp : ''}`} /> : null}
</a>
{showTooltip && author.settings.bio
&& (
<Tooltip>
<div className={`${packagename}-bio`}>
{author.settings.bio}
</div>
<div className={`${packagename}-bio-flag`}>
<FlagBio {...this.props}/>
</div>
</Tooltip>
)}
<div
className={`${packagename}-text`}
className={`${styles.authorName}`}>
{author && author.name}
</div>
);
}
@@ -15,7 +15,6 @@ export default ({showSignInDialog}) => (
From the Settings Page you can
<ul>
<li>See your comment history</li>
<li>Write a bio about yourself to display to the community</li>
</ul>
</div>
</div>
@@ -4,12 +4,10 @@ import React, {Component} from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import {myCommentHistory} from 'coral-framework/graphql/queries';
import {saveBio} from 'coral-framework/actions/user';
import BioContainer from './BioContainer';
import {link} from 'coral-framework/PymConnection';
import NotLoggedIn from '../components/NotLoggedIn';
import {TabBar, Tab, TabContent, Spinner} from 'coral-ui';
import {Spinner} from 'coral-ui';
import SettingsHeader from '../components/SettingsHeader';
import CommentHistory from 'coral-plugin-history/CommentHistory';
@@ -33,8 +31,7 @@ class SettingsContainer extends Component {
}
render() {
const {loggedIn, userData, asset, showSignInDialog, data, user} = this.props;
const {activeTab} = this.state;
const {loggedIn, asset, showSignInDialog, data} = this.props;
const {me} = this.props.data;
if (!loggedIn || !me) {
@@ -48,25 +45,30 @@ class SettingsContainer extends Component {
return (
<div>
<SettingsHeader {...this.props} />
<TabBar onChange={this.handleTabChange} activeTab={activeTab} cStyle='material'>
<Tab>{lang.t('allComments')} ({user.myComments.length})</Tab>
<Tab>{lang.t('profileSettings')}</Tab>
</TabBar>
<TabContent show={activeTab === 0}>
{
me.comments.length ?
<CommentHistory
comments={me.comments}
asset={asset}
link={link}
/>
:
<p>{lang.t('userNoComment')}</p>
}
</TabContent>
<TabContent show={activeTab === 1}>
<BioContainer bio={userData.settings.bio} handleSave={this.handleSave} {...this.props} />
</TabContent>
{
// Hiding bio until moderation can get figured out
/* <TabBar onChange={this.handleTabChange} activeTab={activeTab} cStyle='material'>
<Tab>{lang.t('allComments')} ({user.myComments.length})</Tab>
<Tab>{lang.t('profileSettings')}</Tab>
</TabBar>
<TabContent show={activeTab === 0}> */
me.comments.length ?
<CommentHistory
comments={me.comments}
asset={asset}
link={link}
/>
:
<p>{lang.t('userNoComment')}</p>
// Hiding user bio pending effective moderation system.
/* </TabContent>
<TabContent show={activeTab === 1}>
<BioContainer bio={userData.settings.bio} handleSave={this.handleSave} {...this.props} />
</TabContent> */
}
</div>
);
}
@@ -78,8 +80,9 @@ const mapStateToProps = state => ({
auth: state.auth.toJS()
});
const mapDispatchToProps = dispatch => ({
saveBio: (user_id, formData) => dispatch(saveBio(user_id, formData))
const mapDispatchToProps = () => ({
// saveBio: (user_id, formData) => dispatch(saveBio(user_id, formData))
});
export default compose(
-2
View File
@@ -2,7 +2,6 @@ const _ = require('lodash');
const Comment = require('./comment');
const Action = require('./action');
const User = require('./user');
module.exports = (context) => {
@@ -10,7 +9,6 @@ module.exports = (context) => {
return _.merge(...[
Comment,
Action,
User,
].map((mutators) => {
// Each set of mutators is a function which takes the context.
-31
View File
@@ -1,31 +0,0 @@
const UsersService = require('../../services/users');
/**
* Updates a users settings.
* @param {Object} user the user performing the request
* @param {String} bio the new user bio
* @return {Promise}
*/
const updateUserSettings = ({user}, {bio}) => {
return UsersService.updateSettings(user.id, {bio});
};
module.exports = (context) => {
// TODO: refactor to something that'll return an error in the event an attempt
// is made to mutate state while not logged in. There's got to be a better way
// to do this.
if (context.user && context.user.can('mutation:updateUserSettings')) {
return {
User: {
updateSettings: (settings) => updateUserSettings(context, settings)
}
};
}
return {
User: {
updateSettings: () => {}
}
};
};
-3
View File
@@ -8,9 +8,6 @@ const RootMutation = {
deleteAction(_, {id}, {mutators: {Action}}) {
return Action.delete({id});
},
updateUserSettings(_, {settings}, {mutators: {User}}) {
return User.updateSettings(settings);
}
};
module.exports = RootMutation;
+4 -16
View File
@@ -10,11 +10,6 @@ enum SORT_ORDER {
# Date represented as an ISO8601 string.
scalar Date
type UserSettings {
# bio of the user.
bio: String
}
input CommentsQuery {
# current status of a comment.
statuses: [COMMENT_STATUS]
@@ -22,7 +17,7 @@ input CommentsQuery {
# asset that a comment is on.
asset_id: ID
# the parent of the comment that we want to retrive.
# the parent of the comment that we want to retrieve.
parent_id: ID
# comments returned will only be ones which have at least one action of this
@@ -61,8 +56,8 @@ type User {
# the current roles of the user.
roles: [USER_ROLES]
# settings for a user.
settings: UserSettings
# determines whether the user can edit their username
canEditName: Boolean
# returns all comments based on a query.
comments(query: CommentsQuery): [Comment]
@@ -150,7 +145,7 @@ type Asset {
# The scraped title of the asset.
title: String
# The URL that the asset is locaed on.
# The URL that the asset is located on.
url: String
# The top level comments that are attached to the asset.
@@ -205,11 +200,6 @@ input CreateActionInput {
item_id: ID!
}
input UpdateUserSettingsInput {
# user bio
bio: String!
}
type RootMutation {
# creates a comment on the asset.
createComment(asset_id: ID!, parent_id: ID, body: String!): Comment
@@ -220,8 +210,6 @@ type RootMutation {
# delete an action based on the action id.
deleteAction(id: ID!): Boolean
# updates a user's settings, it will return if the query was successful.
updateUserSettings(settings: UpdateUserSettingsInput!): Boolean
}
schema {
+8 -2
View File
@@ -12,7 +12,7 @@ const USER_STATUS = [
'ACTIVE',
'BANNED',
'PENDING',
'SUSPENDED',
'APPROVED' // Indicates that the users' displayname has been approved
];
// ProfileSchema is the mongoose schema defined as the representation of a
@@ -97,6 +97,12 @@ const UserSchema = new mongoose.Schema({
default: 'ACTIVE'
},
// Determines whether the user can edit their username.
canEditName: {
type: Boolean,
default: false
},
// User's settings
settings: {
bio: {
@@ -141,7 +147,7 @@ const USER_GRAPH_OPERATIONS = [
'mutation:createComment',
'mutation:createAction',
'mutation:deleteAction',
'mutation:updateUserSettings'
'mutation:editName'
];
/**
+8 -9
View File
@@ -115,19 +115,18 @@ router.put('/password/reset', (req, res, next) => {
});
});
router.put('/settings', authorization.needed(), (req, res, next) => {
const {
bio
} = req.body;
router.put('/displayname', authorization.needed(), (req, res, next) => {
UsersService
.updateSettings(req.user.id, {bio})
.editName(req.user.id, req.body.displayName)
.then(() => {
res.status(204).end();
})
.catch((err) => {
next(err);
.catch(error => {
if (error.code === 11000) {
next(errors.ErrDisplayTaken);
} else {
next(error);
}
});
});
+14 -8
View File
@@ -2,6 +2,7 @@ const express = require('express');
const passport = require('../../../services/passport');
const authorization = require('../../../middleware/authorization');
const errors = require('../../../errors');
const UsersService = require('../../../services/users');
const router = express.Router();
@@ -69,15 +70,20 @@ const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null});
}
// Perform the login of the user!
req.logIn(user, (err) => {
if (err) {
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
}
// Authorize the user to edit their displayName.
UsersService.toggleNameEdit(user.id, true)
.then(() => {
// Perform the login of the user!
req.logIn(user, (err) => {
if (err) {
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
}
// We logged in the user! Let's send back the user data.
res.render('auth-callback', {err: null, data: JSON.stringify(user)});
});
// We logged in the user! Let's send back the user data.
res.render('auth-callback', {err: null, data: JSON.stringify(user)});
});
});
};
/**
+1 -1
View File
@@ -79,7 +79,7 @@ router.get('/comments/flagged', authorization.needed('ADMIN'), (req, res, next)
});
// Returns back all the users that are in the moderation queue.
router.get('/users/pending', (req, res, next) => {
router.get('/users/flagged', (req, res, next) => {
UsersService.moderationQueue()
.then((users) => {
return Promise.all([
+9 -8
View File
@@ -60,15 +60,16 @@ router.post('/:user_id/status', authorization.needed('ADMIN'), (req, res, next)
.catch(next);
});
router.post('/:user_id/displayname', authorization.needed(), (req, res, next) => {
UsersService.setDisplayName(req.params.user_id, req.body.displayName)
.then((user) => {
res.status(201).json(user);
})
.catch(next);
router.post('/:user_id/username-enable', authorization.needed('ADMIN'), (req, res, next) => {
UsersService
.toggleNameEdit(req.params.user_id, true)
.then(() => {
res.status(204).end();
});
});
router.post('/:user_id/email', authorization.needed('admin'), (req, res, next) => {
router.post('/:user_id/email', authorization.needed('ADMIN'), (req, res, next) => {
UsersService.findById(req.params.user_id)
.then(user => {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
@@ -170,7 +171,7 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
// Set the user status to "pending" for review by moderators
if (action_type.slice(0, 4) === 'FLAG') {
return UsersService.setStatus(req.user.id, 'PENDING')
return UsersService.setStatus(req.params.user_id, 'PENDING')
.then(() => action);
} else {
return action;
+43 -20
View File
@@ -350,26 +350,16 @@ module.exports = class UsersService {
return Promise.reject(new Error(`status ${status} is not supported`));
}
return UserModel.update({id}, {$set: {status}});
}
/**
* Set the display name of a user.
* @param {String} id id of a user
* @param {String} displayName display name to set
* @param {Function} done callback after the operation is complete
*/
static setDisplayName(id, displayName) {
return UsersService.isValidDisplayName(displayName)
.then(() => { // displayName is valid
return UserModel.update(
{id},
{$set: {'displayName': displayName}})
.then(() => {
return UserModel.findOne({'id': id});
});
});
return UserModel.update({
id,
status: {
$ne: 'APPROVED'
}
}, {
$set: {
status
}
});
}
/**
@@ -644,4 +634,37 @@ module.exports = class UsersService {
return UserModel.find({status: 'PENDING'});
}
/**
* Gives the user the ability to edit their username.
* @param {String} id the id of the user to be toggled.
* @param {Boolean} canEditName sets whether the user can edit their name.
* @return {Promise}
*/
static toggleNameEdit(id, canEditName) {
return UserModel.update({id}, {
$set: {canEditName}
});
}
/**
* Updates the user's displayName.
* @param {String} id the id of the user to be enabled.
* @param {String} displayName The new displayname for the user.
* @return {Promise}
*/
static editName(id, displayName) {
return UserModel.update({
id,
canEditName: true
}, {
$set: {
displayName: displayName.toLowerCase(),
canEditName: false,
status: 'PENDING'
}
}).then((result) => {
return result.nModified > 0 ? result :
Promise.reject(new Error('You do not have permission to update your username.'));
});
}
};
+1 -2
View File
@@ -10,8 +10,7 @@ const embedStreamCommands = {
return this
.waitForElementVisible('@moderationList')
.waitForElementVisible('@approveButton')
.click('@approveButton')
.waitForElementNotPresent('@approveButton');
.click('@approveButton');
}
};
+71
View File
@@ -0,0 +1,71 @@
const passport = require('../../../passport');
const app = require('../../../../app');
const chai = require('chai');
const expect = chai.expect;
const SettingsService = require('../../../../services/settings');
const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
// Setup chai.
chai.should();
chai.use(require('chai-http'));
const UsersService = require('../../../../services/users');
describe('/api/v1/account/displayname', () => {
let mockUser;
beforeEach(() => SettingsService.init(settings).then(() => {
return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
})
.then((user) => {
mockUser = user;
}));
describe('#put', () => {
it('it should enable a user to edit their username if canEditName is enabled', () => {
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}))
.then(() => chai.request(app)
.put('/api/v1/account/displayname')
.set(passport.inject({id: mockUser.id, roles: []}))
.send({displayName: 'MojoJojo'}))
.then((res) => {
expect(res).to.have.status(204);
});
});
it('it should return an error if the wrong user tries to edit a username', (done) => {
chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}))
.then(() => chai.request(app)
.put('/api/v1/account/displayname')
.set(passport.inject({id: 'wrongid', roles: []}))
.send({displayName: 'MojoJojo'}))
.then(() => {
done(new Error('Exected Error'));
})
.catch((err) => {
expect(err).to.be.truthy;
done();
});
});
it('it should return an error when the user tries to edit their username if canEditName is disabled', (done) => {
chai.request(app)
.put('/api/v1/account/displayname')
.set(passport.inject({id: mockUser.id, roles: []}))
.send({username: 'MojoJojo'})
.then(() => {
done(new Error('Exected Error'));
})
.catch((err) => {
expect(err).to.be.truthy;
done();
});
});
});
});
+1 -1
View File
@@ -110,7 +110,7 @@ describe('/api/v1/queue', () => {
it('should return all pending users and actions', function(done){
chai.request(app)
.get('/api/v1/queue/users/pending')
.get('/api/v1/queue/users/flagged')
.set(passport.inject({roles: ['ADMIN']}))
.end(function(err, res){
expect(err).to.be.null;
+31 -16
View File
@@ -52,33 +52,48 @@ describe('/api/v1/users/:user_id/email/confirm', () => {
describe('/api/v1/users/:user_id/actions', () => {
const users = [{
displayName: 'Ana',
email: 'ana@gmail.com',
password: '123456789'
}, {
displayName: 'Maria',
email: 'maria@gmail.com',
password: '123456789'
}];
let mockUser;
beforeEach(() => {
return SettingsService.init(settings).then(() => {
return UsersService.createLocalUsers(users);
});
});
beforeEach(() => SettingsService.init(settings).then(() => {
return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
})
.then((user) => {
mockUser = user;
}));
describe('#post', () => {
it('it should update actions', () => {
return chai.request(app)
.post('/api/v1/users/abc/actions')
.post(`/api/v1/users/${mockUser.id}/actions`)
.set(passport.inject({id: '456', roles: ['ADMIN']}))
.send({'action_type': 'FLAG', metadata: {reason: 'Bio is too awesome.'}})
.then((res) => {
expect(res).to.have.status(201);
expect(res).to.have.body;
expect(res.body).to.have.property('action_type', 'FLAG');
expect(res.body).to.have.property('item_id', 'abc');
expect(res.body).to.have.property('item_id', mockUser.id);
});
});
});
});
describe('/api/v1/users/:user_id/username-enable', () => {
let mockUser;
beforeEach(() => SettingsService.init(settings).then(() => {
return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
})
.then((user) => {
mockUser = user;
}));
describe('#post', () => {
it('it should enable a user to edit their username', () => {
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(204);
});
});
});
+52 -19
View File
@@ -178,25 +178,6 @@ describe('services.UsersService', () => {
});
});
describe('#setDisplayName', () => {
it('should set the display name to a new unique one', () => {
return UsersService
.setDisplayName(mockUsers[0].id, 'maria')
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('displayName', 'maria');
});
});
it('should return an error when the displayName is not unique', () => {
return UsersService
.setDisplayName(mockUsers[0].id, 'marvel')
.catch((error) => {
expect(error).to.not.be.null;
});
});
});
describe('#ban', () => {
it('should set the status to banned', () => {
return UsersService
@@ -227,4 +208,56 @@ describe('services.UsersService', () => {
});
});
});
describe('#toggleNameEdit', () => {
it('should toggle the canEditName field', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('canEditName', true);
});
});
});
describe('#editName', () => {
it('should let the user edit their username if the proper toggle is set', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.editName(mockUsers[0].id, 'Jojo'))
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('displayName', 'jojo');
expect(user).to.have.property('canEditName', false);
});
});
it('should return an error if canEditName is false', (done) => {
UsersService
.editName(mockUsers[0].id, 'Jojo')
.then(() => UsersService.findById(mockUsers[0].id))
.then(() => {
done(new Error('Error expected'));
})
.catch((err) => {
expect(err).to.be.truthy;
done();
});
});
it('should return an error if the username is already taken', (done) => {
UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.editName(mockUsers[0].id, 'Marvel'))
.then(() => UsersService.findById(mockUsers[0].id))
.then(() => {
done(new Error('Error expected'));
})
.catch((err) => {
expect(err).to.be.truthy;
done();
});
});
});
});