Merge pull request #1154 from coralproject/user-status-refactor

User status refactor
This commit is contained in:
Wyatt Johnson
2017-12-21 14:35:23 -07:00
committed by GitHub
195 changed files with 6455 additions and 3465 deletions
+2
View File
@@ -13,6 +13,7 @@ client/coral-framework/graphql/introspection.json
.idea/
*.swp
*.DS_STORE
.prettierrc.json
coverage/
test/e2e/reports/
@@ -52,3 +53,4 @@ plugins/*
!plugins/talk-plugin-slack-notifications
**/node_modules/*
yarn-error.log
+7 -1
View File
@@ -1,5 +1,11 @@
{
"exec": "npm-run-all --parallel generate-introspection start:development",
"verbose": true,
"ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"],
"ext": "js,json,graphql"
"ext": "js,json,graphql",
"watch": [
".",
"bin/cli",
"bin/cli-serve"
]
}
+17
View File
@@ -10,6 +10,7 @@ const {HELMET_CONFIGURATION} = require('./config');
const {MOUNT_PATH} = require('./url');
const routes = require('./routes');
const debug = require('debug')('talk:app');
const {ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT} = require('./config');
const app = express();
@@ -41,6 +42,22 @@ if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
}
if (ENABLE_TRACING && APOLLO_ENGINE_KEY) {
const {Engine} = require('apollo-engine');
const engine = new Engine({
engineConfig: {
apiKey: APOLLO_ENGINE_KEY
},
graphqlPort: PORT,
endpoint: `${MOUNT_PATH}api/v1/graph/ql`,
});
engine.start();
app.use(engine.expressMiddleware());
}
// Trust the first proxy in front of us, this will enable us to trust the fact
// that SSL was terminated correctly.
app.set('trust proxy', 1);
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('./commander');
const program = require('commander');
const {head, map} = require('lodash');
const Matcher = require('did-you-mean');
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('./commander');
const program = require('commander');
const parseDuration = require('ms');
const Table = require('cli-table');
const AssetModel = require('../models/asset');
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('./commander');
const program = require('commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const util = require('./util');
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('./commander');
const program = require('commander');
const util = require('./util');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
+1 -1
View File
@@ -7,7 +7,7 @@
// Interface heavily inspired by the yarn package manager:
// https://yarnpkg.com/
const program = require('./commander');
const program = require('commander');
const inquirer = require('inquirer');
// Make things colorful!
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
const program = require('./commander');
const program = require('commander');
const util = require('./util');
const serve = require('../serve');
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
const program = require('./commander');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingsService = require('../services/settings');
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('./commander');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingModel = require('../models/setting');
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('./commander');
const program = require('commander');
const mongoose = require('../services/mongoose');
const TokensService = require('../services/tokens');
const util = require('./util');
+36 -156
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('./commander');
const program = require('commander');
const inquirer = require('inquirer');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
@@ -37,11 +37,11 @@ function getUserCreateAnswers(options) {
password: options.password,
confirmPassword: options.password,
username: options.name,
roles: []
role: 'COMMENTER'
};
if (options.role && USER_ROLES.indexOf(options.role) > -1) {
user.roles = [options.role];
user.roles = options.role;
}
return Promise.resolve(user);
@@ -90,9 +90,9 @@ function getUserCreateAnswers(options) {
}
},
{
name: 'roles',
name: 'role',
message: 'User Role',
type: 'checkbox',
type: 'list',
choices: USER_ROLES
}
]);
@@ -109,20 +109,11 @@ async function createUser(options) {
}
const user = await UsersService.createLocalUser(answers.email.trim(), answers.password.trim(), answers.username.trim());
console.log(`Created user ${user.id}.`);
if (answers.roles.length > 0) {
return Promise.all(answers.roles.map((role) => {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the role ${role} to User ${user.id}.`);
});
}));
}
await UsersService.setRole(user.id, answers.role);
await UsersService.sendEmailConfirmation(user, answers.email.trim());
console.log(`Created User ${user.id}.`);
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown(1);
@@ -266,19 +257,19 @@ function listUsers() {
});
users.forEach((user) => {
let state = user.disabled ? 'Disabled' : 'Enabled';
const profile = user.profiles.find(({provider}) => provider === 'local');
let state;
if (profile && profile.metadata && profile.metadata.confirmed_at) {
state += ', Verified';
state = 'Verified';
} else {
state += ', Unverified';
state = 'Unverified';
}
table.push([
user.id,
user.username,
user.profiles.map((p) => p.provider).join(', '),
user.roles.join(', '),
user.role,
user.status,
state
]);
@@ -316,117 +307,30 @@ function mergeUsers(dstUserID, srcUserID) {
* @param {String} userUD id of the user to add the role to
* @param {String} role the role to add
*/
function addRole(userID, role) {
async function setRole(userID, role, options) {
try {
if (options.interactive || !role) {
const answers = await inquirer.prompt([
{
name: 'role',
message: 'User Role',
type: 'list',
choices: USER_ROLES
}
]);
if (USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`);
role = answers.role;
}
await UsersService.setRole(userID, role);
console.log(`Set User ${userID} to the ${role} role.`);
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown(1);
return;
}
UsersService
.addRoleToUser(userID, role)
.then(() => {
console.log(`Added the ${role} role to User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Removes a role from a user
* @param {String} userUD id of the user to remove the role from
* @param {String} role the role to remove
*/
function removeRole(userID, role) {
if (USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
UsersService
.removeRoleFromUser(userID, role)
.then(() => {
console.log(`Removed the ${role} role from User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Ban a user
* @param {String} userID id of the user to ban
*/
function ban(userID) {
UsersService
.setStatus(userID, 'BANNED')
.then(() => {
console.log(`Banned the User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Unban a user
* @param {String} userUD id of the user to remove the role from
*/
function unban(userID) {
UsersService
.setStatus(userID, 'ACTIVE')
.then(() => {
console.log(`Unban the User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Disable a given user.
* @param {String} userID the ID of a user to disable
*/
function disableUser(userID) {
UsersService
.disableUser(userID)
.then(() => {
console.log(`User ${userID} was disabled.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Enabled a given user.
* @param {String} userID the ID of a user to enable
*/
function enableUser(userID) {
UsersService
.enableUser(userID)
.then(() => {
console.log(`User ${userID} was enabled.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
@@ -488,34 +392,10 @@ program
.action(mergeUsers);
program
.command('addrole <userID> <role>')
.description('adds a role to a given user')
.action(addRole);
program
.command('removerole <userID> <role>')
.description('removes a role from a given user')
.action(removeRole);
program
.command('ban <userID>')
.description('ban a given user')
.action(ban);
program
.command('uban <userID>')
.description('unban a given user')
.action(unban);
program
.command('disable <userID>')
.description('disable a given user from logging in')
.action(disableUser);
program
.command('enable <userID>')
.description('enable a given user from logging in')
.action(enableUser);
.command('setrole <userID> [role]')
.option('-i, --interactive', 'Enable interactive mode')
.description('sets the role on a user')
.action(setRole);
program
.command('verify <userID> <email>')
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('./commander');
const program = require('commander');
const mongoose = require('../services/mongoose');
const util = require('./util');
const databaseVerifications = require('./verifications/database');
-51
View File
@@ -1,51 +0,0 @@
const pkg = require('../package.json');
const dotenv = require('dotenv');
const fs = require('fs');
const program = require('commander');
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
const parseArgs = require('minimist')(process.argv.slice(2), {
alias: {
'c': 'config'
},
string: [
'config',
'pid'
],
default: {
'config': null,
'pid': null
}
});
/**
* If the config flag is present, then we have to load the configuration from
* the file specified. We will then load those values into the environment.
*/
if (parseArgs.config) {
let envConfig = dotenv.parse(fs.readFileSync(parseArgs.config, {encoding: 'utf8'}));
Object.keys(envConfig).forEach((k) => {
process.env[k] = envConfig[k];
});
}
/**
* If the pid flag is present, then we have to create a pid file at the location
* specified.
*/
if (parseArgs.pid) {
const util = require('./util');
console.log('Wrote PID');
util.pid(parseArgs.pid);
}
module.exports = program
.version(pkg.version)
.option('-c, --config [path]', 'Specify the configuration file to load')
.option('--pid [path]', 'Specify a path to output the current PID to');
-38
View File
@@ -1,5 +1,4 @@
const debug = require('debug')('talk:util');
const fs = require('fs');
const util = module.exports = {};
@@ -49,43 +48,6 @@ util.onshutdown = (jobs) => {
util.toshutdown = util.toshutdown.concat(jobs);
};
/**
* Register a PID file to be maintained for the lifespan of the process.
* @param {String} path path to the PID file to create
*/
util.pid = (path) => {
if (!/\//.test(path)) {
if (!/\.pid/.test(path)) {
path += '.pid';
}
path = `/tmp/${path}`;
}
const pid = `${process.pid.toString()}\n`;
fs.writeFile(path, pid, (err) => {
if (err) {
console.error(`Can't write PID file: ${err}`);
throw err;
}
// Add the cleanup for the fs onto the shutdown.
util.onshutdown([
() => new Promise((resolve, reject) => {
// Remove the pid file.
fs.unlink(path, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
})
]);
});
};
// Attach to the SIGTERM + SIGINT handles to ensure a clean shutdown in the
// event that we have an external event. SIGUSR2 is called when the app is asked
// to be 'killed', same procedure here.
+185
View File
@@ -0,0 +1,185 @@
const UserModel = require('../../../models/user');
const CommentModel = require('../../../models/comment');
const ActionsService = require('../../../services/actions');
const {arrayJoinBy} = require('../../../graph/loaders/util');
const sc = require('snake-case');
const debug = require('debug')('talk:cli:verify');
const MODELS = [
UserModel,
CommentModel,
];
async function processBatch(Model, documents) {
// Get an array of all the document id's.
const documentIDs = documents.map(({id}) => id);
// Store all the operations on this batch in this array that we'll return
// later.
const operations = [];
// Get the action summaries for this batch.
const totalActionSummaries = await ActionsService
.getActionSummaries(documentIDs)
.then(arrayJoinBy(documentIDs, 'item_id'));
// Iterate over the documents.
for (let i = 0; i < documents.length; i++) {
const document = documents[i];
const actionSummaries = totalActionSummaries[i];
let ops = [];
for (const actionSummary of actionSummaries) {
if (actionSummary.group_id === null) {
continue;
}
// And we generate the group id.
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
const GROUP_ID = sc(actionSummary.group_id.toLowerCase());
if (GROUP_ID.length <= 0) {
continue;
}
// And we add a new batch operation if the action summary is associated
// with a group.
const ACTION_COUNT_FIELD = `${ACTION_TYPE}_${GROUP_ID}`;
// Check that the action summaries match the cached counts.
if (
!document.action_counts ||
!(ACTION_COUNT_FIELD in document.action_counts) ||
document.action_counts[ACTION_COUNT_FIELD] !== actionSummary.count
) {
// Batch updates for those changes.
ops.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count,
});
}
}
// Group all the action summaries together from all the different group
// ids.
let groupedActionSummaries = actionSummaries.reduce((acc, actionSummary) => {
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
if (!(ACTION_TYPE in acc)) {
acc[ACTION_TYPE] = 0;
}
acc[ACTION_TYPE] += actionSummary.count;
return acc;
}, {});
for (const ACTION_COUNT_FIELD of Object.keys(groupedActionSummaries)) {
const count = groupedActionSummaries[ACTION_COUNT_FIELD];
// Check that the action summaries match the cached counts.
if (
!document.action_counts ||
!(ACTION_COUNT_FIELD in document.action_counts) ||
document.action_counts[ACTION_COUNT_FIELD] !== count
) {
// Batch updates for those changes.
ops.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: count,
});
}
}
// If this comment has action summaries that should be updated, then
// perform an update!
if (ops.length > 0) {
operations.push({
updateOne: {
filter: {
id: document.id
},
update: {
$set: Object.assign({}, ...ops),
},
},
});
}
}
return operations;
}
module.exports = async ({fix, batch}) => {
for (const Model of MODELS) {
const cursor = Model
.collection
.find({})
.project({
id: 1,
action_counts: 1
})
.sort({created_at: 1});
let operations = [];
let documents = [];
// While there are documents to process.
while (await cursor.hasNext()) {
// Load the document.
const document = await cursor.next();
// Push the document into the documents array.
documents.push(document);
// Check to see if the length of the documents array requires us to
// process it.
if (documents.length > batch) {
// Process this batch.
let batchOperations = await processBatch(Model, documents);
// Push the batch operations into the model operations.
operations.push(...batchOperations);
// Clear this batch contents.
documents = [];
}
}
// Check to see if there are any documents left over.
if (documents.length > 0) {
// Process this batch.
let batchOperations = await processBatch(Model, documents);
// Push the batch operations into the model operations.
operations.push(...batchOperations);
}
const OPERATIONS_LENGTH = operations.length;
console.log(`action_counts.js: ${OPERATIONS_LENGTH} ${Model.collection.name} need their action counts fixed.`);
// If fix was enabled, execute the batch writes.
if (OPERATIONS_LENGTH > 0) {
if (fix) {
debug(`action_counts.js: fixing ${OPERATIONS_LENGTH} ${Model.collection.name}...`);
while (operations.length) {
let result = await Model.collection.bulkWrite(operations.splice(0, batch));
debug(`action_counts.js: fixed batch of ${result.modifiedCount} ${Model.collection.name}.`);
}
console.log(`action_counts.js: applied all ${OPERATIONS_LENGTH} fixes to ${Model.collection.name}.`);
} else {
console.warn('Skipping fixing, --fix was not enabled, pass --fix to fix these errors');
}
}
}
};
@@ -1,7 +1,5 @@
const CommentModel = require('../../../models/comment');
const ActionsService = require('../../../services/actions');
const {arrayJoinBy, singleJoinBy} = require('../../../graph/loaders/util');
const sc = require('snake-case');
const {singleJoinBy} = require('../../../graph/loaders/util');
const debug = require('debug')('talk:cli:verify');
const getBatch = async (limit, offset) => CommentModel
@@ -55,15 +53,9 @@ module.exports = async ({fix, limit, batch}) => {
.then(singleJoinBy(commentIDs, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
// Get their action summaries.
let allActionSummaries = await ActionsService
.getActionSummaries(commentIDs)
.then(arrayJoinBy(commentIDs, 'item_id'));
// Loop over the comments, with their action summaries.
for (let i = 0; i < comments.length; i++) {
let comment = comments[i];
let actionSummaries = allActionSummaries[i];
let replyCount = allReplyCounts[i];
// And check to see if the action summaries we just computed match what is
@@ -77,61 +69,6 @@ module.exports = async ({fix, limit, batch}) => {
});
}
// First we process all the group id's.
for (let actionSummary of actionSummaries) {
if (actionSummary.group_id === null) {
continue;
}
// And we generate the group id.
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
const GROUP_ID = sc(actionSummary.group_id.toLowerCase());
if (GROUP_ID.length <= 0) {
continue;
}
// And we add a new batch operation if the action summary is associated
// with a group.
const ACTION_COUNT_FIELD = `${ACTION_TYPE}_${GROUP_ID}`;
// Check that the action summaries match the cached counts.
if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== actionSummary.count) {
// Batch updates for those changes.
commentOperations.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count,
});
}
}
// Group all the action summaries together from all the different group
// ids.
let groupedActionSummaries = actionSummaries.reduce((acc, actionSummary) => {
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
if (!(ACTION_TYPE in acc)) {
acc[ACTION_TYPE] = 0;
}
acc[ACTION_TYPE] += actionSummary.count;
return acc;
}, {});
for (const ACTION_COUNT_FIELD of Object.keys(groupedActionSummaries)) {
const count = groupedActionSummaries[ACTION_COUNT_FIELD];
// Check that the action summaries match the cached counts.
if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== count) {
// Batch updates for those changes.
commentOperations.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: count,
});
}
}
// If this comment has action summaries that should be updated, then
// perform an update!
if (commentOperations.length > 0) {
+3 -2
View File
@@ -6,7 +6,8 @@
//
// async ({fix = false, batch = 1000}) => {}
//
// where their options are derrived.
// where their options are derived.
module.exports = [
require('./comments'),
require('./comment_replies'),
require('./action_counts'),
];
+5 -2
View File
@@ -7,6 +7,8 @@ machine:
environment:
PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin"
NODE_ENV: "test"
MOCHA_FILE: "${CIRCLE_TEST_REPORTS}/junit/test-results.xml"
MOCHA_REPORTER: "mocha-junit-reporter"
pre:
# TODO: use the following to add in support for MongoDB 3.4.
# # Upgrade the database version to 3.4.
@@ -47,10 +49,11 @@ database:
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
- yarn test
# Run the end to end tests
- yarn e2e:ci
# Check dependancies using nsp.
- nsp check
- yarn e2e-ci
deployment:
release:
@@ -4,4 +4,3 @@ export const showBanUserDialog = ({userId, username, commentId, commentStatus})
({type: SHOW_BAN_USER_DIALOG, userId, username, commentId, commentStatus});
export const hideBanUserDialog = () => ({type: HIDE_BAN_USER_DIALOG});
@@ -7,8 +7,6 @@ import {
SORT_UPDATE,
SET_PAGE,
SET_SEARCH_VALUE,
SET_ROLE,
SET_COMMENTER_STATUS,
SHOW_BANUSER_DIALOG,
HIDE_BANUSER_DIALOG,
SHOW_REJECT_USERNAME_DIALOG,
@@ -56,20 +54,6 @@ export const setSearchValue = (value) => ({
value,
});
export const setRole = (id, role) => (dispatch, _, {rest}) => {
return rest(`/users/${id}/role`, {method: 'POST', body: {role}})
.then(() => {
return dispatch({type: SET_ROLE, id, role});
});
};
export const setCommenterStatus = (id, status) => (dispatch, _, {rest}) => {
return rest(`/users/${id}/status`, {method: 'POST', body: {status}})
.then(() => {
return dispatch({type: SET_COMMENTER_STATUS, id, status});
});
};
// Ban User Dialog
export const showBanUserDialog = (user) => ({type: SHOW_BANUSER_DIALOG, user});
export const hideBanUserDialog = () => ({type: HIDE_BANUSER_DIALOG});
@@ -0,0 +1,30 @@
.table {
flex-direction: column;
}
.table, .headerRow, .row {
display: flex;
}
.headerRowItem, .item {
flex: 1;
padding: 20px;
&:nth-child(2) {
flex: 2;
}
}
.headerRowItem {
color: #595959;
font-weight: bold;
}
.headerRow, .row {
border-bottom: 1px solid #e0e0e0;
}
.action {
color: black;
font-weight: bold;
}
@@ -0,0 +1,63 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './AccountHistory.css';
import cn from 'classnames';
import flatten from 'lodash/flatten';
import orderBy from 'lodash/orderBy';
import moment from 'moment';
const buildUserHistory = (userState = {}) => {
return orderBy(flatten(Object.keys(userState.status)
.filter((k) => k !== '__typename')
.map((k) => userState.status[k].history)), 'created_at', 'desc');
};
const buildActionResponse = (typename, status) => {
const actionResponses = {
'UsernameStatusHistory' : `Username Status: ${status}`,
'BannedStatusHistory': status ? 'User banned' : 'Ban removed',
'SuspensionStatusHistory': status ? 'Account Suspended' : 'Suspension removed'
};
return actionResponses[typename];
};
class AccountHistory extends React.Component {
render() {
const {userState} = this.props;
const userHistory = buildUserHistory(userState);
return (
<div>
<div className={cn(styles.table, 'talk-admin-account-history')}>
<div className={cn(styles.headerRow, 'talk-admin-account-history-header-row')}>
<div className={styles.headerRowItem}>Date</div>
<div className={styles.headerRowItem}>Action</div>
<div className={styles.headerRowItem}>Moderation</div>
</div>
{
userHistory.map((h, i) => (
<div className={cn(styles.row, 'talk-admin-account-history-row')} key={i}>
<div className={cn(styles.item, 'talk-admin-account-history-row-date')}>
{moment(new Date(h.created_at)).format('MMM DD, YYYY')}
</div>
<div className={cn(styles.item, styles.action, 'talk-admin-account-history-row-status')}>
{buildActionResponse(h.__typename, h.status)}
</div>
<div className={cn(styles.item, 'talk-admin-account-history-row-assigned-by')}>
{h.assigned_by ? h.assigned_by.username : 'SYSTEM'}
</div>
</div>
))
}
</div>
</div>
);
}
}
AccountHistory.propTypes = {
history: PropTypes.array,
userState: PropTypes.object,
};
export default AccountHistory;
@@ -8,9 +8,6 @@
color: black;
> :global(.mdl-menu__container) {
margin-left: 10px;
> :global(.mdl-menu__outline) {
box-shadow: none;
}
}
}
@@ -18,12 +15,13 @@
box-shadow: none;
color: white;
background-color: #616161;
border-color: #616161;
}
.arrowIcon {
margin-left: 6px;
margin-right: 0;
vertical-align: middle;
vertical-align: middle;
margin-right: 0;
font-size: 14px;
}
@@ -33,8 +31,10 @@
}
.menuItem {
background-color: #2a2a2a;
color: white;
color: #2a2a2a;
background-color: white;
font-size: 0.95em;
&:first-child {
margin-bottom: 1px;
border-radius: 2px 2px 0px 0px;
@@ -43,7 +43,8 @@
border-radius: 0px 0px 2px 2px;
}
&:hover, &:active, &:focus {
background-color: #767676;
background-color: #e2e2e2;
border-color: #616161;
}
&[disabled], &[disabled]:hover, &[disabled]:focus, &[disabled]:active {
background-color: #262626;
@@ -32,18 +32,18 @@ class ActionsMenu extends React.Component {
};
render() {
const {className = ''} = this.props;
const {className = '', buttonClassNames = '', label = ''} = this.props;
return (
<div className={cn(styles.root, className)} onBlur={this.syncOpenState} >
<Button
cStyle='actions'
className={cn(styles.button, {[styles.buttonOpen]: this.state.open})}
className={cn(styles.button, {[styles.buttonOpen]: this.state.open}, buttonClassNames)}
disabled={false}
id={this.id}
onClick={this.syncOpenState}
icon={this.props.icon}
raised>
{t('modqueue.actions')}
{label ? label : t('modqueue.actions')}
<Icon
name={this.state.open ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
className={styles.arrowIcon}
@@ -61,6 +61,8 @@ ActionsMenu.propTypes = {
icon: PropTypes.string,
children: PropTypes.node,
className: PropTypes.string,
label: PropTypes.string,
buttonClassNames: PropTypes.string,
};
export default ActionsMenu;
@@ -1,153 +1,153 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 500px;
width: 400px;
top: 50%;
transform: translateY(-50%);
height: 184px;
padding: 20px;
border-radius: 4px;
}
h2 {
color: black;
font-size: 1.76em;
font-weight: 500;
margin: 0;
}
.header {
color: black;
font-size: 1.5em;
font-weight: 500;
margin: 0 0 8px 0;
}
h3 {
color: black;
font-size: 1.4em;
font-weight: 500;
margin: 0;
}
.subheader {
color: black;
font-size: 1.3em;
font-weight: 500;
margin: 0 0 8px 0;
}
.textField {
margin-top: 15px;
margin-top: 15px;
}
.textField label {
font-size: 1.08em;
font-weight: bold;
margin-bottom: 5px;
font-size: 1.08em;
font-weight: bold;
margin-bottom: 5px;
}
.textField input {
width: 100%;
display: block;
border: none;
outline: none;
border: 1px solid rgba(0,0,0,.12);
padding: 10px 6px;
box-sizing: border-box;
border-radius: 2px;
margin: 5px auto;
width: 100%;
display: block;
border: none;
outline: none;
border: 1px solid rgba(0,0,0,.12);
padding: 10px 6px;
box-sizing: border-box;
border-radius: 2px;
margin: 5px auto;
}
.footer {
margin: 20px auto 10px;
text-align: center;
margin: 20px auto 10px;
text-align: center;
}
.footer span {
display: block;
margin-bottom: 5px;
display: block;
margin-bottom: 5px;
}
.footer a {
color: #2c69b6;
cursor: pointer;
margin: 0 5px;
color: #2c69b6;
cursor: pointer;
margin: 0 5px;
}
.socialConnections {
margin-bottom: 20px;
margin-bottom: 20px;
}
.signInButton {
margin-top: 10px;
margin-top: 10px;
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
}
.close:hover {
color: #6b6b6b;
color: #6b6b6b;
}
input.error{
border: solid 2px #f44336;
border: solid 2px #f44336;
}
.errorMsg, .hint {
color: grey;
font-weight: 600;
padding: 3px 0 16px;
color: grey;
font-weight: 600;
padding: 3px 0 16px;
}
.alert {
padding: 10px;
margin-bottom: 20px;
border-radius: 2px;
padding: 10px;
margin-bottom: 20px;
border-radius: 2px;
}
.alert--success {
border: solid 1px #1ec00e;
background: #cbf1b8;
color: #006900;
border: solid 1px #1ec00e;
background: #cbf1b8;
color: #006900;
}
.alert--error {
background: #FFEBEE;
color: #B71C1C;
background: #FFEBEE;
color: #B71C1C;
}
.userBox a {
color: #2c69b6;
cursor: pointer;
margin: 0px;
color: #2c69b6;
cursor: pointer;
margin: 0px;
}
.attention {
display: inline-block;
width: 15px;
height: 15px;
background: #B71C1C;
color: #FFEBEE;
font-weight: bolder;
padding: 4px;
vertical-align: middle;
border-radius: 20px;
box-sizing: border-box;
font-size: 9px;
line-height: 7px;
text-align: center;
margin-right: 5px;
display: inline-block;
width: 15px;
height: 15px;
background: #B71C1C;
color: #FFEBEE;
font-weight: bolder;
padding: 4px;
vertical-align: middle;
border-radius: 20px;
box-sizing: border-box;
font-size: 9px;
line-height: 7px;
text-align: center;
margin-right: 5px;
}
.action {
margin-top: 15px;
margin-top: 15px;
}
.passwordRequestSuccess {
border: 1px solid green;
background-color: lightgreen;
padding: 10px;
border: 1px solid green;
background-color: lightgreen;
padding: 10px;
}
.passwordRequestFailure {
border: 1px solid orange;
background-color: 1px solid coral;
padding: 10px;
border: 1px solid orange;
background-color: 1px solid coral;
padding: 10px;
}
.cancel {
@@ -160,6 +160,20 @@ input.error{
}
.buttons {
margin: 20px;
text-align: center;
margin-top: 8px;
margin-bottom: 6px;
text-align: right;
}
.legend {
padding: 0;
font-weight: bold;
}
.messageInput {
border-radius: 3px;
width: 100%;
padding: 10px;
font-size: 14px;
box-sizing: border-box;
}
@@ -7,39 +7,132 @@ import styles from './BanUserDialog.css';
import Button from 'coral-ui/components/Button';
import t from 'coral-framework/services/i18n';
const BanUserDialog = ({open, onCancel, onPerform, username, info}) => (
<Dialog
className={cn(styles.dialog, 'talk-ban-user-dialog')}
id="banUserDialog"
open={open}
onCancel={onCancel}
title={t('bandialog.ban_user')}>
<span className={styles.close} onClick={onCancel}>×</span>
<div className={styles.header}>
<h2>{t('bandialog.ban_user')}</h2>
</div>
<div className={styles.separator}>
<h3>{t('bandialog.are_you_sure', username)}</h3>
<i>{info}</i>
</div>
<div className={styles.buttons}>
<Button
className={cn(styles.cancel, 'talk-ban-user-dialog-button-cancel')}
cStyle="cancel"
onClick={onCancel}
raised >
{t('bandialog.cancel')}
</Button>
<Button
className={cn(styles.ban, 'talk-ban-user-dialog-button-confirm')}
cStyle="black"
onClick={onPerform}
raised >
{t('bandialog.yes_ban_user')}
</Button>
</div>
</Dialog>
);
const initialState = {step: 0, message: ''};
class BanUserDialog extends React.Component {
state = initialState;
componentWillReceiveProps(next) {
if (this.props.open && !next.open) {
this.setState(initialState);
}
}
handleMessageChange = (e) => {
const {value: message} = e;
this.setState({message});
}
goToStep1 = () => {
this.setState({
step: 1,
message: t(
'bandialog.email_message_ban',
this.props.username,
),
});
}
renderStep0() {
const {
onCancel,
username,
info,
} = this.props;
return (
<section>
<h2 className={styles.header}>
{t('bandialog.ban_user')}
</h2>
<h3 className={styles.subheader}>
{t('bandialog.are_you_sure', username)}
</h3>
<p className={styles.description}>
{info}
</p>
<div className={styles.buttons}>
<Button
className={cn('talk-ban-user-dialog-button-cancel')}
cStyle="white"
onClick={onCancel}
raised >
{t('bandialog.cancel')}
</Button>
<Button
className={cn('talk-ban-user-dialog-button-confirm')}
cStyle="black"
onClick={this.goToStep1}
raised >
{t('bandialog.yes_ban_user')}
</Button>
</div>
</section>
);
}
renderStep1() {
const {
onCancel,
onPerform,
} = this.props;
const {message} = this.state;
return (
<section>
<h2 className={styles.header}>
{t('bandialog.notify_ban_headline')}
</h2>
<p className={styles.description}>
{t('bandialog.notify_ban_description')}
</p>
<fieldset>
<legend className={styles.legend}>{t('bandialog.write_a_message')}</legend>
<textarea
rows={5}
className={styles.messageInput}
value={message}
onChange={this.handleMessageChange}
/>
</fieldset>
<div className={styles.buttons}>
<Button
className={cn('talk-ban-user-dialog-button-cancel')}
cStyle="white"
onClick={onCancel}
raised >
{t('bandialog.cancel')}
</Button>
<Button
className={cn('talk-ban-user-dialog-button-confirm')}
cStyle="black"
onClick={onPerform}
raised >
{t('bandialog.send')}
</Button>
</div>
</section>
);
}
render() {
const {step} = this.state;
const {open, onCancel} = this.props;
return (
<Dialog
className={cn(styles.dialog, 'talk-ban-user-dialog')}
id="banUserDialog"
open={open}
onCancel={onCancel}
title={t('bandialog.ban_user')} >
<span className={styles.close} onClick={onCancel}>×</span>
{step === 0 && this.renderStep0()}
{step === 1 && this.renderStep1()}
</Dialog>
);
}
}
BanUserDialog.propTypes = {
open: PropTypes.bool,
@@ -17,7 +17,7 @@ function getUserFlaggedType(actions) {
.some((action) =>
action.__typename === 'FlagAction' &&
action.user &&
action.user.roles.some((role) => staffRoles.includes(role))
staffRoles.includes(action.user.role)
) ? 'Staff' : 'User';
}
@@ -62,7 +62,7 @@ class SuspendUserDialog extends React.Component {
const {onCancel, username} = this.props;
const {duration} = this.state;
return (
<section>
<section className="talk-admin-suspend-user-dialog-step-0">
<h1 className={styles.header}>
{t('suspenduser.title_suspend')}
</h1>
@@ -97,10 +97,10 @@ class SuspendUserDialog extends React.Component {
}
renderStep1() {
const {onCancel, username} = this.props;
const {message} = this.state;
const {onCancel, username} = this.props;
return (
<section>
<section className="talk-admin-suspend-user-dialog-step-1">
<h1 className={styles.header}>
{t('suspenduser.title_notify')}
</h1>
@@ -94,33 +94,16 @@
width: calc(100% - 90px);
}
.commentStatuses {
padding: 0 0 0 10px;
margin: 0;
align-self: center;
list-style: none;
box-sizing: border-box;
li {
display: inline-block;
margin-right: 10px;
cursor: pointer;
padding: 0 10px;
}
}
.active {
font-weight: bold;
border-bottom: 3px solid #F36451;
}
.bulkActionGroup {
height: 52px;
background-color: #efefef;
padding: 0 0 0 10px;
display: flex;
i {
margin-right: 0;
}
.bulkAction {
display: inline-block;
width: 48px;
@@ -139,28 +122,67 @@
margin-left: 15px;
}
.loadMore>button {
background-color: #696969;
&:hover {
background-color: #404040;
color: white;
}
}
.toggleAll {
padding: 0 10px 0 0;
align-self: center;
}
.commentList {
clear: both;
}
.bulkActionHeader {
display: flex;
justify-content: space-between;
height: 52px;
&.selected {
background-color: #efefef;
}
}
}
.tabBar {
padding: 0 0 0 10px;
margin: 0;
align-self: center;
list-style: none;
box-sizing: border-box;
border: none;
}
.tab {
display: inline-block;
margin-right: 10px;
cursor: pointer;
padding: 0 10px;
}
.tabButton {
border: none;
background: white;
border-radius: 0;
font-size: 1em;
padding: 5px;
cursor: pointer;
}
.tabButtonActive {
font-weight: bold;
border-bottom: 3px solid #F36451;
}
.username {
display: inline-block;
vertical-align: middle;
}
.actionsMenu {
display: inline-block;
}
.actionsMenuSuspended {
background-color: #F29336;
border-color: #F29336;
color: white;
}
.actionsMenuBanned {
background-color: #E45241;
border-color: #E45241;
color: white;
}
+186 -81
View File
@@ -1,42 +1,23 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import Comment from '../containers/UserDetailComment';
import capitalize from 'lodash/capitalize';
import {getErrorMessages} from 'coral-framework/utils';
import styles from './UserDetail.css';
import {Icon, Drawer, Spinner} from 'coral-ui';
import RejectButton from './RejectButton';
import ApproveButton from './ApproveButton';
import AccountHistory from './AccountHistory';
import {Slot} from 'coral-framework/components';
import UserDetailCommentList from '../components/UserDetailCommentList';
import {getReliability, isSuspended, isBanned} from 'coral-framework/utils/user';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import ClickOutside from 'coral-framework/components/ClickOutside';
import LoadMore from '../components/LoadMore';
import cn from 'classnames';
import capitalize from 'lodash/capitalize';
import {getReliability} from 'coral-framework/utils/user';
import ApproveButton from './ApproveButton';
import RejectButton from './RejectButton';
import {getErrorMessages} from 'coral-framework/utils';
import {Icon, Drawer, Spinner, TabBar, Tab, TabContent, TabPane} from 'coral-ui';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import UserInfoTooltip from './UserInfoTooltip';
export default class UserDetail extends React.Component {
static propTypes = {
userId: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
changeStatus: PropTypes.func.isRequired,
toggleSelect: PropTypes.func.isRequired,
bulkAccept: PropTypes.func.isRequired,
bulkReject: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
data: PropTypes.shape({
refetch: PropTypes.func.isRequired,
}),
activeTab: PropTypes.string.isRequired,
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
loadMore: PropTypes.any.isRequired,
notify: PropTypes.func.isRequired
}
class UserDetail extends React.Component {
rejectThenReload = async (info) => {
try {
@@ -82,13 +63,19 @@ export default class UserDetail extends React.Component {
}
}
showAll = () => {
this.props.changeStatus('all');
changeTab = (tab) => {
this.props.changeStatus(tab);
}
showRejected = () => {
this.props.changeStatus('rejected');
}
showSuspenUserDialog = () => this.props.showSuspendUserDialog({
userId: this.props.root.user.id,
username: this.props.root.user.username,
});
showBanUserDialog = () => this.props.showBanUserDialog({
userId: this.props.root.user.id,
username: this.props.root.user.username,
});
renderLoading() {
return (
@@ -100,15 +87,30 @@ export default class UserDetail extends React.Component {
);
}
getActionMenuLabel() {
const {root: {user}} = this.props;
if (isBanned(user)) {
return 'Banned';
} else if (isSuspended(user)) {
return 'Suspended';
}
return '';
}
renderLoaded() {
const {
data,
root,
root: {
me,
user,
totalComments,
rejectedComments,
comments: {nodes, hasNextPage}
comments: {
nodes,
}
},
activeTab,
selectedCommentIds,
@@ -116,20 +118,59 @@ export default class UserDetail extends React.Component {
hideUserDetail,
viewUserDetail,
loadMore,
toggleSelectAll
toggleSelectAll,
unBanUser,
unSuspendUser,
} = this.props;
let rejectedPercent = (rejectedComments / totalComments) * 100;
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
// if totalComments is 0, you're dividing by zero, which is naughty
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
rejectedPercent = 0;
}
const banned = isBanned(user);
const suspended = isSuspended(user);
return (
<ClickOutside onClickOutside={hideUserDetail}>
<Drawer onClose={hideUserDetail}>
<h3>{user.username}</h3>
<Drawer className="talk-admin-user-detail-drawer" onClose={hideUserDetail}>
<h3 className={cn(styles.username, 'talk-admin-user-detail-username')}>
{user.username}
</h3>
{user.id &&
<ActionsMenu
icon="person"
className={cn(styles.actionsMenu, 'talk-admin-user-detail-actions-menu')}
buttonClassNames={cn({
[styles.actionsMenuSuspended]: suspended,
[styles.actionsMenuBanned]: banned,
}, 'talk-admin-user-detail-actions-button')}
label={this.getActionMenuLabel()}>
{suspended ? <ActionsMenuItem
onClick={() => unSuspendUser({id: user.id})}>
Remove Suspension
</ActionsMenuItem> : <ActionsMenuItem
disabled={me.id === user.id}
onClick={this.showSuspenUserDialog}>
Suspend User
</ActionsMenuItem>}
{banned ? <ActionsMenuItem
onClick={() => unBanUser({id: user.id})}>
Remove Ban
</ActionsMenuItem> : <ActionsMenuItem
disabled={me.id === user.id}
onClick={this.showBanUserDialog}>
Ban User
</ActionsMenuItem>}
</ActionsMenu>
}
{(banned || suspended) && <UserInfoTooltip user={user} banned={banned} suspended={suspended} />}
<div>
<ul className={styles.userDetailList}>
@@ -173,15 +214,37 @@ export default class UserDetail extends React.Component {
data={this.props.data}
queryData={{root, user}}
/>
<hr />
<div className={(selectedCommentIds.length > 0) ? cn(styles.bulkActionHeader, styles.selected) : styles.bulkActionHeader}>
{
selectedCommentIds.length === 0
? (
<ul className={styles.commentStatuses}>
<li className={activeTab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={activeTab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
<TabBar
onTabClick={this.changeTab}
activeTab={activeTab}
className={cn(styles.tabBar, 'talk-admin-user-detail-tab-bar')}
aria-controls='talk-admin-user-detail-content'
tabClassNames={{
button: styles.tabButton,
buttonActive: styles.tabButtonActive,
}} >
<Tab
tabId={'all'}
className={cn(styles.tab, styles.button, 'talk-admin-user-detail-all-tab')} >
All
</Tab>
<Tab
tabId={'rejected'}
className={cn(styles.tab, 'talk-admin-user-detail-rejected-tab')} >
Rejected
</Tab>
<Tab tabId={'history'} className={cn(styles.tab, styles.button, 'talk-admin-user-detail-history-tab')}>
Account History
</Tab>
</TabBar>
)
: (
<div className={styles.bulkActionGroup}>
@@ -197,41 +260,55 @@ export default class UserDetail extends React.Component {
</div>
)
}
<div className={styles.toggleAll}>
<input
type='checkbox'
id='toogleAll'
checked={selectedCommentIds.length > 0 && selectedCommentIds.length === nodes.length}
onChange={(e) => {
toggleSelectAll(nodes.map((comment) => comment.id), e.target.checked);
}} />
<label htmlFor='toogleAll'>Select all</label>
</div>
{(activeTab === 'all' || activeTab === 'rejected') && (
<div className={styles.toggleAll}>
<input
type='checkbox'
id='toogleAll'
checked={selectedCommentIds.length > 0 && selectedCommentIds.length === nodes.length}
onChange={(e) => {
toggleSelectAll(nodes.map((comment) => comment.id), e.target.checked);
}} />
<label htmlFor='toogleAll'>Select all</label>
</div>
)}
</div>
<div className={styles.commentList}>
{
nodes.map((comment) => {
const selected = selectedCommentIds.indexOf(comment.id) !== -1;
return <Comment
key={comment.id}
user={user}
root={root}
data={data}
comment={comment}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selected={selected}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
/>;
})
}
</div>
<LoadMore
className={styles.loadMore}
loadMore={loadMore}
showLoadMore={hasNextPage}
/>
<TabContent activeTab={activeTab} className='talk-admin-user-detail-content'>
<TabPane tabId={'all'} className={'talk-admin-user-detail-all-tab-pane'}>
<UserDetailCommentList
user={user}
root={root}
data={data}
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selectedCommentIds={selectedCommentIds}
/>
</TabPane>
<TabPane tabId={'rejected'} className={'talk-admin-user-detail-rejected-tab-pane'}>
<UserDetailCommentList
user={user}
root={root}
data={data}
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selectedCommentIds={selectedCommentIds}
/>
</TabPane>
<TabPane tabId={'history'} className={'talk-admin-user-detail-history-tab-pane'}>
<AccountHistory
userState={user.state}
/>
</TabPane>
</TabContent>
</Drawer>
</ClickOutside>
);
@@ -244,3 +321,31 @@ export default class UserDetail extends React.Component {
return this.renderLoaded();
}
}
UserDetail.propTypes = {
userId: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
changeStatus: PropTypes.func.isRequired,
toggleSelect: PropTypes.func.isRequired,
bulkAccept: PropTypes.func.isRequired,
bulkReject: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
data: PropTypes.shape({
refetch: PropTypes.func.isRequired,
}),
activeTab: PropTypes.string.isRequired,
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
loadMore: PropTypes.any.isRequired,
notify: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
unBanUser: PropTypes.func.isRequired,
unSuspendUser: PropTypes.func.isRequired,
};
export default UserDetail;
@@ -0,0 +1,11 @@
.commentList {
clear: both;
}
.loadMore > button {
background-color: #696969;
&:hover {
background-color: #404040;
color: white;
}
}
@@ -0,0 +1,65 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './UserDetailCommentList.css';
import LoadMore from '../components/LoadMore';
import Comment from '../containers/UserDetailComment';
const UserDetailCommentList = (props) => {
const {
data,
root,
root: {
user,
comments: {
nodes,
hasNextPage}
},
acceptComment,
rejectComment,
selectedCommentIds,
toggleSelect,
viewUserDetail,
loadMore,
} = props;
return (
<div className={cn(styles.commentList, 'talk-admin-user-detail-comment-list')}>
{
nodes.map((comment) => {
const selected = selectedCommentIds.indexOf(comment.id) !== -1;
return <Comment
key={comment.id}
user={user}
root={root}
data={data}
comment={comment}
acceptComment={acceptComment}
rejectComment={rejectComment}
selected={selected}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
/>;
})
}
<LoadMore
className={styles.loadMore}
loadMore={loadMore}
showLoadMore={hasNextPage}
/>
</div>
);
};
UserDetailCommentList.propTypes = {
root: PropTypes.object.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
loadMore: PropTypes.any.isRequired,
toggleSelect: PropTypes.func.isRequired,
};
export default UserDetailCommentList;
@@ -0,0 +1,69 @@
.userInfo {
position: relative;
display: inline-block;
}
.icon {
font-size: 16px;
color: #616161;
-ms-user-select:none;
-moz-user-select: none;
-webkit-user-select: none;
-webkit-touch-callout:none;
user-select: none;
-webkit-tap-highlight-color:rgba(0,0,0,0);
}
.icon:hover {
cursor: pointer;
}
.menu {
background-color: white;
border: solid 1px #999;
border-radius: 3px;
padding: 10px;
position: absolute;
-webkit-box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12), 0 3px 1px -2px rgba(0,0,0,0.2);
box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12), 0 3px 1px -2px rgba(0,0,0,0.2);
z-index: 10;
top: 32px;
right: 0px;
width: 140px;
text-align: left;
color: #616161;
}
.menu::before{
content: '';
border: 10px solid transparent;
border-top-color: #999;
position: absolute;
right: 0px;
top: -20px;
transform: rotate(180deg);
}
.menu::after{
content: '';
border: 10px solid transparent;
border-top-color: white;
position: absolute;
right: 0px;
top: -19px;
transform: rotate(180deg);
}
.descriptionList {
padding: 0;
margin: 0;
list-style: none;
}
.strongItem {
margin-right: 3px;
}
.descriptionItem {
font-size: 0.9em;
}
@@ -0,0 +1,95 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import {Icon} from 'coral-ui';
import styles from './UserInfoTooltip.css';
import ClickOutside from 'coral-framework/components/ClickOutside';
import moment from 'moment';
const initialState = {menuVisible: false};
class UserInfoTooltip extends React.Component {
state = initialState;
toogleMenu = () => {
this.setState({menuVisible: !this.state.menuVisible});
}
hideMenu = () => {
this.setState({menuVisible: false});
}
getLastHistoryItem = (user, status = 'banned') => {
const userHistory = user.state.status[status].history;
return userHistory[userHistory.length - 1];
}
render() {
const {menuVisible} = this.state;
const {user, banned, suspended} = this.props;
return (
<ClickOutside onClickOutside={this.hideMenu}>
<div className={cn(styles.userInfo, 'talk-admin-user-info-tooltip')}>
<span onClick={this.toogleMenu} className={cn(styles.icon, 'talk-admin-user-info-tooltip-icon')}>
<Icon name="info_outline" />
</span>
{menuVisible && (
<div className={cn(styles.menu, 'talk-admin-user-info-tooltip-menu')}>
{
banned && (
<div className={cn(styles.description, 'talk-admin-user-info-tooltip-description-banned')}>
<ul className={cn(styles.descriptionList, 'talk-admin-user-info-tooltip-description-list')}>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>Banned On</strong>
<span>{moment(new Date(this.getLastHistoryItem(user, 'banned').created_at)).format('MMMM Do YYYY, h:mm:ss a')}</span>
</li>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>By</strong>
<span>{this.getLastHistoryItem(user, 'banned').assigned_by.username}</span>
</li>
</ul>
</div>
)
}
{
suspended && (
<div className={cn(styles.description, 'talk-admin-user-info-tooltip-description-suspended')}>
<ul className={cn(styles.descriptionList, 'talk-admin-user-info-tooltip-description-list')}>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>Suspension</strong>
<span></span>
</li>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>By</strong>
<span>{this.getLastHistoryItem(user, 'suspension').assigned_by.username}</span>
</li>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>Start</strong>
<span>{moment(new Date(this.getLastHistoryItem(user, 'suspension').created_at)).format('MMMM Do YYYY, h:mm:ss a')}</span>
</li>
<li className={cn(styles.descriptionItem, 'talk-admin-user-info-tooltip-description-item')}>
<strong className={styles.strongItem}>End</strong>
<span>{moment(new Date(this.getLastHistoryItem(user, 'suspension').until)).format('MMMM Do YYYY, h:mm:ss a')}</span>
</li>
</ul>
</div>
)
}
</div>
)}
</div>
</ClickOutside>
);
}
}
UserInfoTooltip.propTypes = {
user: PropTypes.object,
banned: PropTypes.bool,
suspended: PropTypes.bool,
};
export default UserInfoTooltip;
@@ -6,8 +6,6 @@ export const FETCH_USERS_FAILURE = `${prefix}_FETCH_USERS_FAILURE`;
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 FETCH_FLAGGED_COMMENTERS_REQUEST = `${prefix}_FETCH_FLAGGED_COMMENTERS_REQUEST`;
export const FETCH_FLAGGED_COMMENTERS_SUCCESS = `${prefix}_FETCH_FLAGGED_COMMENTERS_SUCCESS`;
@@ -1,9 +1,10 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import BanUserDialog from '../components/BanUserDialog';
import {hideBanUserDialog} from '../actions/banUserDialog';
import {withSetUserStatus, withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {withBanUser, withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {compose} from 'react-apollo';
import t from 'coral-framework/services/i18n';
import {getErrorMessages} from 'coral-framework/utils';
@@ -12,9 +13,9 @@ import {notify} from 'coral-framework/actions/notification';
class BanUserDialogContainer extends Component {
banUser = async () => {
const {userId, commentId, commentStatus, setUserStatus, setCommentStatus, hideBanUserDialog, notify} = this.props;
const {userId, commentId, commentStatus, banUser, setCommentStatus, hideBanUserDialog, notify} = this.props;
try {
await setUserStatus({userId, status: 'BANNED'});
await banUser({id: userId, message: ''});
hideBanUserDialog();
if (commentId && commentStatus && commentStatus !== 'REJECTED') {
await setCommentStatus({commentId, status: 'REJECTED'});
@@ -46,6 +47,14 @@ class BanUserDialogContainer extends Component {
}
}
BanUserDialogContainer.propTypes = {
banUser: PropTypes.func.isRequired,
hideBanUserDialog: PropTypes.func,
open: PropTypes.bool,
username: PropTypes.string,
commentStatus: PropTypes.string,
};
const mapStateToProps = ({banUserDialog: {open, userId, username, commentId, commentStatus}}) => ({
open,
userId,
@@ -62,7 +71,7 @@ const mapDispatchToProps = (dispatch) => ({
});
export default compose(
withSetUserStatus,
withBanUser,
withSetCommentStatus,
connect(
mapStateToProps,
@@ -25,7 +25,7 @@ export default withFragments({
}
user {
id
roles
role
}
}
${getSlotFragmentSpreads(slots, 'comment')}
+5 -1
View File
@@ -14,7 +14,11 @@ export default withQuery(gql`
})
flaggedUsernamesCount: userCount(query: {
action_type: FLAG,
statuses: [PENDING]
state: {
status: {
username: [SET, CHANGED]
}
}
})
}
`, {
@@ -1,4 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import {compose, gql} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
@@ -13,10 +14,12 @@ import {
toggleSelectCommentInUserDetail,
toggleSelectAllCommentInUserDetail
} from 'coral-admin/src/actions/userDetail';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {withSetCommentStatus, withUnBanUser, withUnSuspendUser} from 'coral-framework/graphql/mutations';
import UserDetailComment from './UserDetailComment';
import update from 'immutability-helper';
import {notify} from 'coral-framework/actions/notification';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
const commentConnectionFragment = gql`
fragment CoralAdmin_UserDetail_CommentConnection on CommentConnection {
@@ -125,6 +128,19 @@ class UserDetailContainer extends React.Component {
}
}
UserDetailContainer.propTypes = {
changeUserDetailStatuses: PropTypes.func,
toggleSelectCommentInUserDetail: PropTypes.func,
toggleSelectAllCommentInUserDetail: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object,
setCommentStatus: PropTypes.func,
clearUserDetailSelections: PropTypes.func,
selectedCommentIds: PropTypes.array,
unBanUser: PropTypes.func.isRequired,
unSuspendUser: PropTypes.func.isRequired,
};
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}) {
@@ -147,8 +163,45 @@ export const withUserDetailQuery = withQuery(gql`
reliable {
flagger
}
state {
status {
suspension {
until
history {
until
created_at
assigned_by {
username
}
}
}
banned {
status
history {
status
assigned_by {
username
}
created_at
}
}
username {
status
history {
status
assigned_by {
username
}
created_at
}
}
}
}
${getSlotFragmentSpreads(slots, 'user')}
}
me {
id
}
totalComments: commentCount(query: {author_id: $author_id, statuses: []})
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
comments: comments(query: {
@@ -181,6 +234,8 @@ const mapStateToProps = (state) => ({
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
showBanUserDialog,
showSuspendUserDialog,
changeUserDetailStatuses,
clearUserDetailSelections,
toggleSelectCommentInUserDetail,
@@ -195,4 +250,6 @@ export default compose(
connect(mapStateToProps, mapDispatchToProps),
withUserDetailQuery,
withSetCommentStatus,
withUnBanUser,
withUnSuspendUser,
)(UserDetailContainer);
+128 -7
View File
@@ -1,35 +1,157 @@
import update from 'immutability-helper';
import {mapLeaves} from 'coral-framework/utils';
import {gql} from 'react-apollo';
const userStatusFragment = gql`
fragment Talk_UpdateUserStatus on User {
state {
status {
banned {
status
}
suspension {
until
}
}
}
}`;
export default {
mutations: {
SetUserStatus: ({variables: {status, userId}}) => ({
SetUserRole: ({variables: {id: userId, role}}) => ({
updateQueries: {
TalkAdmin_Community: (prev) => {
if (status !== 'APPROVED') {
const updated = update(prev, {
users: {
nodes: {
$apply: (nodes) => nodes.map((node) => {
if (node.id === userId) {
node.role = role;
}
return node;
})
},
},
});
return updated;
}
}
}),
SuspendUser: ({variables: {input: {id, until}}}) => ({
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId});
const updated = update(data, {
state : {
status: {
suspension: {
until: {$set: until}
}
}
}
});
proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated});
}
}),
UnSuspendUser: ({variables: {input: {id}}}) => ({
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId});
const updated = update(data, {
state : {
status: {
suspension: {
until: {$set: null}
}
}
}
});
proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated});
},
}),
BanUser: ({variables: {input: {id}}}) => ({
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId});
const updated = update(data, {
state : {
status: {
banned: {
status: {$set: true}
}
}
}
});
proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated});
}
}),
UnBanUser: ({variables: {input: {id}}}) => ({
update: (proxy) => {
const fragmentId = `User_${id}`;
const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId});
const updated = update(data, {
state : {
status: {
banned: {
status: {$set: false}
}
}
}
});
proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated});
}
}),
SetUserBanStatus: ({variables: {status, id}}) => ({
updateQueries: {
TalkAdmin_Community: (prev) => {
if (!status) {
return prev;
}
const updated = update(prev, {
users: {
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== userId)},
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== id)},
},
});
return updated;
}
}
}),
RejectUsername: ({variables: {input: {id: userId}}}) => ({
ApproveUsername: ({variables: {id}}) => ({
updateQueries: {
TalkAdmin_Community: (prev) => {
const updated = update(prev, {
users: {
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== userId)},
flaggedUsers: {
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== id)},
},
});
return updated;
}
}
}),
RejectUsername: ({variables: {id: userId}}) => ({
updateQueries: {
TalkAdmin_Community: (prev) => {
const updated = update(prev, {
flaggedUsers: {
nodes: {$apply: (nodes) => nodes.filter((node) => node.id !== userId)},
},
});
return updated;
}
},
}),
UpdateSettings: ({variables: {input}}) => ({
updateQueries: {
TalkAdmin_Configure: (prev) => {
@@ -42,4 +164,3 @@ export default {
}),
},
};
@@ -5,8 +5,6 @@ import {
SORT_UPDATE,
SET_PAGE,
SET_SEARCH_VALUE,
SET_ROLE,
SET_COMMENTER_STATUS,
SHOW_BANUSER_DIALOG,
HIDE_BANUSER_DIALOG,
SHOW_REJECT_USERNAME_DIALOG,
@@ -60,27 +58,6 @@ export default function community (state = initialState, action) {
...state,
pagePeople: action.page,
};
case SET_ROLE : {
const commenters = state.users;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].roles[0] = action.role;
return {
...state,
users: commenters.map((id) => id),
};
}
case SET_COMMENTER_STATUS: {
const commenters = state.users;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].status = action.status;
return {
...state,
users: commenters.map((id) => id),
};
}
case SORT_UPDATE :
return {
...state,
@@ -12,7 +12,11 @@ class Community extends Component {
const activeTab = route.path === ':id' ? 'flagged' : route.path;
if (activeTab === 'people') {
return <People community={community} />;
return <People
community={community}
data={this.props.data}
root={this.props.root}
/>;
}
return (
@@ -13,8 +13,6 @@ class FlaggedAccounts extends React.Component {
const {
users,
loadMore,
showBanUserDialog,
showSuspendUserDialog,
showRejectUsernameDialog,
approveUser,
me,
@@ -48,8 +46,6 @@ class FlaggedAccounts extends React.Component {
<FlaggedUser
user={user}
key={user.id}
showBanUserDialog={showBanUserDialog}
showSuspendUserDialog={showSuspendUserDialog}
showRejectUsernameDialog={showRejectUsernameDialog}
approveUser={approveUser}
me={me}
@@ -74,8 +70,6 @@ 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,
@@ -4,8 +4,6 @@ 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';
@@ -19,18 +17,10 @@ const shortReasons = {
class User extends React.Component {
showSuspenUserDialog = () => this.props.showSuspendUserDialog({
userId: this.props.user.id,
username: this.props.user.username,
});
showBanUserDialog = () => this.props.showBanUserDialog({
userId: this.props.user.id,
username: this.props.user.username,
});
viewAuthorDetail = () => this.props.viewUserDetail(this.props.user.id);
showRejectUsernameDialog = () => this.props.showRejectUsernameDialog({id: this.props.user.id});
approveUser = () => this.props.approveUser({
userId: this.props.user.id,
});
@@ -40,7 +30,6 @@ class User extends React.Component {
user,
viewUserDetail,
selected,
me,
className,
} = this.props;
@@ -55,20 +44,6 @@ class User extends React.Component {
className={styles.button}>
{user.username}
</button>
{me.id !== user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={user.status === 'BANNED'}
onClick={this.showSuspenUserDialog}>
Suspend User
</ActionsMenuItem>
<ActionsMenuItem
disabled={user.status === 'BANNED'}
onClick={this.showBanUserDialog}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
</div>
</div>
<div className={cn('talk-admin-community-flagged-user-body', styles.body)}>
@@ -136,8 +111,6 @@ class User extends React.Component {
}
User.propTypes = {
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
viewUserDetail: PropTypes.func,
showRejectUsernameDialog: PropTypes.func,
approveUser: PropTypes.func,
@@ -1,75 +1,190 @@
import React from 'react';
import cn from 'classnames';
import styles from './styles.css';
import Table from './Table';
import {Icon} from 'coral-ui';
import {Icon, Dropdown, Option} from 'coral-ui';
import EmptyCard from '../../../components/EmptyCard';
import t from 'coral-framework/services/i18n';
import LoadMore from '../../../components/LoadMore';
import PropTypes from 'prop-types';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import {isSuspended, isBanned} from 'coral-framework/utils/user';
import moment from 'moment';
const People = (props) => {
const {
users = [],
searchValue,
onSearchChange,
onHeaderClickHandler,
onPageChange,
totalPages,
page,
setRole,
setCommenterStatus,
viewUserDetail,
} = props;
const headers = [
{
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 hasResults = !!users.length;
class People extends React.Component {
return (
<div className={cn(styles.container, 'talk-admin-community-people-container')}>
<div className={styles.leftColumn}>
<div className={styles.searchBox}>
<Icon name='search' className={styles.searchIcon}/>
<input
id="commenters-search"
type="text"
className={styles.searchBoxInput}
value={searchValue}
onChange={onSearchChange}
placeholder={t('streams.search')}
/>
getActionMenuLabel = (user) => {
if (isBanned(user)) {
return 'Banned';
} else if (isSuspended(user)) {
return 'Suspended';
}
return '';
};
unSuspendUser = (input) => {
this.props.unSuspendUser(input);
}
unBanUser = (input) => {
this.props.unBanUser(input);
}
showBanUserDialog = (input) => {
this.props.showBanUserDialog(input);
}
showSuspendUserDialog = (input) => {
this.props.showSuspendUserDialog(input);
}
render () {
const {
onSearchChange,
users = [],
setUserRole,
viewUserDetail,
loadMore,
} = this.props;
const hasResults = !!users.nodes.length;
return (
<div className={cn(styles.container, 'talk-admin-community-people-container')}>
<div className={styles.leftColumn}>
<div className={styles.searchBox}>
<Icon name='search' className={styles.searchIcon}/>
<input
id="commenters-search"
type="text"
className={styles.searchBoxInput}
defaultValue=''
onChange={onSearchChange}
placeholder={t('streams.search')}
/>
</div>
</div>
<div className={styles.mainContent}>
{
hasResults
? <div>
<div>
<table className={`mdl-data-table ${styles.dataTable}`}>
<thead>
<tr>
{headers.map((header, i) =>(
<th
key={i}
className={cn('mdl-data-table__cell--non-numeric', styles.header)}
scope="col" >
{header.title}
</th>
))}
</tr>
</thead>
<tbody>
{users.nodes.map((user, i)=> (
<tr key={i} className="talk-admin-community-people-row">
<td className="mdl-data-table__cell--non-numeric">
<button onClick={() => {viewUserDetail(user.id);}} className={cn(styles.username, styles.button)}>{user.username}</button>
<span className={styles.email}>{user.profiles.map(({id}) => id)}</span>
</td>
<td className="mdl-data-table__cell--non-numeric">
{moment(new Date(user.created_at)).format('MMMM Do YYYY, h:mm:ss a')}
</td>
<td className="mdl-data-table__cell--non-numeric">
<ActionsMenu
icon="person"
className={cn(styles.actionsMenu, 'talk-admin-community-people-dd-status')}
buttonClassNames={cn(styles.actionsMenuButton, {
[styles.actionsMenuSuspended]: isSuspended(user),
[styles.actionsMenuBanned]: isBanned(user),
}, 'talk-admin-user-detail-actions-button')}
label={this.getActionMenuLabel(user)} >
{isSuspended(user) ? <ActionsMenuItem
onClick={() => this.unSuspendUser({id: user.id})}>
Remove Suspension
</ActionsMenuItem> : <ActionsMenuItem
onClick={() => this.showSuspendUserDialog({
userId: user.id,
username: user.username,
})}>
Suspend User
</ActionsMenuItem>}
{isBanned(user) ? <ActionsMenuItem
onClick={() => this.unBanUser({id: user.id})}>
Remove Ban
</ActionsMenuItem> : <ActionsMenuItem
onClick={() => this.showBanUserDialog({
userId: user.id,
username: user.username,
})}>
Ban User
</ActionsMenuItem>}
</ActionsMenu>
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-role"
value={user.role}
placeholder={t('community.role')}
onChange={(role) => setUserRole(user.id, role)}>
<Option value={'COMMENTER'} label={t('community.commenter')} />
<Option value={'STAFF'} label={t('community.staff')} />
<Option value={'MODERATOR'} label={t('community.moderator')} />
<Option value={'ADMIN'} label={t('community.admin')} />
</Dropdown>
</td>
</tr>
))}
</tbody>
</table>
</div>
<LoadMore
loadMore={loadMore}
showLoadMore={users.hasNextPage}
/>
</div>
: <EmptyCard>{t('community.no_results')}</EmptyCard>
}
</div>
</div>
<div className={styles.mainContent}>
{
hasResults
? <Table
users={users}
setRole={setRole}
viewUserDetail={viewUserDetail}
setCommenterStatus={setCommenterStatus}
onHeaderClickHandler={onHeaderClickHandler}
pageCount={totalPages}
onPageChange={onPageChange}
page={page}
/>
: <EmptyCard>{t('community.no_results')}</EmptyCard>
}
</div>
</div>
);
};
);
}
}
People.propTypes = {
onHeaderClickHandler: PropTypes.func,
users: PropTypes.array,
page: PropTypes.number.isRequired,
searchValue: PropTypes.string,
onSearchChange: PropTypes.func,
totalPages: PropTypes.number,
onPageChange: PropTypes.func,
setCommenterStatus: PropTypes.func.isRequired,
setRole: PropTypes.func.isRequired,
users: PropTypes.object.isRequired,
onSearchChange: PropTypes.func.isRequired,
setUserRole: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
unBanUser: PropTypes.func.isRequired,
unSuspendUser: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
loadMore: PropTypes.func.isRequired,
};
export default People;
@@ -2,7 +2,7 @@
.modalButtons {
display: flex;
justify-content: flex-end;
margin-top: 50px;
margin-top: 20px;
}
.emailInput {
@@ -45,7 +45,7 @@ class RejectUsernameDialog extends Component {
const next = () => this.setState({stage: stage + 1});
const suspend = async () => {
try {
await rejectUsername({id: user.id, message: this.state.email});
await rejectUsername(user.id);
this.props.handleClose();
} catch (err) {
@@ -70,7 +70,7 @@ class RejectUsernameDialog extends Component {
const {stage} = this.state;
return <Dialog
className={cn(styles.suspendDialog, 'talk-reject-username-dialog')}
className={cn(styles.suspendDialog, 'talk-admin-reject-username-dialog')}
id="rejectUsernameDialog"
open={open}
onClose={handleClose}
@@ -79,28 +79,28 @@ class RejectUsernameDialog extends Component {
<div className={styles.title}>
{t(stages[stage].title, t('reject_username.username'))}
</div>
<div className={styles.container}>
<div className={cn(styles.container, `talk-admin-reject-username-dialog-step-${stage}`)}>
<div className={styles.description}>
{t(stages[stage].description, t('reject_username.username'))}
</div>
{
{/* {
stage === 1 &&
<div className={styles.writeContainer}>
<div className={styles.emailMessage}>{t('reject_username.write_message')}</div>
<div className={styles.emailContainer}>
<textarea
rows={5}
className={cn(styles.emailInput, 'talk-reject-username-dialog-suspension-message')}
className={cn(styles.emailInput, 'talk-admin-reject-username-dialog-suspension-message')}
value={this.state.email}
onChange={this.onEmailChange}/>
</div>
</div>
}
<div className={cn(styles.modalButtons, 'talk-reject-username-dialog-buttons')}>
} */}
<div className={cn(styles.modalButtons, 'talk-admin-reject-username-dialog-buttons')}>
{Object.keys(stages[stage].options).map((key, i) => (
<Button
key={i}
className={cn('talk-reject-username-dialog-button', `talk-reject-username-dialog-button-${key}`)}
className={cn('talk-admin-username-dialog-button', `talk-admin-reject-username-dialog-button-${key}`)}
onClick={this.onActionClick(stage, i)} >
{t(stages[stage].options[key], t('reject_username.username'))}
</Button>
@@ -1,69 +0,0 @@
.dataTable {
width: 100%;
border-left: none;
border-right: none;
}
th.header {
font-size: 1.1em;
}
th.header:hover {
cursor: pointer;
}
th.header:nth-child(2), th.header:nth-child(3) {
width: 100px;
}
.button {
composes: buttonReset from 'coral-framework/styles/reset.css';
&:hover {
background-color: #D0D0D0;
}
}
.username, .email {
max-width: 215px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email {
display: block;
}
.selectField {
position: relative;
width: 150px;
height: 36px;
background: #2c2c2c;
padding: 10px 15px;
box-sizing: border-box;
color: white;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
> div {
padding: 0;
}
i {
position: absolute;
top: 7px;
right: 7px;
}
input {
padding: 0;
font-size: 13px;
letter-spacing: 0.7px;
font-weight: 400;
}
&:hover {
cursor: pointer;
}
}
@@ -1,98 +0,0 @@
import React from 'react';
import styles from './Table.css';
import t from 'coral-framework/services/i18n';
import PropTypes from 'prop-types';
import {Paginate, Dropdown, Option} from 'coral-ui';
import cn from 'classnames';
const headers = [
{
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 Table = ({users, setRole, onHeaderClickHandler, setCommenterStatus, viewUserDetail, pageCount, page, onPageChange}) => (
<div>
<table className={`mdl-data-table ${styles.dataTable}`}>
<thead>
<tr>
{headers.map((header, i) =>(
<th
key={i}
className={cn('mdl-data-table__cell--non-numeric', styles.header)}
scope="col"
onClick={() => onHeaderClickHandler({field: header.field})}>
{header.title}
</th>
))}
</tr>
</thead>
<tbody>
{users.map((row, i)=> (
<tr key={i} className="talk-admin-community-people-row">
<td className="mdl-data-table__cell--non-numeric">
<button onClick={() => {viewUserDetail(row.id);}} className={cn(styles.username, styles.button)}>{row.username}</button>
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
</td>
<td className="mdl-data-table__cell--non-numeric">
{row.created_at}
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-status"
value={row.status}
placeholder={t('community.status')}
onChange={(status) => setCommenterStatus(row.id, status)}>
<Option value={'ACTIVE'} label={t('community.active')} />
<Option value={'BANNED'} label={t('community.banned')} />
</Dropdown>
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-role"
value={row.roles[0] || ''}
placeholder={t('community.role')}
onChange={(role) => setRole(row.id, role)}>
<Option value={''} label={t('community.none')} />
<Option value={'STAFF'} label={t('community.staff')} />
<Option value={'MODERATOR'} label={t('community.moderator')} />
<Option value={'ADMIN'} label={t('community.admin')} />
</Dropdown>
</td>
</tr>
))}
</tbody>
</table>
<Paginate
pageCount={pageCount}
page={page - 1}
onPageChange={onPageChange}
/>
</div>
);
Table.propTypes = {
users: PropTypes.array,
onHeaderClickHandler: PropTypes.func.isRequired,
setRole: PropTypes.func.isRequired,
setCommenterStatus: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
pageCount: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
export default Table;
@@ -240,3 +240,97 @@
}
}
/* ========================================================================== */
/* TABLE */
/* ========================================================================== */
.dataTable {
width: 100%;
border-left: none;
border-right: none;
}
th.header {
font-size: 1.1em;
}
th.header:hover {
cursor: pointer;
}
th.header:nth-child(2), th.header:nth-child(3) {
width: 100px;
}
.button {
composes: buttonReset from 'coral-framework/styles/reset.css';
&:hover {
background-color: #D0D0D0;
}
}
.username, .email {
max-width: 215px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email {
display: block;
}
.selectField {
position: relative;
width: 150px;
height: 36px;
background: #2c2c2c;
padding: 10px 15px;
box-sizing: border-box;
color: white;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
> div {
padding: 0;
}
i {
position: absolute;
top: 7px;
right: 7px;
}
input {
padding: 0;
font-size: 13px;
letter-spacing: 0.7px;
font-weight: 400;
}
&:hover {
cursor: pointer;
}
}
.actionsMenu {
font-size: 1em;
}
.actionsMenu .actionsMenuButton {
-webkit-transform: scale(1);
transform: scale(1);
font-size: 0.98em;
}
.actionsMenuSuspended {
background-color: #F29336;
border-color: #F29336;
color: white;
}
.actionsMenuBanned {
background-color: #E45241;
border-color: #E45241;
color: white;
}
@@ -3,9 +3,10 @@ import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
import {getDefinitionName} from 'coral-framework/utils';
import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations';
import {withRejectUsername} from 'coral-framework/graphql/mutations';
import FlaggedAccounts from '../containers/FlaggedAccounts';
import FlaggedUser from '../containers/FlaggedUser';
import People from '../containers/People';
import {hideRejectUsernameDialog} from '../../../actions/community';
import Community from '../components/Community';
@@ -20,17 +21,25 @@ const mapDispatchToProps = (dispatch) =>
const withData = withQuery(gql`
query TalkAdmin_Community {
flaggedUsernamesCount: userCount(query: {
action_type: FLAG,
statuses: [PENDING]
})
flaggedUsernamesCount: userCount(
query:{
action_type: FLAG,
state: {
status: {
username: [SET, CHANGED]
}
}
}
)
...${getDefinitionName(FlaggedAccounts.fragments.root)}
...${getDefinitionName(FlaggedUser.fragments.root)}
...${getDefinitionName(People.fragments.root)}
me {
...${getDefinitionName(FlaggedUser.fragments.me)}
__typename
}
}
${People.fragments.root}
${FlaggedAccounts.fragments.root}
${FlaggedUser.fragments.root}
${FlaggedUser.fragments.me}
@@ -42,7 +51,6 @@ const withData = withQuery(gql`
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withSetUserStatus,
withRejectUsername,
withData
)(Community);
@@ -5,10 +5,7 @@ import {compose, gql} from 'react-apollo';
import {withFragments} from 'plugin-api/beta/client/hocs';
import {Spinner} from 'coral-ui';
import PropTypes from 'prop-types';
import {withSetUserStatus} from 'coral-framework/graphql/mutations';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {withApproveUsername} from 'coral-framework/graphql/mutations';
import {showRejectUsernameDialog} from '../../../actions/community';
import {viewUserDetail} from '../../../actions/userDetail';
import {getDefinitionName} from 'coral-framework/utils';
@@ -24,11 +21,8 @@ class FlaggedAccountsContainer extends Component {
super(props);
}
approveUser = ({userId}) => {
return this.props.setUserStatus({
userId,
status: 'APPROVED'
});
approveUser = ({userId: id}) => {
return this.props.approveUsername(id);
}
loadMore = () => {
@@ -40,7 +34,7 @@ class FlaggedAccountsContainer extends Component {
},
updateQuery: (previous, {fetchMoreResult:{users}}) => {
const updated = update(previous, {
users: {
flaggedUsers: {
nodes: {
$apply: (nodes) => appendNewNodes(nodes, users.nodes),
},
@@ -63,15 +57,13 @@ class FlaggedAccountsContainer extends Component {
}
return (
<FlaggedAccounts
showBanUserDialog={this.props.showBanUserDialog}
showSuspendUserDialog={this.props.showSuspendUserDialog}
showRejectUsernameDialog={this.props.showRejectUsernameDialog}
viewUserDetail={this.props.viewUserDetail}
approveUser={this.approveUser}
loadMore={this.loadMore}
data={this.props.data}
root={this.props.root}
users={this.props.root.users}
users={this.props.root.flaggedUsers}
me={this.props.root.me}
/>
);
@@ -79,18 +71,25 @@ class FlaggedAccountsContainer extends Component {
}
FlaggedAccountsContainer.propTypes = {
showBanUserDialog: PropTypes.func,
showSuspendUserDialog: PropTypes.func,
showRejectUsernameDialog: PropTypes.func,
viewUserDetail: PropTypes.func,
setUserStatus: PropTypes.func,
approveUsername: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object
root: PropTypes.object,
};
const LOAD_MORE_QUERY = gql`
query TalkAdmin_LoadMoreFlaggedAccounts($limit: Int, $cursor: Cursor) {
users(query:{action_type: FLAG, statuses: [PENDING], limit: $limit, cursor: $cursor}){
flaggedUsers: users(query:{
action_type: FLAG,
state: {
status: {
username: [PENDING]
}
},
limit: $limit,
cursor: $cursor
}){
hasNextPage
endCursor
nodes {
@@ -104,19 +103,25 @@ const LOAD_MORE_QUERY = gql`
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
showBanUserDialog,
showSuspendUserDialog,
showRejectUsernameDialog,
viewUserDetail,
}, dispatch);
export default compose(
connect(null, mapDispatchToProps),
withSetUserStatus,
withApproveUsername,
withFragments({
root: gql`
fragment TalkAdminCommunity_FlaggedAccounts_root on RootQuery {
users(query:{action_type: FLAG, statuses: [PENDING], limit: 10}){
flaggedUsers: users(query:{
action_type: FLAG,
state: {
status: {
username: [SET, CHANGED]
}
}
limit: 10
}){
hasNextPage
endCursor
nodes {
@@ -17,8 +17,20 @@ export default withFragments({
fragment TalkAdminCommunity_FlaggedUser_user on User {
id
username
status
roles
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
role
actions{
id
created_at
@@ -1,104 +1,164 @@
import React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import People from '../components/People';
import PropTypes from 'prop-types';
import {withFragments} from 'plugin-api/beta/client/hocs';
import {withUnBanUser, withUnSuspendUser, withSetUserRole} from 'coral-framework/graphql/mutations';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {viewUserDetail} from '../../../actions/userDetail';
import {
fetchUsers,
updateSorting,
setPage,
hideRejectUsernameDialog,
setCommenterStatus,
setRole,
setSearchValue,
} from '../../../actions/community';
import {appendNewNodes} from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import {Spinner} from 'coral-ui';
class PeopleContainer extends React.Component {
timer=null;
fetchUsers = (query = {}) => {
const {community} = this.props;
this.props.fetchUsers({
value: community.searchValue,
field: community.fieldPeople,
asc: community.ascPeople,
...query
});
}
componentWillMount() {
this.fetchUsers();
}
timer = null;
onKeyDownHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.fetchUsers();
// this.fetchUsers();
}
}
onSearchChange = (e) => {
const value = e.target.value;
console.log(value);
this.props.setSearchValue(value);
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.fetchUsers({value});
}, 350);
// clearTimeout(this.timer);
// this.timer = setTimeout(() => {
// // this.fetchUsers({value});
// }, 350);
}
onHeaderClickHandler = (sort) => {
this.props.updateSorting(sort);
this.fetchUsers();
setUserRole = async (id, role) => {
await this.props.setUserRole(id, role);
}
onPageChange = ({selected}) => {
const page = selected + 1;
this.props.setPage(page);
this.fetchUsers({page});
}
loadMore = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
cursor: this.props.root.users.endCursor,
},
updateQuery: (previous, {fetchMoreResult:{users}}) => {
const updated = update(previous, {
flaggedUsers: {
nodes: {
$apply: (nodes) => appendNewNodes(nodes, users.nodes),
},
hasNextPage: {$set: users.hasNextPage},
endCursor: {$set: users.endCursor},
},
});
return updated;
},
});
};
render() {
if (this.props.data.error) {
return <div>{this.props.data.error.message}</div>;
}
if (this.props.data.loading) {
return <div><Spinner/></div>;
}
return <People
users={this.props.community.users}
searchValue={this.props.community.searchValue}
onSearchChange={this.onSearchChange}
onHeaderClickHandler={this.onHeaderClickHandler}
onPageChange={this.onPageChange}
totalPages={this.props.community.totalPagesPeople}
setCommenterStatus={this.props.setCommenterStatus}
setRole={this.props.setRole}
page={this.props.community.pagePeople}
onSearchChange={this.onSearchChange}
viewUserDetail={this.props.viewUserDetail}
setUserRole={this.setUserRole}
showSuspendUserDialog={this.props.showSuspendUserDialog}
showBanUserDialog={this.props.showBanUserDialog}
unBanUser={this.props.unBanUser}
unSuspendUser={this.props.unSuspendUser}
data={this.props.data}
root={this.props.root}
users={this.props.root.users}
loadMore={this.loadMore}
/>;
}
}
PeopleContainer.propTypes = {
setPage: PropTypes.func,
fetchUsers: PropTypes.func,
updateSorting: PropTypes.func,
setRole: PropTypes.func.isRequired,
setCommenterStatus: PropTypes.func.isRequired,
setSearchValue: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
community: PropTypes.object,
setUserRole: PropTypes.func.isRequired,
unBanUser: PropTypes.func.isRequired,
unSuspendUser: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object,
};
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
setPage,
fetchUsers,
updateSorting,
hideRejectUsernameDialog,
setCommenterStatus,
setRole,
viewUserDetail,
setSearchValue,
showSuspendUserDialog,
showBanUserDialog,
}, dispatch);
export default connect(null, mapDispatchToProps)(PeopleContainer);
const LOAD_MORE_QUERY = gql`
query TalkAdminCommunity_People_LoadMoreUsers($limit: Int, $cursor: Cursor) {
users(query: {}){
hasNextPage
endCursor
nodes {
__typename
id
username
created_at
profiles {
id
provider
}
}
}
}
`;
export default compose(
connect(null, mapDispatchToProps),
withSetUserRole,
withUnSuspendUser,
withUnBanUser,
withFragments({
root: gql`
fragment TalkAdminCommunity_People_root on RootQuery {
users(query: {}){
hasNextPage
endCursor
nodes {
__typename
id
username
role
created_at
profiles {
id
provider
}
state {
status {
banned {
status
}
suspension {
until
}
}
}
}
}
}
`,
}),
)(PeopleContainer);
@@ -5,6 +5,7 @@ import styles from './EmbedLink.css';
import {Button} from 'coral-ui';
import {BASE_URL} from 'coral-framework/constants/url';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
import {stripIndent} from 'common-tags';
class EmbedLink extends Component {
@@ -23,17 +24,16 @@ class EmbedLink extends Component {
}
render () {
const coralJsUrl = join(BASE_URL, '/embed.js');
const nonce = String(Math.random()).slice(2);
const streamElementId = `coral_talk_${nonce}`;
const embedText = `
<div id="${streamElementId}"></div>
<script src="${coralJsUrl}" async onload="
Coral.Talk.render(document.getElementById('${streamElementId}'), {
talk: '${BASE_URL}'
});
"></script>
`.trim();
const coralJsUrl = join(BASE_URL, '/static/embed.js');
const embedText = stripIndent`
<div id="coral_talk_stream"></div>
<script src="${coralJsUrl}" async onload="
Coral.Talk.render(document.getElementById('coral_talk_stream'), {
talk: '${BASE_URL}'
});
"></script>
`;
return (
<ConfigureCard title={'Embed Comment Stream'}>
<p>{t('configure.copy_and_paste')}</p>
@@ -8,8 +8,6 @@ import styles from './Comment.css';
import CommentLabels from 'coral-admin/src/components/CommentLabels';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import Slot from 'coral-framework/components/Slot';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
@@ -19,10 +17,9 @@ import RejectButton from 'coral-admin/src/components/RejectButton';
import t, {timeago} from 'coral-framework/services/i18n';
class Comment extends React.Component {
ref = null;
handleRef = (ref) => this.ref = ref;
handleRef = (ref) => (this.ref = ref);
handleFocusOrClick = () => {
if (!this.props.selected) {
@@ -30,40 +27,20 @@ class Comment extends React.Component {
}
};
showSuspendUserDialog = () => {
const {comment, showSuspendUserDialog} = this.props;
return showSuspendUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
};
showBanUserDialog = () => {
const {comment, showBanUserDialog} = this.props;
return showBanUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
};
viewUserDetail = () => {
const {viewUserDetail, comment} = this.props;
return viewUserDetail(comment.user.id);
};
approve = () => (this.props.comment.status === 'ACCEPTED'
? null
: this.props.acceptComment({commentId: this.props.comment.id})
);
approve = () =>
this.props.comment.status === 'ACCEPTED'
? null
: this.props.acceptComment({commentId: this.props.comment.id});
reject = () => (this.props.comment.status === 'REJECTED'
? null
: this.props.rejectComment({commentId: this.props.comment.id})
);
reject = () =>
this.props.comment.status === 'REJECTED'
? null
: this.props.rejectComment({commentId: this.props.comment.id});
componentDidUpdate(prev) {
if (!prev.selected && this.props.selected) {
@@ -79,7 +56,6 @@ class Comment extends React.Component {
data,
root,
root: {settings},
currentUserId,
currentAsset,
clearHeightCache,
dangling,
@@ -91,7 +67,14 @@ class Comment extends React.Component {
return (
<li
tabIndex={0}
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected, [styles.dangling]: dangling}, 'talk-admin-moderate-comment')}
className={cn(
className,
'mdl-card',
selectionStateCSS,
styles.root,
{[styles.selected]: selected, [styles.dangling]: dangling},
'talk-admin-moderate-comment'
)}
id={`comment_${comment.id}`}
onClick={this.handleFocusOrClick}
ref={this.handleRef}
@@ -100,38 +83,28 @@ class Comment extends React.Component {
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
{
(
<span className={styles.username} onClick={this.viewUserDetail}>
{comment.user.username}
</span>
)
}
<span
className={cn(
styles.username,
'talk-admin-moderate-comment-username'
)}
onClick={this.viewUserDetail}
>
{comment.user.username}
</span>
<span className={styles.created}>
{timeago(comment.created_at)}
</span>
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
{currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested" className="talk-admin-moderate-comment-actions-menu">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={this.showSuspendUserDialog}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={this.showBanUserDialog}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
{comment.editing && comment.editing.edited ? (
<span>
&nbsp;<span className={styles.editedMarker}>
({t('comment.edited')})
</span>
</span>
) : null}
<div className={styles.adminCommentInfoBar}>
<CommentLabels
comment={comment}
/>
<CommentLabels comment={comment} />
<Slot
fill="adminCommentInfoBar"
data={data}
@@ -144,8 +117,11 @@ class Comment extends React.Component {
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
{!currentAsset && (
<Link to={`/admin/moderate/${comment.asset.id}`}>
{t('modqueue.moderate')}
</Link>
)}
</div>
<CommentAnimatedEdit body={comment.body}>
<div className={styles.itemBody}>
@@ -154,8 +130,7 @@ class Comment extends React.Component {
suspectWords={settings.wordlist.suspect}
bannedWords={settings.wordlist.banned}
body={comment.body}
/>
{' '}
/>{' '}
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
@@ -216,8 +191,6 @@ Comment.propTypes = {
className: PropTypes.string,
dangling: PropTypes.bool,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
currentUserId: PropTypes.string.isRequired,
clearHeightCache: PropTypes.func,
comment: PropTypes.shape({
@@ -229,12 +202,12 @@ Comment.propTypes = {
created_at: PropTypes.string.isRequired,
user: PropTypes.shape({
id: PropTypes.string,
status: PropTypes.string
status: PropTypes.string,
}).isRequired,
asset: PropTypes.shape({
title: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string
id: PropTypes.string,
}),
}),
data: PropTypes.object.isRequired,
@@ -12,7 +12,6 @@ import Slot from 'coral-framework/components/Slot';
import ViewOptions from './ViewOptions';
class Moderation extends Component {
state = {
isLoadingMore: false,
};
@@ -27,13 +26,14 @@ class Moderation extends Component {
key('t', () => this.nextQueue());
key('f', () => this.moderate(false));
key('d', () => this.moderate(true));
this.getMenuItems()
.forEach((menuItem, idx) => key(`${idx + 1}`, () => this.selectQueue(menuItem)));
this.getMenuItems().forEach((menuItem, idx) =>
key(`${idx + 1}`, () => this.selectQueue(menuItem))
);
}
onClose = () => {
this.props.toggleModal(false);
}
};
nextQueue = () => {
const activeTab = this.props.activeTab;
@@ -41,38 +41,45 @@ class Moderation extends Component {
const menuItems = this.getMenuItems();
const activeTabIndex = menuItems.findIndex((item) => item === activeTab);
const nextQueueIndex = (activeTabIndex === menuItems.length - 1) ? 0 : activeTabIndex + 1;
const nextQueueIndex =
activeTabIndex === menuItems.length - 1 ? 0 : activeTabIndex + 1;
this.selectQueue(menuItems[nextQueueIndex]);
}
};
selectQueue = (key) => {
const assetId = this.props.data.variables.asset_id;
this.props.router.push(this.props.getModPath(key, assetId));
}
};
getMenuItems = () => Object.keys(this.props.queueConfig);
closeSearch = () => {
const {toggleStorySearch} = this.props;
toggleStorySearch(false);
}
};
openSearch = () => {
this.props.toggleStorySearch(true);
}
};
getActiveTabCount = (props = this.props) => {
return props.root[`${props.activeTab}Count`];
}
};
moderate = (accept) => {
const {acceptComment, rejectComment, moderation: {selectedCommentId}} = this.props;
const {
acceptComment,
rejectComment,
moderation: {selectedCommentId},
} = this.props;
// Accept or reject only if there's a selected comment
if(selectedCommentId != null){
if (selectedCommentId != null) {
const comments = this.getComments();
const commentIdx = comments.findIndex((comment) => comment.id === selectedCommentId);
const commentIdx = comments.findIndex(
(comment) => comment.id === selectedCommentId
);
const comment = comments[commentIdx];
if (accept) {
@@ -81,12 +88,12 @@ class Moderation extends Component {
comment.status !== 'REJECTED' && rejectComment({commentId: comment.id});
}
}
}
};
getComments = (props = this.props) => {
const {root, activeTab} = props;
return root[activeTab].nodes;
}
};
loadMore = async () => {
if (!this.state.isLoadingMore) {
@@ -95,14 +102,13 @@ class Moderation extends Component {
const result = await this.props.loadMore(this.props.activeTab);
this.setState({isLoadingMore: false});
return result;
}
catch (e) {
} catch (e) {
this.setState({isLoadingMore: false});
throw e;
}
}
return false;
}
};
componentWillUnmount() {
key.unbind('s');
@@ -112,12 +118,21 @@ class Moderation extends Component {
key.unbind('t');
key.unbind('f');
key.unbind('d');
this.getMenuItems()
.forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
this.getMenuItems().forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
}
render () {
const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
render() {
const {
root,
data,
moderation,
viewUserDetail,
activeTab,
getModPath,
queueConfig,
handleCommentChange,
...props
} = this.props;
const {asset} = root;
const assetId = asset && asset.id;
@@ -128,7 +143,7 @@ class Moderation extends Component {
key: queue,
name: queueConfig[queue].name,
icon: queueConfig[queue].icon,
count: root[`${queue}Count`]
count: root[`${queue}Count`],
}));
return (
@@ -145,7 +160,9 @@ class Moderation extends Component {
items={menuItems}
activeTab={activeTab}
/>
<div className={cn(styles.container, 'talk-admin-moderation-container')}>
<div
className={cn(styles.container, 'talk-admin-moderation-container')}
>
<ViewOptions
selectSort={this.props.setSortOrder}
sort={this.props.moderation.sortOrder}
@@ -159,9 +176,7 @@ class Moderation extends Component {
hasNextPage={comments.hasNextPage}
activeTab={activeTab}
singleView={moderation.singleView}
selectedCommentId={moderation.selectedCommentId}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
selectedCommentId={this.state.selectedCommentId}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={this.loadMore}
@@ -192,7 +207,7 @@ class Moderation extends Component {
queryData={{root, asset}}
activeTab={activeTab}
handleCommentChange={handleCommentChange}
fill='adminModeration'
fill="adminModeration"
/>
</div>
);
@@ -213,8 +228,6 @@ Moderation.propTypes = {
commentBelongToQueue: PropTypes.func.isRequired,
handleCommentChange: PropTypes.func.isRequired,
setSortOrder: PropTypes.func.isRequired,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
loadMore: PropTypes.func.isRequired,
@@ -7,7 +7,12 @@ import EmptyCard from '../../../components/EmptyCard';
import AutoLoadMore from './AutoLoadMore';
import ViewMore from './ViewMore';
import t from 'coral-framework/services/i18n';
import {WindowScroller, CellMeasurer, CellMeasurerCache, List} from 'react-virtualized';
import {
WindowScroller,
CellMeasurer,
CellMeasurerCache,
List,
} from 'react-virtualized';
import throttle from 'lodash/throttle';
import key from 'keymaster';
@@ -49,7 +54,6 @@ function invalidateCursor(invalidated, state, props) {
// getVisibileComments returns a list containing comments
// which comes after the `idCursor`.
function getVisibleComments(comments, idCursor) {
if (!comments) {
return [];
}
@@ -97,8 +101,7 @@ class ModerationQueue extends React.Component {
const view = this.state.view;
if (index < view.length) {
return view[index].id;
}
else if (index === view.length) {
} else if (index === view.length) {
return 'loadMore';
}
throw new Error(`unknown index ${index}`);
@@ -133,7 +136,10 @@ class ModerationQueue extends React.Component {
const {comments: nextComments} = next;
// New comments where added and our cursor list is incomplete.
if (this.state.idCursors.length < 2 && nextComments.length > this.state.idCursors.length) {
if (
this.state.idCursors.length < 2 &&
nextComments.length > this.state.idCursors.length
) {
this.setState(resetCursors(this.state, next));
return;
}
@@ -142,17 +148,24 @@ class ModerationQueue extends React.Component {
// Comments have been removed.
if (
prevComments && nextComments &&
prevComments &&
nextComments &&
nextComments.length < prevComments.length
) {
// Invalidate first cursor if referenced comment was removed.
if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) {
if (
this.state.idCursors[0] &&
!hasComment(nextComments, this.state.idCursors[0])
) {
idCursors = invalidateCursor(0, this.state, next);
}
// Invalidate second cursor if referenced comment was removed.
if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) {
if (
this.state.idCursors[1] &&
!hasComment(nextComments, this.state.idCursors[1])
) {
idCursors = invalidateCursor(1, this.state, next);
}
@@ -165,10 +178,12 @@ class ModerationQueue extends React.Component {
let nextSelectedCommentId = null;
// Determine a comment to select.
const prevIndex = view.findIndex((comment) => comment.id === this.props.selectedCommentId);
const prevIndex = view.findIndex(
(comment) => comment.id === this.props.selectedCommentId
);
if (prevIndex !== view.length - 1) {
nextSelectedCommentId = view[prevIndex + 1].id;
} else if(prevIndex > 0) {
} else if (prevIndex > 0) {
nextSelectedCommentId = view[prevIndex - 1].id;
}
this.props.selectCommentId(nextSelectedCommentId);
@@ -182,24 +197,29 @@ class ModerationQueue extends React.Component {
// TODO: removing or adding a comment from the list seems to render incorrect, is this a bug?
// Find first changed comment and perform a reflow.
const index = this.state.view.findIndex((comment, i) => !nextView[i] || nextView[i].id !== comment.id);
const index = this.state.view.findIndex(
(comment, i) => !nextView[i] || nextView[i].id !== comment.id
);
this.reflowList(index);
}
}
componentDidUpdate (prev) {
componentDidUpdate(prev) {
const {commentCount, selectedCommentId} = this.props;
// If the user just moderated the last (visible) comment
// AND there are more comments available on the server,
// go ahead and load more comments
if (prev.comments.length > 0 && this.getCommentCountWithoutDagling() === 0 && commentCount > 0) {
if (
prev.comments.length > 0 &&
this.getCommentCountWithoutDagling() === 0 &&
commentCount > 0
) {
this.props.loadMore();
}
// Scroll to selected comment.
if (prev.selectedCommentId !== selectedCommentId && this.listRef) {
const view = this.state.view;
const index = view.findIndex(({id}) => id === selectedCommentId);
@@ -209,13 +229,18 @@ class ModerationQueue extends React.Component {
// Returns comment counts without dangling comments.
getCommentCountWithoutDagling(props = this.props) {
return props.comments.filter((comment) => props.commentBelongToQueue(props.activeTab, comment)).length;
return props.comments.filter((comment) =>
props.commentBelongToQueue(props.activeTab, comment)
).length;
}
async selectDown() {
const view = this.state.view;
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
if (index === view.length - 1 && this.getCommentCountWithoutDagling() !== this.props.commentCount) {
if (
index === view.length - 1 &&
this.getCommentCountWithoutDagling() !== this.props.commentCount
) {
await this.props.loadMore();
this.selectDown();
return;
@@ -253,17 +278,16 @@ class ModerationQueue extends React.Component {
if (index >= 0) {
cache.clear(index);
this.listRef && this.listRef.recomputeRowHeights(index);
}
else {
} else {
cache.clearAll();
this.listRef && this.listRef.recomputeRowHeights();
}
}, 500);
rowRenderer = ({
index, // Index of row within collection
index, // Index of row within collection
parent,
style // Style object to be applied to row (to position it)
style, // Style object to be applied to row (to position it)
}) => {
const view = this.state.view;
const rowCount = view.length + 1;
@@ -276,43 +300,41 @@ class ModerationQueue extends React.Component {
if (index === rowCount - 1) {
key = 'end-of-comment-list';
child = (
<div
style={style}
id={'end-of-comment-list'}
>
{this.props.hasNextPage && <AutoLoadMore
loadMore={this.props.loadMore}
loading={this.props.isLoadingMore}
/>}
<div style={style} id={'end-of-comment-list'}>
{this.props.hasNextPage && (
<AutoLoadMore
loadMore={this.props.loadMore}
loading={this.props.isLoadingMore}
/>
)}
</div>
);
}
else {
} else {
const comment = view[index];
// Use callback cache so not to change the identity of these arrow functions.
// Otherwise shallow compare will fail to optimize.
if (!this.callbackCaches.clearHeightCache[index]) {
this.callbackCaches.clearHeightCache[index] = () => this.reflowList(index);
this.callbackCaches.clearHeightCache[index] = () =>
this.reflowList(index);
}
if (!this.callbackCaches.selectCommentId[comment.id]) {
this.callbackCaches.selectCommentId[comment.id] = () => this.props.selectCommentId(comment.id);
this.callbackCaches.selectCommentId[comment.id] = () =>
this.props.selectCommentId(comment.id);
}
key = comment.id;
child = (
<div
style={style}
>
<div style={style}>
<Comment
data={this.props.data}
root={this.props.root}
comment={comment}
dangling={!this.props.commentBelongToQueue(this.props.activeTab, comment)}
dangling={
!this.props.commentBelongToQueue(this.props.activeTab, comment)
}
selected={comment.id === this.props.selectedCommentId}
viewUserDetail={this.props.viewUserDetail}
showBanUserDialog={this.props.showBanUserDialog}
showSuspendUserDialog={this.props.showSuspendUserDialog}
acceptComment={this.props.acceptComment}
rejectComment={this.props.rejectComment}
currentAsset={this.props.currentAsset}
@@ -337,7 +359,7 @@ class ModerationQueue extends React.Component {
);
};
render () {
render() {
const {
comments,
selectedCommentId,
@@ -355,7 +377,9 @@ class ModerationQueue extends React.Component {
}
if (singleView) {
const index = comments.findIndex((comment) => comment.id === selectedCommentId);
const index = comments.findIndex(
(comment) => comment.id === selectedCommentId
);
const comment = comments[index];
return (
<div className={styles.root}>
@@ -366,8 +390,6 @@ class ModerationQueue extends React.Component {
comment={comment}
selected={true}
viewUserDetail={viewUserDetail}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
@@ -412,18 +434,16 @@ class ModerationQueue extends React.Component {
}
ModerationQueue.propTypes = {
selectCommentId: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
viewUserDetail: PropTypes.func.isRequired,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
selectCommentId: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
commentBelongToQueue: PropTypes.func.isRequired,
cleanUpQueue: PropTypes.func.isRequired,
commentCount: PropTypes.number.isRequired,
loadMore: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
singleView: PropTypes.bool,
isLoadingMore: PropTypes.bool,
hasNextPage: PropTypes.bool,
@@ -37,7 +37,19 @@ export default withFragments({
user {
id
username
status
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
asset {
id
@@ -11,10 +11,12 @@ import NotFoundAsset from '../components/NotFoundAsset';
import {isPremod, getModPath} from '../../../utils';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentChange, commentBelongToQueue, cleanUpQueue} from '../graphql';
import {
handleCommentChange,
commentBelongToQueue,
cleanUpQueue,
} from '../graphql';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {viewUserDetail} from '../../../actions/userDetail';
import {
toggleModal,
@@ -67,13 +69,15 @@ class ModerationContainer extends Component {
};
get activeTab() {
const {root: {asset, settings}} = this.props;
const id = getAssetId(this.props);
const tab = getTab(this.props);
// Grab premod from asset or from settings if it's defined.
const setting = id && asset && asset.settings ? asset.settings.moderation : settings.moderation;
const setting =
id && asset && asset.settings
? asset.settings.moderation
: settings.moderation;
const queue = isPremod(setting) ? 'premod' : 'new';
const activeTab = tab ? tab : queue;
@@ -86,60 +90,101 @@ class ModerationContainer extends Component {
{
document: COMMENT_ADDED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAdded: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentAdded: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_ACCEPTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentAccepted: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_accepted',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_REJECTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentRejected: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_rejected',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_RESET_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentReset: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_reset', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentReset: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_reset',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentEdited: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_FLAGGED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentFlagged: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
];
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param));
this.subscriptions = parameters.map((param) =>
this.props.data.subscribeToMoreThrottled(param)
);
}
unsubscribe() {
@@ -164,7 +209,9 @@ class ModerationContainer extends Component {
componentWillReceiveProps(nextProps) {
// Resubscribe when we change between assets.
if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
if (
this.props.data.variables.asset_id !== nextProps.data.variables.asset_id
) {
this.resubscribe(nextProps.data.variables);
}
}
@@ -172,22 +219,27 @@ class ModerationContainer extends Component {
cleanUpQueue = (queue) => {
if (!this.props.data.loading) {
this.props.data.updateQuery((query) => {
return cleanUpQueue(query, queue, this.props.moderation.sortOrder, this.props.queueConfig);
return cleanUpQueue(
query,
queue,
this.props.moderation.sortOrder,
this.props.queueConfig
);
});
}
}
};
acceptComment = ({commentId}) => {
return this.props.setCommentStatus({commentId, status: 'ACCEPTED'});
}
};
rejectComment = ({commentId}) => {
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
}
};
commentBelongToQueue = (queue, comment) => {
return commentBelongToQueue(queue, comment, this.props.queueConfig);
}
};
loadMore = (tab) => {
const variables = {
@@ -202,7 +254,7 @@ class ModerationContainer extends Component {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables,
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
updateQuery: (prev, {fetchMoreResult: {comments}}) => {
return update(prev, {
[tab]: {
nodes: {$push: comments.nodes},
@@ -210,11 +262,11 @@ class ModerationContainer extends Component {
endCursor: {$set: comments.endCursor},
},
});
}
},
});
};
render () {
render() {
const {root, root: {asset, settings}, data} = this.props;
const assetId = getAssetId(this.props);
@@ -230,14 +282,15 @@ class ModerationContainer extends Component {
}
}
if(data.loading) {
if (data.loading) {
// loading.
return <Spinner />;
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) :
isPremod(settings.moderation);
const premodEnabled = assetId
? isPremod(asset.settings.moderation)
: isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
@@ -249,19 +302,21 @@ class ModerationContainer extends Component {
delete currentQueueConfig.premod;
}
return <Moderation
{...this.props}
getModPath={getModPath}
loadMore={this.loadMore}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
selectedCommentId={this.props.selectedCommentId}
commentBelongToQueue={this.commentBelongToQueue}
cleanUpQueue={this.cleanUpQueue}
/>;
return (
<Moderation
{...this.props}
getModPath={getModPath}
loadMore={this.loadMore}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
selectedCommentId={this.props.selectedCommentId}
commentBelongToQueue={this.commentBelongToQueue}
cleanUpQueue={this.cleanUpQueue}
/>
);
}
}
const COMMENT_ADDED_SUBSCRIPTION = gql`
@@ -368,28 +423,57 @@ const commentConnectionFragment = gql`
${Comment.fragments.comment}
`;
const withModQueueQuery = withQuery(({queueConfig}) => gql`
const withModQueueQuery = withQuery(
({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!, $nullStatuses: [COMMENT_STATUS!]) {
${Object.keys(queueConfig).map((queue) => `
${Object.keys(queueConfig).map(
(queue) => `
${queue}: comments(query: {
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
: '$nullStatuses'
}
${
queueConfig[queue].tags
? `tags: ["${queueConfig[queue].tags.join('", "')}"],`
: ''
}
${
queueConfig[queue].action_type
? `action_type: ${queueConfig[queue].action_type}`
: ''
}
asset_id: $asset_id,
sortOrder: $sortOrder,
limit: 20,
}) {
...CoralAdmin_Moderation_CommentConnection
}
`)}
${Object.keys(queueConfig).map((queue) => `
`
)}
${Object.keys(queueConfig).map(
(queue) => `
${queue}Count: commentCount(query: {
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
: '$nullStatuses'
}
${
queueConfig[queue].tags
? `tags: ["${queueConfig[queue].tags.join('", "')}"],`
: ''
}
${
queueConfig[queue].action_type
? `action_type: ${queueConfig[queue].action_type}`
: ''
}
asset_id: $asset_id,
})
`)}
`
)}
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
@@ -409,20 +493,22 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
}
${Comment.fragments.root}
${commentConnectionFragment}
`, {
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only'
};
},
});
`,
{
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only',
};
},
}
);
const mapStateToProps = (state) => ({
moderation: state.moderation,
@@ -430,25 +516,26 @@ const mapStateToProps = (state) => ({
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
toggleModal,
singleView,
showBanUserDialog,
hideShortcutsNote,
toggleStorySearch,
showSuspendUserDialog,
viewUserDetail,
setSortOrder,
storySearchChange,
clearState,
notify,
selectCommentId,
}, dispatch),
...bindActionCreators(
{
toggleModal,
singleView,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
setSortOrder,
storySearchChange,
clearState,
notify,
selectCommentId,
},
dispatch
),
});
export default compose(
withQueueConfig(baseQueueConfig),
connect(mapStateToProps, mapDispatchToProps),
withSetCommentStatus,
withModQueueQuery,
withModQueueQuery
)(ModerationContainer);
-1
View File
@@ -11,4 +11,3 @@ export const isPremod = (mod) => mod === 'PRE';
export const getModPath = (type = 'all', assetId) =>
assetId ? `/admin/moderate/${type}/${assetId}` : `/admin/moderate/${type}`;
@@ -0,0 +1,29 @@
import React from 'react';
import {Button} from 'coral-ui';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
const CloseCommentsInfo = ({status, onClick}) => (
status === 'open' ? (
<div className="close-comments-intro-wrapper">
<p>
{t('configure.open_stream_configuration')}
</p>
<Button onClick={onClick}>{t('configure.close_stream')}</Button>
</div>
) : (
<div className="close-comments-intro-wrapper">
<p>
{t('configure.close_stream_configuration')}
</p>
<Button onClick={onClick}>{t('configure.open_stream')}</Button>
</div>
)
);
CloseCommentsInfo.propTypes = {
status: PropTypes.string,
onClick: PropTypes.func,
};
export default CloseCommentsInfo;
+10 -32
View File
@@ -2,8 +2,8 @@ import jwtDecode from 'jwt-decode';
import bowser from 'bowser';
import * as actions from '../constants/auth';
import {notify} from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
import get from 'lodash/get';
export const showSignInDialog = () => ({
type: actions.SHOW_SIGNIN_DIALOG,
@@ -35,45 +35,19 @@ export const blurSignInDialog = () => ({
type: actions.BLUR_SIGNIN_DIALOG,
});
export const createUsernameRequest = () => ({
type: actions.CREATE_USERNAME_REQUEST
});
export const showCreateUsernameDialog = () => ({
type: actions.SHOW_CREATEUSERNAME_DIALOG
});
export const hideCreateUsernameDialog = () => ({
type: actions.HIDE_CREATEUSERNAME_DIALOG
});
const createUsernameSuccess = () => ({
type: actions.CREATE_USERNAME_SUCCESS
});
const createUsernameFailure = (error) => ({
type: actions.CREATE_USERNAME_FAILURE,
error
});
export const updateUsername = ({username}) => ({
type: actions.UPDATE_USERNAME,
username
});
export const createUsername = (userId, formData) => (dispatch, _, {rest}) => {
dispatch(createUsernameRequest());
rest('/account/username', {method: 'PUT', body: formData})
.then(() => {
dispatch(createUsernameSuccess());
dispatch(hideCreateUsernameDialog());
dispatch(updateUsername(formData));
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(createUsernameFailure(errorMessage));
});
};
export const changeView = (view) => (dispatch) => {
dispatch({
type: actions.CHANGE_VIEW,
@@ -304,6 +278,8 @@ const checkLoginSuccess = (user, isAdmin) => ({
isAdmin
});
const ErrNotLoggedIn = new Error('Not logged in');
export const checkLogin = () => (dispatch, _, {rest, client, pym, storage}) => {
dispatch(checkLoginRequest());
rest('/auth')
@@ -312,7 +288,7 @@ export const checkLogin = () => (dispatch, _, {rest, client, pym, storage}) => {
if (storage) {
storage.removeItem('token');
}
throw new Error('Not logged in');
throw ErrNotLoggedIn;
}
// Reset the websocket.
@@ -321,13 +297,15 @@ export const checkLogin = () => (dispatch, _, {rest, client, pym, storage}) => {
dispatch(checkLoginSuccess(result.user));
pym.sendMessage('coral-auth-changed', JSON.stringify(result.user));
// Display create username dialog if necessary.
if (result.user.canEditName && result.user.status !== 'BANNED') {
// This is for login via social. Usernames should be set.
if (get(result.user, 'status.username.status') === 'UNSET' && !get(result.user, 'status.banned.status')) {
dispatch(showCreateUsernameDialog());
}
})
.catch((error) => {
console.error(error);
if (error !== ErrNotLoggedIn) {
console.error(error);
}
if (error.status && error.status === 401 && storage) {
// Unauthorized.
@@ -0,0 +1,17 @@
import React, {Component} from 'react';
import t from 'coral-framework/services/i18n';
import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox';
class BannedAccount extends Component {
render () {
return (
<RestrictedMessageBox>
<span>
<b>{t('framework.banned_account_header')}</b><br/> {t('framework.banned_account_body')}
</span>
</RestrictedMessageBox>
);
}
}
export default BannedAccount;
@@ -0,0 +1,13 @@
.editNameInput {
margin-top: 10px;
margin-bottom: 10px;
padding: 10px;
border-radius: 3px;
border: solid 1px #d8d8d8;
font-size: 0.9em;
}
.alert {
margin-top: 10px;
color: #B71C1C;
}
@@ -0,0 +1,76 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
import styles from './ChangeUsername.css';
import {Button} from 'coral-ui';
import validate from 'coral-framework/helpers/validate';
import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox';
class ChangeUsername extends Component {
static propTypes = {
changeUsername: PropTypes.func.isRequired,
user: PropTypes.object.isRequired,
}
state = {
username: '',
alert: '',
}
onSubmitClick = (e) => {
const {changeUsername, user} = this.props;
const {username} = this.state;
e.preventDefault();
if (username === this.props.user.username) {
this.setState({alert: t('error.SAME_USERNAME_PROVIDED')});
}
else if (validate.username(username)) {
changeUsername(user.id, username)
.then(() => location.reload())
.catch((error) => {
this.setState({alert: t(`error.${error.translation_key}`)});
});
} else {
this.setState({alert: t('framework.edit_name.error')});
}
}
render () {
const {username, alert} = this.state;
return <RestrictedMessageBox>
<div className="talk-change-username">
<span>
{t('framework.edit_name.msg')}
</span>
<div className={styles.alert}>
{alert}
</div>
<label
htmlFor='username'
className="screen-reader-text"
aria-hidden={true}>
{t('framework.edit_name.label')}
</label>
<input
type='text'
className={cn(styles.editNameInput, 'talk-change-username-username-input')}
value={username}
placeholder={t('framework.edit_name.label')}
id='username'
onChange={(e) => this.setState({username: e.target.value})}
rows={3}/><br/>
<Button
className="talk-change-username-submit-button"
onClick={this.onSubmitClick} >
{t('framework.edit_name.button')}
</Button>
</div>
</RestrictedMessageBox>;
}
}
export default ChangeUsername;
@@ -0,0 +1,7 @@
import {compose} from 'react-apollo';
import {withChangeUsername} from 'coral-framework/graphql/mutations';
import ChangeUsername from '../components/ChangeUsername';
export default compose(
withChangeUsername,
)(ChangeUsername);
@@ -116,10 +116,18 @@ const USER_BANNED_SUBSCRIPTION = gql`
subscription UserBanned($user_id: ID!) {
userBanned(user_id: $user_id){
id
status
canEditName
suspension {
until
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
}
@@ -129,10 +137,18 @@ const USER_SUSPENDED_SUBSCRIPTION = gql`
subscription UserSuspended($user_id: ID!) {
userSuspended(user_id: $user_id){
id
status
canEditName
suspension {
until
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
}
@@ -142,10 +158,18 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql`
subscription UsernameRejected($user_id: ID!) {
usernameRejected(user_id: $user_id){
id
status
canEditName
suspension {
until
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
}
@@ -170,7 +194,19 @@ const EMBED_QUERY = gql`
) {
me {
id
status
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
asset(id: $assetId, url: $assetUrl) {
...${getDefinitionName(Configure.fragments.asset)}
+34 -22
View File
@@ -1,7 +1,10 @@
import {gql} from 'react-apollo';
import update from 'immutability-helper';
import uuid from 'uuid/v4';
import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils';
import {
insertCommentIntoEmbedQuery,
removeCommentFromEmbedQuery,
} from './utils';
import {mapLeaves} from 'coral-framework/utils';
export default {
@@ -45,7 +48,7 @@ export default {
}
}
`,
CreateDontAgreeResponse : gql`
CreateDontAgreeResponse: gql`
fragment CoralEmbedStream_CreateDontAgreeResponse on CreateDontAgreeResponse {
dontagree {
id
@@ -143,13 +146,13 @@ export default {
tag: {
name: tag,
created_at: new Date().toISOString(),
__typename: 'Tag'
__typename: 'Tag',
},
assigned_by: {
id: auth.user.id,
__typename: 'User'
__typename: 'User',
},
__typename: 'TagLink'
__typename: 'TagLink',
})),
},
created_at: new Date().toISOString(),
@@ -159,9 +162,9 @@ export default {
tag: {
name: tag,
created_at: new Date().toISOString(),
__typename: 'Tag'
__typename: 'Tag',
},
__typename: 'TagLink'
__typename: 'TagLink',
})),
status: 'NONE',
replyCount: 0,
@@ -171,9 +174,7 @@ export default {
title: '',
url: '',
},
parent: parent_id
? {__typename: 'Comment', id: parent_id}
: null,
parent: parent_id ? {__typename: 'Comment', id: parent_id} : null,
replies: {
__typename: 'CommentConnection',
nodes: [],
@@ -188,13 +189,17 @@ export default {
},
status_history: [],
id: `pending-${uuid()}`,
}
}
},
},
},
updateQueries: {
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
CoralEmbedStream_Embed: (
prev,
{mutationResult: {data: {createComment: {comment}}}}
) => {
if (
prev.me.roles.indexOf('ADMIN') === -1 && prev.asset.settings.moderation === 'PRE' ||
(prev.me.role !== 'ADMIN' &&
prev.asset.settings.moderation === 'PRE') ||
comment.status === 'PREMOD' ||
comment.status === 'REJECTED' ||
comment.status === 'SYSTEM_WITHHELD'
@@ -203,7 +208,10 @@ export default {
}
return insertCommentIntoEmbedQuery(prev, comment);
},
CoralEmbedStream_Profile: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
CoralEmbedStream_Profile: (
prev,
{mutationResult: {data: {createComment: {comment}}}}
) => {
return update(prev, {
me: {
comments: {
@@ -212,19 +220,24 @@ export default {
},
});
},
}
},
}),
EditComment: () => ({
updateQueries: {
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {editComment: {comment}}}}) => {
if (!['PREMOD', 'REJECTED', 'SYSTEM_WITHHELD'].includes(comment.status)) {
CoralEmbedStream_Embed: (
prev,
{mutationResult: {data: {editComment: {comment}}}}
) => {
if (
!['PREMOD', 'REJECTED', 'SYSTEM_WITHHELD'].includes(comment.status)
) {
return null;
}
return removeCommentFromEmbedQuery(prev, comment.id);
},
},
}),
UpdateAssetSettings: ({variables: {input}}) => ({
UpdateAssetSettings: ({variables: {input}}) => ({
updateQueries: {
CoralEmbedStream_Embed: (prev) => {
const updated = update(prev, {
@@ -233,9 +246,8 @@ export default {
},
});
return updated;
}
}
},
},
}),
},
};
@@ -228,7 +228,7 @@ export default function auth (state = initialState, action) {
redirectUri: action.uri,
};
case 'APOLLO_SUBSCRIPTION_RESULT':
if (action.operationName === 'UserBanned' && state.getIn(['user', 'id']) === action.variables.user_id) {
if (action.operationName === 'UserBanned' && state.user.id === action.variables.user_id) {
return {
...state,
user: {
@@ -237,7 +237,7 @@ export default function auth (state = initialState, action) {
},
};
}
if (action.operationName === 'UserSuspended' && state.getIn(['user', 'id']) === action.variables.user_id) {
if (action.operationName === 'UserSuspended' && state.user.id === action.variables.user_id) {
return {
...state,
user: {
@@ -246,7 +246,7 @@ export default function auth (state = initialState, action) {
},
};
}
if (action.operationName === 'UsernameRejected' && state.getIn(['user', 'id']) === action.variables.user_id) {
if (action.operationName === 'UsernameRejected' && state.user.id === action.variables.user_id) {
return {
...state,
user: {
@@ -2,7 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import {StreamError} from './StreamError';
import Comment from '../containers/Comment';
import SuspendedAccount from './SuspendedAccount';
import BannedAccount from '../../../components/BannedAccount';
import ChangeUsername from '../../../containers/ChangeUsername';
import Slot from 'coral-framework/components/Slot';
import InfoBox from 'talk-plugin-infobox/InfoBox';
import {can} from 'coral-framework/services/perms';
@@ -15,6 +16,7 @@ import QuestionBox from '../../../components/QuestionBox';
import {isCommentActive} from 'coral-framework/utils';
import {Tab, TabCount, TabPane} from 'coral-ui';
import cn from 'classnames';
import get from 'lodash/get';
import {getTopLevelParent, attachCommentToParent} from '../../../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
@@ -223,19 +225,20 @@ class Stream extends React.Component {
notify,
updateItem,
auth: {loggedIn, user},
editName,
} = this.props;
const {keepCommentBox} = this.state;
const open = !asset.isClosed;
const banned = user && user.status === 'BANNED';
const banned = get(user, 'status.banned.status');
const suspensionUntil = get(user, 'status.suspension.until');
const rejectedUsername = get(user, 'status.username.status') === 'REJECTED';
const temporarilySuspended =
user &&
user.suspension.until &&
new Date(user.suspension.until) > new Date();
suspensionUntil &&
new Date(suspensionUntil) > new Date();
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !rejectedUsername && !highlightedComment) || keepCommentBox);
const slotProps = {data};
const slotQueryData = {root, asset};
@@ -268,15 +271,11 @@ class Stream extends React.Component {
{t(
'stream.temporarily_suspended',
root.settings.organizationName,
timeago(user.suspension.until)
timeago(suspensionUntil)
)}
</RestrictedMessageBox>}
{banned &&
<SuspendedAccount
canEditName={user && user.canEditName}
editName={editName}
currentUsername={user.username}
/>}
{!banned && rejectedUsername && <ChangeUsername user={user} />}
{banned && <BannedAccount />}
{showCommentBox &&
<CommentBox
notify={notify}
@@ -2,15 +2,25 @@ import React from 'react';
import {gql, compose} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../../../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree,
withDeleteAction, withEditComment
ADDTL_COMMENTS_ON_LOAD_MORE,
THREADING_LEVEL,
} from '../../../constants/stream';
import {
withPostComment,
withPostFlag,
withPostDontAgree,
withDeleteAction,
withEditComment,
} from 'coral-framework/graphql/mutations';
import * as authActions from 'coral-embed-stream/src/actions/auth';
import * as notificationActions from 'coral-framework/actions/notification';
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../../../actions/stream';
import {
setActiveReplyBox,
setActiveTab,
viewAllComments,
} from '../../../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments, withEmit} from 'coral-framework/hocs';
@@ -43,7 +53,10 @@ class StreamContainer extends React.Component {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
if (this.props.auth.user && commentEdited.user.id === this.props.auth.user.id) {
if (
this.props.auth.user &&
commentEdited.user.id === this.props.auth.user.id
) {
return prev;
}
@@ -52,7 +65,11 @@ class StreamContainer extends React.Component {
return prev;
}
if (['PREMOD', 'REJECTED', 'SYSTEM_WITHHELD'].includes(commentEdited.status)) {
if (
['PREMOD', 'REJECTED', 'SYSTEM_WITHHELD'].includes(
commentEdited.status
)
) {
return removeCommentFromEmbedQuery(prev, commentEdited.id);
}
},
@@ -69,14 +86,20 @@ class StreamContainer extends React.Component {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
if (this.props.auth.user && commentAdded.user.id === this.props.auth.user.id) {
if (
this.props.auth.user &&
commentAdded.user.id === this.props.auth.user.id
) {
return prev;
}
// Exit if author is ignored.
if (
this.props.root.me &&
this.props.root.me.ignoredUsers.some(({id}) => id === commentAdded.user.id)) {
this.props.root.me.ignoredUsers.some(
({id}) => id === commentAdded.user.id
)
) {
return prev;
}
@@ -121,11 +144,11 @@ class StreamContainer extends React.Component {
sortOrder: 'ASC',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
updateQuery: (prev, {fetchMoreResult: {comments}}) => {
return insertFetchedCommentsIntoEmbedQuery(prev, comments, parent_id);
},
});
}
};
loadMoreComments = () => {
return this.props.data.fetchMore({
@@ -139,7 +162,7 @@ class StreamContainer extends React.Component {
sortBy: this.props.data.variables.sortBy,
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
updateQuery: (prev, {fetchMoreResult: {comments}}) => {
return insertFetchedCommentsIntoEmbedQuery(prev, comments);
},
});
@@ -168,7 +191,10 @@ class StreamContainer extends React.Component {
}
componentWillReceiveProps(nextProps) {
if (this.props.sortOrder !== nextProps.sortOrder || this.props.sortBy !== nextProps.sortBy) {
if (
this.props.sortOrder !== nextProps.sortOrder ||
this.props.sortBy !== nextProps.sortBy
) {
nextProps.data.refetch();
}
}
@@ -178,23 +204,25 @@ class StreamContainer extends React.Component {
}
render() {
if (!this.props.asset
|| this.props.asset.comment === undefined
&& !this.props.asset.comments
if (
!this.props.asset ||
(this.props.asset.comment === undefined && !this.props.asset.comments)
) {
return <Spinner />;
}
const streamLoading = this.props.refetching || this.props.data.loading;
return <Stream
{...this.props}
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewReplies={this.loadNewReplies}
userIsDegraged={this.userIsDegraged()}
loading={streamLoading}
/>;
return (
<Stream
{...this.props}
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewReplies={this.loadNewReplies}
userIsDegraged={this.userIsDegraged()}
loading={streamLoading}
/>
);
}
}
@@ -211,8 +239,8 @@ const commentFragment = gql`
`;
const COMMENTS_ADDED_SUBSCRIPTION = gql`
subscription CommentAdded($assetId: ID!, $excludeIgnored: Boolean){
commentAdded(asset_id: $assetId){
subscription CommentAdded($assetId: ID!, $excludeIgnored: Boolean) {
commentAdded(asset_id: $assetId) {
parent {
id
}
@@ -223,8 +251,8 @@ const COMMENTS_ADDED_SUBSCRIPTION = gql`
`;
const COMMENTS_EDITED_SUBSCRIPTION = gql`
subscription CommentEdited($assetId: ID!){
commentEdited(asset_id: $assetId){
subscription CommentEdited($assetId: ID!) {
commentEdited(asset_id: $assetId) {
id
body
status
@@ -281,11 +309,23 @@ const fragments = {
root: gql`
fragment CoralEmbedStream_Stream_root on RootQuery {
me {
status
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
ignoredUsers {
id
}
roles
role
}
settings {
organizationName
@@ -299,12 +339,15 @@ const fragments = {
fragment CoralEmbedStream_Stream_asset on Asset {
comment(id: $commentId) @include(if: $hasComment) {
...CoralEmbedStream_Stream_comment
${nest(`
${nest(
`
parent {
...CoralEmbedStream_Stream_comment
...nest
}
`, THREADING_LEVEL)}
`,
THREADING_LEVEL
)}
}
id
title
@@ -360,14 +403,17 @@ const mapStateToProps = (state) => ({
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
showSignInDialog,
notify,
setActiveReplyBox,
editName,
viewAllComments,
setActiveStreamTab: setActiveTab,
}, dispatch);
bindActionCreators(
{
showSignInDialog,
notify,
setActiveReplyBox,
editName,
viewAllComments,
setActiveStreamTab: setActiveTab,
},
dispatch
);
export default compose(
withFragments(fragments),
@@ -377,5 +423,5 @@ export default compose(
withPostFlag,
withPostDontAgree,
withDeleteAction,
withEditComment,
withEditComment
)(StreamContainer);
+7 -2
View File
@@ -3,11 +3,16 @@ import {createDefaultResponseFragments} from '../utils';
// fragments defined here are automatically registered.
export default {
...createDefaultResponseFragments(
'SetUserRoleResponse',
'ChangeUsernameResponse',
'BanUsersResponse',
'UnBanUserResponse',
'SetUserSuspensionStatusResponse',
'SetCommentStatusResponse',
'SetUsernameStatusResponse',
'UnSuspendUserResponse',
'SuspendUserResponse',
'RejectUsernameResponse',
'CreateCommentResponse',
'SetUserStatusResponse',
'CreateFlagResponse',
'EditCommentResponse',
'PostFlagResponse',
+110 -14
View File
@@ -177,39 +177,135 @@ export const withSuspendUser = withMutation(
})
});
export const withRejectUsername = withMutation(
export const withUnSuspendUser = withMutation(
gql`
mutation RejectUsername($input: RejectUsernameInput!) {
rejectUsername(input: $input) {
...RejectUsernameResponse
mutation UnSuspendUser($input: UnSuspendUserInput!) {
unSuspendUser(input: $input) {
...UnSuspendUserResponse
}
}
`, {
props: ({mutate}) => ({
rejectUsername: (input) => {
unSuspendUser: (input) => {
return mutate({
variables: {
input,
},
});
}
})
}),
});
export const withSetUserStatus = withMutation(
export const withApproveUsername = withMutation(
gql`
mutation SetUserStatus($userId: ID!, $status: USER_STATUS!) {
setUserStatus(id: $userId, status: $status) {
...SetUserStatusResponse
mutation ApproveUsername($id: ID!) {
approveUsername(id: $id) {
...SetUsernameStatusResponse
}
}
`, {
props: ({mutate}) => ({
setUserStatus: ({userId, status}) => {
approveUsername: (id) => {
return mutate({
variables: {
userId,
status
id,
},
});
}
})
});
export const withRejectUsername = withMutation(
gql`
mutation RejectUsername($id: ID!) {
rejectUsername(id: $id) {
...SetUsernameStatusResponse
}
}
`, {
props: ({mutate}) => ({
rejectUsername: (id) => {
return mutate({
variables: {
id,
},
});
}
})
});
export const withChangeUsername = withMutation(
gql`
mutation ChangeUsername($id: ID!, $username: String!) {
changeUsername(id: $id, username: $username) {
...ChangeUsernameResponse
}
}
`, {
props: ({mutate}) => ({
changeUsername: (id, username) => {
return mutate({
variables: {
id,
username,
},
});
}
})
});
export const withBanUser = withMutation(
gql`
mutation BanUser($input: BanUserInput!) {
banUser(input: $input) {
...BanUsersResponse
}
}
`, {
props: ({mutate}) => ({
banUser: (input) => {
return mutate({
variables: {
input,
},
});
}
}),
});
export const withUnBanUser = withMutation(
gql`
mutation UnBanUser($input: UnBanUserInput!) {
unBanUser(input: $input) {
...UnBanUserResponse
}
}
`, {
props: ({mutate}) => ({
unBanUser: (input) => {
return mutate({
variables: {
input,
},
});
}
}),
});
export const withSetUserRole = withMutation(
gql`
mutation SetUserRole($id: ID!, $role: USER_ROLES!) {
setUserRole(id: $id, role: $role) {
...SetUserRoleResponse
}
}
`, {
props: ({mutate}) => ({
setUserRole: (id, role) => {
return mutate({
variables: {
id,
role,
},
});
}
@@ -228,7 +324,7 @@ export const withPostComment = withMutation(
postComment: (input) => {
return mutate({
variables: {
input
input,
},
});
}
+5
View File
@@ -3,6 +3,11 @@ import has from 'lodash/has';
import get from 'lodash/get';
import merge from 'lodash/merge';
import 'moment/locale/da';
import 'moment/locale/es';
import 'moment/locale/fr';
import 'moment/locale/pt-br';
import daTA from 'timeago.js/locales/da';
import esTA from 'timeago.js/locales/es';
import frTA from 'timeago.js/locales/fr';
+35 -12
View File
@@ -1,40 +1,63 @@
import intersection from 'lodash/intersection';
import get from 'lodash/get';
// =========================================================================
// BASIC PERMISSIONS
// =========================================================================
const basicPerms = {
INTERACT_WITH_COMMUNITY: (user) => {
const banned = get(user, 'status.banned.status');
const suspensionUntil = get(user, 'status.suspension.until');
const suspended = suspensionUntil && new Date(suspensionUntil) > new Date();
return !banned && !suspended;
},
EDIT_NAME: (user) => {
const usernameStatus = user.status.username.status;
return usernameStatus === 'UNSET' || usernameStatus === 'REJECTED';
},
};
// =========================================================================
// PERMISSIONS BY ROLE
// =========================================================================
const basicRoles = {
HAS_STAFF_TAG: ['ADMIN', 'MODERATOR', 'STAFF']
HAS_STAFF_TAG: ['ADMIN', 'MODERATOR', 'STAFF'],
};
const queryRoles = {
UPDATE_CONFIG: ['ADMIN'],
ACCESS_ADMIN: ['ADMIN', 'MODERATOR'],
VIEW_USER_EMAILS: ['ADMIN']
VIEW_USER_EMAILS: ['ADMIN'],
};
const mutationRoles = {
CHANGE_ROLES: ['ADMIN'],
MODERATE_COMMENTS: ['ADMIN', 'MODERATOR']
MODERATE_COMMENTS: ['ADMIN', 'MODERATOR'],
};
const roles = {...basicRoles, ...queryRoles, ...mutationRoles};
export const can = (user, ...perms) => {
if (!user) {
return false;
}
const banned = user.status === 'BANNED';
const suspended = user.suspension.until && new Date(user.suspension.until) > new Date();
return perms.every((perm) => {
if (perm === 'INTERACT_WITH_COMMUNITY') {
return !banned && !suspended;
// Basic Permissions
const permAction = basicPerms[perm];
if (typeof permAction !== 'undefined') {
return permAction(user);
}
// Permissions by Role
const role = roles[perm];
if (typeof role === 'undefined') {
throw new Error(`${perm} is not a valid role`);
throw new Error(`${perm} is not a valid role or permission`);
}
return intersection(role, user.roles).length > 0;
return role.includes(user.role);
});
};
+21
View File
@@ -1,3 +1,5 @@
import get from 'lodash/get';
/**
* getReliability
* retrieves reliability value as string
@@ -12,3 +14,22 @@ export const getReliability = (reliabilityValue) => {
return 'unreliable';
}
};
/**
* isSuspended
* retrieves boolean based on the user suspension status
*/
export const isSuspended = (user) => {
const suspensionUntil = get(user, 'state.status.suspension.until');
return user && suspensionUntil && new Date(suspensionUntil) > new Date();
};
/**
* isBanned
* retrieves boolean based on the user ban status
*/
export const isBanned = (user) => {
return get(user, 'state.status.banned.status');
};
-21
View File
@@ -1,21 +0,0 @@
.bio textarea {
width: 100%;
box-sizing: border-box;
border-radius: 2px;
min-height: 100px;
margin: 10px 0;
border: solid 1px #d8d8d8;
}
.bio h1 {
font-size: 16px;
margin: 3px 0;
}
.bio p {
margin: 3px 0;
}
.actions {
text-align: right;
}
-18
View File
@@ -1,18 +0,0 @@
import React from 'react';
import styles from './Bio.css';
import {Button} from '../../coral-ui';
export default ({bio, handleSave, handleInput, handleCancel}) => (
<div className={styles.bio}>
<h1>Bio</h1>
<p>Tell the community about yourself</p>
<form>
<textarea value={bio} onChange={handleInput} />
<div className={styles.actions}>
<Button cStyle='cancel' type="button" onClick={handleCancel} raised>Cancel</Button>
<Button cStyle='success' type="submit" onClick={handleSave}>Save Changes</Button>
</div>
</form>
</div>
);
@@ -1,45 +0,0 @@
import React, {Component} from 'react';
import Bio from '../components/Bio';
export default class BioContainer extends Component {
constructor (props) {
super(props);
this.state = {
bio: props.bio
};
this.handleSave = this.handleSave.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
handleInput(e) {
this.setState({
bio: e.target.value
});
}
handleSave (e) {
e.preventDefault();
const {userData, saveBio} = this.props;
const {bio} = this.state;
saveBio(userData.id, {bio});
}
handleCancel () {
this.setState({
bio: this.props.bio
});
}
render () {
return <Bio
bio={this.state.bio}
userData={this.props.userData}
handleSave={this.handleSave}
handleInput={this.handleInput}
handleCancel={this.handleCancel}
/>;
}
}
+1
View File
@@ -150,6 +150,7 @@
box-shadow: none;
color: white;
background-color: #616161;
border-color: transparent;
}
> .icon {
+8 -4
View File
@@ -1,11 +1,13 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './Drawer.css';
const Drawer = ({children, onClose}) => {
const Drawer = ({children, onClose, className = ''}) => {
return (
<div className={styles.drawer}>
<div className={styles.closeButton} onClick={onClose}>×</div>
<div className={cn(styles.drawer, className)}>
{/* TODO: Swap out with button */}
<div className={cn(styles.closeButton, [className, 'close-button'].join('-'))} onClick={onClose}>×</div>
<div className={styles.content}>
{children}
</div>
@@ -15,7 +17,9 @@ const Drawer = ({children, onClose}) => {
Drawer.propTypes = {
active: PropTypes.bool,
onClose: PropTypes.func.isRequired
onClose: PropTypes.func.isRequired,
children: PropTypes.node,
className: PropTypes.string,
};
export default Drawer;
+6 -6
View File
@@ -1,19 +1,19 @@
.dropdown {
position: relative;
width: 150px;
height: 36px;
height: 34px;
background: #2c2c2c;
box-sizing: border-box;
color: white;
border-radius: 2px;
border-radius: 3px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
line-height: 1.4;
font-size: 13px;
font-size: 0.98em;
border-radius: 3px;
}
.toggle {
padding: 10px 15px;
padding: 8px 15px;
cursor: pointer;
outline: none;
@@ -58,5 +58,5 @@
right: 15px;
font-size: 1.2rem;
vertical-align: middle;
top: 13px;
top: 11px;
}
+2 -2
View File
@@ -17,14 +17,14 @@ class Tab extends React.Component {
getRootClassName({active, className, sub, classNames = {}} = this.props) {
return cn(
'talk-tab',
className,
{
[classNames.root || styles.root]: !sub,
[classNames.rootSub || styles.rootSub]: sub,
[classNames.rootActive || styles.rootActive]: active && !sub,
[classNames.rootSubActive || styles.rootSubActive]: active && sub,
'talk-tab-active': active,
}
},
className,
);
}
+2 -2
View File
@@ -13,11 +13,11 @@ class TabBar extends React.Component {
getRootClassName({className, classNames = {}, sub} = this.props) {
return cn(
'talk-tab-bar',
className,
{
[classNames.root || styles.root]: !sub,
[classNames.rootSub || styles.rootSub]: sub,
}
},
className,
);
}
+76 -19
View File
@@ -7,6 +7,13 @@
// entrypoint for the entire applications configuration.
require('env-rewrite').rewrite();
if (process.env.NODE_ENV === 'development') {
// Apply all the configuration provided in the .env file if it isn't already
// in the environment.
require('dotenv').config();
}
const uniq = require('lodash/uniq');
const ms = require('ms');
const debug = require('debug')('talk:config');
@@ -20,6 +27,9 @@ const CONFIG = {
// WEBPACK indicates when webpack is currently building.
WEBPACK: process.env.WEBPACK === 'TRUE',
APOLLO_ENGINE_KEY: process.env.APOLLO_ENGINE_KEY || null,
ENABLE_TRACING: Boolean(process.env.APOLLO_ENGINE_KEY),
// EMAIL_SUBJECT_PREFIX is the string before emails in the subject.
EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX || '[Talk]',
@@ -52,14 +62,19 @@ const CONFIG = {
// JWT_SIGNING_COOKIE_NAME will be the cookie set when cookies are issued.
// This defaults to the TALK_JWT_COOKIE_NAME value.
JWT_SIGNING_COOKIE_NAME: process.env.TALK_JWT_SIGNING_COOKIE_NAME || process.env.TALK_JWT_COOKIE_NAME || 'authorization',
JWT_SIGNING_COOKIE_NAME:
process.env.TALK_JWT_SIGNING_COOKIE_NAME ||
process.env.TALK_JWT_COOKIE_NAME ||
'authorization',
// JWT_COOKIE_NAMES declares the many cookie names used for verification.
JWT_COOKIE_NAMES: process.env.TALK_JWT_COOKIE_NAMES || null,
// JWT_CLEAR_COOKIE_LOGOUT specifies whether the named cookie should be
// cleared when the user is logged out.
JWT_CLEAR_COOKIE_LOGOUT: process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT ? process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT !== 'FALSE' : true,
JWT_CLEAR_COOKIE_LOGOUT: process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT
? process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT !== 'FALSE'
: true,
// JWT_DISABLE_AUDIENCE when TRUE will disable the audience claim (aud) from tokens.
JWT_DISABLE_AUDIENCE: process.env.TALK_JWT_DISABLE_AUDIENCE === 'TRUE',
@@ -100,7 +115,9 @@ const CONFIG = {
// HELMET_CONFIGURATION provides the entrypoint to override options for the
// helmet middleware used.
HELMET_CONFIGURATION: JSON.parse(process.env.TALK_HELMET_CONFIGURATION || '{}'),
HELMET_CONFIGURATION: JSON.parse(
process.env.TALK_HELMET_CONFIGURATION || '{}'
),
//------------------------------------------------------------------------------
// External database url's
@@ -119,22 +136,30 @@ const CONFIG = {
// REDIS_CLUSTER_CONFIGURATION contains the json string for the redis cluster
// configuration.
REDIS_CLUSTER_CONFIGURATION: process.env.TALK_REDIS_CLUSTER_CONFIGURATION || '[]',
REDIS_CLUSTER_CONFIGURATION:
process.env.TALK_REDIS_CLUSTER_CONFIGURATION || '[]',
// REDIS_RECONNECTION_BACKOFF_FACTOR is the factor that will be multiplied
// against the current attempt count inbetween attempts to connect to redis.
REDIS_RECONNECTION_BACKOFF_FACTOR: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_FACTOR || '500 ms'),
REDIS_RECONNECTION_BACKOFF_FACTOR: ms(
process.env.TALK_REDIS_RECONNECTION_BACKOFF_FACTOR || '500 ms'
),
// REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME is the minimum time used to delay
// before attempting to reconnect to redis.
REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME || '1 sec'),
REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME: ms(
process.env.TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME || '1 sec'
),
//------------------------------------------------------------------------------
// Server Config
//------------------------------------------------------------------------------
// Port to bind to.
PORT: process.env.TALK_PORT || process.env.PORT || (process.env.NODE_ENV === 'test' ? '3001' : '3000'),
PORT:
process.env.TALK_PORT ||
process.env.PORT ||
(process.env.NODE_ENV === 'test' ? '3001' : '3000'),
// The URL for this Talk Instance as viewable from the outside.
ROOT_URL: process.env.TALK_ROOT_URL || null,
@@ -158,7 +183,8 @@ const CONFIG = {
// Cache configuration
//------------------------------------------------------------------------------
CACHE_EXPIRY_COMMENT_COUNT: process.env.TALK_CACHE_EXPIRY_COMMENT_COUNT || '1hr',
CACHE_EXPIRY_COMMENT_COUNT:
process.env.TALK_CACHE_EXPIRY_COMMENT_COUNT || '1hr',
//------------------------------------------------------------------------------
// Recaptcha configuration
@@ -178,7 +204,9 @@ const CONFIG = {
SMTP_FROM_ADDRESS: process.env.TALK_SMTP_FROM_ADDRESS,
SMTP_HOST: process.env.TALK_SMTP_HOST,
SMTP_PASSWORD: process.env.TALK_SMTP_PASSWORD,
SMTP_PORT: process.env.TALK_SMTP_PORT ? parseInt(process.env.TALK_SMTP_PORT) : undefined,
SMTP_PORT: process.env.TALK_SMTP_PORT
? parseInt(process.env.TALK_SMTP_PORT)
: undefined,
SMTP_USERNAME: process.env.TALK_SMTP_USERNAME,
//------------------------------------------------------------------------------
@@ -187,7 +215,8 @@ const CONFIG = {
// DISABLE_AUTOFLAG_SUSPECT_WORDS is true when the suspect words that are
// matched should not be flagged.
DISABLE_AUTOFLAG_SUSPECT_WORDS: process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE',
DISABLE_AUTOFLAG_SUSPECT_WORDS:
process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE',
// TRUST_THRESHOLDS defines the thresholds used for automoderation.
TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1',
@@ -195,7 +224,8 @@ const CONFIG = {
// IGNORE_FLAGS_AGAINST_STAFF disables staff members from entering the
// reported queue from comments after this was enabled and from reports
// against the staff members user account.
IGNORE_FLAGS_AGAINST_STAFF: process.env.TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF !== 'TRUE',
IGNORE_FLAGS_AGAINST_STAFF:
process.env.TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF !== 'TRUE',
};
//==============================================================================
@@ -222,7 +252,9 @@ if (CONFIG.JWT_SECRETS) {
} else if (!CONFIG.JWT_SECRET) {
if (process.env.NODE_ENV === 'test') {
if (!CONFIG.JWT_ALG.startsWith('HS')) {
throw new Error('Providing a asymmetric signing/verfying algorithm without a corresponding secret is not permitted');
throw new Error(
'Providing a asymmetric signing/verfying algorithm without a corresponding secret is not permitted'
);
}
CONFIG.JWT_SECRET = 'keyboard cat';
@@ -251,7 +283,12 @@ if (CONFIG.JWT_COOKIE_NAMES) {
}
// Add in the default cookie names and strip duplicates.
CONFIG.JWT_COOKIE_NAMES = uniq(CONFIG.JWT_COOKIE_NAMES.concat([CONFIG.JWT_COOKIE_NAME, CONFIG.JWT_SIGNING_COOKIE_NAME]));
CONFIG.JWT_COOKIE_NAMES = uniq(
CONFIG.JWT_COOKIE_NAMES.concat([
CONFIG.JWT_COOKIE_NAME,
CONFIG.JWT_SIGNING_COOKIE_NAME,
])
);
//------------------------------------------------------------------------------
// External database url's
@@ -273,17 +310,25 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) {
// REDIS_CLUSTER_CONFIGURATION should be parsed when the cluster mode !== none.
if (CONFIG.REDIS_CLUSTER_MODE === 'CLUSTER') {
try {
CONFIG.REDIS_CLUSTER_CONFIGURATION = JSON.parse(CONFIG.REDIS_CLUSTER_CONFIGURATION);
CONFIG.REDIS_CLUSTER_CONFIGURATION = JSON.parse(
CONFIG.REDIS_CLUSTER_CONFIGURATION
);
} catch (err) {
throw new Error('TALK_REDIS_CLUSTER_CONFIGURATION is not valid JSON, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes');
throw new Error(
'TALK_REDIS_CLUSTER_CONFIGURATION is not valid JSON, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'
);
}
if (!Array.isArray(CONFIG.REDIS_CLUSTER_CONFIGURATION)) {
throw new Error('TALK_REDIS_CLUSTER_MODE is CLUSTER, but the TALK_REDIS_CLUSTER_CONFIGURATION is invalid, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes');
throw new Error(
'TALK_REDIS_CLUSTER_MODE is CLUSTER, but the TALK_REDIS_CLUSTER_CONFIGURATION is invalid, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'
);
}
if (CONFIG.REDIS_CLUSTER_CONFIGURATION.length === 0) {
throw new Error('TALK_REDIS_CLUSTER_CONFIGURATION must have at least one node specified in the cluster, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes');
throw new Error(
'TALK_REDIS_CLUSTER_CONFIGURATION must have at least one node specified in the cluster, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'
);
}
}
@@ -304,7 +349,13 @@ CONFIG.RECAPTCHA_ENABLED =
CONFIG.RECAPTCHA_PUBLIC &&
CONFIG.RECAPTCHA_PUBLIC.length > 0;
debug(`reCAPTCHA is ${CONFIG.RECAPTCHA_ENABLED ? 'enabled' : 'disabled, required config is not present'}`);
debug(
`reCAPTCHA is ${
CONFIG.RECAPTCHA_ENABLED
? 'enabled'
: 'disabled, required config is not present'
}`
);
//------------------------------------------------------------------------------
// SMTP Server configuration
@@ -320,6 +371,12 @@ CONFIG.EMAIL_ENABLED =
CONFIG.SMTP_HOST &&
CONFIG.SMTP_HOST.length > 0;
debug(`Email is ${CONFIG.EMAIL_ENABLED ? 'enabled' : 'disabled, required config is not present'}`);
debug(
`Email is ${
CONFIG.EMAIL_ENABLED
? 'enabled'
: 'disabled, required config is not present'
}`
);
module.exports = CONFIG;
+1 -8
View File
@@ -165,12 +165,6 @@ const ErrPermissionUpdateUsername = new APIError('You do not have permission to
status: 403
});
// ErrSameUsernameProvided is returned when attempting to update a username to the same username.
const ErrSameUsernameProvided = new APIError('Same username provided.', {
translation_key: 'SAME_USERNAME_PROVIDED',
status: 400
});
// ErrLoginAttemptMaximumExceeded is returned when the login maximum is exceeded.
const ErrLoginAttemptMaximumExceeded = new APIError('You have made too many incorrect password attempts.', {
translation_key: 'LOGIN_MAXIMUM_EXCEEDED',
@@ -238,10 +232,9 @@ module.exports = {
ErrNotVerified,
ErrPasswordTooShort,
ErrPermissionUpdateUsername,
ErrSameUsernameProvided,
ErrSettingsInit,
ErrSettingsNotInit,
ErrSpecialChars,
ErrUsernameTaken,
ExtendableError,
};
};
+1 -1
View File
@@ -15,7 +15,7 @@ const Actions = require('../services/actions');
const Assets = require('../services/assets');
const Cache = require('../services/cache');
const Comments = require('../services/comments');
const DomainList = require('../services/domainlist');
const DomainList = require('../services/domain_list');
const I18n = require('../services/i18n');
const Jwt = require('../services/jwt');
const Karma = require('../services/karma');
+6 -1
View File
@@ -1,6 +1,7 @@
const schema = require('./schema');
const Context = require('./context');
const {createSubscriptionManager} = require('./subscriptions');
const {ENABLE_TRACING} = require('../config');
module.exports = {
createGraphOptions: (req) => ({
@@ -10,7 +11,11 @@ module.exports = {
// Load in the new context here, this'll create the loaders + mutators for
// the lifespan of this request.
context: new Context(req)
context: new Context(req),
// Tracing request options, needed for Apollo Engine.
tracing: ENABLE_TRACING,
cacheControl: ENABLE_TRACING
}),
createSubscriptionManager
};
+67 -25
View File
@@ -1,7 +1,7 @@
const DataLoader = require('dataloader');
const util = require('./util');
const union = require('lodash/union');
const sc = require('snake-case');
const {
SEARCH_OTHER_USERS,
@@ -10,6 +10,47 @@ const {
const UsersService = require('../../services/users');
const UserModel = require('../../models/user');
const mergeState = (query, state) => {
const {status} = state;
if (status) {
const {username, banned, suspended} = status;
if (typeof username !== 'undefined' && username && username.length > 0) {
query.merge({
'status.username.status': {
$in: username
}
});
}
if (typeof banned !== 'undefined' && banned !== null) {
query.merge({
'status.banned.status': banned
});
}
if (typeof suspended !== 'undefined' && suspended !== null) {
if (suspended) {
query.merge({
'status.suspension.until': {
$gte: Date.now()
}
});
} else {
query.merge({
$or: [
{'status.suspension.until': null},
{'status.suspension.until': {
$lt: Date.now()
}}
]
});
}
}
}
};
const genUserByIDs = async (context, ids) => {
if (!ids || ids.length === 0) {
return [];
@@ -31,23 +72,24 @@ const genUserByIDs = async (context, ids) => {
* @param {Object} context graph context
* @param {Object} query query terms to apply to the users query
*/
const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor, statuses, action_type, sortOrder}) => {
const getUsersByQuery = async ({user}, {ids, limit, cursor, state, action_type, sortOrder}) => {
let query = UserModel.find();
if (action_type || statuses) {
if (action_type || state) {
if (!user || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
if (statuses) {
query = query.where({
status: {
$in: statuses
if (state) {
mergeState(query, state);
}
if (action_type) {
query.merge({
[`action_counts.${sc(action_type.toLowerCase())}`]: {
$gt: 0
}
});
} else {
const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'});
ids = ids ? union(ids, userIds) : userIds;
}
}
@@ -115,25 +157,25 @@ const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor,
* @return {Promise} resolves to the counts of the users from the
* query
*/
const getCountByQuery = async ({loaders: {Actions}}, {action_type, statuses}) => {
const getCountByQuery = async ({user}, {action_type, state}) => {
let query = UserModel.find();
if (action_type) {
const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'});
if (action_type || state) {
if (!user || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
query = query.find({
id: {
$in: userIds
}
});
}
if (state) {
mergeState(query, state);
}
if (statuses) {
query = query.where({
status: {
$in: statuses
}
});
if (action_type) {
query.merge({
[`action_counts.${sc(action_type.toLowerCase())}`]: {
$gt: 0
}
});
}
}
return UserModel
+13 -11
View File
@@ -1,5 +1,4 @@
const errors = require('../../errors');
const UsersService = require('../../services/users');
const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
const {
IGNORE_FLAGS_AGAINST_STAFF,
@@ -69,6 +68,15 @@ const createAction = async (ctx, {item_id, item_type, action_type, group_id, met
}
}
if (action_type === 'FLAG' && item_type === 'USERS') {
// The item is a user, and this is a flag. Check to see if they are staff,
// if they are, don't permit the flag.
if (item.isStaff()) {
throw errors.ErrNotAuthorized;
}
}
// Create the action itself.
let action = await Actions.create({
item_id,
@@ -79,17 +87,11 @@ const createAction = async (ctx, {item_id, item_type, action_type, group_id, met
metadata
});
if (action_type === 'FLAG') {
if (item_type === 'USERS') {
if (action_type === 'FLAG' && item_type === 'COMMENTS') {
// Set the user's status as pending, as we need to review it.
await UsersService.setStatus(item_id, 'PENDING');
} else if (item_type === 'COMMENTS') {
// The item is a comment, and this is a flag. Push that the comment was
// flagged, don't wait for it to finish.
pubsub.publish('commentFlagged', item);
}
// The item is a comment, and this is a flag. Push that the comment was
// flagged, don't wait for it to finish.
pubsub.publish('commentFlagged', item);
}
return action;
+68 -30
View File
@@ -1,29 +1,35 @@
const errors = require('../../errors');
const UsersService = require('../../services/users');
const {SET_USER_STATUS, SUSPEND_USER, REJECT_USERNAME} = require('../../perms/constants');
const {
CHANGE_USERNAME,
SET_USERNAME,
SET_USER_USERNAME_STATUS,
SET_USER_BAN_STATUS,
SET_USER_SUSPENSION_STATUS,
UPDATE_USER_ROLES,
} = require('../../perms/constants');
const setUserStatus = async ({pubsub}, {id, status}) => {
const result = await UsersService.setStatus(id, status);
if (result && result.status === 'BANNED') {
pubsub.publish('userBanned', result);
const setUserUsernameStatus = async (ctx, id, status) => {
const user = await UsersService.setUsernameStatus(id, status, ctx.user.id);
if (status === 'REJECTED') {
ctx.pubsub.publish('usernameRejected', user);
} else if (status === 'APPROVED') {
ctx.pubsub.publish('usernameApproved', user);
}
return result;
};
const suspendUser = async ({pubsub}, {id, message, until}) => {
const result = await UsersService.suspendUser(id, message, until);
if (result) {
pubsub.publish('userSuspended', result);
const setUserBanStatus = async (ctx, id, status = false, message = null) => {
const user = await UsersService.setBanStatus(id, status, ctx.user.id, message);
if (user.banned) {
ctx.pubsub.publish('userBanned', user);
}
return result;
};
const rejectUsername = async ({pubsub}, {id, message}) => {
const result = await UsersService.rejectUsername(id, message);
if (result) {
pubsub.publish('usernameRejected', result);
const setUserSuspensionStatus = async (ctx, id, until = null, message = null) => {
const user = await UsersService.setSuspensionStatus(id, until, ctx.user.id, message);
if (user.suspended) {
ctx.pubsub.publish('userSuspended', user);
}
return result;
};
const ignoreUser = ({user}, userToIgnore) => {
@@ -34,27 +40,59 @@ const stopIgnoringUser = ({user}, userToStopIgnoring) => {
return UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]);
};
module.exports = (context) => {
const changeUsername = async (ctx, id, username) => {
return UsersService.changeUsername(id, username, ctx.user.id);
};
const setUsername = async (ctx, id, username) => {
return UsersService.setUsername(id, username, ctx.user.id);
};
const setRole = (ctx, id, role) => {
return UsersService.setRole(id, role);
};
module.exports = (ctx) => {
let mutators = {
User: {
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
suspendUser: () => Promise.reject(errors.ErrNotAuthorized),
rejectUsername: () => Promise.reject(errors.ErrNotAuthorized),
ignoreUser: (action) => ignoreUser(context, action),
stopIgnoringUser: (action) => stopIgnoringUser(context, action),
changeUsername: () => Promise.reject(errors.ErrNotAuthorized),
ignoreUser: () => Promise.reject(errors.ErrNotAuthorized),
setRole: () => Promise.reject(errors.ErrNotAuthorized),
setUserBanStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserSuspensionStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserUsernameStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUsername: () => Promise.reject(errors.ErrNotAuthorized),
stopIgnoringUser: () => Promise.reject(errors.ErrNotAuthorized),
}
};
if (context.user && context.user.can(SET_USER_STATUS)) {
mutators.User.setUserStatus = (action) => setUserStatus(context, action);
}
if (ctx.user) {
mutators.User.ignoreUser = (action) => ignoreUser(ctx, action);
mutators.User.stopIgnoringUser = (action) => stopIgnoringUser(ctx, action);
if (context.user && context.user.can(SUSPEND_USER)) {
mutators.User.suspendUser = (action) => suspendUser(context, action);
}
if (ctx.user.can(UPDATE_USER_ROLES)) {
mutators.User.setRole = (id, role) => setRole(ctx, id, role);
}
if (context.user && context.user.can(REJECT_USERNAME)) {
mutators.User.rejectUsername = (action) => rejectUsername(context, action);
if (ctx.user.can(CHANGE_USERNAME)) {
mutators.User.changeUsername = (id, username) => changeUsername(ctx, id, username);
}
if (ctx.user.can(SET_USERNAME)) {
mutators.User.setUsername = (id, username) => setUsername(ctx, id, username);
}
if (ctx.user.can(SET_USER_USERNAME_STATUS)) {
mutators.User.setUserUsernameStatus = (id, status) => setUserUsernameStatus(ctx, id, status);
}
if (ctx.user.can(SET_USER_BAN_STATUS)) {
mutators.User.setUserBanStatus = (id, status, message) => setUserBanStatus(ctx, id, status, message);
}
if (ctx.user.can(SET_USER_SUSPENSION_STATUS)) {
mutators.User.setUserSuspensionStatus = (id, until, message) => setUserSuspensionStatus(ctx, id, until, message);
}
}
return mutators;
+7
View File
@@ -0,0 +1,7 @@
const {decorateUserField} = require('./util');
const BannedStatusHistory = {};
decorateUserField(BannedStatusHistory, 'assigned_by');
module.exports = BannedStatusHistory;
+3 -9
View File
@@ -1,13 +1,7 @@
const {SEARCH_OTHER_USERS} = require('../../perms/constants');
const {decorateUserField} = require('./util');
const CommentStatusHistory = {
assigned_by({assigned_by}, _, {user, loaders: {Users}}) {
if (!user || !user.can(SEARCH_OTHER_USERS) || assigned_by == null) {
return null;
}
const CommentStatusHistory = {};
return Users.getByID.load(assigned_by);
}
};
decorateUserField(CommentStatusHistory, 'assigned_by');
module.exports = CommentStatusHistory;
+22 -14
View File
@@ -1,54 +1,62 @@
const _ = require('lodash');
const debug = require('debug')('talk:graph:resolvers');
const ActionSummary = require('./action_summary');
const Action = require('./action');
const AssetActionSummary = require('./asset_action_summary');
const ActionSummary = require('./action_summary');
const Asset = require('./asset');
const CommentStatusHistory = require('./comment_status_history');
const AssetActionSummary = require('./asset_action_summary');
const BannedStatusHistory = require('./banned_status_history');
const Comment = require('./comment');
const CommentStatusHistory = require('./comment_status_history');
const Cursor = require('./cursor');
const Date = require('./date');
const FlagActionSummary = require('./flag_action_summary');
const FlagAction = require('./flag_action');
const DontAgreeAction = require('./dont_agree_action');
const DontAgreeActionSummary = require('./dont_agree_action_summary');
const FlagAction = require('./flag_action');
const FlagActionSummary = require('./flag_action_summary');
const GenericUserError = require('./generic_user_error');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
const Subscription = require('./subscription');
const TagLink = require('./tag_link');
const SuspensionStatusHistory = require('./suspension_status_history');
const Tag = require('./tag');
const UserError = require('./user_error');
const TagLink = require('./tag_link');
const User = require('./user');
const UserError = require('./user_error');
const UserState = require('./user_state');
const UsernameStatusHistory = require('./username_status_history');
const ValidationUserError = require('./validation_user_error');
const plugins = require('../../services/plugins');
// Provide the core resolvers.
let resolvers = {
ActionSummary,
Action,
AssetActionSummary,
ActionSummary,
Asset,
CommentStatusHistory,
AssetActionSummary,
BannedStatusHistory,
Comment,
CommentStatusHistory,
Cursor,
Date,
FlagActionSummary,
FlagAction,
DontAgreeAction,
DontAgreeActionSummary,
FlagAction,
FlagActionSummary,
GenericUserError,
RootMutation,
RootQuery,
Settings,
Subscription,
TagLink,
SuspensionStatusHistory,
Tag,
UserError,
TagLink,
User,
UserError,
UserState,
UsernameStatusHistory,
ValidationUserError,
};
+24 -6
View File
@@ -19,14 +19,29 @@ const RootMutation = {
deleteAction: async (_, {id}, {mutators: {Action}}) => {
await Action.delete({id});
},
setUserStatus: async (_, {id, status}, {mutators: {User}}) => {
await User.setUserStatus({id, status});
approveUsername: async (_, {id}, {mutators: {User}}) => {
await User.setUserUsernameStatus(id, 'APPROVED');
},
suspendUser: async (_, {input: {id, message, until}}, {mutators: {User}}) => {
await User.suspendUser({id, message, until});
rejectUsername: async (_, {id}, {mutators: {User}}) => {
await User.setUserUsernameStatus(id, 'REJECTED');
},
rejectUsername: async (_, {input: {id, message}}, {mutators: {User}}) => {
await User.rejectUsername({id, message});
changeUsername: async (_, {id, username}, {mutators: {User}}) => {
await User.changeUsername(id, username);
},
setUsername: async (_, {id, username}, {mutators: {User}}) => {
await User.setUsername(id, username);
},
suspendUser: async (obj, {input: {id, until, message}}, {mutators: {User}}) => {
await User.setUserSuspensionStatus(id, until, message);
},
unSuspendUser: async (obj, {input: {id}}, {mutators: {User}}) => {
await User.setUserSuspensionStatus(id);
},
banUser: async (obj, {input: {id, message}}, {mutators: {User}}) => {
await User.setUserBanStatus(id, true, message);
},
unBanUser: async (obj, {input: {id}}, {mutators: {User}}) => {
await User.setUserBanStatus(id, false);
},
ignoreUser: async (_, {id}, {mutators: {User}}) => {
await User.ignoreUser({id});
@@ -40,6 +55,9 @@ const RootMutation = {
updateAssetStatus: async (_, {id, input: status}, {mutators: {Asset}}) => {
await Asset.updateStatus(id, status);
},
setUserRole: async (_, {id, role}, {mutators: {User}}) => {
await User.setRole(id, role);
},
setCommentStatus: async (_, {id, status}, {mutators: {Comment}, pubsub}) => {
const comment = await Comment.setStatus({id, status});
if (status === 'ACCEPTED') {
@@ -0,0 +1,7 @@
const {decorateUserField} = require('./util');
const SuspensionStatusHistory = {};
decorateUserField(SuspensionStatusHistory, 'assigned_by');
module.exports = SuspensionStatusHistory;
+7 -8
View File
@@ -5,8 +5,8 @@ const {
SEARCH_OTHER_USERS,
SEARCH_OTHERS_COMMENTS,
UPDATE_USER_ROLES,
VIEW_SUSPENSION_INFO,
LIST_OWN_TOKENS
LIST_OWN_TOKENS,
VIEW_USER_STATUS,
} = require('../../perms/constants');
const User = {
@@ -66,11 +66,11 @@ const User = {
const connection = await Users.getByQuery({ids: user.ignoresUsers});
return connection.nodes;
},
roles({id, roles}, _, {user}) {
role({id, role}, _, {user}) {
// If the user is not an admin, only return the current user's roles.
if (user && (user.can(UPDATE_USER_ROLES) || user.id === id)) {
return roles;
return role;
}
return null;
@@ -83,11 +83,10 @@ const User = {
}
},
suspension({id, suspension}, _, {user}) {
if (user.id !== id && !user.can(VIEW_SUSPENSION_INFO)) {
return null;
state(user, args, ctx) {
if (ctx.user && (ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS))) {
return user;
}
return suspension;
}
};

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