diff --git a/.nsprc b/.nsprc index 071431eff..583560bdd 100644 --- a/.nsprc +++ b/.nsprc @@ -1,5 +1,6 @@ { "exceptions": [ - "https://nodesecurity.io/advisories/531" + "https://nodesecurity.io/advisories/531", + "https://nodesecurity.io/advisories/532" ] -} \ No newline at end of file +} diff --git a/Dockerfile.onbuild b/Dockerfile.onbuild index b9cf8ce87..20d34ffb9 100644 --- a/Dockerfile.onbuild +++ b/Dockerfile.onbuild @@ -1,11 +1,18 @@ FROM coralproject/talk:latest +# Setup the build arguments +ONBUILD ARG TALK_THREADING_LEVEL=3 +ONBUILD ARG TALK_DEFAULT_STREAM_TAB=all +ONBUILD ARG TALK_DEFAULT_LANG=en +ONBUILD ARG TALK_PLUGINS_JSON + # Bundle app source ONBUILD COPY . /usr/src/app -# At this stage, we need to install the development dependancies again because -# we need to have webpack available. We then build the new dependancies and -# clear out the development dependancies again. After this we of course need to +# At this stage, we need to install the development dependencies again because +# we need to have webpack available. We then build the new dependencies and +# clear out the development dependencies again. After this we of course need to # clear out the yarn cache, this saves quite a lot of size. ONBUILD RUN cli plugins reconcile && \ - yarn build + yarn build && \ + yarn cache clean \ No newline at end of file diff --git a/LICENSE b/LICENSE index 597d8fa73..e0a687532 100644 --- a/LICENSE +++ b/LICENSE @@ -4,8 +4,12 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 +http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. -See the License for the specific language governing permissions and limitations under the License. +See the License for the specific language governing permissions +and limitations under the License. diff --git a/README.md b/README.md index a089f040c..89474bf6d 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,37 @@ -# Talk +# Talk · [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) · [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8/badge)](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) · [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests) -[![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) -[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fcoralproject%2Ftalk&env[TALK_FACEBOOK_APP_ID]=ignore&env[TALK_FACEBOOK_APP_SECRET]=ignore) -[![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8/badge)](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) - -Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html). +Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html). Built with <3 by The Coral Project & Mozilla. -## Getting Started +## Try Talk! -Check out our Docs: https://coralproject.github.io/talk/ +You're just one click away from trying Talk - all you need is a Heroku account and a few minutes of your time. + +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fcoralproject%2Ftalk&env[TALK_FACEBOOK_APP_ID]=ignore&env[TALK_FACEBOOK_APP_SECRET]=ignore) + +## Technical Documentation + +From getting up and running, to advanced configuration, to how to scale Talk, our [Talk Technical Docs](https://coralproject.github.io/talk/) have everything you need to know. + +## Product Guide + +Learn more about Talk, including a deep dive into features for commenters and moderators, and FAQs in our [Talk Product Guide](https://coralproject.github.io/talk/how-talk-works). ## Relevant Links -- Blog: https://blog.coralproject.net/ -- Community Forums: https://community.coralproject.net/ -- Community Guides for Journalism: https://guides.coralproject.net/ -- Project: https://coralproject.net/ -- Roadmap: https://www.pivotaltracker.com/n/projects/1863625 +- [Our Blog](https://blog.coralproject.net/) +- [Community Forums](https://community.coralproject.net/) +- [Community Guides for Journalism](https://guides.coralproject.net/) +- [More About Us](https://coralproject.net/) +- [Talk Roadmap](https://www.pivotaltracker.com/n/projects/1863625) ## End-to-End Testing -Talk uses [Nightwatch](http://nightwatchjs.org/) to write e2e tests. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at Browserstack. +Talk uses [Nightwatch](https://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com). - -![](/public/img/browserstack_logo.png) +[![Browserstack](/public/img/browserstack_logo.png)](https://browserstack.com) ## License - Copyright 2017 Mozilla Foundation - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, - software distributed under the License is distributed on an - "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, - either express or implied. - - See the License for the specific language governing permissions - and limitations under the License. +Talk is released under the [Apache License, v2.0](/LICENSE). diff --git a/app.js b/app.js index 2989ae00a..c180d2c04 100644 --- a/app.js +++ b/app.js @@ -1,19 +1,37 @@ const express = require('express'); -const bodyParser = require('body-parser'); const morgan = require('morgan'); const path = require('path'); +const uuid = require('uuid'); const merge = require('lodash/merge'); const helmet = require('helmet'); +const plugins = require('./services/plugins'); const compression = require('compression'); -const cookieParser = require('cookie-parser'); const {HELMET_CONFIGURATION} = require('./config'); const {MOUNT_PATH} = require('./url'); -const {applyLocals} = require('./services/locals'); const routes = require('./routes'); const debug = require('debug')('talk:app'); const app = express(); +// Request Identity Middleware +app.use((req, res, next) => { + req.id = uuid.v4(); + + next(); +}); + +//============================================================================== +// PLUGIN PRE APPLICATION MIDDLEWARE +//============================================================================== + +// Inject server route plugins. +plugins.get('server', 'app').forEach(({plugin, app: callback}) => { + debug(`added plugin '${plugin.name}'`); + + // Pass the app to the plugin to mount it's routes. + callback(app); +}); + //============================================================================== // APPLICATION WIDE MIDDLEWARE //============================================================================== @@ -36,12 +54,6 @@ app.use(helmet(merge(HELMET_CONFIGURATION, { // Compress the responses if appropriate. app.use(compression()); -// Parse the cookies on the request. -app.use(cookieParser()); - -// Parse the body json if it's there. -app.use(bodyParser.json()); - //============================================================================== // VIEW CONFIGURATION //============================================================================== @@ -53,9 +65,6 @@ app.set('view engine', 'ejs'); // ROUTES //============================================================================== -// Add the locals to the app renderer. -applyLocals(app.locals); - debug(`mounting routes on the ${MOUNT_PATH} path`); // Actually apply the routes. diff --git a/bin/cli-jobs b/bin/cli-jobs index c5aefc518..6048a69db 100755 --- a/bin/cli-jobs +++ b/bin/cli-jobs @@ -12,7 +12,7 @@ const mongoose = require('../services/mongoose'); const kue = require('../services/kue'); util.onshutdown([ - () => mongoose.disconnect() + () => mongoose.disconnect(), ]); /** @@ -20,17 +20,17 @@ util.onshutdown([ */ function processJobs() { - // Start the scraper processor. - scraper.process(); - - // Start the mail processor. - mailer.process(); - // The scraper only needs to shutdown when the scraper has actually been // started. util.onshutdown([ () => kue.Task.shutdown() ]); + + // Start the scraper processor. + scraper.process(); + + // Start the mail processor. + mailer.process(); } /** @@ -48,22 +48,13 @@ function removeJob(job) { })); } -/** - * Removes the jobs passed in and returns a promise. - * @param {Array} jobs array of jobs - * @return {Promise} - */ -function removeJobs(jobs) { - return Promise.all(jobs.map(removeJob)); -} - /** * Get the top n jobs with a specific state. * @param {String} [state='complete'] state to list jobs by * @param {Number} limit limit of jobs to load * @return {Promise} */ -function rangeJobsByState(state = 'complete', limit) { +function rangeJobsByState(state, limit) { return new Promise((resolve, reject) => { kue.Job.rangeByState(state, 0, limit, 'asc', (err, jobs) => { if (err) { @@ -75,22 +66,52 @@ function rangeJobsByState(state = 'complete', limit) { }); } +async function getJobBatch(n, includeStuck) { + let jobs = []; + + jobs = await rangeJobsByState('complete', n); + + if (includeStuck) { + jobs = jobs.concat(await rangeJobsByState('failed', n)); + } + + return jobs; +} + /** * Cleans up the jobs that are in the queue. */ async function cleanupJobs(options) { + + // The scraper only needs to shutdown when the scraper has actually been + // started. + util.onshutdown([ + () => kue.Task.shutdown() + ]); + const n = 100; try { - const joblists = await Promise.all([ - rangeJobsByState('complete', n), - options.stuck ? rangeJobsByState('failed', n) : false - ]); - await joblists.filter((jobs) => jobs).map(removeJobs); + // Connect to redis by establishing a queue. + kue.Task.connect(); + + let jobCount = 0; + let jobs = await getJobBatch(n, options.stuck); + + while (jobs.length > 0) { + + // Remove all the jobs. + await Promise.all(jobs.map((job) => removeJob(job))); + + jobCount += jobs.length; + + // Get the next batch of jobs. + jobs = await getJobBatch(n, options.stuck); + } util.shutdown(); - console.log('Removed old jobs'); + console.log(`Removed ${jobCount} jobs`); } catch (err) { console.error(err); util.shutdown(1); diff --git a/bin/cli-serve b/bin/cli-serve index 4324cac92..723558651 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -1,6 +1,7 @@ #!/usr/bin/env node const program = require('./commander'); +const util = require('./util'); const serve = require('../serve'); //============================================================================== @@ -13,5 +14,8 @@ program .parse(process.argv); // Start serving. -serve({jobs: program.jobs, websockets: program.websockets}); +serve({jobs: program.jobs, websockets: program.websockets}).catch((err) => { + console.error(err); + util.shutdown(1); +}); diff --git a/bin/cli-users b/bin/cli-users index c6555ab28..89bf667a7 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -8,10 +8,13 @@ const program = require('./commander'); const inquirer = require('inquirer'); const UsersService = require('../services/users'); const UserModel = require('../models/user'); +const CommentModel = require('../models/comment'); +const ActionModel = require('../models/action'); const USER_ROLES = require('../models/enum/user_roles'); const mongoose = require('../services/mongoose'); const util = require('./util'); const Table = require('cli-table'); +const databaseVerifications = require('./verifications/database'); const validateRequired = (msg = 'Field is required', len = 1) => (input) => { if (input && input.length >= len) { @@ -122,26 +125,48 @@ async function createUser(options) { } catch (err) { console.error(err); - util.shutdown(); + util.shutdown(1); } } /** * Deletes a user. */ -function deleteUser(userID) { - UserModel - .findOneAndRemove({ - id: userID - }) - .then(() => { - console.log('Deleted user'); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(); - }); +async function deleteUser(userID) { + + try { + + // Find the user we're removing. + const user = await UserModel.findOne({id: userID}); + if (!user) { + throw new Error(`user with id ${userID} not found`); + } + + // Remove all the user's actions. + await ActionModel + .where({user_id: user.id}) + .setOptions({multi: true}) + .remove(); + + // Remove all the user's comments. + await CommentModel + .where({author_id: user.id}) + .setOptions({multi: true}) + .remove(); + + // Update the counts that might have changed. + for (const verification of databaseVerifications) { + await verification({fix: true, limit: Infinity, batch: 1000}); + } + + // Remove the user. + await user.remove(); + + util.shutdown(); + } catch (err) { + console.error(err); + util.shutdown(1); + } } /** @@ -241,13 +266,21 @@ function listUsers() { }); users.forEach((user) => { + let state = user.disabled ? 'Disabled' : 'Enabled'; + const profile = user.profiles.find(({provider}) => provider === 'local'); + if (profile && profile.metadata && profile.metadata.confirmed_at) { + state += ', Verified'; + } else { + state += ', Unverified'; + } + table.push([ user.id, user.username, user.profiles.map((p) => p.provider).join(', '), user.roles.join(', '), user.status, - user.disabled ? 'Disabled' : 'Enabled' + state ]); }); @@ -396,6 +429,23 @@ function enableUser(userID) { }); } +/** + * Verifies an email address for a user. + * + * @param userID the user's id + * @param email the user's email address to be verified + */ +async function verify(userID, email) { + try { + await UsersService.confirmEmail(userID, email); + console.log(`User ${userID} had their email ${email} verified.`); + util.shutdown(); + } catch (err) { + console.error(err); + util.shutdown(1); + } +} + //============================================================================== // Setting up the program command line arguments. //============================================================================== @@ -467,6 +517,11 @@ program .description('enable a given user from logging in') .action(enableUser); +program + .command('verify ') + .description('verifies the given user\'s email address') + .action(verify); + program.parse(process.argv); // If there is no command listed, output help. diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index d8e1b5377..362143d38 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import {Router, Route, IndexRedirect, IndexRoute} from 'react-router'; import Configure from 'routes/Configure'; -import Dashboard from 'routes/Dashboard'; import Install from 'routes/Install'; import Stories from 'routes/Stories'; import Community from 'routes/Community/containers/Community'; @@ -18,7 +17,6 @@ const routes = ( - {/* Community Routes */} diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 921f0a0a2..6a091d214 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -1,11 +1,12 @@ import queryString from 'query-string'; import { - FETCH_COMMENTERS_REQUEST, - FETCH_COMMENTERS_SUCCESS, - FETCH_COMMENTERS_FAILURE, + FETCH_USERS_REQUEST, + FETCH_USERS_SUCCESS, + FETCH_USERS_FAILURE, SORT_UPDATE, - COMMENTERS_NEW_PAGE, + SET_PAGE, + SET_SEARCH_VALUE, SET_ROLE, SET_COMMENTER_STATUS, SHOW_BANUSER_DIALOG, @@ -16,13 +17,13 @@ import { import t from 'coral-framework/services/i18n'; -export const fetchAccounts = (query = {}) => (dispatch, _, {rest}) => { - dispatch(requestFetchAccounts()); +export const fetchUsers = (query = {}) => (dispatch, _, {rest}) => { + dispatch(requestFetchUsers()); rest(`/users?${queryString.stringify(query)}`) .then(({result, page, count, limit, totalPages}) =>{ dispatch({ - type: FETCH_COMMENTERS_SUCCESS, - accounts: result, + type: FETCH_USERS_SUCCESS, + users: result, page, count, limit, @@ -32,12 +33,12 @@ export const fetchAccounts = (query = {}) => (dispatch, _, {rest}) => { .catch((error) => { console.error(error); const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); - dispatch({type: FETCH_COMMENTERS_FAILURE, error: errorMessage}); + dispatch({type: FETCH_USERS_FAILURE, error: errorMessage}); }); }; -const requestFetchAccounts = () => ({ - type: FETCH_COMMENTERS_REQUEST +const requestFetchUsers = () => ({ + type: FETCH_USERS_REQUEST }); export const updateSorting = (sort) => ({ @@ -45,8 +46,14 @@ export const updateSorting = (sort) => ({ sort }); -export const newPage = () => ({ - type: COMMENTERS_NEW_PAGE +export const setPage = (page) => ({ + type: SET_PAGE, + page, +}); + +export const setSearchValue = (value) => ({ + type: SET_SEARCH_VALUE, + value, }); export const setRole = (id, role) => (dispatch, _, {rest}) => { diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index a3ff2ca3e..18e31091f 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -34,3 +34,8 @@ export const storySearchChange = (value) => ({ export const clearState = () => ({ type: actions.MODERATION_CLEAR_STATE }); + +export const selectCommentId = (id) => ({ + type: actions.MODERATION_SELECT_COMMENT, + id, +}); diff --git a/client/coral-admin/src/actions/assets.js b/client/coral-admin/src/actions/stories.js similarity index 70% rename from client/coral-admin/src/actions/assets.js rename to client/coral-admin/src/actions/stories.js index 1ce6ad6ac..2524b0c3c 100644 --- a/client/coral-admin/src/actions/assets.js +++ b/client/coral-admin/src/actions/stories.js @@ -1,12 +1,17 @@ +import queryString from 'query-string'; + import { FETCH_ASSETS_REQUEST, FETCH_ASSETS_SUCCESS, FETCH_ASSETS_FAILURE, + SET_PAGE, + SET_SEARCH_VALUE, + SET_CRITERIA, UPDATE_ASSET_STATE_REQUEST, UPDATE_ASSET_STATE_SUCCESS, UPDATE_ASSET_STATE_FAILURE, UPDATE_ASSETS -} from '../constants/assets'; +} from '../constants/stories'; import t from 'coral-framework/services/i18n'; @@ -16,13 +21,16 @@ import t from 'coral-framework/services/i18n'; // Fetch a page of assets // Get comments to fill each of the three lists on the mod queue -export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch, _, {rest}) => { +export const fetchAssets = (query = {}) => (dispatch, _, {rest}) => { dispatch({type: FETCH_ASSETS_REQUEST}); - return rest(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`) - .then(({result, count}) => + return rest(`/assets?${queryString.stringify(query)}`) + .then(({result, page, count, limit, totalPages}) => dispatch({type: FETCH_ASSETS_SUCCESS, assets: result, - count + page, + count, + limit, + totalPages, })) .catch((error) => { console.error(error); @@ -47,3 +55,19 @@ export const updateAssetState = (id, closedAt) => (dispatch, _, {rest}) => { export const updateAssets = (assets) => (dispatch) => { dispatch({type: UPDATE_ASSETS, assets}); }; + +export const setPage = (page) => ({ + type: SET_PAGE, + page, +}); + +export const setSearchValue = (value) => ({ + type: SET_SEARCH_VALUE, + value, +}); + +export const setCriteria = (criteria) => ({ + type: SET_CRITERIA, + criteria, +}); + diff --git a/client/coral-admin/src/actions/userDetail.js b/client/coral-admin/src/actions/userDetail.js index f8b22008c..3a1de5745 100644 --- a/client/coral-admin/src/actions/userDetail.js +++ b/client/coral-admin/src/actions/userDetail.js @@ -20,3 +20,9 @@ export const toggleSelectCommentInUserDetail = (id, active) => { }; }; +export const toggleSelectAllCommentInUserDetail = (ids, active) => { + return { + type: active ? actions.SELECT_ALL_USER_DETAIL_COMMENT : actions.CLEAR_USER_DETAIL_SELECTIONS, + ids + }; +}; diff --git a/client/coral-admin/src/components/ActionsMenu.js b/client/coral-admin/src/components/ActionsMenu.js index 25aee1fbc..6351afda7 100644 --- a/client/coral-admin/src/components/ActionsMenu.js +++ b/client/coral-admin/src/components/ActionsMenu.js @@ -32,8 +32,9 @@ class ActionsMenu extends React.Component { }; render() { + const {className = ''} = this.props; return ( -
+
- -
+
+

