Merge branch 'master' of github.com:coralproject/talk into load-more-comments

This commit is contained in:
David Jay
2017-02-13 18:26:05 -05:00
107 changed files with 1380 additions and 878 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
There are some runtime requirements for running Talk from source:
- [Node](https://nodejs.org/) v7 or later
- [MongoDB](https://www.mongodb.com/) v3.2 or later
- [MongoDB](https://www.mongodb.com/) v3.4 or later
- [Redis](https://redis.io/) v3.2 or later
- [Yarn](https://yarnpkg.com/) v0.19.1 or later
+5 -5
View File
@@ -116,11 +116,11 @@ const performSetup = () => {
return inquirer.prompt([
{
type: 'input',
name: 'displayName',
message: 'Display Name',
filter: (displayName) => {
name: 'username',
message: 'Username',
filter: (username) => {
return UsersService
.isValidDisplayName(displayName, false)
.isValidDisplayName(username, false)
.catch((err) => {
throw err.message;
});
@@ -174,7 +174,7 @@ const performSetup = () => {
settings: settings.toObject(),
user: {
email: user.email,
displayName: user.displayName,
username: user.username,
password: user.password
}
});
+9 -9
View File
@@ -32,7 +32,7 @@ function getUserCreateAnswers(options) {
email: options.email,
password: options.password,
confirmPassword: options.password,
displayName: options.name,
username: options.name,
roles: []
};
@@ -75,11 +75,11 @@ function getUserCreateAnswers(options) {
}
},
{
name: 'displayName',
message: 'Display Name',
filter: (displayName) => {
name: 'username',
message: 'Username',
filter: (username) => {
return UsersService
.isValidDisplayName(displayName)
.isValidDisplayName(username)
.catch((err) => {
throw err.message;
});
@@ -108,7 +108,7 @@ function createUser(options) {
})
.then((answers) => {
return UsersService
.createLocalUser(answers.email.trim(), answers.password.trim(), answers.displayName.trim())
.createLocalUser(answers.email.trim(), answers.password.trim(), answers.username.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
@@ -209,7 +209,7 @@ function updateUser(userID, options) {
'id': userID
}, {
$set: {
displayName: options.name
username: options.name
}
});
@@ -238,7 +238,7 @@ function listUsers() {
let table = new Table({
head: [
'ID',
'Display Name',
'Username',
'Profiles',
'Roles',
'Status',
@@ -249,7 +249,7 @@ function listUsers() {
users.forEach((user) => {
table.push([
user.id,
user.displayName,
user.username,
user.profiles.map((p) => p.provider).join(', '),
user.roles.join(', '),
user.status,
+12 -2
View File
@@ -10,11 +10,21 @@ machine:
dependencies:
override:
# TODO: use the following to add in support for MongoDB 3.4.
# # Upgrade the database version to 3.4.
# - sudo apt-get purge mongodb-org*
# - sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 0C49F3730359A14518585931BC711F9BA15703C6
# - echo "deb [ arch=amd64 ] http://repo.mongodb.org/apt/ubuntu precise/mongodb-org/3.4 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.4.list
# - sudo apt-get update
# - sudo apt-get install -y mongodb-org
# - sudo service mongod restart
# Install node dependencies.
- yarn
cache_directories:
- ~/.cache/yarn
post:
# Build the static assets
# Build the static assets.
- yarn build
# Lint the project here, before tests are ran.
- yarn lint
@@ -30,7 +40,7 @@ test:
override:
# Run the tests using the junit reporter.
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test
# Run the e2e test suite
# Run the e2e test suite.
- E2E_REPORT_PATH=$CIRCLE_TEST_REPORTS/e2e yarn e2e
deployment:
+3
View File
@@ -18,6 +18,9 @@ const routes = (
<Route path='community' component={CommunityContainer} />
<Route path='configure' component={Configure} />
<Route path='streams' component={Streams} />
<Route path='moderate' component={ModerationContainer} />
<Route path='moderate/:id' component={ModerationContainer} />
</Route>
</div>
);
+7 -1
View File
@@ -4,8 +4,10 @@ import {
FETCH_ASSETS_FAILURE,
UPDATE_ASSET_STATE_REQUEST,
UPDATE_ASSET_STATE_SUCCESS,
UPDATE_ASSET_STATE_FAILURE
UPDATE_ASSET_STATE_FAILURE,
UPDATE_ASSETS
} from '../constants/assets';
import coralApi from '../../../coral-framework/helpers/response';
/**
@@ -34,3 +36,7 @@ export const updateAssetState = (id, closedAt) => (dispatch) => {
dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
.catch(error => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error}));
};
export const updateAssets = assets => dispatch => {
dispatch({type: UPDATE_ASSETS, assets});
};
@@ -101,12 +101,3 @@ export const flagComment = id => (dispatch, getState) => {
dispatch({type: commentTypes.COMMENT_FLAG, id});
dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)});
};
// Dialog Actions
export const showBanUserDialog = (userId, userName, commentId) => {
return {type: commentTypes.SHOW_BANUSER_DIALOG, userId, userName, commentId};
};
export const hideBanUserDialog = (showDialog) => {
return {type: commentTypes.HIDE_BANUSER_DIALOG, showDialog};
};
@@ -0,0 +1,9 @@
import * as actions from 'constants/moderation';
export const setActiveTab = activeTab => ({type: actions.SET_ACTIVE_TAB, activeTab});
export const toggleModal = open => ({type: actions.TOGGLE_MODAL, open});
export const singleView = () => ({type: actions.SINGLE_VIEW});
// Ban User Dialog
export const showBanUserDialog = (user, commentId) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId});
export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog});
@@ -1,48 +1,22 @@
import React from 'react';
import styles from './ModerationList.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
import {FabButton, Button, Icon} from 'coral-ui';
import BanUserButton from './BanUserButton';
import {FabButton} from 'coral-ui';
import {menuActionsMap} from '../containers/ModerationQueue/helpers/moderationQueueActionsMap';
const ActionButton = ({option, type, comment = {}, user, menuOptionsMap, onClickAction, onClickShowBanDialog}) =>
{
const banned = user.status === 'BANNED';
const ActionButton = ({type = '', user, ...props}) => {
if (type === 'BAN') {
return <BanUserButton user={user} onClick={() => props.showBanUserDialog(props.user, props.id)} />;
}
if (option === 'flag' && (type === 'USERS' || comment.status || comment.flagged === true)) {
return null;
}
if (option === 'ban') {
return (
<div className={styles.ban}>
<Button
className={`ban ${styles.banButton}`}
cStyle='darkGrey'
disabled={banned ? 'disabled' : ''}
onClick={() => onClickShowBanDialog(user.id, user.displayName, comment.id)
}
raised
>
<Icon name='not_interested' className={styles.banIcon} />
{lang.t('comment.ban_user')}
</Button>
</div>
);
}
const menuOption = menuOptionsMap[option];
const action = {
item_type: type,
item_id: type === 'COMMENTS' ? comment.id : user.id
};
return (
<FabButton
className={`${option} ${styles.actionButton}`}
cStyle={option}
icon={menuOption.icon}
onClick={() => onClickAction(menuOption.status, type === 'COMMENTS' ? comment : user, action)}
className={`${type.toLowerCase()} ${styles.actionButton}`}
cStyle={type.toLowerCase()}
icon={menuActionsMap[type].icon}
onClick={type === 'APPROVE' ? props.acceptComment : props.rejectComment}
/>
);
};
export default ActionButton;
const lang = new I18n(translations);
@@ -0,0 +1,10 @@
.banButton {
width: 114px;
letter-spacing: 1px;
i {
vertical-align: middle;
margin-right: 10px;
font-size: 14px;
}
}
@@ -0,0 +1,26 @@
import React, {PropTypes} from 'react';
import {Button, Icon} from 'coral-ui';
import styles from './BanUserButton.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
const lang = new I18n(translations);
const BanUserButton = ({user, ...props}) => (
<div className={styles.ban}>
<Button cStyle='darkGrey'
className={`ban ${styles.banButton}`}
disabled={user.status === 'BANNED' ? 'disabled' : ''}
onClick={props.onClick}
raised>
<Icon name='not_interested' />
{lang.t('comment.ban_user')}
</Button>
</div>
);
BanUserButton.propTypes = {
onClick: PropTypes.func.isRequired
};
export default BanUserButton;
@@ -1,4 +1,4 @@
import React from 'react';
import React, {PropTypes} from 'react';
import {Dialog} from 'coral-ui';
import styles from './BanUserDialog.css';
@@ -8,34 +8,28 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => (
const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
<Dialog
className={styles.dialog}
id="banuserDialog"
open={open}
onClose={() => handleClose()}
onCancel={() => handleClose()}
onClose={handleClose}
onCancel={handleClose}
title={lang.t('bandialog.ban_user')}>
<span className={styles.close} onClick={handleClose}>×</span>
<div>
<div className={styles.header}>
<h2>
{lang.t('bandialog.ban_user')}
</h2>
<h2>{lang.t('bandialog.ban_user')}</h2>
</div>
<div className={styles.separator}>
<h3>
{lang.t('bandialog.are_you_sure', user.userName)}
</h3>
<i>
{lang.t('bandialog.note')}
</i>
<h3>{lang.t('bandialog.are_you_sure', user.name)}</h3>
<i>{lang.t('bandialog.note')}</i>
</div>
<div className={styles.buttons}>
<Button cStyle="cancel" className={styles.cancel} onClick={() => handleClose()} raised>
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
{lang.t('bandialog.cancel')}
</Button>
<Button cStyle="black" className={styles.ban} onClick={() => onClickBanUser('BANNED', user.userId, user.commentId)} raised>
<Button cStyle="black" className={styles.ban} onClick={() => handleBanUser({userId: user.id})} raised>
{lang.t('bandialog.yes_ban_user')}
</Button>
</div>
@@ -43,4 +37,10 @@ const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => (
</Dialog>
);
BanUserDialog.propTypes = {
handleBanUser: PropTypes.func.isRequired,
handleClose: PropTypes.func.isRequired,
user: PropTypes.object.isRequired,
};
export default BanUserDialog;
+1 -1
View File
@@ -23,7 +23,7 @@ const Comment = props => {
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span>{author.displayName || lang.t('comment.anon')}</span>
<span>{author.username || lang.t('comment.anon')}</span>
<span className={styles.created}>{timeago().format(comment.createdAt || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}</span>
{comment.flagged ? <p className={styles.flagged}>{lang.t('comment.flagged')}</p> : null}
</div>
@@ -96,11 +96,6 @@
margin-left: 40px;
}
.actionButton {
transform: scale(.8);
margin: 0;
}
.body {
margin-top: 20px;
flex: 1;
@@ -182,3 +177,9 @@
font-size: 14px;
}
}
.actionButton {
transform: scale(.8);
margin: 0;
}
+2 -3
View File
@@ -18,16 +18,15 @@ const User = props => {
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span>{user.displayName}</span>
<span>{user.username}</span>
</div>
<div className={styles.sideActions}>
<div className={`actions ${styles.actions}`}>
{props.modActions.map(
(action, i) =>
<ActionButton
option={action}
type={action.toUpperCase()}
key={i}
type='USERS'
user={user}
menuOptionsMap={props.menuOptionsMap}
onClickAction={props.onClickAction}
@@ -16,6 +16,7 @@
.rightPanel {
position: absolute;
top: 0;
right: 0;
width: 170px;
height: 100%;
@@ -13,10 +13,14 @@ export default ({handleLogout, restricted = false}) => (
!restricted ?
<div>
<Navigation className={styles.nav}>
<IndexLink className={styles.navLink} to="/admin"
<IndexLink className={styles.navLink} to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</IndexLink>
<Link className={styles.navLink} to="/admin/streams"
activeClassName={styles.active}>
{lang.t('configure.streams')}
</Link>
<Link className={styles.navLink} to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
@@ -25,10 +29,6 @@ export default ({handleLogout, restricted = false}) => (
activeClassName={styles.active}>
{lang.t('configure.configure')}
</Link>
<Link className={styles.navLink} to="/admin/streams"
activeClassName={styles.active}>
{lang.t('configure.streams')}
</Link>
</Navigation>
<div className={styles.rightPanel}>
<ul>
@@ -1,6 +1,9 @@
export const FETCH_ASSETS_REQUEST = 'FETCH_ASSETS_REQUEST';
export const FETCH_ASSETS_SUCCESS = 'FETCH_ASSETS_SUCCESS';
export const FETCH_ASSETS_FAILURE = 'FETCH_ASSETS_FAILURE';
export const UPDATE_ASSET_STATE_REQUEST = 'UPDATE_ASSET_STATE_REQUEST';
export const UPDATE_ASSET_STATE_SUCCESS = 'UPDATE_ASSET_STATE_SUCCESS';
export const UPDATE_ASSET_STATE_FAILURE = 'UPDATE_ASSET_STATE_FAILURE';
export const UPDATE_ASSETS = 'UPDATE_ASSETS';
@@ -0,0 +1,5 @@
export const SET_ACTIVE_TAB = 'SET_ACTIVE_TAB';
export const TOGGLE_MODAL = 'TOGGLE_MODAL';
export const SINGLE_VIEW = 'SINGLE_VIEW';
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
@@ -13,7 +13,7 @@ const lang = new I18n(translations);
const tableHeaders = [
{
title: lang.t('community.username_and_email'),
field: 'displayName'
field: 'username'
},
{
title: lang.t('community.account_creation_date'),
@@ -44,7 +44,7 @@ class Table extends Component {
{commenters.map((row, i)=> (
<tr key={i}>
<td className="mdl-data-table__cell--non-numeric">
{row.displayName}
{row.username}
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
</td>
<td className="mdl-data-table__cell--non-numeric">
@@ -21,12 +21,12 @@ const InitialStep = props => {
<FormField
className={styles.formField}
id="displayName"
id="username"
type="text"
label='Username'
onChange={handleUserChange}
showErrors={install.showErrors}
errorMsg={install.errors.displayName}
errorMsg={install.errors.username}
/>
<FormField
@@ -1,42 +1,33 @@
import React from 'react';
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {compose} from 'react-apollo';
import key from 'keymaster';
import isEqual from 'lodash/isEqual';
import {modQueueQuery} from '../../graphql/queries';
import {banUser, setCommentStatus} from '../../graphql/mutations';
import {
updateStatus,
showBanUserDialog,
hideBanUserDialog,
fetchPremodQueue,
fetchRejectedQueue,
fetchFlaggedQueue,
fetchModerationQueueComments,
} from 'actions/comments';
import {userStatusUpdate, sendNotificationEmail, enableUsernameEdit} from 'actions/users';
import {fetchSettings} from 'actions/settings';
import {updateAssets} from 'actions/assets';
import {setActiveTab, toggleModal, singleView, showBanUserDialog, hideBanUserDialog} from 'actions/moderation';
import {Spinner} from 'coral-ui';
import BanUserDialog from '../../components/BanUserDialog';
import ModerationQueue from './ModerationQueue';
import ModerationMenu from './components/ModerationMenu';
import ModerationHeader from './components/ModerationHeader';
import NotFoundAsset from './components/NotFoundAsset';
class ModerationContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
activeTab: 'all',
singleView: false,
modalOpen: false
};
this.onClose = this.onClose.bind(this);
this.onTabClick = this.onTabClick.bind(this);
}
class ModerationContainer extends Component {
componentWillMount() {
this.props.fetchModerationQueueComments();
const {toggleModal, singleView} = this.props;
this.props.fetchSettings();
key('s', () => this.setState({singleView: !this.state.singleView}));
key('shift+/', () => this.setState({modalOpen: true}));
key('esc', () => this.setState({modalOpen: false}));
key('s', () => singleView());
key('shift+/', () => toggleModal(true));
key('esc', () => toggleModal(false));
}
componentWillUnmount() {
@@ -45,90 +36,85 @@ class ModerationContainer extends React.Component {
key.unbind('esc');
}
componentDidMount() {
// Hack for dynamic mdl tabs
if (typeof componentHandler !== 'undefined') {
// FIXME: fix this hack
componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef
componentWillReceiveProps(nextProps) {
const {updateAssets} = this.props;
if(!isEqual(nextProps.data.assets, this.props.data.assets)) {
updateAssets(nextProps.data.assets);
}
}
onTabClick(activeTab) {
this.setState({activeTab});
if (activeTab === 'premod') {
this.props.fetchPremodQueue();
} else if (activeTab === 'rejected') {
this.props.fetchRejectedQueue();
} else if (activeTab === 'flagged') {
this.props.fetchFlaggedQueue();
} else {
this.props.fetchModerationQueueComments();
}
}
onClose() {
this.setState({modalOpen: false});
}
render () {
const {comments, actions, settings} = this.props;
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'PREMOD');
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'REJECTED');
const flaggedIds = comments.ids.filter(id =>
comments.byId[id].flagged === true &&
comments.byId[id].status !== 'REJECTED' &&
comments.byId[id].status !== 'ACCEPTED'
);
const userActionIds = actions.ids.filter(id => actions.byId[id].item_type === 'USERS');
const {data, moderation, settings, assets, ...props} = this.props;
const providedAssetId = this.props.params.id;
let asset;
// show the Pre-Mod tab if premod is enabled globally OR there are pre-mod comments in the db.
let enablePremodTab = (settings.settings && settings.settings.moderation === 'PRE') || premodIds.length;
if (data.loading) {
return <div><Spinner/></div>;
}
if (data.error) {
console.log(data);
return <div>Error</div>;
}
if (providedAssetId) {
asset = assets.find(asset => asset.id === this.props.params.id);
if (!asset) {
return <NotFoundAsset assetId={providedAssetId} />;
}
}
const enablePremodTab = !!data.premod.length;
return (
<ModerationQueue
enablePremodTab={enablePremodTab}
onTabClick={this.onTabClick}
onClose={this.onClose}
premodIds={premodIds}
userActionIds={userActionIds}
rejectedIds={rejectedIds}
flaggedIds={flaggedIds}
{...this.props}
{...this.state}
/>
<div>
<ModerationHeader asset={asset} />
<ModerationMenu
onTabClick={props.onTabClick}
enablePremodTab={enablePremodTab}
activeTab={moderation.activeTab}
/>
<ModerationQueue
data={data}
currentAsset={asset}
activeTab={moderation.activeTab}
enablePremodTab={enablePremodTab}
suspectWords={settings.wordlist.suspect}
showBanUserDialog={props.showBanUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
/>
<BanUserDialog
open={moderation.banDialog}
user={moderation.user}
handleClose={props.hideBanUserDialog}
handleBanUser={props.banUser}
/>
</div>
);
}
}
const mapStateToProps = state => ({
comments: state.comments.toJS(),
moderation: state.moderation.toJS(),
settings: state.settings.toJS(),
users: state.users.toJS(),
actions: state.actions.toJS(),
assets: state.assets.get('assets')
});
const mapDispatchToProps = dispatch => {
return {
fetchSettings: () => dispatch(fetchSettings()),
fetchModerationQueueComments: () => dispatch(fetchModerationQueueComments()),
fetchPremodQueue: () => dispatch(fetchPremodQueue()),
fetchRejectedQueue: () => dispatch(fetchRejectedQueue()),
fetchFlaggedQueue: () => dispatch(fetchFlaggedQueue()),
showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
userStatusUpdate: (status, userId, commentId) => dispatch(userStatusUpdate(status, userId, commentId)).then(() => {
dispatch(fetchModerationQueueComments());
}),
suspendUser: (userId, subject, text) => dispatch(userStatusUpdate('BANNED', userId))
.then(() => dispatch(enableUsernameEdit(userId)))
.then(() => dispatch(sendNotificationEmail(userId, subject, text)))
.then(() => dispatch(fetchModerationQueueComments()))
,
updateStatus: (action, comment) => dispatch(updateStatus(action, comment))
};
};
const mapDispatchToProps = dispatch => ({
onTabClick: activeTab => dispatch(setActiveTab(activeTab)),
toggleModal: toggle => dispatch(toggleModal(toggle)),
onClose: () => dispatch(toggleModal(false)),
singleView: () => dispatch(singleView()),
updateAssets: assets => dispatch(updateAssets(assets)),
fetchSettings: () => dispatch(fetchSettings()),
showBanUserDialog: (user, commentId) => dispatch(showBanUserDialog(user, commentId)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
});
export default connect(mapStateToProps, mapDispatchToProps)(ModerationContainer);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
setCommentStatus,
modQueueQuery,
banUser
)(ModerationContainer);
@@ -1,53 +0,0 @@
@custom-media --big-viewport (min-width: 780px);
.listContainer {
max-width: 860px;
margin: 0 auto;
}
.tabBar {
background: #262626;
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;
}
}
@@ -1,212 +1,34 @@
import React, {PropTypes} from 'react';
import styles from './ModerationQueue.css';
import ModerationKeysModal from 'components/ModerationKeysModal';
import ModerationList from 'components/ModerationList';
import BanUserDialog from 'components/BanUserDialog';
import Comment from './components/Comment';
import {actionsMap} from './helpers/moderationQueueActionsMap';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
const lang = new I18n(translations);
const ModerationQueue = (props) => (
<div>
<div className='mdl-tabs'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<a href='#all'
onClick={(e) => {
e.preventDefault();
props.onTabClick('all');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'all' ? styles.active : ''}`}
>
{lang.t('modqueue.all')}
</a>
{
props.enablePremodTab
? <a href='#premod'
onClick={(e) => {
e.preventDefault();
props.onTabClick('premod');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'premod' ? styles.active : ''}`}>
{lang.t('modqueue.premod')}
</a>
: null
}
<a href='#account'
onClick={(e) => {
e.preventDefault();
props.onTabClick('account');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'account' ? styles.active : ''}`}>
{lang.t('modqueue.account')}
</a>
<a href='#rejected'
onClick={(e) => {
e.preventDefault();
props.onTabClick('rejected');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'rejected' ? styles.active : ''}`}
>
{lang.t('modqueue.rejected')}
</a>
<a href='#flagged'
onClick={(e) => {
e.preventDefault();
props.onTabClick('flagged');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'flagged' ? styles.active : ''}`}
>
{lang.t('modqueue.flagged')}
</a>
</div>
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='all'>
{
props.activeTab === 'all' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'all'}
singleView={props.singleView}
commentIds={[...props.premodIds, ...props.flaggedIds]}
comments={props.comments.byId}
users={props.users.byId}
actionIds={props.userActionIds}
actions={props.actions.byId}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
updateCommentStatus={props.updateStatus}
onClickShowBanDialog={props.showBanUserDialog}
modActions={['reject', 'approve', 'ban']}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
const ModerationQueue = props => {
return (
<div id="moderationList">
<ul>
{
props.enablePremodTab
? <div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='premod'>
{
props.activeTab === 'premod' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'premod'}
singleView={props.singleView}
commentIds={props.premodIds}
comments={props.comments.byId}
users={props.users.byId}
actions={props.actions.byId}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
updateCommentStatus={props.updateStatus}
onClickShowBanDialog={props.showBanUserDialog}
modActions={['reject', 'approve', 'ban']}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
: null
props.data[props.activeTab].map((comment, i) => {
return <Comment
key={i}
index={i}
comment={comment}
suspectWords={props.suspectWords}
actions={actionsMap[comment.status]}
showBanUserDialog={props.showBanUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
/>;
})
}
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='account'>
{
props.activeTab === 'account' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'account'}
singleView={props.singleView}
users={props.users.byId}
actionIds={props.userActionIds}
actions={props.actions.byId}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
updateCommentStatus={props.updateStatus}
onClickShowBanDialog={props.showBanUserDialog}
modActions={['reject', 'approve', 'ban']}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
{
props.activeTab === 'flagged' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'flagged'}
singleView={props.singleView}
commentIds={props.flaggedIds}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
comments={props.comments.byId}
users={props.users.byId}
updateCommentStatus={props.updateStatus}
modActions={['reject', 'approve']}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
{
props.activeTab === 'rejected' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'rejected'}
singleView={props.singleView}
commentIds={props.rejectedIds}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
comments={props.comments.byId}
users={props.users.byId}
updateCommentStatus={props.updateStatus}
modActions={['approve']}
loading={props.comments.loading}
/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
<ModerationKeysModal open={props.modalOpen} onClose={props.closeModal} />
</ul>
</div>
</div>
);
);
};
ModerationQueue.propTypes = {
enablePremodTab: PropTypes.bool.isRequired
data: PropTypes.object.isRequired
};
export default ModerationQueue;
@@ -0,0 +1,79 @@
import React from 'react';
import timeago from 'timeago.js';
import Linkify from 'react-linkify';
import Highlighter from 'react-highlight-words';
import {Link} from 'react-router';
import styles from './styles.css';
import {Icon} from 'coral-ui';
import ActionButton from '../../../components/ActionButton';
import FlagBox from './FlagBox';
const linkify = new Linkify();
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
const lang = new I18n(translations);
const Comment = ({actions = [], ...props}) => {
const links = linkify.getMatches(props.comment.body);
const actionSumaries = props.comment.action_summaries;
return (
<li tabIndex={props.index}
className={`mdl-card mdl-shadow--2dp ${styles.Comment} ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span>{props.comment.user.name}</span>
<span className={styles.created}>
{timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
</span>
{props.comment.action_summaries ? <p className={styles.flagged}>{lang.t('comment.flagged')}</p> : null}
</div>
<div className={styles.sideActions}>
{links ? <span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) =>
<ActionButton key={i}
type={action}
user={props.comment.user}
acceptComment={() => props.acceptComment({commentId: props.comment.id})}
rejectComment={() => props.rejectComment({commentId: props.comment.id})}
showBanUserDialog={() => props.showBanUserDialog(props.comment.user, props.comment.id)}
/>
)}
</div>
{props.comment.user.banned === 'banned' ?
<span className={styles.banned}>
<Icon name='error_outline'/>
{lang.t('comment.banned_user')}
</span>
: null}
</div>
</div>
{!props.currentAsset && (
<div className={styles.moderateArticle}>
Article: {props.comment.asset.title} <Link to={`/admin/moderate/${props.comment.asset.id}`}>Moderate Article</Link>
</div>
)}
<div className={styles.itemBody}>
<p className={styles.body}>
<Linkify component='span' properties={{style: linkStyles}}>
<Highlighter searchWords={props.suspectWords} textToHighlight={props.comment.body}/>
</Linkify>
</p>
</div>
{actionSumaries && <FlagBox actionSumaries={actionSumaries} />}
{/* <span className={styles.context}>*/}
{/* <a>View context</a>*/}
{/* </span>*/}
</li>
);
};
const linkStyles = {
backgroundColor: 'rgb(255, 219, 135)',
padding: '1px 2px'
};
export default Comment;
@@ -0,0 +1,19 @@
import React, {PropTypes} from 'react';
import styles from './styles.css';
const FlagBox = props => (
<div className={styles.flagBox}>
<h3>Flags:</h3>
<ul>
{props.actionSumaries.map((action, i) =>
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
)}
</ul>
</div>
);
FlagBox.propTypes = {
actionSumaries: PropTypes.array.isRequired
};
export default FlagBox;
@@ -0,0 +1,25 @@
import React from 'react';
import {Link} from 'react-router';
import styles from './styles.css';
const ModerationHeader = props => (
<div className=''>
<div className={`mdl-tabs ${styles.header}`}>
{
props.asset ?
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
<Link className="mdl-tabs__tab" to="/admin/moderate">All Streams</Link>
<a className="mdl-tabs__tab">{props.asset.title}</a>
<Link className="mdl-tabs__tab" to="/admin/streams">Select Stream</Link>
</div>
:
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
<a className="mdl-tabs__tab" />
<a className="mdl-tabs__tab">All Streams</a>
<Link className="mdl-tabs__tab" to="/admin/streams">Select Stream</Link>
</div>
}
</div>
</div>
);
export default ModerationHeader;
@@ -0,0 +1,59 @@
import React, {PropTypes} from 'react';
import styles from './styles.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
const lang = new I18n(translations);
const ModerationMenu = (props) => (
<div className='mdl-tabs'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<a href='#all'
onClick={(e) => {
e.preventDefault();
props.onTabClick('all');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'all' ? styles.active : ''}`}
>
{lang.t('modqueue.all')}
</a>
{
props.enablePremodTab
? <a href='#premod'
onClick={(e) => {
e.preventDefault();
props.onTabClick('premod');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'premod' ? styles.active : ''}`}>
{lang.t('modqueue.premod')}
</a>
: null
}
<a href='#rejected'
onClick={(e) => {
e.preventDefault();
props.onTabClick('rejected');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'rejected' ? styles.active : ''}`}
>
{lang.t('modqueue.rejected')}
</a>
<a href='#flagged'
onClick={(e) => {
e.preventDefault();
props.onTabClick('flagged');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'flagged' ? styles.active : ''}`}
>
{lang.t('modqueue.flagged')}
</a>
</div>
</div>
);
ModerationMenu.propTypes = {
activeTab: PropTypes.string.isRequired,
enablePremodTab: PropTypes.bool
};
export default ModerationMenu;
@@ -0,0 +1,14 @@
import React from 'react';
import {Link} from 'react-router';
import styles from './styles.css';
const NotFound = props => (
<div className={`mdl-card mdl-shadow--2dp ${styles.notFound}`}>
<p>
The provided asset id <Link to={`/admin/moderate/${props.assetId}`}>{props.assetId}</Link> does not exist.
<Link className={styles.goToStreams} to="/admin/streams">Go to Streams</Link>
</p>
</div>
);
export default NotFound;
@@ -0,0 +1,327 @@
@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;
.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 {
border-top: 1px solid rgba(66, 66, 66, 0.12);
h3 {
font-size: 14px;
margin: 0;
font-weight: 500;
}
}
@@ -0,0 +1,13 @@
export const actionsMap = {
PREMOD: ['REJECT', 'APPROVE', 'BAN'],
FLAGGED: ['REJECT', 'APPROVE'],
REJECTED: ['APPROVE']
};
export const menuActionsMap = {
'REJECT': {status: 'REJECTED', icon: 'close', key: 'r'},
'APPROVE': {status: 'ACCEPTED', icon: 'done', key: 't'},
'FLAGGED': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
'BAN': {status: 'BANNED', icon: 'not interested'},
'': {icon: 'done'}
};
@@ -55,6 +55,12 @@
border-left: none;
border-right: none;
a {
color: rgb(44, 44, 44);
font-weight: 500;
text-decoration: none;
}
th {
font-size: 1.1em;
}
@@ -4,14 +4,10 @@ import {connect} from 'react-redux';
import I18n from 'coral-framework/modules/i18n/i18n';
import {fetchAssets, updateAssetState} from '../../actions/assets';
import translations from '../../translations.json';
import {
RadioGroup,
Radio,
Icon,
DataTable,
TableHeader
} from 'react-mdl';
import Pager from 'coral-ui/components/Pager';
import {Link} from 'react-router';
import {Pager, Icon} from 'coral-ui';
import {DataTable, TableHeader, RadioGroup, Radio} from 'react-mdl';
const limit = 25;
@@ -74,6 +70,8 @@ class Streams extends Component {
}
}
renderTitle = (title, {id}) => <Link to={`/admin/moderate/${id}`}>{title}</Link>
renderStatus = (closedAt, {id}) => {
const closed = closedAt && new Date(closedAt).getTime() < Date.now();
const statusMenuOpen = this.state.statusMenus[id];
@@ -104,6 +102,9 @@ class Streams extends Component {
render () {
const {search, sort, filter} = this.state;
const {assets} = this.props;
const assetsIds = assets.ids.map((id) => assets.byId[id]);
return (
<div className={styles.container}>
<div className={styles.leftColumn}>
@@ -142,16 +143,14 @@ class Streams extends Component {
</RadioGroup>
</div>
<div className={styles.mainContent}>
<DataTable
className={styles.streamsTable}
rows={assets.ids.map((id) => assets.byId[id])}>
<TableHeader name="title">{lang.t('streams.article')}</TableHeader>
<TableHeader name="publication_date" cellFormatter={this.renderDate}>
{lang.t('streams.pubdate')}
</TableHeader>
<TableHeader name="closedAt" cellFormatter={this.renderStatus} className={styles.status}>
{lang.t('streams.status')}
</TableHeader>
<DataTable className={styles.streamsTable} rows={assetsIds} onClick={this.goToModeration}>
<TableHeader name="title" cellFormatter={this.renderTitle}>{lang.t('streams.article')}</TableHeader>
<TableHeader name="publication_date" cellFormatter={this.renderDate}>
{lang.t('streams.pubdate')}
</TableHeader>
<TableHeader name="closedAt" cellFormatter={this.renderStatus} className={styles.status}>
{lang.t('streams.status')}
</TableHeader>
</DataTable>
<Pager
totalPages={Math.ceil((assets.count || 0) / limit)}
@@ -169,6 +168,7 @@ const mapStateToProps = ({assets}) => {
assets: assets.toJS()
};
};
const mapDispatchToProps = (dispatch) => {
return {
fetchAssets: (...args) => {
@@ -0,0 +1,15 @@
fragment commentView on Comment {
id
body
created_at
status
user {
id
name: username
status
}
asset {
id
title
}
}
@@ -0,0 +1,38 @@
import {graphql} from 'react-apollo';
import SET_USER_STATUS from './setUserStatus.graphql';
import SET_COMMENT_STATUS from './setCommentStatus.graphql';
export const banUser = graphql(SET_USER_STATUS, {
props: ({mutate}) => ({
banUser: ({userId}) => {
return mutate({
variables: {
userId,
status: 'BANNED'
}
});
}}),
});
export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
props: ({mutate}) => ({
acceptComment: ({commentId}) => {
return mutate({
variables: {
commentId,
status: 'ACCEPTED'
},
refetchQueries: ['ModQueue']
});
},
rejectComment: ({commentId}) => {
return mutate({
variables: {
commentId,
status: 'REJECTED'
},
refetchQueries: ['ModQueue']
});
}
})
});
@@ -0,0 +1,7 @@
mutation setCommentStatus($commentId: ID!, $status: COMMENT_STATUS!){
setCommentStatus(id: $commentId, status: $status) {
errors {
translation_key
}
}
}
@@ -0,0 +1,7 @@
mutation setUserStatus($userId: ID!, $status: USER_STATUS!) {
setUserStatus(id: $userId, status: $status) {
errors {
translation_key
}
}
}
@@ -0,0 +1,6 @@
query Assets {
assets {
id
title
}
}
@@ -0,0 +1,12 @@
import {graphql} from 'react-apollo';
import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
options: ({params: {id = ''}}) => {
return {
variables: {
asset_id: id
}
};
}
});
@@ -0,0 +1,38 @@
#import "../fragments/commentView.graphql"
query ModQueue ($asset_id: ID!) {
all: comments(query: {
statuses: [REJECTED, PREMOD],
asset_id: $asset_id
}) {
...commentView
}
premod: comments(query: {
statuses: [PREMOD],
asset_id: $asset_id
}) {
...commentView
}
flagged: comments(query: {
action_type: FLAG,
asset_id: $asset_id
}) {
...commentView
action_summaries {
count
... on FlagActionSummary {
reason
}
}
}
rejected: comments(query: {
statuses: [REJECTED],
asset_id: $asset_id
}) {
...commentView
}
assets: assets {
id
title
}
}
@@ -1,27 +0,0 @@
import {Map, Set, fromJS} from 'immutable';
import * as types from '../constants/actions';
const initialState = Map({
ids: Set(),
byId: Map()
});
export default (state = initialState, action) => {
switch (action.type) {
case types.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS: return addActions(state, action);
default:
return state;
}
};
const addActions = (state, action) => {
// Make ids that are unique by item_id and by action type
const typeId = (action) => `${action.action_type}_${action.item_id}`;
const ids = action.actions.map(action => typeId(action));
const map = action.actions.reduce((memo, action) => {
memo[typeId(action)] = action;
return memo;
}, {});
return state.set('byId', fromJS(map)).set('ids', new Set(ids));
};
+14 -8
View File
@@ -1,20 +1,26 @@
import {Map, List, fromJS} from 'immutable';
import {FETCH_ASSETS_SUCCESS, UPDATE_ASSET_STATE_REQUEST} from '../constants/assets';
import * as actions from '../constants/assets';
const initialState = Map({
byId: Map(),
ids: List()
ids: List(),
assets: List()
});
export default (state = initialState, action) => {
export default function assets (state = initialState, action) {
switch (action.type) {
case FETCH_ASSETS_SUCCESS:
case actions.FETCH_ASSETS_SUCCESS:
return replaceAssets(action, state);
case UPDATE_ASSET_STATE_REQUEST:
return state.setIn(['byId', action.id, 'closedAt'], action.closedAt);
default: return state;
case actions.UPDATE_ASSET_STATE_REQUEST:
return state
.setIn(['byId', action.id, 'closedAt'], action.closedAt);
case actions.UPDATE_ASSETS:
return state
.set('assets', List(action.assets));
default:
return state;
}
};
}
const replaceAssets = (action, state) => {
const assets = fromJS(action.assets.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {}));
@@ -1,77 +0,0 @@
import * as actions from '../constants/comments';
import * as userActions from '../constants/users';
import {Map, List, fromJS} from 'immutable';
/**
* Comments state is stored using 2 structures:
* - byId is a Map holding the comments using the item_id property as keys
* - ids is a List of item_id, this allows us to order and iterate easily
* since maps are unordered and some times we just need a list of things
*/
const initialState = Map({
byId: Map(),
ids: List(),
loading: false,
showBanUserDialog: false,
banUser: {
'userName': '',
'userId': '',
'commentId': ''
}
});
// Handle the comment actions
export default (state = initialState, action) => {
switch (action.type) {
case actions.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST: return state.set('loading', true);
case actions.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS: return replaceComments(action, state);
case actions.COMMENTS_MODERATION_QUEUE_FAILED: return state.set('loading', false);
case actions.COMMENT_STATUS_UPDATE_REQUEST: return updateStatus(state, action);
case actions.COMMENT_FLAG: return flag(state, action);
case actions.COMMENT_CREATE_SUCCESS: return addComment(state, action);
case actions.COMMENT_STREAM_FETCH_SUCCESS: return replaceComments(action, state);
case actions.SHOW_BANUSER_DIALOG: return setBanUser(state, true, action);
case actions.HIDE_BANUSER_DIALOG: return setBanUser(state, false, action);
case actions.USER_BAN_SUCCESS: return setBanUser(state, false, action);
case userActions.UPDATE_STATUS_SUCCESS: return setBanUser(state, false, action);
default: return state;
}
};
// hide or show the UI for the dialog confirming the ban
// set the user that is going to set and the comment that is the reason
const setBanUser = (state, showBanUser, action) => {
const banUser = {'userName': action.userName, 'userId': action.userId, 'commentId': action.commentId};
return state.set('showBanUserDialog', showBanUser)
.set('banUser', banUser);
};
// Update a comment status
const updateStatus = (state, action) => {
const byId = state.get('byId');
const data = byId.get(action.id).set('status', action.status.toLowerCase());
return state.set('byId', byId.set(action.id, data));
};
// Flag a comment
const flag = (state, action) => {
const byId = state.get('byId');
const data = byId.get(action.id).set('flagged', true);
const comment = byId.get(action.id).set('data', data);
return state.set('byId', byId.set(action.id, comment));
};
// Replace the comment list with a new one
const replaceComments = (action, state) => {
const comments = fromJS(action.comments.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {}));
return state.set('byId', comments).set('loading', false)
.set('ids', List(comments.keys()));
};
// Add a new comment
const addComment = (state, action) => {
const comment = fromJS(action.comment);
return state.set('byId', state.get('byId').set(comment.get('item_id'), comment))
.set('ids', state.get('ids').unshift(comment.get('item_id')));
};
+8 -14
View File
@@ -1,19 +1,13 @@
import auth from 'reducers/auth';
import users from 'reducers/users';
import assets from 'reducers/assets';
import actions from 'reducers/actions';
import install from 'reducers/install';
import comments from 'reducers/comments';
import settings from 'reducers/settings';
import community from 'reducers/community';
import auth from './auth';
import assets from './assets';
import settings from './settings';
import community from './community';
import moderation from './moderation';
export default {
settings,
comments,
community,
auth,
actions,
assets,
users,
install
settings,
community,
moderation
};
+2 -2
View File
@@ -9,7 +9,7 @@ const initialState = Map({
organizationName: ''
}),
user: Map({
displayName: '',
username: '',
email: '',
password: '',
confirmPassword: ''
@@ -17,7 +17,7 @@ const initialState = Map({
}),
errors: Map({
organizationName: '',
displayName: '',
username: '',
email: '',
password: '',
confirmPassword: ''
@@ -0,0 +1,37 @@
import {Map} from 'immutable';
import * as actions from '../constants/moderation';
const initialState = Map({
activeTab: 'all',
singleView: false,
modalOpen: false,
user: Map({}),
commentId: null,
banDialog: false
});
export default function moderation (state = initialState, action) {
switch (action.type) {
case actions.HIDE_BANUSER_DIALOG:
return state
.set('banDialog', false);
case actions.SHOW_BANUSER_DIALOG:
return state
.merge({
user: Map(action.user),
commentId: action.commentId,
banDialog: true
});
case actions.SET_ACTIVE_TAB:
return state
.set('activeTab', action.activeTab);
case actions.TOGGLE_MODAL:
return state
.set('modalOpen', action.open);
case actions.SINGLE_VIEW:
return state
.set('singleView', !state.get('singleView'));
default :
return state;
}
}
+45 -44
View File
@@ -1,5 +1,5 @@
import {Map, List} from 'immutable';
import * as types from '../actions/settings';
import * as actions from '../actions/settings';
const initialState = Map({
settings: Map({
@@ -16,48 +16,49 @@ const initialState = Map({
fetchingSettings: false
});
// Handle the comment actions
export default (state = initialState, action) => {
export default function settings (state = initialState, action) {
switch (action.type) {
case types.SETTINGS_LOADING: return state.set('fetchingSettings', true).set('fetchSettingsError', null);
case types.SETTINGS_RECEIVED: return updateSettings(state, action);
case types.SETTINGS_FETCH_ERROR: return settingsFetchFailed(state, action);
case types.SETTINGS_UPDATED: return updateSettings(state, action);
case types.SAVE_SETTINGS_LOADING: return state.set('fetchingSettings', true).set('saveSettingsError', null);
case types.SAVE_SETTINGS_SUCCESS: return saveComplete(state, action);
case types.SAVE_SETTINGS_FAILED: return settingsSaveFailed(state, action);
case types.WORDLIST_UPDATED: return updateWordlist(state, action);
case types.DOMAINLIST_UPDATED: return updateDomainlist(state, action);
default: return state;
case actions.SETTINGS_LOADING:
return state
.set('fetchingSettings', true)
.set('fetchSettingsError', null);
case actions.SETTINGS_RECEIVED:
return state.merge({
fetchingSettings: null,
fetchSettingsError: null,
...action.settings
});
case actions.SETTINGS_FETCH_ERROR:
return state
.set('fetchingSettings', false)
.set('fetchSettingsError', action.error);
case actions.SETTINGS_UPDATED:
return state.merge({
fetchingSettings: null,
fetchSettingsError: null,
...action.settings
});
case actions.SAVE_SETTINGS_LOADING:
return state
.set('fetchingSettings', true)
.set('saveSettingsError', null);
case actions.SAVE_SETTINGS_SUCCESS:
return state.merge({
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
});
case actions.SAVE_SETTINGS_FAILED:
return state
.set('fetchingSettings', false)
.set('fetchSettingsError', action.error);
case actions.WORDLIST_UPDATED:
return state
.setIn(['settings', 'wordlist', action.listName], action.list);
case actions.DOMAINLIST_UPDATED:
return state
.setIn(['settings', 'domains', action.listName], action.list);
default:
return state;
}
};
// only for updating top-level settings
const updateSettings = (state, action) => {
const s = state.set('fetchingSettings', false).set('fetchSettingsError', null);
const settings = s.get('settings').merge(action.settings);
return s.set('settings', settings);
};
// any nested settings must have a specialized setter
const updateWordlist = (state, action) => {
return state.setIn(['settings', 'wordlist', action.listName], action.list);
};
const updateDomainlist = (state, action) => {
return state.setIn(['settings', 'domains', action.listName], action.list);
};
const saveComplete = (state, action) => {
const s = state.set('fetchingSettings', false).set('saveSettingsError', null);
const settings = s.get('settings').merge(action.settings);
return s.set('settings', settings);
};
const settingsFetchFailed = (state, action) => {
return state.set('fetchingSettings', false).set('fetchSettingsError', action.error);
};
const settingsSaveFailed = (state, action) => {
return state.set('fetchingSettings', false).set('fetchSettingsError', action.error);
};
}
-28
View File
@@ -1,28 +0,0 @@
import {Map, List, fromJS} from 'immutable';
const initialState = Map({
byId: Map(),
ids: List()
});
export default (state = initialState, action) => {
switch (action.type) {
case 'USERS_MODERATION_QUEUE_FETCH_SUCCESS': return replaceUsers(action, state);
case 'USER_STATUS_UPDATE': return updateUserStatus(state, action);
default: return state;
}
};
// Replace the comment list with a new one
const replaceUsers = (action, state) => {
const users = fromJS(action.users.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {}));
return state.set('byId', users)
.set('ids', List(users.keys()));
};
// Update a user status
const updateUserStatus = (state, action) => {
const byId = state.get('byId');
const data = byId.get(action.author_id).set('status', action.status.toLowerCase());
return state.set('byId', byId.set(action.author_id, data));
};
@@ -0,0 +1,9 @@
import Pym from '../../node_modules/pym.js';
const pym = new Pym.Child({polling: 100});
export default pym;
export const link = (url) => (e) => {
e.preventDefault();
pym.sendMessage('navigate', url);
};
@@ -2,7 +2,6 @@ import ApolloClient, {addTypename} from 'apollo-client';
import getNetworkInterface from './transport';
export const client = new ApolloClient({
connectToDevTools: true,
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
+1 -1
View File
@@ -198,7 +198,7 @@
},
"bandialog": {
"ban_user": "Quieres suspender el Usuario?",
"are_you_sure": "Estas segura que quieres suspender a {props.author.displayName}?",
"are_you_sure": "Estas segura que quieres suspender a {props.author.username}?",
"note": "Nota: Suspender este usuario también va a colocar este comentario en la cola de Rechazados.",
"cancel": "Cancelar",
"yes_ban_user": "Si, Suspendan el usuario"
+3 -1
View File
@@ -16,6 +16,7 @@ import {Notification, notificationActions, authActions, assetActions, pym} from
import Stream from './Stream';
import InfoBox from 'coral-plugin-infobox/InfoBox';
import {ModerationLink} from 'coral-plugin-moderation';
import Count from 'coral-plugin-comment-count/CommentCount';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import UserBox from 'coral-sign-in/components/UserBox';
@@ -135,6 +136,7 @@ class Embed extends Component {
charCount={asset.settings.charCountEnable && asset.settings.charCount} />
: null
}
<ModerationLink assetId={asset.id} isAdmin={isAdmin} />
</RestrictedContent>
</div>
: <p>{asset.settings.closedMessage}</p>
@@ -207,7 +209,7 @@ const mapDispatchToProps = dispatch => ({
});
},
clearNotification: () => dispatch(clearNotification()),
editName: (displayName) => dispatch(editName(displayName)),
editName: (username) => dispatch(editName(username)),
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
logout: () => dispatch(logout()),
dispatch: d => dispatch(d)
+1 -1
View File
@@ -10,7 +10,7 @@ class Stream extends React.Component {
asset: PropTypes.object.isRequired,
comments: PropTypes.array.isRequired,
currentUser: PropTypes.shape({
displayName: PropTypes.string,
username: PropTypes.string,
id: PropTypes.string
})
}
+2 -2
View File
@@ -15,11 +15,11 @@ 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 = ({username}) => ({type: actions.UPDATE_DISPLAYNAME, username});
export const createDisplayName = (userId, formData) => dispatch => {
dispatch(createDisplayNameRequest());
coralApi('/account/displayname', {method: 'PUT', body: formData})
coralApi('/account/username', {method: 'PUT', body: formData})
.then(() => {
dispatch(createDisplayNameSuccess());
dispatch(hideCreateDisplayNameDialog());
+2 -2
View File
@@ -5,8 +5,8 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from './../translations';
const lang = new I18n(translations);
export const editName = (displayName) => (dispatch) => {
return coralApi('/account/displayname', {method: 'PUT', body: {displayName}})
export const editName = (username) => (dispatch) => {
return coralApi('/account/username', {method: 'PUT', body: {username}})
.then(() => {
dispatch(addNotification('success', lang.t('successNameUpdate')));
});
@@ -14,16 +14,16 @@ class SuspendedAccount extends Component {
}
state = {
displayName: '',
username: '',
alert: ''
}
onSubmitClick = (e) => {
const {editName} = this.props;
const {displayName} = this.state;
const {username} = this.state;
e.preventDefault();
if (validate.displayName(displayName)) {
editName(displayName)
if (validate.username(username)) {
editName(username)
.then(() => location.reload())
.catch((error) => {
this.setState({alert: lang.t(`error.${error.message}`)});
@@ -36,7 +36,7 @@ class SuspendedAccount extends Component {
render () {
const {canEditName} = this.props;
const {displayName, alert} = this.state;
const {username, alert} = this.state;
return <div className={styles.message}>
<span>{
@@ -51,7 +51,7 @@ class SuspendedAccount extends Component {
{alert}
</div>
<label
htmlFor='displayName'
htmlFor='username'
className="screen-reader-text"
aria-hidden={true}>
{lang.t('editName.label')}
@@ -59,10 +59,10 @@ class SuspendedAccount extends Component {
<input
type='text'
className={styles.editNameInput}
value={displayName}
value={username}
placeholder={lang.t('editName.label')}
id='displayName'
onChange={(e) => this.setState({displayName: e.target.value})}
id='username'
onChange={(e) => this.setState({username: e.target.value})}
rows={3}/><br/>
<Button
onClick={this.onSubmitClick}>
@@ -10,7 +10,7 @@ fragment commentView on Comment {
}
user {
id
name: displayName
name: username
}
action_summaries {
...actionSummaryView
+1 -1
View File
@@ -5,7 +5,7 @@ const lang = new I18n(translations);
export default {
email: lang.t('error.email'),
password: lang.t('error.password'),
displayName: lang.t('error.displayName'),
username: lang.t('error.username'),
confirmPassword: lang.t('error.confirmPassword'),
organizationName: lang.t('error.organizationName'),
};
+1 -1
View File
@@ -2,6 +2,6 @@ export default {
email: email => (/^([A-Za-z0-9_\-\.\+])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
password: pass => (/^(?=.{8,}).*$/.test(pass)),
confirmPassword: () => true,
displayName: displayName => (/^[a-zA-Z0-9_]+$/.test(displayName)),
username: username => (/^[a-zA-Z0-9_]+$/.test(username)),
organizationName: org => (/^[a-zA-Z0-9_ ]+$/).test(org)
};
+1 -1
View File
@@ -133,7 +133,7 @@ export default function auth (state = initialState, action) {
case actions.UPDATE_DISPLAYNAME:
console.log('Action', action);
return state
.setIn(['user', 'displayName'], action.displayName);
.setIn(['user', 'username'], action.username);
case actions.VERIFY_EMAIL_FAILURE:
return state
.set('emailVerificationFailure', true)
+1 -1
View File
@@ -4,7 +4,7 @@ import * as actions from '../constants/user';
import * as assetActions from '../constants/assets';
const initialState = Map({
displayName: '',
username: '',
profiles: [],
settings: {},
myComments: [],
+7 -7
View File
@@ -1,13 +1,13 @@
{
"en": {
"successUpdateSettings": "The changes you have made have been applied to the comment stream on this article",
"successNameUpdate": "Your display name has been updated",
"successNameUpdate": "Your username has been updated",
"contentNotAvailable": "This content is not available",
"loadMore": "Load More",
"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",
"msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. You may contact moderator@fakeurl.com for more information.",
"label": "New Username",
"button": "Submit",
"error": "Display names can contain letters, numbers and _ only"
},
@@ -15,7 +15,7 @@
"emailNotVerified": "Email address {0} not verified.",
"email": "Not a valid E-Mail",
"password": "Password must be at least 8 characters",
"displayName": "Display names can contain letters, numbers and _ only",
"username": "Display names can contain letters, numbers and _ only",
"confirmPassword": "Passwords don't match. Please, check again",
"organizationName": "Organization name must only contain letters or numbers.",
"emailPasswordError": "Email and/or password combination incorrect.",
@@ -23,9 +23,9 @@
"PASSWORD_REQUIRED": "Must input a password",
"PASSWORD_LENGTH": "Password is too short",
"EMAIL_IN_USE": "Email address already in use",
"EMAIL_DISPLAY_NAME_IN_USE": "Email address or display name already in use",
"EMAIL_DISPLAY_NAME_IN_USE": "Email address or username already in use",
"DISPLAYNAME_IN_USE": "Display name already in use",
"DISPLAY_NAME_REQUIRED": "Must input a display name",
"DISPLAY_NAME_REQUIRED": "Must input a username",
"NO_SPECIAL_CHARACTERS": "Display names can contain letters, numbers and _ only",
"PROFANITY_ERROR": "Display names must not contain profanity. Please contact the administrator if you believe this to be in error."
}
@@ -41,7 +41,7 @@
"emailNotVerified": "Dirección de correo electrónico {0} no verificada.",
"email": "No es un email válido",
"password": "La contraseña debe tener por lo menos 8 caracteres",
"displayName": "Los nombres pueden contener letras, números y _",
"username": "Los nombres pueden contener letras, números y _",
"organizationName": "El nombre de la organización debe contener letras y/o números.",
"confirmPassword": "Las contraseñas no coinciden",
"emailPasswordError": "Email y/o contraseña incorrecta.",
+1 -1
View File
@@ -1,7 +1,7 @@
.authorName {
color: black;
display: inline-block;
margin: 10px 5px 10px 0;
margin: 10px 8px 10px 0;
}
.hasBio {
+1 -1
View File
@@ -8,7 +8,7 @@ class LikeButton extends Component {
static propTypes = {
like: PropTypes.shape({
current: PropTypes.obect,
current: PropTypes.object,
count: PropTypes.number
}),
id: PropTypes.string,
@@ -0,0 +1,22 @@
import React, {PropTypes} from 'react';
import styles from './styles.css';
import {I18n} from '../coral-framework';
import translations from './translations.json';
const ModerationLink = props => props.isAdmin ? (
<div className={styles.moderationLink}>
<a href={`/admin/moderate/${props.assetId}`} target="_blank">
{lang.t('MODERATE_THIS_STREAM')}
</a>
</div>
) : null;
ModerationLink.propTypes = {
assetId: PropTypes.string.isRequired,
isAdmin: PropTypes.bool.isRequired
};
const lang = new I18n(translations);
export default ModerationLink;
+1
View File
@@ -0,0 +1 @@
export {default as ModerationLink} from './ModerationLink';
@@ -0,0 +1,9 @@
.moderationLink {
a {
color: #679af3;
text-decoration: none;
font-size: 1em;
font-weight: 600;
letter-spacing: .3px;
}
}
@@ -0,0 +1,8 @@
{
"en": {
"MODERATE_THIS_STREAM": "Moderate this stream"
},
"es": {
"MODERATE_THIS_STREAM": "Modera este stream"
}
}
@@ -32,7 +32,7 @@ const postComment = gql`
id
body
user {
name: displayName
name: username
}
actions {
type: action_type
+1 -1
View File
@@ -55,7 +55,7 @@ const StreamQuery = gql`fragment commentView on Comment {
id
body
user {
name: displayName
name: username
}
tags {
name
@@ -3,7 +3,7 @@ import styles from './SettingsHeader.css';
export default ({userData}) => (
<div className={styles.header}>
<h1>{userData.displayName}</h1>
<h1>{userData.username}</h1>
{
@@ -25,17 +25,17 @@ const CreateDisplayNameDialog = ({open, handleClose, offset, formData, handleSub
</h1>
</div>
<div>
<label htmlFor="displayName">{lang.t('createdisplay.yourusername')}</label>
<label htmlFor="username">{lang.t('createdisplay.yourusername')}</label>
{ props.auth.error && <Alert>{props.auth.error}</Alert> }
<form id="saveDisplayName" onSubmit={handleSubmitDisplayName}>
<FormField
id="displayName"
id="username"
type="string"
label={lang.t('createdisplay.displayName')}
value={formData.displayName}
label={lang.t('createdisplay.username')}
value={formData.username}
onChange={handleChange}
/>
{ props.errors.displayName && <span className={styles.hint}> {lang.t('createdisplay.specialCharacters')} </span> }
{ props.errors.username && <span className={styles.hint}> {lang.t('createdisplay.specialCharacters')} </span> }
<div className={styles.action}>
<Button id="save" type="submit" className={styles.saveButton}>{lang.t('createdisplay.save')}</Button>
</div>
@@ -21,13 +21,13 @@ class SignUpContent extends React.Component {
showErrors: PropTypes.bool,
errors: PropTypes.shape({
email: PropTypes.string,
displayName: PropTypes.string,
username: PropTypes.string,
password: PropTypes.string,
confirmPassword: PropTypes.string,
}),
formData: PropTypes.shape({
email: PropTypes.string,
displayName: PropTypes.string,
username: PropTypes.string,
password: PropTypes.string,
confirmPassword: PropTypes.string
})
@@ -89,12 +89,12 @@ class SignUpContent extends React.Component {
onChange={handleChange}
/>
<FormField
id="displayName"
id="username"
type="text"
label={lang.t('signIn.displayName')}
value={formData.displayName}
label={lang.t('signIn.username')}
value={formData.username}
showErrors={showErrors}
errorMsg={errors.displayName}
errorMsg={errors.username}
onChange={handleChange}
/>
<FormField
+1 -1
View File
@@ -7,7 +7,7 @@ const lang = new I18n(translations);
const UserBox = ({className, user, logout, changeTab}) => (
<div className={`${styles.userBox} ${className ? className : ''}`}>
{lang.t('signIn.loggedInAs')}
<a onClick={() => changeTab(1)}>{user.displayName}</a>. {lang.t('signIn.notYou')}
<a onClick={() => changeTab(1)}>{user.username}</a>. {lang.t('signIn.notYou')}
<a className={styles.logout} onClick={logout} id='logout'>{lang.t('signIn.logout')}</a>
</div>
);
@@ -21,7 +21,7 @@ import {
class ChangeDisplayNameContainer extends Component {
initialState = {
formData: {
displayName: '',
username: '',
},
errors: {},
showErrors: false
@@ -29,7 +29,7 @@ class SignInContainer extends Component {
initialState = {
formData: {
email: '',
displayName: '',
username: '',
password: '',
confirmPassword: ''
},
+5 -5
View File
@@ -19,7 +19,7 @@ export default {
register: 'Register',
signUp: 'Sign Up',
confirmPassword: 'Confirm Password',
displayName: 'Display Name',
username: 'Username',
alreadyHaveAnAccount: 'Already have an account?',
recoverPassword: 'Recover password',
emailInUse: 'Email address already in use',
@@ -32,10 +32,10 @@ export default {
'createdisplay': {
writeyourusername: 'Write your username',
yourusername: 'Your username is publicly visible on all comments you post. A username is needed before you can post your first comment.',
displayName: 'Display Name',
username: 'Username',
save: 'Save',
requiredField: 'Required field',
errorCreate: 'Error when changing display name',
errorCreate: 'Error when changing username',
checkTheForm: 'Invalid Form. Please, check the fields',
specialCharacters: 'Display names can contain letters, numbers and _ only'
},
@@ -60,7 +60,7 @@ export default {
register: 'Regístrate',
signUp: 'Registro',
confirmPassword: 'Confirmar Contraseña',
displayName: 'Nombre',
username: 'Nombre',
alreadyHaveAnAccount: 'Ya tienes una cuenta?',
recoverPassword: 'Recuperar contraseña',
emailInUse: 'Este email se encuentra en uso',
@@ -73,7 +73,7 @@ export default {
'createdisplay': {
writeyourusername: 'Escribe tu nombre',
yourusername: 'Tu nombre es visible publicamente en todos los comentarios que publiques. Es necesario tener un nombre de usuario antes de poder publicar tu primer comentario.',
displayName: 'Nombre a mostrar',
username: 'Nombre a mostrar',
save: 'Guardar',
requiredField: 'Campo necesario',
errorCreate: 'Hubo un error al cambiar el nombre de usuario',
+3 -3
View File
@@ -515,7 +515,7 @@ paths:
- name: value
in: query
type: string
description: A term to search users' displayNames and email addresses.
description: A term to search users' usernames and email addresses.
- name: sort
in: query
type: string
@@ -576,7 +576,7 @@ paths:
format: email
password:
type: string
displayName:
username:
type: string
responses:
201:
@@ -899,7 +899,7 @@ definitions:
id:
type: string
description: The uuid.v4 id of the user.
displayName:
username:
type: string
description: The name appearing next to the user's comments.
disabled:
+2 -2
View File
@@ -59,12 +59,12 @@ const ErrDisplayTaken = new APIError('Display name already in use', {
status: 400
});
const ErrSpecialChars = new APIError('No special characters are allowed in a display name', {
const ErrSpecialChars = new APIError('No special characters are allowed in a username', {
translation_key: 'NO_SPECIAL_CHARACTERS',
status: 400
});
const ErrMissingDisplay = new APIError('A display name is required to create a user', {
const ErrMissingDisplay = new APIError('A username is required to create a user', {
translation_key: 'DISPLAY_NAME_REQUIRED',
status: 400
});
+25 -10
View File
@@ -162,22 +162,37 @@ const createPublicComment = (context, commentInput) => {
}));
};
/**
* Sets the status of a comment
* @param {String} comment comment in graphql context
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
const setCommentStatus = ({comment}, {id, status}) => {
return CommentsService.setStatus(id, status)
.then(res => res);
};
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:createComment')) {
return {
Comment: {
create: (comment) => createPublicComment(context, comment)
}
};
}
return {
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized)
create: () => Promise.reject(errors.ErrNotAuthorized),
setCommentStatus: () => Promise.reject(errors.ErrNotAuthorized)
}
};
if (context.user && context.user.can('mutation:createComment')) {
mutators.Comment.create = (comment) => createPublicComment(context, comment);
}
if (context.user && context.user.can('mutation:setCommentStatus')) {
mutators.Comment.setCommentStatus = (action) => setCommentStatus(context, action);
}
return mutators;
};
+2
View File
@@ -2,6 +2,7 @@ const _ = require('lodash');
const Comment = require('./comment');
const Action = require('./action');
const User = require('./user');
module.exports = (context) => {
@@ -9,6 +10,7 @@ module.exports = (context) => {
return _.merge(...[
Comment,
Action,
User,
].map((mutators) => {
// Each set of mutators is a function which takes the context.
+27
View File
@@ -0,0 +1,27 @@
const errors = require('../../errors');
const UsersService = require('../../services/users');
const setUserStatus = ({user}, {id, status}) => {
return UsersService.setStatus(id, status)
.then(res => res);
};
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:setUserStatus')) {
return {
User: {
setUserStatus: (action) => setUserStatus(context, action)
}
};
}
return {
User: {
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized)
}
};
};
+6
View File
@@ -27,6 +27,12 @@ const RootMutation = {
deleteAction(_, {id}, {mutators: {Action}}) {
return wrapResponse(null)(Action.delete({id}));
},
setUserStatus(_, {id, status}, {mutators: {User}}) {
return wrapResponse(null)(User.setUserStatus({id, status}));
},
setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
return wrapResponse(null)(Comment.setCommentStatus({id, status}));
}
};
module.exports = RootMutation;
+1 -6
View File
@@ -29,12 +29,7 @@ const RootQuery = {
if (user != null && user.hasRoles('ADMIN') && action_type) {
return Actions.getByTypes({action_type, item_type: 'COMMENTS'})
.then((actions) => {
// Map the actions from the items referenced byt this query. The actions
// returned by this query are explicitly going to be distinct by their
// `item_id`'s.
let ids = actions.map((action) => action.item_id);
.then((ids) => {
// Perform the query using the available resolver.
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort});
+34 -2
View File
@@ -27,8 +27,8 @@ type User {
# The ID of the User.
id: ID!
# display name of a user.
displayName: String!
# username of a user.
username: String!
# Action summaries against the user.
action_summaries: [ActionSummary]
@@ -44,6 +44,9 @@ type User {
# returns all comments based on a query.
comments(query: CommentsQuery): [Comment]
# returns user status
status: USER_STATUS
}
type Tag {
@@ -360,6 +363,13 @@ enum SORT_ORDER {
}
# All queries that can be executed.
enum USER_STATUS {
ACTIVE
BANNED
PENDING
APPROVED
}
type RootQuery {
# Site wide settings and defaults.
@@ -466,6 +476,22 @@ type DeleteActionResponse implements Response {
errors: [UserError]
}
# SetUserStatusResponse is the response returned with possibly some errors
# relating to the delete action attempt.
type SetUserStatusResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError]
}
# SetCommentStatusResponse is the response returned with possibly some errors
# relating to the delete action attempt.
type SetCommentStatusResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -480,6 +506,12 @@ type RootMutation {
# Delete an action based on the action id.
deleteAction(id: ID!): DeleteActionResponse
# Sets User status
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
# Sets Comment status
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
}
################################################################################
+18 -5
View File
@@ -12,7 +12,7 @@ const USER_STATUS = [
'ACTIVE',
'BANNED',
'PENDING',
'APPROVED' // Indicates that the users' displayname has been approved
'APPROVED' // Indicates that the users' username has been approved
];
// ProfileSchema is the mongoose schema defined as the representation of a
@@ -61,13 +61,20 @@ const UserSchema = new mongoose.Schema({
// This is sourced from the social provider or set manually during user setup
// and simply provides a name to display for the given user.
displayName: {
username: {
type: String,
unique: true,
lowercase: true,
required: true
},
// TODO: find a way that we can instead utilize MongoDB 3.4's collation
// options to build the index in a case insenstive manner:
// https://docs.mongodb.com/manual/reference/collation/
lowercaseUsername: {
type: String,
required: true,
unique: true
},
// This is true when the user account is disabled, no action should be
// acknowledged when they are disabled. Logins are also prevented.
disabled: Boolean,
@@ -147,7 +154,9 @@ const USER_GRAPH_OPERATIONS = [
'mutation:createComment',
'mutation:createAction',
'mutation:deleteAction',
'mutation:editName'
'mutation:editName',
'mutation:setUserStatus',
'mutation:setCommentStatus'
];
/**
@@ -163,6 +172,10 @@ UserSchema.method('can', function(...actions) {
return false;
}
if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
return false;
}
return true;
});
+2 -2
View File
@@ -115,9 +115,9 @@ router.put('/password/reset', (req, res, next) => {
});
});
router.put('/displayname', authorization.needed(), (req, res, next) => {
router.put('/username', authorization.needed(), (req, res, next) => {
UsersService
.editName(req.user.id, req.body.displayName)
.editName(req.user.id, req.body.username)
.then(() => {
res.status(204).end();
})
+1 -1
View File
@@ -70,7 +70,7 @@ const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null});
}
// Authorize the user to edit their displayName.
// Authorize the user to edit their username.
UsersService.toggleNameEdit(user.id, true)
.then(() => {
+2 -2
View File
@@ -32,11 +32,11 @@ router.post('/', (req, res, next) => {
const {
settings,
user: {email, password, displayName}
user: {email, password, username}
} = req.body;
SetupService
.setup({settings, user: {email, password, displayName}})
.setup({settings, user: {email, password, username}})
.then(() => {
// We're setup!
+2 -2
View File
@@ -120,11 +120,11 @@ const SendEmailConfirmation = (app, userID, email, referer) => UsersService
// create a local user.
router.post('/', (req, res, next) => {
const {email, password, displayName} = req.body;
const {email, password, username} = req.body;
const redirectUri = req.header('X-Pym-Url') || req.header('Referer');
UsersService
.createLocalUser(email, password, displayName)
.createLocalUser(email, password, username)
.then((user) => {
// Send an email confirmation. The Front end will know about the
+3
View File
@@ -1,5 +1,8 @@
#!/bin/bash
# fail the e2e if any of these fail
set -e
# install selenium
selenium-standalone install --config=./selenium.config.js
+27
View File
@@ -7,6 +7,12 @@ const ALLOWED_TAGS = [
{name: 'STAFF'}
];
const STATUSES = [
'ACCEPTED',
'REJECTED',
'PREMOD',
];
module.exports = class CommentsService {
/**
@@ -249,4 +255,25 @@ module.exports = class CommentsService {
return CommentModel.find(query);
}
/**
* Sets Comment Status
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
* @return {Promise}
*/
static setStatus(id, status) {
// Check to see if the comment status is in the allowable set of statuses.
if (STATUSES.indexOf(status) === -1) {
// Comment status is not supported! Error out here.
return Promise.reject(new Error(`status ${status} is not supported`));
}
return CommentModel.update({id}, {
$set: {status}
});
}
};
+3
View File
@@ -102,6 +102,9 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET &&
clientID: process.env.TALK_FACEBOOK_APP_ID,
clientSecret: process.env.TALK_FACEBOOK_APP_SECRET,
callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`,
// TODO: remove displayName reference when we have steps in the FE to handle
// the username create flow.
profileFields: ['id', 'displayName', 'picture.type(large)']
}, (accessToken, refreshToken, profile, done) => {
UsersService
+5 -5
View File
@@ -45,7 +45,7 @@ module.exports = class SetupService {
/**
* This verifies that the current input for the setup is valid.
*/
static validate({settings, user: {email, displayName, password}}) {
static validate({settings, user: {email, username, password}}) {
// Verify the email address of the user.
if (!email) {
@@ -57,7 +57,7 @@ module.exports = class SetupService {
// Verify other properties of the user.
return Promise.all([
UsersService.isValidDisplayName(displayName, false),
UsersService.isValidDisplayName(username, false),
UsersService.isValidPassword(password),
settingsModel.validate()
]);
@@ -66,11 +66,11 @@ module.exports = class SetupService {
/**
* This will perform the setup.
*/
static setup({settings, user: {email, password, displayName}}) {
static setup({settings, user: {email, password, username}}) {
// Validate the settings first.
return SetupService
.validate({settings, user: {email, password, displayName}})
.validate({settings, user: {email, password, username}})
.then(() => {
return SettingsService.update(settings);
})
@@ -80,7 +80,7 @@ module.exports = class SetupService {
// Create the user.
return UsersService
.createLocalUser(email, password, displayName)
.createLocalUser(email, password, username)
// Grant them administrative privileges and confirm the email account.
.then((user) => {
+40 -34
View File
@@ -99,19 +99,23 @@ module.exports = class UsersService {
.then(() => dstUser.save());
}
static castDisplayName(displayName) {
return displayName.replace(/ /g, '_').replace(/[^a-zA-Z_]/g, '');
}
/**
* Finds a user given a social profile and if the user does not exist, creates
* them.
* @param {Object} profile - User social/external profile
* @param {Function} done [description]
*/
static findOrCreateExternalUser(profile) {
static findOrCreateExternalUser({id, provider, displayName}) {
return UserModel
.findOne({
profiles: {
$elemMatch: {
id: profile.id,
provider: profile.provider
id,
provider
}
}
})
@@ -120,16 +124,16 @@ module.exports = class UsersService {
return user;
}
// TODO: remove displayName reference when we have steps in the FE to handle
// the username create flow.
let username = UsersService.castDisplayName(displayName);
// The user was not found, lets create them!
user = new UserModel({
displayName: profile.displayName,
username,
lowercaseUsername: username.toLowerCase(),
roles: [],
profiles: [
{
id: profile.id,
provider: profile.provider
}
]
profiles: [{id, provider}]
});
return user.save();
@@ -164,24 +168,24 @@ module.exports = class UsersService {
static createLocalUsers(users) {
return Promise.all(users.map((user) => {
return UsersService
.createLocalUser(user.email, user.password, user.displayName);
.createLocalUser(user.email, user.password, user.username);
}));
}
/**
* Check the requested displayname for naughty words (currently in English) and special chars
* @param {String} displayName word to be checked for profanity
* Check the requested username for blocked words and special chars
* @param {String} username word to be checked for profanity
* @param {Boolean} checkAgainstWordlist enables cheching against the wordlist
* @return {Promise} rejected if the machine's sensibilites are offended
* @return {Promise}
*/
static isValidDisplayName(displayName, checkAgainstWordlist = true) {
static isValidDisplayName(username, checkAgainstWordlist = true) {
const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/;
if (!displayName) {
if (!username) {
return Promise.reject(errors.ErrMissingDisplay);
}
if (!onlyLettersNumbersUnderscore.test(displayName)) {
if (!onlyLettersNumbersUnderscore.test(username)) {
return Promise.reject(errors.ErrSpecialChars);
}
@@ -189,11 +193,11 @@ module.exports = class UsersService {
if (checkAgainstWordlist) {
// check for profanity
return Wordlist.displayNameCheck(displayName);
return Wordlist.usernameCheck(username);
}
// No errors found!
return Promise.resolve(displayName);
return Promise.resolve(username);
}
/**
@@ -215,23 +219,23 @@ module.exports = class UsersService {
* Creates the local user with a given email, password, and name.
* @param {String} email email of the new user
* @param {String} password plaintext password of the new user
* @param {String} displayName name of the display user
* @param {String} username name of the display user
* @param {Function} done callback
*/
static createLocalUser(email, password, displayName) {
static createLocalUser(email, password, username) {
if (!email) {
return Promise.reject(errors.ErrMissingEmail);
}
email = email.toLowerCase().trim();
displayName = displayName.toLowerCase().trim();
username = username.trim();
return Promise.all([
UsersService.isValidDisplayName(displayName),
UsersService.isValidDisplayName(username),
UsersService.isValidPassword(password)
])
.then(() => { // displayName is valid
.then(() => { // username is valid
return new Promise((resolve, reject) => {
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
if (err) {
@@ -239,7 +243,8 @@ module.exports = class UsersService {
}
let user = new UserModel({
displayName: displayName,
username,
lowercaseUsername: username.toLowerCase(),
password: hashedPassword,
roles: [],
profiles: [
@@ -253,7 +258,7 @@ module.exports = class UsersService {
user.save((err) => {
if (err) {
if (err.code === 11000) {
if (err.message.match('displayName')) {
if (err.message.match('username')) {
return reject(errors.ErrDisplayTaken);
}
return reject(errors.ErrEmailTaken);
@@ -397,7 +402,7 @@ module.exports = class UsersService {
static findPublicByIdArray(ids) {
return UserModel.find({
id: {$in: ids}
}, 'id displayName');
}, 'id username');
}
/**
@@ -473,7 +478,7 @@ module.exports = class UsersService {
/**
* Finds a user using a value which gets compared using a prefix match against
* the user's email address and/or their display name.
* the user's email address and/or their username.
* @param {String} value value to search by
* @return {Promise}
*/
@@ -481,9 +486,9 @@ module.exports = class UsersService {
return UserModel.find({
$or: [
// Search by a prefix match on the displayName.
// Search by a prefix match on the username.
{
'displayName': {
'username': {
$regex: new RegExp(`^${value}`),
$options: 'i'
}
@@ -667,18 +672,19 @@ module.exports = class UsersService {
}
/**
* Updates the user's displayName.
* Updates the user's username.
* @param {String} id the id of the user to be enabled.
* @param {String} displayName The new displayname for the user.
* @param {String} username The new username for the user.
* @return {Promise}
*/
static editName(id, displayName) {
static editName(id, username) {
return UserModel.update({
id,
canEditName: true
}, {
$set: {
displayName: displayName.toLowerCase(),
username: username,
lowercaseUsername: username.toLowerCase(),
canEditName: false,
status: 'PENDING'
}
+4 -4
View File
@@ -201,22 +201,22 @@ class Wordlist {
/**
* check potential username for banned words, special characters
*/
static displayNameCheck(displayName) {
static usernameCheck(username) {
const wl = new Wordlist();
return wl.load()
.then(() => {
displayName = displayName.replace(/_/g, '');
username = username.replace(/_/g, '');
// test each word, and fail if we find a match
const hasBadWords = wl.lists.banned.some(phrase => {
return displayName.indexOf(phrase.join('')) !== -1;
return username.indexOf(phrase.join('')) !== -1;
});
if (hasBadWords) {
throw Errors.ErrContainsProfanity;
} else {
return Promise.resolve(displayName);
return Promise.resolve(username);
}
});
}
+2 -2
View File
@@ -19,7 +19,7 @@ const embedStreamCommands = {
.setValue('@signInDialogEmail', user.email)
.setValue('@signInDialogPassword', user.pass)
.setValue('@signUpDialogConfirmPassword', user.pass)
.setValue('@signUpDialogDisplayName', user.displayName)
.setValue('@signUpDialogDisplayName', user.username)
.waitForElementVisible('@signUpButton')
.click('@signUpButton')
.waitForElementVisible('@signInViewTrigger')
@@ -96,7 +96,7 @@ module.exports = {
selector: '#signInDialog #confirmPassword'
},
signUpDialogDisplayName: {
selector: '#signInDialog #displayName'
selector: '#signInDialog #username'
},
logInButton: {
selector: '#coralLogInButton'
+2 -2
View File
@@ -37,7 +37,7 @@ module.exports = {
.click('#coralRegister')
.waitForElementVisible('#email', 1000)
.setValue('#email', mockUser.email)
.setValue('#displayName', mockUser.name)
.setValue('#username', mockUser.name)
.setValue('#password', mockUser.pw)
.setValue('#confirmPassword', mockUser.pw)
.click('#coralSignUpButton')
@@ -125,7 +125,7 @@ module.exports = {
// Add a mock user
.then(() => mocks.users([{
displayName: 'BabyBlue',
username: 'BabyBlue',
email: 'whale@tale.sea',
password: 'krillaretasty'
}]))
+1 -1
View File
@@ -13,7 +13,7 @@ module.exports = {
embedStreamPage
.signUp({
email: `visitor_${Date.now()}@test.com`,
displayName: `visitor${Date.now()}`,
username: `visitor${Date.now()}`,
pass: 'testtest'
});
},

Some files were not shown because too many files have changed in this diff Show More