{t('bandialog.ban_user')}

+
+
+

{t('bandialog.are_you_sure', username)}

+ {info} +
+
+ +
); diff --git a/client/coral-admin/src/components/CommentBodyHighlighter.js b/client/coral-admin/src/components/CommentBodyHighlighter.js index e27d3ce6d..177df5338 100644 --- a/client/coral-admin/src/components/CommentBodyHighlighter.js +++ b/client/coral-admin/src/components/CommentBodyHighlighter.js @@ -15,7 +15,15 @@ function generateRegExp(phrases) { .join('[\\s"?!.]+') ).join('|'); - return new RegExp(`(^|[^\\w])(${inner})(?=[^\\w]|$)`, 'iu'); + const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`; + try { + return new RegExp(pattern, 'iu'); + } + catch (_err) { + + // IE does not support unicode support, so we'll create one without. + return new RegExp(pattern, 'i'); + } } // Generate a regular expression detecting `suspectWords` and `bannedWords` phrases. diff --git a/client/coral-admin/src/components/CommentDetails.js b/client/coral-admin/src/components/CommentDetails.js index cd504a40f..418be0354 100644 --- a/client/coral-admin/src/components/CommentDetails.js +++ b/client/coral-admin/src/components/CommentDetails.js @@ -21,10 +21,11 @@ class CommentDetails extends Component { this.setState((state) => ({ showDetail: !state.showDetail })); + this.props.clearHeightCache && this.props.clearHeightCache(); } render() { - const {data, root, comment} = this.props; + const {data, root, comment, clearHeightCache} = this.props; const {showDetail} = this.state; const queryData = { root, @@ -44,12 +45,14 @@ class CommentDetails extends Component { {showDetail && }
@@ -61,6 +64,7 @@ CommentDetails.propTypes = { data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, comment: PropTypes.object.isRequired, + clearHeightCache: PropTypes.func, }; export default CommentDetails; diff --git a/client/coral-admin/src/components/CommentLabels.js b/client/coral-admin/src/components/CommentLabels.js index 7af7e9a7e..bff4f575a 100644 --- a/client/coral-admin/src/components/CommentLabels.js +++ b/client/coral-admin/src/components/CommentLabels.js @@ -6,10 +6,21 @@ import FlagLabel from 'coral-ui/components/FlagLabel'; import cn from 'classnames'; import styles from './CommentLabels.css'; +const staffRoles = ['ADMIN', 'STAFF', 'MODERATOR']; + function isUserFlagged(actions) { return actions.some((action) => action.__typename === 'FlagAction' && action.user); } +function getUserFlaggedType(actions) { + return actions + .some((action) => + action.__typename === 'FlagAction' && + action.user && + action.user.roles.some((role) => staffRoles.includes(role)) + ) ? 'Staff' : 'User'; +} + function hasSuspectedWords(actions) { return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'Matched suspect word filter'); } @@ -24,7 +35,7 @@ const CommentLabels = ({comment, comment: {className, status, actions, hasParent
{hasParent && } {status === 'PREMOD' && } - {isUserFlagged(actions) && User} + {isUserFlagged(actions) && {getUserFlaggedType(actions)}} {hasSuspectedWords(actions) && Suspect} {hasHistoryFlag(actions) && History}
diff --git a/client/coral-admin/src/components/Drawer.js b/client/coral-admin/src/components/Drawer.js index 0385b1773..29ee8d17e 100644 --- a/client/coral-admin/src/components/Drawer.js +++ b/client/coral-admin/src/components/Drawer.js @@ -5,34 +5,31 @@ import {IndexLink, Link} from 'react-router'; import styles from './Drawer.css'; import t from 'coral-framework/services/i18n'; import {can} from 'coral-framework/services/perms'; +import cn from 'classnames'; const CoralDrawer = ({handleLogout, auth = {}}) => ( - + { auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
- - {t('configure.dashboard')} - { can(auth.user, 'MODERATE_COMMENTS') && ( - {t('configure.moderate')} - + ) } - {t('configure.stories')} - {t('configure.community')} @@ -41,7 +38,7 @@ const CoralDrawer = ({handleLogout, auth = {}}) => ( can(auth.user, 'UPDATE_CONFIG') && ( {t('configure.configure')} diff --git a/client/coral-admin/src/components/ModerationKeysModal.js b/client/coral-admin/src/components/ModerationKeysModal.js index 45304992c..5f43daf09 100644 --- a/client/coral-admin/src/components/ModerationKeysModal.js +++ b/client/coral-admin/src/components/ModerationKeysModal.js @@ -4,34 +4,38 @@ import Modal from 'components/Modal'; import styles from './ModerationKeysModal.css'; import t from 'coral-framework/services/i18n'; -const shortcuts = [ - { - title: 'modqueue.navigation', - shortcuts: { - 'j': 'modqueue.next_comment', - 'k': 'modqueue.prev_comment', - 'ctrl+f': 'modqueue.toggle_search', - 't': 'modqueue.next_queue', - 's': 'modqueue.singleview', - '?': 'modqueue.thismenu' - } - }, - { - title: 'modqueue.actions', - shortcuts: { - 'd': 'modqueue.approve', - 'f': 'modqueue.reject' - } - } -]; - export default class ModerationKeysModal extends React.Component { static propTypes = { open: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, hideShortcutsNote: PropTypes.func.isRequired, - shortcutsNoteVisible: PropTypes.string.isRequired + shortcutsNoteVisible: PropTypes.string.isRequired, + queueCount: PropTypes.number.isRequired + } + + buildShortcuts = () => { + return [ + { + title: 'modqueue.navigation', + shortcuts: { + 'j': 'modqueue.next_comment', + 'k': 'modqueue.prev_comment', + 'ctrl+f': 'modqueue.toggle_search', + 't': 'modqueue.next_queue', + [`1...${this.props.queueCount}`]: 'modqueue.jump_to_queue', + 's': 'modqueue.singleview', + '?': 'modqueue.thismenu' + } + }, + { + title: 'modqueue.actions', + shortcuts: { + 'd': 'modqueue.approve', + 'f': 'modqueue.reject' + } + } + ]; } render () { @@ -51,7 +55,7 @@ export default class ModerationKeysModal extends React.Component {

{t('modqueue.shortcuts')}

- {shortcuts.map((shortcut, i) => ( + {this.buildShortcuts().map((shortcut, i) => ( diff --git a/client/coral-admin/src/components/RejectButton.js b/client/coral-admin/src/components/RejectButton.js index e01102dc3..8e0873162 100644 --- a/client/coral-admin/src/components/RejectButton.js +++ b/client/coral-admin/src/components/RejectButton.js @@ -7,11 +7,11 @@ import {Icon} from 'coral-ui'; import t from 'coral-framework/services/i18n'; -const RejectButton = ({active, minimal, onClick}) => { +const RejectButton = ({active, minimal, onClick, className}) => { const text = active ? t('modqueue.rejected') : t('modqueue.reject'); return ( - @@ -120,7 +121,7 @@ class SuspendUserDialog extends React.Component { - - {selectedCommentIds.length} comments selected - - ) - } - -
+
+
0) ? cn(styles.bulkActionHeader, styles.selected) : styles.bulkActionHeader}> + { + selectedCommentIds.length === 0 + ? ( +
    +
  • All
  • +
  • Rejected
  • +
+ ) + : ( +
+ + + {selectedCommentIds.length} comments selected +
+ ) + } +
+ 0 && selectedCommentIds.length === nodes.length} + onChange={(e) => { + toggleSelectAll(nodes.map((comment) => comment.id), e.target.checked); + }} /> + +
+
+
{ nodes.map((comment) => { const selected = selectedCommentIds.indexOf(comment.id) !== -1; @@ -205,7 +237,7 @@ export default class UserDetail extends React.Component { ); } - render () { + render() { if (this.props.loading) { return this.renderLoading(); } diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index 62f6a7fd1..4e7826ee6 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -1,4 +1,5 @@ import React from 'react'; +import cn from 'classnames'; import PropTypes from 'prop-types'; import {Navigation, Header, IconButton, MenuItem, Menu} from 'react-mdl'; import {Link, IndexLink} from 'react-router'; @@ -22,28 +23,21 @@ const CoralHeader = ({ { auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? - - {t('configure.dashboard')} - { can(auth.user, 'MODERATE_COMMENTS') && ( - {t('configure.moderate')} {(root.premodCount !== 0 || root.reportedCount !== 0) && } - + ) } {t('configure.stories')} @@ -51,7 +45,7 @@ const CoralHeader = ({ {t('configure.community')} @@ -62,7 +56,7 @@ const CoralHeader = ({ can(auth.user, 'UPDATE_CONFIG') && ( {t('configure.configure')} @@ -75,9 +69,9 @@ const CoralHeader = ({ }
    -
  • +
  • - + showShortcuts(true)}>{t('configure.shortcuts')} @@ -86,11 +80,11 @@ const CoralHeader = ({ - + Report a bug or give feedback - + {t('configure.sign_out')} diff --git a/client/coral-admin/src/constants/assets.js b/client/coral-admin/src/constants/assets.js deleted file mode 100644 index 20ec0a9c3..000000000 --- a/client/coral-admin/src/constants/assets.js +++ /dev/null @@ -1,9 +0,0 @@ -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'; diff --git a/client/coral-admin/src/constants/community.js b/client/coral-admin/src/constants/community.js index be11e9b0c..eea946efb 100644 --- a/client/coral-admin/src/constants/community.js +++ b/client/coral-admin/src/constants/community.js @@ -1,17 +1,22 @@ -export const FETCH_COMMENTERS_REQUEST = 'FETCH_COMMENTERS_REQUEST'; -export const FETCH_COMMENTERS_SUCCESS = 'FETCH_COMMENTERS_SUCCESS'; -export const FETCH_COMMENTERS_FAILURE = 'FETCH_COMMENTERS_FAILURE'; -export const SORT_UPDATE = 'SORT_UPDATE'; -export const COMMENTERS_NEW_PAGE = 'COMMENTERS_NEW_PAGE'; -export const SET_ROLE = 'SET_ROLE'; -export const SET_COMMENTER_STATUS = 'SET_COMMENTER_STATUS'; +const prefix = 'COMMUNITY'; -export const FETCH_FLAGGED_COMMENTERS_REQUEST = 'FETCH_FLAGGED_COMMENTERS_REQUEST'; -export const FETCH_FLAGGED_COMMENTERS_SUCCESS = 'FETCH_FLAGGED_COMMENTERS_SUCCESS'; -export const FETCH_FLAGGED_COMMENTERS_FAILURE = 'FETCH_FLAGGED_COMMENTERS_FAILURE'; +export const FETCH_USERS_REQUEST = `${prefix}_FETCH_USERS_REQUEST`; +export const FETCH_USERS_SUCCESS = `${prefix}_FETCH_USERS_SUCCESS`; +export const FETCH_USERS_FAILURE = `${prefix}_FETCH_USERS_FAILURE`; -export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG'; -export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG'; +export const SORT_UPDATE = `${prefix}_SORT_UPDATE`; +export const SET_PAGE = `${prefix}_SET_PAGE`; +export const SET_ROLE = `${prefix}_SET_ROLE`; +export const SET_COMMENTER_STATUS = `${prefix}_SET_COMMENTER_STATUS`; -export const SHOW_REJECT_USERNAME_DIALOG = 'SHOW_REJECT_USERNAME_DIALOG'; -export const HIDE_REJECT_USERNAME_DIALOG = 'HIDE_REJECT_USERNAME_DIALOG'; +export const FETCH_FLAGGED_COMMENTERS_REQUEST = `${prefix}_FETCH_FLAGGED_COMMENTERS_REQUEST`; +export const FETCH_FLAGGED_COMMENTERS_SUCCESS = `${prefix}_FETCH_FLAGGED_COMMENTERS_SUCCESS`; +export const FETCH_FLAGGED_COMMENTERS_FAILURE = `${prefix}_FETCH_FLAGGED_COMMENTERS_FAILURE`; + +export const SHOW_BANUSER_DIALOG = `${prefix}_SHOW_BANUSER_DIALOG`; +export const HIDE_BANUSER_DIALOG = `${prefix}_HIDE_BANUSER_DIALOG`; + +export const SHOW_REJECT_USERNAME_DIALOG = `${prefix}_SHOW_REJECT_USERNAME_DIALOG`; +export const HIDE_REJECT_USERNAME_DIALOG = `${prefix}_HIDE_REJECT_USERNAME_DIALOG`; + +export const SET_SEARCH_VALUE = `${prefix}_SET_SEARCH_VALUE`; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index cae13947d..8eb939d76 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -6,3 +6,4 @@ export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH'; export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH'; export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE'; export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE'; +export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT'; diff --git a/client/coral-admin/src/constants/stories.js b/client/coral-admin/src/constants/stories.js new file mode 100644 index 000000000..d1994e19a --- /dev/null +++ b/client/coral-admin/src/constants/stories.js @@ -0,0 +1,15 @@ +const prefix = 'STORIES'; + +export const FETCH_ASSETS_REQUEST = `${prefix}_FETCH_ASSETS_REQUEST`; +export const FETCH_ASSETS_SUCCESS = `${prefix}_FETCH_ASSETS_SUCCESS`; +export const FETCH_ASSETS_FAILURE = `${prefix}_FETCH_ASSETS_FAILURE`; + +export const UPDATE_ASSET_STATE_REQUEST = `${prefix}_UPDATE_ASSET_STATE_REQUEST`; +export const UPDATE_ASSET_STATE_SUCCESS = `${prefix}_UPDATE_ASSET_STATE_SUCCESS`; +export const UPDATE_ASSET_STATE_FAILURE = `${prefix}_UPDATE_ASSET_STATE_FAILURE`; + +export const UPDATE_ASSETS = `${prefix}_UPDATE_ASSETS`; + +export const SET_PAGE = `${prefix}_SET_PAGE`; +export const SET_SEARCH_VALUE = `${prefix}_SET_SEARCH_VALUE`; +export const SET_CRITERIA = `${prefix}_SET_CRITERIA`; diff --git a/client/coral-admin/src/constants/userDetail.js b/client/coral-admin/src/constants/userDetail.js index b68efb5ea..f1617f5a3 100644 --- a/client/coral-admin/src/constants/userDetail.js +++ b/client/coral-admin/src/constants/userDetail.js @@ -4,4 +4,4 @@ export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES'; export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT'; export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT'; export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS'; - +export const SELECT_ALL_USER_DETAIL_COMMENT = 'SELECT_ALL_USER_DETAIL_COMMENT'; diff --git a/client/coral-admin/src/containers/CommentLabels.js b/client/coral-admin/src/containers/CommentLabels.js index 1942d1ad0..1d57aa526 100644 --- a/client/coral-admin/src/containers/CommentLabels.js +++ b/client/coral-admin/src/containers/CommentLabels.js @@ -25,6 +25,7 @@ export default withFragments({ } user { id + roles } } ${getSlotFragmentSpreads(slots, 'comment')} diff --git a/client/coral-admin/src/containers/Header.js b/client/coral-admin/src/containers/Header.js index a5c1f0afc..c107d58f9 100644 --- a/client/coral-admin/src/containers/Header.js +++ b/client/coral-admin/src/containers/Header.js @@ -19,6 +19,6 @@ export default withQuery(gql` } `, { options: { - pollInterval: 5000 + pollInterval: 10000 } })(Header); diff --git a/client/coral-admin/src/containers/SuspendUserDialog.js b/client/coral-admin/src/containers/SuspendUserDialog.js index 12e9843b4..5c554e70c 100644 --- a/client/coral-admin/src/containers/SuspendUserDialog.js +++ b/client/coral-admin/src/containers/SuspendUserDialog.js @@ -1,4 +1,5 @@ import React, {Component} from 'react'; +import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import SuspendUserDialog from '../components/SuspendUserDialog'; @@ -44,6 +45,12 @@ class SuspendUserDialogContainer extends Component { } } +SuspendUserDialogContainer.propTypes = { + open: PropTypes.bool, + hideSuspendUserDialog: PropTypes.func, + username: PropTypes.string, +}; + const withOrganizationName = withQuery(gql` query CoralAdmin_SuspendUserDialog { __typename diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js index 5d9ef0537..01ceeedc0 100644 --- a/client/coral-admin/src/containers/UserDetail.js +++ b/client/coral-admin/src/containers/UserDetail.js @@ -11,13 +11,15 @@ import { changeUserDetailStatuses, clearUserDetailSelections, toggleSelectCommentInUserDetail, + toggleSelectAllCommentInUserDetail } from 'coral-admin/src/actions/userDetail'; import {withSetCommentStatus} from 'coral-framework/graphql/mutations'; import UserDetailComment from './UserDetailComment'; import update from 'immutability-helper'; +import {notify} from 'coral-framework/actions/notification'; const commentConnectionFragment = gql` - fragment CoralAdmin_Moderation_CommentConnection on CommentConnection { + fragment CoralAdmin_UserDetail_CommentConnection on CommentConnection { nodes { ...${getDefinitionName(UserDetailComment.fragments.comment)} } @@ -41,22 +43,16 @@ class UserDetailContainer extends React.Component { return this.props.setCommentStatus({commentId, status}); }); - try { - await Promise.all(changes); - this.props.clearUserDetailSelections(); // un-select everything - } catch (err) { - - // TODO: handle error. - console.error(err); - } + await Promise.all(changes); + this.props.clearUserDetailSelections(); // un-select everything } bulkReject = () => { - this.bulkSetCommentStatus('REJECTED'); + return this.bulkSetCommentStatus('REJECTED'); } bulkAccept = () => { - this.bulkSetCommentStatus('ACCEPTED'); + return this.bulkSetCommentStatus('ACCEPTED'); } acceptComment = ({commentId}) => { @@ -120,6 +116,7 @@ class UserDetailContainer extends React.Component { bulkAccept={this.bulkAccept} changeStatus={this.props.changeUserDetailStatuses} toggleSelect={this.props.toggleSelectCommentInUserDetail} + toggleSelectAll={this.props.toggleSelectAllCommentInUserDetail} acceptComment={this.acceptComment} rejectComment={this.rejectComment} loading={loading} @@ -131,7 +128,7 @@ class UserDetailContainer extends React.Component { const LOAD_MORE_QUERY = gql` query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) { comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) { - ...CoralAdmin_Moderation_CommentConnection + ...CoralAdmin_UserDetail_CommentConnection } } ${commentConnectionFragment} @@ -158,7 +155,7 @@ export const withUserDetailQuery = withQuery(gql` author_id: $author_id, statuses: $statuses }) { - ...CoralAdmin_Moderation_CommentConnection + ...CoralAdmin_UserDetail_CommentConnection } ...${getDefinitionName(UserDetailComment.fragments.root)} ${getSlotFragmentSpreads(slots, 'root')} @@ -168,7 +165,8 @@ export const withUserDetailQuery = withQuery(gql` `, { options: ({userId, statuses}) => { return { - variables: {author_id: userId, statuses} + variables: {author_id: userId, statuses}, + fetchPolicy: 'network-only', }; }, skip: (ownProps) => !ownProps.userId, @@ -188,6 +186,8 @@ const mapDispatchToProps = (dispatch) => ({ toggleSelectCommentInUserDetail, viewUserDetail, hideUserDetail, + toggleSelectAllCommentInUserDetail, + notify }, dispatch) }); diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index d7656d9a5..42e96f382 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -1,15 +1,5 @@ import update from 'immutability-helper'; -import mapValues from 'lodash/mapValues'; - -// Map nested object leaves. Array objects are considered leaves. -function mapLeaves(o, mapper) { - return mapValues(o, (val) => { - if (typeof val === 'object' && !Array.isArray(val)) { - return mapLeaves(val, mapper); - } - return mapper(val); - }); -} +import {mapLeaves} from 'coral-framework/utils'; export default { mutations: { diff --git a/client/coral-admin/src/reducers/assets.js b/client/coral-admin/src/reducers/assets.js deleted file mode 100644 index bb8751973..000000000 --- a/client/coral-admin/src/reducers/assets.js +++ /dev/null @@ -1,40 +0,0 @@ -import * as actions from '../constants/assets'; -import update from 'immutability-helper'; - -const initialState = { - byId: {}, - ids: [], - assets: [] -}; - -export default function assets (state = initialState, action) { - switch (action.type) { - case actions.FETCH_ASSETS_SUCCESS: { - const assets = action.assets.reduce((prev, curr) => { - prev[curr.id] = curr; - return prev; - }, {}); - - return update(state, { - byId: {$set: assets}, - count: {$set: action.count}, - ids: {$set: Object.keys(assets)}, - }); - } - case actions.UPDATE_ASSET_STATE_REQUEST: - return update(state, { - byId: { - [action.id]: { - closedAt: {$set: action.closedAt}, - }, - }, - }); - case actions.UPDATE_ASSETS: - return update(state, { - assets: {$set: action.assets}, - }); - default: - return state; - } -} - diff --git a/client/coral-admin/src/reducers/community.js b/client/coral-admin/src/reducers/community.js index 9f724c8d6..170804b60 100644 --- a/client/coral-admin/src/reducers/community.js +++ b/client/coral-admin/src/reducers/community.js @@ -1,8 +1,10 @@ import { - FETCH_COMMENTERS_REQUEST, - FETCH_COMMENTERS_FAILURE, - FETCH_COMMENTERS_SUCCESS, + FETCH_USERS_REQUEST, + FETCH_USERS_SUCCESS, + FETCH_USERS_FAILURE, SORT_UPDATE, + SET_PAGE, + SET_SEARCH_VALUE, SET_ROLE, SET_COMMENTER_STATUS, SHOW_BANUSER_DIALOG, @@ -15,7 +17,8 @@ const initialState = { community: {}, isFetchingPeople: false, errorPeople: '', - accounts: [], + users: [], + searchValue: '', fieldPeople: 'created_at', ascPeople: false, totalPagesPeople: 0, @@ -27,19 +30,19 @@ const initialState = { export default function community (state = initialState, action) { switch (action.type) { - case FETCH_COMMENTERS_REQUEST : + case FETCH_USERS_REQUEST : return { ...state, isFetchingPeople: true, }; - case FETCH_COMMENTERS_FAILURE : + case FETCH_USERS_FAILURE : return { ...state, isFetchingPeople: false, errorPeople: action.error, }; - case FETCH_COMMENTERS_SUCCESS : { - const {accounts, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line + case FETCH_USERS_SUCCESS : { + const {users, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line return { ...state, isFetchingPeople: false, @@ -49,27 +52,32 @@ export default function community (state = initialState, action) { limitPeople: limit, totalPagesPeople: totalPages, ...rest, - accounts, // Sets to normal array + users, // Sets to normal array }; } + case SET_PAGE: + return { + ...state, + pagePeople: action.page, + }; case SET_ROLE : { - const commenters = state.accounts; + const commenters = state.users; const idx = commenters.findIndex((el) => el.id === action.id); commenters[idx].roles[0] = action.role; return { ...state, - accounts: commenters.map((id) => id), + users: commenters.map((id) => id), }; } case SET_COMMENTER_STATUS: { - const commenters = state.accounts; + const commenters = state.users; const idx = commenters.findIndex((el) => el.id === action.id); commenters[idx].status = action.status; return { ...state, - accounts: commenters.map((id) => id), + users: commenters.map((id) => id), }; } @@ -101,6 +109,11 @@ export default function community (state = initialState, action) { user: action.user, rejectUsernameDialog: true }; + case SET_SEARCH_VALUE: + return { + ...state, + searchValue: action.value, + }; default : return state; } diff --git a/client/coral-admin/src/reducers/dashboard.js b/client/coral-admin/src/reducers/dashboard.js deleted file mode 100644 index 3b3414d3a..000000000 --- a/client/coral-admin/src/reducers/dashboard.js +++ /dev/null @@ -1,15 +0,0 @@ -// this is initialized here because -// currently you have to reload the dashboard to get new stats -// cleaner updates are planned in the future. -const DASHBOARD_WINDOW_MINUTES = 5; -let then = new Date(); -then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES); - -const initialState = { - windowStart: then.toISOString(), - windowEnd: new Date().toISOString(), -}; - -export default function dashboard (state = initialState, _action) { - return state; -} diff --git a/client/coral-admin/src/reducers/index.js b/client/coral-admin/src/reducers/index.js index 372fedbae..450a914c3 100644 --- a/client/coral-admin/src/reducers/index.js +++ b/client/coral-admin/src/reducers/index.js @@ -1,6 +1,5 @@ import auth from './auth'; -import assets from './assets'; -import dashboard from './dashboard'; +import stories from './stories'; import configure from './configure'; import community from './community'; import moderation from './moderation'; @@ -13,11 +12,10 @@ import userDetail from './userDetail'; export default { auth, banUserDialog, - dashboard, configure, suspendUserDialog, userDetail, - assets, + stories, community, moderation, install, diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index 43ae81573..6a64b380a 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -7,6 +7,7 @@ const initialState = { storySearchString: '', shortcutsNoteVisible: 'show', sortOrder: 'DESC', + selectedCommentId: '', }; export default function moderation (state = initialState, action) { @@ -51,6 +52,11 @@ export default function moderation (state = initialState, action) { ...state, sortOrder: action.order, }; + case actions.MODERATION_SELECT_COMMENT: + return { + ...state, + selectedCommentId: action.id, + }; default: return state; } diff --git a/client/coral-admin/src/reducers/stories.js b/client/coral-admin/src/reducers/stories.js new file mode 100644 index 000000000..271ec8cfe --- /dev/null +++ b/client/coral-admin/src/reducers/stories.js @@ -0,0 +1,73 @@ +import * as actions from '../constants/stories'; +import update from 'immutability-helper'; + +const initialState = { + assets: { + byId: {}, + ids: [], + assets: [] + }, + searchValue: '', + criteria: { + asc: 'false', + filter: 'all', + }, +}; + +export default function assets (state = initialState, action) { + switch (action.type) { + case actions.FETCH_ASSETS_SUCCESS: { + const assets = action.assets.reduce((prev, curr) => { + prev[curr.id] = curr; + return prev; + }, {}); + + return update(state, { + assets: { + totalPages: {$set: action.totalPages}, + page: {$set: action.page}, + byId: {$set: assets}, + count: {$set: action.count}, + ids: {$set: Object.keys(assets)}, + }, + }); + } + case actions.UPDATE_ASSET_STATE_REQUEST: + return update(state, { + assets: { + byId: { + [action.id]: { + closedAt: {$set: action.closedAt}, + }, + }, + }, + }); + case actions.UPDATE_ASSETS: + return update(state, { + assets: { + assets: {$set: action.assets}, + }, + }); + case actions.SET_PAGE: + return { + ...state, + page: action.page, + }; + case actions.SET_SEARCH_VALUE: + return { + ...state, + searchValue: action.value, + }; + case actions.SET_CRITERIA: + return { + ...state, + criteria: { + ...state.criteria, + ...action.criteria, + }, + }; + default: + return state; + } +} + diff --git a/client/coral-admin/src/reducers/userDetail.js b/client/coral-admin/src/reducers/userDetail.js index e3c49a0d1..3ad435973 100644 --- a/client/coral-admin/src/reducers/userDetail.js +++ b/client/coral-admin/src/reducers/userDetail.js @@ -41,6 +41,11 @@ export default function banUserDialog(state = initialState, action) { ...state, selectedCommentIds: state.selectedCommentIds.filter((id) => id !== action.id), }; + case actions.SELECT_ALL_USER_DETAIL_COMMENT: + return { + ...state, + selectedCommentIds: action.ids + }; default: return state; } diff --git a/client/coral-admin/src/routes/Community/components/Community.css b/client/coral-admin/src/routes/Community/components/Community.css new file mode 100644 index 000000000..8f7641418 --- /dev/null +++ b/client/coral-admin/src/routes/Community/components/Community.css @@ -0,0 +1,4 @@ +.container { + max-width: 1280px; + margin: 0 auto; +} \ No newline at end of file diff --git a/client/coral-admin/src/routes/Community/components/Community.js b/client/coral-admin/src/routes/Community/components/Community.js index bf300a166..3b60b357a 100644 --- a/client/coral-admin/src/routes/Community/components/Community.js +++ b/client/coral-admin/src/routes/Community/components/Community.js @@ -1,78 +1,18 @@ import React, {Component} from 'react'; - -import CommunityMenu from './CommunityMenu'; -import People from './People'; -import FlaggedAccounts from '../containers/FlaggedAccounts'; -import RejectUsernameDialog from './RejectUsernameDialog'; import PropTypes from 'prop-types'; +import styles from './Community.css'; +import People from '../containers/People'; +import CommunityMenu from './CommunityMenu'; +import RejectUsernameDialog from './RejectUsernameDialog'; +import FlaggedAccounts from '../containers/FlaggedAccounts'; -export default class Community extends Component { - - state = { - searchValue: '', - timer: null - }; - - onKeyDownHandler = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - this.search(); - } - } - - onSearchChange = (e) => { - const value = e.target.value; - this.setState((prevState) => { - prevState.searchValue = value; - clearTimeout(prevState.timer); - const fetchAccounts = this.props.fetchAccounts; - prevState.timer = setTimeout(() => { - fetchAccounts({value}); - }, 350); - return prevState; - }); - } - - onHeaderClickHandler = (sort) => { - this.props.updateSorting(sort); - this.search(); - } - - onNewPageHandler = (page) => { - this.props.newPage(page); - this.search({page}); - } - - search(query = {}) { - const {community} = this.props; - - this.props.fetchAccounts({ - value: this.state.searchValue, - field: community.fieldPeople, - asc: community.ascPeople, - ...query - }); - } - - getTabContent(searchValue, props) { - const {community} = props; - const activeTab = props.route.path === ':id' ? 'flagged' : props.route.path; +class Community extends Component { + renderTab() { + const {route, community, ...props} = this.props; + const activeTab = route.path === ':id' ? 'flagged' : route.path; if (activeTab === 'people') { - return ( - - ); + return ; } return ( @@ -82,37 +22,36 @@ export default class Community extends Component { root={this.props.root} />
    ); } render() { - const {searchValue} = this.state; - const tab = this.getTabContent(searchValue, this.props); const {root: {flaggedUsernamesCount}} = this.props; return ( -
    +
    -
    {tab}
    +
    + {this.renderTab()} +
    ); } } Community.propTypes = { - community: PropTypes.object, - fetchAccounts: PropTypes.func, - hideRejectUsernameDialog: PropTypes.func, - updateSorting: PropTypes.func, - newPage: PropTypes.func, route: PropTypes.object, - rejectUsername: PropTypes.func, + community: PropTypes.object, + rejectUsername: PropTypes.func.isRequired, + hideRejectUsernameDialog: PropTypes.func.isRequired, data: PropTypes.object, - root: PropTypes.object + root: PropTypes.object, }; + +export default Community; diff --git a/client/coral-admin/src/routes/Community/components/CommunityMenu.js b/client/coral-admin/src/routes/Community/components/CommunityMenu.js index 3e13617fe..251762b43 100644 --- a/client/coral-admin/src/routes/Community/components/CommunityMenu.js +++ b/client/coral-admin/src/routes/Community/components/CommunityMenu.js @@ -13,11 +13,11 @@ const CommunityMenu = ({flaggedUsernamesCount = 0}) => {
    - + {t('community.flaggedaccounts')} - + {t('community.people')}
    diff --git a/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js b/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js index e617def53..cbfd903c6 100644 --- a/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js +++ b/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js @@ -1,5 +1,6 @@ import React from 'react'; - +import PropTypes from 'prop-types'; +import cn from 'classnames'; import t from 'coral-framework/services/i18n'; import EmptyCard from 'coral-admin/src/components/EmptyCard'; import LoadMore from '../../../components/LoadMore'; @@ -23,7 +24,7 @@ class FlaggedAccounts extends React.Component { const hasResults = users.nodes && !!users.nodes.length; return ( -
    +
    { hasResults @@ -70,4 +71,15 @@ class FlaggedAccounts extends React.Component { } } +FlaggedAccounts.propTypes = { + users: PropTypes.object, + loadMore: PropTypes.func, + showBanUserDialog: PropTypes.func, + showSuspendUserDialog: PropTypes.func, + showRejectUsernameDialog: PropTypes.func, + approveUser: PropTypes.func, + me: PropTypes.object, + viewUserDetail: PropTypes.func, +}; + export default FlaggedAccounts; diff --git a/client/coral-admin/src/routes/Community/components/FlaggedUser.js b/client/coral-admin/src/routes/Community/components/FlaggedUser.js index 409e867a3..b2156f479 100644 --- a/client/coral-admin/src/routes/Community/components/FlaggedUser.js +++ b/client/coral-admin/src/routes/Community/components/FlaggedUser.js @@ -1,18 +1,14 @@ import React from 'react'; import styles from './FlaggedUser.css'; - -// TODO: Should not rely on plugin. +import PropTypes from 'prop-types'; +import cn from 'classnames'; +import t from 'coral-framework/services/i18n'; import {username} from 'talk-plugin-flags/helpers/flagReasons'; - import ActionsMenu from 'coral-admin/src/components/ActionsMenu'; import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem'; import ApproveButton from 'coral-admin/src/components/ApproveButton'; import RejectButton from 'coral-admin/src/components/RejectButton'; -import cn from 'classnames'; -import t from 'coral-framework/services/i18n'; - -// TODO: Should work with custom flags too. const shortReasons = { [username.other]: t('community.other'), [username.spam]: t('community.spam_ads'), @@ -21,7 +17,6 @@ const shortReasons = { [username.impersonating]: t('community.impersonating'), }; -// Render a single user for the list class User extends React.Component { showSuspenUserDialog = () => this.props.showSuspendUserDialog({ @@ -50,12 +45,10 @@ class User extends React.Component { } = this.props; return ( -
  • -
    -
    +
  • +
    +
    - -
    +
    flag @@ -127,9 +119,11 @@ class User extends React.Component {
    @@ -141,4 +135,16 @@ class User extends React.Component { } } +User.propTypes = { + showSuspendUserDialog: PropTypes.func, + showBanUserDialog: PropTypes.func, + viewUserDetail: PropTypes.func, + showRejectUsernameDialog: PropTypes.func, + approveUser: PropTypes.func, + user: PropTypes.object, + className: PropTypes.string, + selected: PropTypes.bool, + me: PropTypes.object, +}; + export default User; diff --git a/client/coral-admin/src/routes/Community/components/People.js b/client/coral-admin/src/routes/Community/components/People.js index b0524f273..b92bd679a 100644 --- a/client/coral-admin/src/routes/Community/components/People.js +++ b/client/coral-admin/src/routes/Community/components/People.js @@ -1,35 +1,31 @@ import React from 'react'; - +import cn from 'classnames'; import styles from './styles.css'; -import Table from '../containers/Table'; -import {Pager, Icon} from 'coral-ui'; +import Table from './Table'; +import {Icon} from 'coral-ui'; import EmptyCard from '../../../components/EmptyCard'; import t from 'coral-framework/services/i18n'; + import PropTypes from 'prop-types'; -const tableHeaders = [ - { - title: t('community.username_and_email'), - field: 'username' - }, - { - title: t('community.account_creation_date'), - field: 'created_at' - }, - { - title: t('community.status'), - field: 'status' - }, - { - title: t('community.newsroom_role'), - field: 'role' - } -]; +const People = (props) => { + const { + users = [], + searchValue, + onSearchChange, + onHeaderClickHandler, + onPageChange, + totalPages, + page, + setRole, + setCommenterStatus, + viewUserDetail, + } = props; + + const hasResults = !!users.length; -const People = ({commenters, searchValue, onSearchChange, ...props}) => { - const hasResults = !!commenters.length; return ( -
    +
    @@ -47,28 +43,33 @@ const People = ({commenters, searchValue, onSearchChange, ...props}) => { { hasResults ?
: {t('community.no_results')} } - ); }; People.propTypes = { - commenters: PropTypes.array, + onHeaderClickHandler: PropTypes.func, + users: PropTypes.array, + page: PropTypes.number.isRequired, searchValue: PropTypes.string, onSearchChange: PropTypes.func, totalPages: PropTypes.number, - onNewPageHandler: PropTypes.func, + onPageChange: PropTypes.func, + setCommenterStatus: PropTypes.func.isRequired, + setRole: PropTypes.func.isRequired, + viewUserDetail: PropTypes.func.isRequired, }; export default People; diff --git a/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js b/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js index 20c411e52..18e9394a5 100644 --- a/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js +++ b/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js @@ -1,6 +1,6 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; - +import cn from 'classnames'; import {Dialog, Button} from 'coral-ui'; import styles from './RejectUsernameDialog.css'; @@ -29,12 +29,6 @@ class RejectUsernameDialog extends Component { state = {email: '', stage: 0} - static propTypes = { - stage: PropTypes.number, - handleClose: PropTypes.func.isRequired, - rejectUsername: PropTypes.func.isRequired - } - componentDidMount() { this.setState({email: t('reject_username.email_message_reject'), about: t('reject_username.username')}); } @@ -76,7 +70,7 @@ class RejectUsernameDialog extends Component { const {stage} = this.state; return