Merge pull request #3 from coralproject/master

merge from master
This commit is contained in:
Morten Nissen
2018-03-08 08:40:34 +01:00
committed by GitHub
1096 changed files with 39642 additions and 21768 deletions
+292
View File
@@ -0,0 +1,292 @@
# job_defaults applies all the defaults for each job.
job_defaults: &job_defaults
working_directory: ~/coralproject/talk
docker:
- image: circleci/node:8
# integration_environment is the environment that configures the tests.
integration_environment: &integration_environment
NODE_ENV: test
CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
E2E_MAX_RETRIES: 3
# integration_job runs the integration tests and saves the test results.
integration_job: &integration_job
<<: *job_defaults
environment:
<<: *integration_environment
docker:
- image: circleci/node:8-browsers
- image: circleci/mongo:3
- image: circleci/redis:4-alpine
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- run:
name: Setup the database with defaults
command: ./bin/cli setup --defaults
- run:
name: Run the integration tests
command: bash .circleci/e2e.sh
- store_test_results:
when: always
path: /tmp/circleci-test-results
version: 2
jobs:
# npm_dependencies will install the dependencies used by all other steps.
npm_dependencies:
<<: *job_defaults
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- restore_cache:
key: dependency-cache-{{ checksum "yarn.lock" }}
- run:
name: Install dependencies
command: |
yarn global add node-gyp &&
yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-{{ checksum "yarn.lock" }}
paths:
- ./node_modules
- persist_to_workspace:
root: .
paths: node_modules
# lint will perform file linting.
lint:
<<: *job_defaults
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- run:
name: Perform linting
command: yarn lint
# build_assets will build the static assets.
build_assets:
<<: *job_defaults
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- restore_cache:
keys:
- build-cache-{{ .Branch }}-{{ .Revision }}
- build-cache-{{ .Branch }}-
- build-cache-
- run:
name: Build static assets
command: yarn build
- save_cache:
key: build-cache-{{ .Branch }}-{{ .Revision }}
paths:
- ./node_modules/.cache/hard-source
- persist_to_workspace:
root: .
paths: dist
# test_unit will run the unit tests.
test_unit:
<<: *job_defaults
docker:
- image: circleci/node:8
- image: circleci/mongo:3
- image: circleci/redis:4-alpine
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- run:
name: Setup the test results directory
command: mkdir -p /tmp/circleci-test-results
- run:
name: Run the client unit tests
command: yarn test:client --ci
environment:
JEST_JUNIT_OUTPUT: /tmp/circleci-test-results/jest/test-results.xml
JEST_REPORTER: jest-junit
- run:
name: Run the server unit tests
command: yarn test:server
environment:
MOCHA_FILE: /tmp/circleci-test-results/mocha/test-results.xml
MOCHA_REPORTER: mocha-junit-reporter
- store_test_results:
when: always
path: /tmp/circleci-test-results
# test_integration_chrome_local will run the integration tests locally with
# chrome headless.
test_integration_chrome_local:
<<: *integration_job
environment:
<<: *integration_environment
E2E_BROWSERS: chrome
# test_integration_firefox_local will run the integration tests locally with
# firefox headless.
test_integration_firefox_local:
<<: *integration_job
environment:
<<: *integration_environment
E2E_BROWSERS: firefox
# test_integration_chrome will run the integration tests with chrome in
# browserstack.
test_integration_chrome:
<<: *integration_job
environment:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: chrome
# test_integration_firefox will run the integration tests with firefox in
# browserstack.
test_integration_firefox:
<<: *integration_job
environment:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: firefox
# test_integration_edge will run the integration tests with edge in
# browserstack.
test_integration_edge:
<<: *integration_job
environment:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: edge
# test_integration_ie will run the integration tests with ie in
# browserstack.
test_integration_ie:
<<: *integration_job
environment:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: ie
# TODO: remove when more reliable
E2E_MAX_RETRIES: 1
# test_integration_safari will run the integration tests with safari in
# browserstack.
test_integration_safari:
<<: *integration_job
environment:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: safari
# TODO: remove when more reliable
E2E_MAX_RETRIES: 1
# deploy will deploy the application as a docker image.
deploy:
<<: *job_defaults
steps:
- checkout
- setup_remote_docker
- run:
name: Deploy the code
command: bash ./scripts/docker.sh deploy
# filter_deploy will add the filters for a deploy job in a workflow to make it
# only execute on a deploy related job.
filter_deploy: &filter_deploy
filters:
branches:
only:
- master
- next
tags:
only: /v[0-9]+(\.[0-9]+)*/
# filter_develop will add the filters for a development related commit.
filter_develop: &filter_develop
filters:
branches:
ignore:
- master
- next
workflows:
version: 2
# All PR's will hit this workflow.
build-and-test:
jobs:
- npm_dependencies:
<<: *filter_develop
- lint:
<<: *filter_develop
requires:
- npm_dependencies
- test_unit:
<<: *filter_develop
requires:
- npm_dependencies
- build_assets:
<<: *filter_develop
requires:
- npm_dependencies
- test_integration_chrome_local:
<<: *filter_develop
requires:
- build_assets
- test_integration_firefox_local:
<<: *filter_develop
requires:
- build_assets
deploy-tagged:
jobs:
- npm_dependencies:
<<: *filter_deploy
- lint:
<<: *filter_deploy
requires:
- npm_dependencies
- test_unit:
<<: *filter_deploy
requires:
- npm_dependencies
- build_assets:
<<: *filter_deploy
requires:
- npm_dependencies
- test_integration_chrome:
<<: *filter_deploy
requires:
- build_assets
- test_integration_firefox:
<<: *filter_deploy
requires:
- build_assets
- test_integration_edge:
<<: *filter_deploy
requires:
- build_assets
- test_integration_ie:
<<: *filter_deploy
requires:
- build_assets
- test_integration_safari:
<<: *filter_deploy
requires:
- build_assets
- deploy:
<<: *filter_deploy
requires:
- lint
- test_unit
- test_integration_chrome
- test_integration_firefox
- test_integration_edge
# TODO: uncomment when more reliable
# - test_integration_ie
# - test_integration_safari
+3 -3
View File
@@ -19,11 +19,11 @@ if [[ "${E2E_DISABLE}" == "true" ]]; then
exit
fi
if [[ "${CIRCLE_BRANCH}" == "master" && -n "$BROWSERSTACK_KEY" ]]; then
if [[ "$BROWSERSTACK" == "true" && -n "$BROWSERSTACK_KEY" ]]; then
echo Testing on browserstack
yarn e2e --reports-folder "$REPORTS_FOLDER" --bs-key "$BROWSERSTACK_KEY" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS"
node scripts/e2e.js --reports-folder "$REPORTS_FOLDER" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS" --browserstack
else
# When browserstack is not available test locally using chrome headless.
echo Testing locally
yarn e2e --reports-folder "$REPORTS_FOLDER" --retries "$E2E_MAX_RETRIES" --headless
node scripts/e2e.js --reports-folder "$REPORTS_FOLDER" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS" --headless
fi
+3 -29
View File
@@ -1,32 +1,6 @@
**/*.html
dist
docs
**/*.html
plugins/*
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-auth
!plugins/talk-plugin-respect
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-like
!plugins/talk-plugin-mod
!plugins/talk-plugin-love
!plugins/talk-plugin-viewing-options
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-respected
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-member-since
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-subscriber
!plugins/talk-plugin-flag-details
node_modules
public
+32 -3
View File
@@ -1,5 +1,34 @@
### Expected behavior
<!--
### Actual behavior
Thank you for filing an issue on Coral Talk!
### Steps to reproduce behavior
Please fill out the questions below so we can take action on your issue as soon as we can.
If you're filing a feature request, you do not need to follow the outline below. Instead please include "Feature Idea" in your issue title and explain a specific example in which that feature would be useful.
-->
#### Do you want to request a **feature** or report a **bug**?
#### Intended outcome:
<!--
What you were trying to accomplish when the bug occurred?
-->
#### Actual outcome:
<!--
What happened instead?
Please provide as much detail as possible, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. The more information you can give the better.
-->
#### How to reproduce the issue:
<!--
Instructions for how the issue can be reproduced by someone from our team or by a contributor. Be as specific as possible, and only mention what is necessary to reproduce the bug. If possible, try to isolate the exact circumstances in which the bug occurs and avoid speculation over what the cause might be.
-->
#### Version and environment
<!--
List what version of Talk you're using, as well as any other relevant environment information, such as operating system or browser
-->
+32 -21
View File
@@ -13,6 +13,7 @@ client/coral-framework/graphql/introspection.json
.idea/
*.swp
*.DS_STORE
.prettierrc.json
coverage/
test/e2e/reports/
@@ -22,33 +23,43 @@ browserstack.err
plugins.json
plugins/*
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-akismet
!plugins/talk-plugin-auth
!plugins/talk-plugin-respect
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-like
!plugins/talk-plugin-mod
!plugins/talk-plugin-love
!plugins/talk-plugin-viewing-options
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-flag-details
!plugins/talk-plugin-google-auth
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-like
!plugins/talk-plugin-love
!plugins/talk-plugin-member-since
!plugins/talk-plugin-mod
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-notifications
!plugins/talk-plugin-notifications-category-featured
!plugins/talk-plugin-notifications-category-reply
!plugins/talk-plugin-notifications-category-staff
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-respect
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-respected
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-member-since
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-subscriber
!plugins/talk-plugin-flag-details
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-viewing-options
!plugins/talk-plugin-rich-text
!plugins/talk-plugin-rich-text-pell
**/node_modules/*
yarn-error.log
+13
View File
@@ -0,0 +1,13 @@
{
"linters": {
"*.js": [
"git-exec-and-restage eslint --fix --"
],
"bin/cli*": [
"git-exec-and-restage eslint --fix --"
],
"*.yml": [
"yamllint"
]
}
}
+7 -2
View File
@@ -1,5 +1,10 @@
{
"verbose": true,
"exec": "npm-run-all --parallel generate-introspection start:development",
"ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"],
"ext": "js,json,graphql"
"ext": "js,json,graphql,yml",
"watch": [
".",
"bin/cli",
"bin/cli-serve"
]
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"exceptions": [
"https://nodesecurity.io/advisories/531",
"https://nodesecurity.io/advisories/532"
"https://nodesecurity.io/advisories/532",
"https://nodesecurity.io/advisories/566"
]
}
+4 -1
View File
@@ -15,10 +15,13 @@ COPY . /usr/src/app
# Ensure the runtime of the container is in production mode.
ENV NODE_ENV production
# Store the current git revision.
ARG REVISION_HASH
ENV REVISION_HASH=${REVISION_HASH}
# Install app dependencies and build static assets.
RUN yarn global add node-gyp && \
yarn install --frozen-lockfile && \
cli plugins reconcile && \
yarn build && \
yarn cache clean
+5
View File
@@ -1,10 +1,14 @@
FROM coralproject/talk:latest
# Setup the build arguments
ONBUILD ARG TALK_ADDTL_COMMENTS_ON_LOAD_MORE=10
ONBUILD ARG TALK_ASSET_COMMENTS_LOAD_DEPTH=10
ONBUILD ARG TALK_REPLY_COMMENTS_LOAD_DEPTH=3
ONBUILD ARG TALK_THREADING_LEVEL=3
ONBUILD ARG TALK_DEFAULT_STREAM_TAB=all
ONBUILD ARG TALK_DEFAULT_LANG=en
ONBUILD ARG TALK_PLUGINS_JSON
ONBUILD ARG TALK_WEBPACK_SOURCE_MAP
# Bundle app source
ONBUILD COPY . /usr/src/app
@@ -14,5 +18,6 @@ ONBUILD COPY . /usr/src/app
# clear out the development dependencies again. After this we of course need to
# clear out the yarn cache, this saves quite a lot of size.
ONBUILD RUN cli plugins reconcile && \
yarn && \
yarn build && \
yarn cache clean
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 2017 Mozilla Foundation
Copyright 2018 Mozilla Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,4 +1,4 @@
# Talk &middot; [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) &middot; [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8/badge)](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) &middot; [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests)
# Talk &middot; [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) &middot; [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946/badge)](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946) &middot; [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests)
Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
+27 -18
View File
@@ -1,31 +1,23 @@
const express = require('express');
const morgan = require('morgan');
const path = require('path');
const uuid = require('uuid');
const merge = require('lodash/merge');
const helmet = require('helmet');
const plugins = require('./services/plugins');
const compression = require('compression');
const {HELMET_CONFIGURATION} = require('./config');
const {MOUNT_PATH} = require('./url');
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();
// Request Identity Middleware
app.use((req, res, next) => {
req.id = uuid.v4();
next();
});
//==============================================================================
// PLUGIN PRE APPLICATION MIDDLEWARE
//==============================================================================
// Inject server route plugins.
plugins.get('server', 'app').forEach(({plugin, app: callback}) => {
plugins.get('server', 'app').forEach(({ plugin, app: callback }) => {
debug(`added plugin '${plugin.name}'`);
// Pass the app to the plugin to mount it's routes.
@@ -41,18 +33,35 @@ 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);
// Enable a suite of security good practices through helmet. We disable
// frameguard to allow crossdomain injection of the embed.
app.use(helmet(merge(HELMET_CONFIGURATION, {
frameguard: false,
})));
// Compress the responses if appropriate.
app.use(compression());
app.use(
helmet(
merge(HELMET_CONFIGURATION, {
frameguard: false,
})
)
);
//==============================================================================
// VIEW CONFIGURATION
+13 -28
View File
@@ -4,8 +4,9 @@
* Module dependencies.
*/
const program = require('./commander');
const {head, map} = require('lodash');
require('./util');
const program = require('commander');
const { head, map } = require('lodash');
const Matcher = require('did-you-mean');
program
@@ -17,7 +18,6 @@ program
.command('token', 'work with the access tokens')
.command('users', 'work with the application auth')
.command('migration', 'provides utilities for migrating the database')
.command('verify', 'provides utilities for performing data verification')
.command(
'plugins',
'provides utilities for interacting with the plugin system'
@@ -25,33 +25,18 @@ program
.parse(process.argv);
// If the command wasn't found, output help.
const cmds = map(program.commands, '_name');
const cmd = head(program.args);
if (!cmds.includes(cmd)) {
const m = new Matcher(cmds);
const similarCMDs = m.list(cmd);
const commands = map(program.commands, '_name');
const command = head(program.args);
if (!commands.includes(command)) {
const m = new Matcher(commands);
const similarCommands = m.list(command);
console.error(`cli '${cmd}' is not a talk cli command. See 'cli --help'.`);
if (similarCMDs.length > 0) {
const sc = similarCMDs.map(({value}) => `\t${value}\n`).join('');
console.error(
`cli '${command}' is not a talk cli command. See 'cli --help'.`
);
if (similarCommands.length > 0) {
const sc = similarCommands.map(({ value }) => `\t${value}\n`).join('');
console.error(`\nThe most similar commands are\n${sc}`);
}
process.exit(1);
}
/**
* When this provess exists, check to see if we have a running command, if we do
* check to see if it is still running. If it is, then kill it with a SIGINT
* signal. This is for the use case where we want to kill the process that is
* labled with the PID written out by the parent process.
*/
process.once('exit', () => {
if (
// program.runningCommand &&
program.runningCommand.killed === false &&
program.runningCommand.exitCode === null
) {
program.runningCommand.kill('SIGINT');
}
});
+95 -26
View File
@@ -4,7 +4,8 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const parseDuration = require('ms');
const Table = require('cli-table');
const AssetModel = require('../models/asset');
@@ -12,34 +13,28 @@ const CommentModel = require('../models/comment');
const AssetsService = require('../services/assets');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const util = require('./util');
const inquirer = require('inquirer');
const { URL } = require('url');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
util.onshutdown([() => mongoose.disconnect()]);
/**
* Lists all the assets registered in the database.
*/
async function listAssets() {
try {
let assets = await AssetModel.find({}).sort({'created_at': 1});
let assets = await AssetModel.find({}).sort({ created_at: 1 });
let table = new Table({
head: [
'ID',
'Title',
'URL'
]
head: ['ID', 'Title', 'URL'],
});
assets.forEach((asset) => {
assets.forEach(asset => {
table.push([
asset.id,
asset.title ? asset.title : '',
asset.url ? asset.url : ''
asset.url ? asset.url : '',
]);
});
@@ -61,13 +56,13 @@ async function refreshAssets(ageString) {
$or: [
{
scraped: {
$lte: age
}
$lte: age,
},
},
{
scraped: null
}
]
scraped: null,
},
],
});
// Queue all the assets for scraping.
@@ -95,7 +90,6 @@ async function updateURL(assetID, assetURL) {
async function merge(srcID, dstID) {
try {
// Grab the assets...
let [srcAsset, dstAsset] = await AssetsService.findByIDs([srcID, dstID]);
if (!srcAsset || !dstAsset) {
@@ -103,21 +97,22 @@ async function merge(srcID, dstID) {
}
// Count the affected resources...
let srcCommentCount = await CommentModel.find({asset_id: srcID}).count();
let srcCommentCount = await CommentModel.find({ asset_id: srcID }).count();
console.log(`Now going to update ${srcCommentCount} comments and delete the source Asset[${srcID}].`);
console.log(
`Now going to update ${srcCommentCount} comments and delete the source Asset[${srcID}].`
);
let {confirm} = await inquirer.prompt([
let { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Proceed with merge',
default: false
}
default: false,
},
]);
if (confirm) {
// Perform the merge!
await AssetsService.merge(srcID, dstID);
} else {
@@ -131,6 +126,70 @@ async function merge(srcID, dstID) {
}
}
async function rewrite(search, replace, options) {
try {
search = new RegExp(search);
const assets = await AssetModel.find({
url: { $regex: search },
});
if (assets.length === 0) {
console.log(`No assets found with the pattern: ${search}`);
return util.shutdown(0);
}
let opts = [];
assets.forEach(({ id, url: oldURL }) => {
// Replace the url.
const newURL = oldURL.replace(search, replace);
// Try to validate that the new url is valid.
try {
new URL(newURL);
} catch (err) {
throw new Error(
`Rewrite would have replaced the valid URL ${oldURL} with an invalid one ${newURL}`
);
}
opts.push({
find: { id },
updateOne: { $set: { url: newURL } },
id,
oldURL,
newURL,
});
});
if (opts.length > 0) {
if (options.dryRun) {
const table = new Table({ head: ['ID', 'Old URL', 'New URL'] });
opts.forEach(({ id, oldURL, newURL }) => {
table.push([id, oldURL, newURL]);
});
console.log(table.toString());
} else {
const bulk = AssetModel.collection.initializeUnorderedBulkOp();
opts.forEach(({ find, updateOne, oldURL, newURL }) => {
// If the url was updated with the operation, then queue up the update op.
if (newURL !== oldURL) {
bulk.find(find).updateOne(updateOne);
}
});
await bulk.execute();
console.log(`${opts.length} assets had their url's updated`);
}
}
util.shutdown(0);
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
@@ -152,9 +211,19 @@ program
program
.command('merge <srcID> <dstID>')
.description('merges two assets together by moving comments from src to dst and deleting the src asset')
.description(
'merges two assets together by moving comments from src to dst and deleting the src asset'
)
.action(merge);
program
.command('rewrite <search> <replace>')
.option('-d, --dry-run', 'enables dry run of the replacement')
.description(
"rewrites asset url's using the provided regex replacement pattern"
)
.action(rewrite);
program.parse(process.argv);
// If there is no command listed, output help.
+16 -28
View File
@@ -4,33 +4,24 @@
* Module dependencies.
*/
const program = require('./commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const util = require('./util');
const program = require('commander');
const jobs = require('../jobs');
const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
util.onshutdown([
() => mongoose.disconnect(),
]);
util.onshutdown([() => mongoose.disconnect()]);
/**
* Starts the job processor.
*/
function processJobs() {
// The scraper only needs to shutdown when the scraper has actually been
// started.
util.onshutdown([
() => kue.Task.shutdown()
]);
util.onshutdown([() => kue.Task.shutdown()]);
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
// Start the jobs processor.
jobs.process();
}
/**
@@ -39,13 +30,15 @@ function processJobs() {
* @return {Promise}
*/
function removeJob(job) {
return new Promise((resolve, reject) => job.remove((err) => {
if (err) {
return reject(err);
}
return new Promise((resolve, reject) =>
job.remove(err => {
if (err) {
return reject(err);
}
return resolve(job);
}));
return resolve(job);
})
);
}
/**
@@ -82,17 +75,13 @@ async function getJobBatch(n, includeStuck) {
* Cleans up the jobs that are in the queue.
*/
async function cleanupJobs(options) {
// The scraper only needs to shutdown when the scraper has actually been
// started.
util.onshutdown([
() => kue.Task.shutdown()
]);
util.onshutdown([() => kue.Task.shutdown()]);
const n = 100;
try {
// Connect to redis by establishing a queue.
kue.Task.connect();
@@ -100,9 +89,8 @@ async function cleanupJobs(options) {
let jobs = await getJobBatch(n, options.stuck);
while (jobs.length > 0) {
// Remove all the jobs.
await Promise.all(jobs.map((job) => removeJob(job)));
await Promise.all(jobs.map(job => removeJob(job)));
jobCount += jobs.length;
+60 -32
View File
@@ -4,20 +4,18 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const _ = require('lodash');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const MigrationService = require('../services/migration');
// Register shutdown hooks.
util.onshutdown([
() => mongoose.disconnect()
]);
util.onshutdown([() => mongoose.disconnect()]);
async function createMigration(name) {
try {
// Create the migration.
await MigrationService.create(name);
@@ -28,47 +26,60 @@ async function createMigration(name) {
}
}
async function runMigrations() {
async function runMigrations(options) {
const { yes, queryBatchSize, updateBatchSize } = options;
try {
if (!yes) {
const { backedUp } = await inquirer.prompt([
{
type: 'confirm',
name: 'backedUp',
message: 'Did you perform a database backup',
default: false,
},
]);
let {backedUp} = await inquirer.prompt([
{
type: 'confirm',
name: 'backedUp',
message: 'Did you perform a database backup',
default: false
if (!backedUp) {
throw new Error(
'Please backup your databases prior to migrations occuring'
);
}
]);
if (!backedUp) {
throw new Error('Please backup your databases prior to migrations occuring');
}
// Get the migrations to run.
let migrations = await MigrationService.listPending();
const migrations = await MigrationService.listPending();
console.log('Now going to run the following migrations:\n');
for (let {filename} of migrations) {
for (const { filename } of migrations) {
console.log(`\tmigrations/${filename}`);
}
let {confirm} = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Proceed with migrations',
default: false
if (!yes) {
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Proceed with migrations',
default: false,
},
]);
if (confirm) {
// Run the migrations.
await MigrationService.run(migrations, {
queryBatchSize,
updateBatchSize,
});
} else {
console.warn('Skipping migrations');
}
]);
if (confirm) {
// Run the migrations.
await MigrationService.run(migrations);
} else {
console.warn('Skipping migrations');
// Run the migrations.
await MigrationService.run(migrations, {
queryBatchSize,
updateBatchSize,
});
}
util.shutdown();
@@ -87,8 +98,25 @@ program
.description('creates a new migration')
.action(createMigration);
// Bypasses issue that defaults + coercion doesn't work well together.
// Ref: https://github.com/tj/commander.js/issues/400#issuecomment-310860869
const parse10 = _.ary(_.partialRight(parseInt, 10), 1);
program
.command('run')
.option(
'-q, --query-batch-size <n>',
'change the size of queried documents that are batched at a time',
parse10,
10000
)
.option(
'-u, --update-batch-size <n>',
'change the size of documents that are batched before the update is sent',
parse10,
20000
)
.option('-y, --yes', 'will answer yes to all questions')
.description('runs all pending migrations')
.action(runMigrations);
+106 -130
View File
@@ -7,24 +7,25 @@
// Interface heavily inspired by the yarn package manager:
// https://yarnpkg.com/
const program = require('./commander');
require('./util');
const program = require('commander');
const inquirer = require('inquirer');
// Make things colorful!
require('colors');
const emoji = require('node-emoji');
const dir = process.cwd();
const fs = require('fs-extra');
const path = require('path');
const dir = path.resolve(__dirname, '..');
const spawn = require('cross-spawn');
const semver = require('semver');
const resolve = require('resolve');
const {plugins, itteratePlugins, isInternal} = require('../plugins');
const { plugins, iteratePlugins, isInternal } = require('../plugins');
function existsInNodeModules(name) {
try {
resolve.sync(name, {basedir: dir});
resolve.sync(name, { basedir: dir });
return true;
} catch (e) {
@@ -38,13 +39,13 @@ function versionMatch(name, version) {
resolve.sync(name, {
basedir: dir,
packageFilter: (pkg) => {
packageFilter: pkg => {
if (pkg && pkg.version && semver.satisfies(pkg.version, version)) {
matched = true;
}
return pkg;
}
},
});
return matched;
@@ -55,7 +56,7 @@ function versionMatch(name, version) {
const EXTERNAL = /^\w[a-z\-0-9.]+$/; // Match "react", "path", "fs", "lodash.random", etc.
function reconcilePackages({quiet = false, upgradeRemote = false}) {
function reconcilePackages({ quiet = false, upgradeRemote = false }) {
const fetchable = [];
const local = [];
const upgradable = [];
@@ -70,13 +71,14 @@ function reconcilePackages({quiet = false, upgradeRemote = false}) {
}
for (let i in plugins) {
let section = itteratePlugins(plugins[i]);
let section = iteratePlugins(plugins[i]);
for (let j in section) {
let {name, version} = section[j];
let { name, version } = section[j];
let namespaced = name.charAt(0) === '@';
let dep = name.split('/')
let dep = name
.split('/')
.slice(0, namespaced ? 2 : 1)
.join('/');
@@ -90,7 +92,7 @@ function reconcilePackages({quiet = false, upgradeRemote = false}) {
console.log(` l ${name}`);
}
local.push({name, version});
local.push({ name, version });
continue;
}
@@ -98,9 +100,8 @@ function reconcilePackages({quiet = false, upgradeRemote = false}) {
if (!quiet) {
console.log(` m ${name}`);
}
fetchable.push({name, version});
fetchable.push({ name, version });
} else if (!versionMatch(dep, version)) {
// A plugin was found, yet the current version does not match the
// current version installed. We should warn if upgradeRemote is
// not enabled that it is currently not supported.
@@ -114,14 +115,14 @@ function reconcilePackages({quiet = false, upgradeRemote = false}) {
console.log(` oe ${name} (package upgrade may be required)`);
upgradable.push({name, version});
upgradable.push({ name, version });
} else {
if (!quiet) {
console.log(` e ${name}`);
}
if (upgradeRemote) {
upgradable.push({name, version});
upgradable.push({ name, version });
}
}
}
@@ -131,33 +132,38 @@ function reconcilePackages({quiet = false, upgradeRemote = false}) {
console.log();
}
return {local, fetchable, upgradable};
return { local, fetchable, upgradable };
}
async function reconcileRemotePlugins({skipLocal, dryRun, upgradeRemote}) {
console.log(`\n[${skipLocal ? '1/2' : '2/3'}] ${emoji.get('mag')} Reconciling plugins...`.yellow);
const {fetchable, upgradable} = reconcilePackages({upgradeRemote});
async function reconcileRemotePlugins({ dryRun, upgradeRemote }) {
console.log(`\n['1/2'] ${emoji.get('mag')} Reconciling plugins...`.yellow);
const { fetchable, upgradable } = reconcilePackages({ upgradeRemote });
console.log(`[${skipLocal ? '2/2' : '3/3'}] ${emoji.get('truck')} Fetching plugins...\n`.yellow);
console.log(`['2/2'] ${emoji.get('truck')} Fetching plugins...\n`.yellow);
if (fetchable.length > 0) {
console.log(`$ yarn add --ignore-scripts ${fetchable.map(({name, version}) => `${name}@${version}`.cyan)}`);
console.log(
`$ yarn add --ignore-scripts --ignore-workspace-root-check ${fetchable.map(
({ name, version }) => `${name}@${version}`.cyan
)}`
);
if (!dryRun) {
let args = [
'add',
'--ignore-scripts',
...fetchable.map(({name, version}) => `${name}@${version}`)
'--ignore-workspace-root-check',
...fetchable.map(({ name, version }) => `${name}@${version}`),
];
let output = spawn.sync('yarn', args, {
stdio: ['ignore', 'pipe', 'inherit']
stdio: ['ignore', 'pipe', 'inherit'],
});
if (output.status) {
throw new Error('Could not install external plugins, errors occured during install');
throw new Error(
'Could not install external plugins, errors occured during install'
);
}
console.log(output.stdout.toString());
@@ -165,86 +171,47 @@ async function reconcileRemotePlugins({skipLocal, dryRun, upgradeRemote}) {
}
if (upgradable.length > 0) {
console.log(`$ yarn upgrade ${upgradable.map(({name, version}) => `${name}@${version}`.cyan)}`);
console.log(
`$ yarn upgrade ${upgradable.map(
({ name, version }) => `${name}@${version}`.cyan
)}`
);
if (!dryRun) {
let args = [
'upgrade',
...upgradable.map(({name, version}) => `${name}@${version}`)
...upgradable.map(({ name, version }) => `${name}@${version}`),
];
let output = spawn.sync('yarn', args, {
stdio: ['ignore', 'pipe', 'inherit']
});
if (output.status) {
throw new Error('Could not install external plugins, errors occured during install');
}
console.log(output.stdout.toString());
}
}
return {upgradable, fetchable};
}
async function reconcileLocalPlugins({skipRemote, dryRun}) {
console.log(`\n[${skipRemote ? '1/1' : '1/3'}] ${emoji.get('pick')} Installing local plugin dependencies...\n`.yellow);
const {local} = reconcilePackages({quiet: true});
for (let i in local) {
let {name} = local[i];
if (!fs.existsSync(path.join(dir, 'plugins', name, 'package.json'))) {
continue;
}
let wd = path.join(dir, 'plugins', name);
console.log(`$ cd ${wd.cyan} && yarn`);
if (!dryRun) {
let args = [];
let output = spawn.sync('yarn', args, {
stdio: ['ignore', 'pipe', 'inherit'],
cwd: wd
});
if (output.status) {
throw new Error('Could not install local plugin dependencies, errors occured during install');
throw new Error(
'Could not install external plugins, errors occured during install'
);
}
console.log(output.stdout.toString());
}
}
return { upgradable, fetchable };
}
// This traverses the local plugins and installs any dependencies listed there,
// this only is really needed for plugins that are installed via docker because
// core plugins will have their dependencies already included in core.
async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote}) {
let startTime = new Date();
async function reconcilePluginDeps({ dryRun, upgradeRemote }) {
try {
let startTime = new Date();
// We don't need to do anything if we skip everything....
if (skipLocal && skipRemote) {
return;
}
// Traverse local plugins and install dependencies if enabled.
if (!skipLocal) {
await reconcileLocalPlugins({skipRemote, dryRun});
}
// Locate any external plugins and install them.
if (!skipRemote) {
let results = [];
try {
results = await reconcileRemotePlugins({skipLocal, skipRemote, dryRun, upgradeRemote});
} catch (e) {
throw e;
}
// Locate any external plugins and install them.
const results = await reconcileRemotePlugins({
dryRun,
upgradeRemote,
});
let status;
if (dryRun) {
@@ -261,16 +228,23 @@ async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote
} else if (results.fetchable.length === 0) {
message = `Upgraded ${results.upgradable.length} new plugins.`;
} else {
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${results.upgradable.length} plugins.`;
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${
results.upgradable.length
} plugins.`;
}
console.log(`\n${status} ${message}`);
let endTime = new Date();
let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(
2
);
console.log(`✨ Done in ${totalTime}s.`);
} catch (err) {
console.error(err);
process.exit(1);
}
let endTime = new Date();
let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(2);
console.log(`✨ Done in ${totalTime}s.`);
}
async function createSeedPlugin() {
@@ -278,8 +252,7 @@ async function createSeedPlugin() {
function pluginNameExists(pluginName) {
const pluginNames = fs.readdirSync(pluginsDir);
return !!pluginNames
.filter((pn) => pn === pluginName).length;
return !!pluginNames.filter(pn => pn === pluginName).length;
}
let answers = await inquirer.prompt([
@@ -287,8 +260,7 @@ async function createSeedPlugin() {
type: 'input',
name: 'pluginName',
message: 'Plugin Name:',
validate: (input) => {
validate: input => {
if (pluginNameExists(input)) {
return 'Please, choose another name. That name already exists';
}
@@ -298,23 +270,23 @@ async function createSeedPlugin() {
}
return 'Plugin Name is required.';
}
},
},
{
type: 'confirm',
name: 'server',
message: 'Is this plugin extending the server capabilities?'
message: 'Is this plugin extending the server capabilities?',
},
{
type: 'confirm',
name: 'client',
message: 'Is this plugin extending the client capabilities?'
message: 'Is this plugin extending the client capabilities?',
},
{
type: 'confirm',
name: 'addPluginsJson',
message: 'Should we add it to the plugins.json?'
}
message: 'Should we add it to the plugins.json?',
},
]);
//==============================================================================
@@ -325,41 +297,41 @@ async function createSeedPlugin() {
const newPluginPath = path.join(pluginsDir, answers.pluginName);
if (fs.existsSync(seedPlugin)) {
if (answers.server && answers.client) {
// This is a server-side and client-side plugin!, let's copy the template
fs.copySync(seedPlugin, newPluginPath);
} else {
} else {
fs.copySync(seedPlugin, newPluginPath, {
filter: p => {
// Allowing plugin folder and files with no subfolders
const rootRx = /plugin$|plugin\/[^/]*(\.).{2,3}/gim;
if (
rootRx.test(p) &&
(fs.lstatSync(p).isDirectory() || fs.lstatSync(p).isFile())
) {
return true;
}
fs.copySync(seedPlugin, newPluginPath, {filter: (p) => {
// If it's a client-side plugin, copying client folder
if (answers.client) {
return /client/.test(p);
}
// Allowing plugin folder and files with no subfolders
const rootRx = /plugin$|plugin\/[^/]*(\.).{2,3}/igm;
if (rootRx.test(p) && (fs.lstatSync(p).isDirectory() || fs.lstatSync(p).isFile())) {
return true;
}
// If it's a client-side plugin, copying client folder
if (answers.client) {
return /client/.test(p);
}
// If it's a server-side plugin, copying server folder
if (answers.server) {
return /server/.test(p);
}
}});
// If it's a server-side plugin, copying server folder
if (answers.server) {
return /server/.test(p);
}
},
});
}
// Let's add this to the plugins.json
if (answers.addPluginsJson) {
const pluginsJson = path.resolve(__dirname, '..', 'plugins.json');
fs.readJson(pluginsJson)
.then((j) => {
fs
.readJson(pluginsJson)
.then(j => {
// This is a client-side plugin, let's push this.
if (answers.client) {
j.client.push(answers.pluginName);
@@ -376,14 +348,17 @@ async function createSeedPlugin() {
fs.writeFileSync(pluginsJson, output);
}
})
.catch((err) => {
.catch(err => {
console.error(err);
});
}
console.log(`✨ Yay! Plugin created! Find your plugin: ${answers.pluginName} in the ./plugins folder`);
console.log(
`✨ Yay! Plugin created! Find your plugin: ${
answers.pluginName
} in the ./plugins folder`
);
}
}
//==============================================================================
@@ -402,11 +377,12 @@ program
program
.command('reconcile')
.description('reconciles local plugin dependencies and downloads external plugins')
.description('reconciles dependencies by downloading external plugins')
.option('-u, --upgrade-remote', 'upgrades remote dependencies')
.option('-d, --dry-run', 'does not actually change anything on the filesystem acts only as a simulation')
.option('--skip-local', 'skips the local dependancy reconciliation')
.option('--skip-remote', 'skips the remote plugin reconciliation')
.option(
'-d, --dry-run',
'does not actually change anything on the filesystem acts only as a simulation'
)
.action(reconcilePluginDeps);
program.parse(process.argv);
+6 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const serve = require('../serve');
//==============================================================================
@@ -10,12 +10,14 @@ const serve = require('../serve');
program
.option('-j, --jobs', 'enable job processing on this thread')
.option('-w, --websockets', 'enable the websocket (subscriptions) handler on this thread')
.option(
'-w, --websockets',
'enable the websocket (subscriptions) handler on this thread'
)
.parse(process.argv);
// Start serving.
serve({jobs: program.jobs, websockets: program.websockets}).catch((err) => {
serve({ jobs: program.jobs, websockets: program.websockets }).catch(err => {
console.error(err);
util.shutdown(1);
});
+5 -5
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env node
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingsService = require('../services/settings');
const util = require('./util');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
@@ -16,12 +16,12 @@ async function changeOrgName() {
try {
let settings = await SettingsService.retrieve();
let {organizationName} = await inquirer.prompt([
let { organizationName } = await inquirer.prompt([
{
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName
}
default: settings.organizationName,
},
]);
if (settings.organizationName !== organizationName) {
+49 -50
View File
@@ -4,7 +4,8 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingModel = require('../models/setting');
@@ -12,13 +13,11 @@ const MODERATION_OPTIONS = require('../models/enum/moderation_options');
const SettingsService = require('../services/settings');
const SetupService = require('../services/setup');
const UsersService = require('../services/users');
const util = require('./util');
const MigrationService = require('../services/migration');
const errors = require('../errors');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
util.onshutdown([() => mongoose.disconnect()]);
//==============================================================================
// Setting up the program command line arguments.
@@ -34,19 +33,15 @@ program
//==============================================================================
const performSetup = async () => {
// Get the current settings, we are expecing an error here.
try {
// Try to get the settings.
await SettingsService.retrieve();
// We should NOT have gotten a settings object, this means that the
// application is already setup. Error out here.
throw errors.ErrSettingsInit;
} catch (e) {
// If the error is `not init`, then we're good, otherwise, it's something
// else.
if (e !== errors.ErrSettingsNotInit) {
@@ -57,6 +52,12 @@ const performSetup = async () => {
if (program.defaults) {
await SettingsService.init();
// Get the migrations to run.
let migrations = await MigrationService.listPending();
// Perform all migrations.
await MigrationService.run(migrations);
console.log('Settings created.');
console.log('\nTalk is now installed!');
@@ -66,7 +67,9 @@ const performSetup = async () => {
// Create the base settings model.
let settings = new SettingModel();
console.log('\nWe\'ll ask you some questions in order to setup your installation of Talk.\n');
console.log(
"\nWe'll ask you some questions in order to setup your installation of Talk.\n"
);
let answers = await inquirer.prompt([
{
@@ -74,31 +77,31 @@ const performSetup = async () => {
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName,
validate: (input) => {
validate: input => {
if (input && input.length > 0) {
return true;
}
return 'Organization Name is required.';
}
},
},
{
type: 'list',
choices: MODERATION_OPTIONS,
name: 'moderation',
default: settings.moderation,
message: 'Select a moderation mode'
message: 'Select a moderation mode',
},
{
type: 'confirm',
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
message: 'Should emails always be confirmed',
},
]);
// Update the settings that were changed.
Object.keys(answers).forEach((key) => {
Object.keys(answers).forEach(key => {
if (answers[key] !== undefined) {
settings[key] = answers[key];
}
@@ -109,105 +112,101 @@ const performSetup = async () => {
type: 'confirm',
name: 'inputWhitelistedDomains',
default: true,
message: 'Would you like to specify a whitelisted domain'
message: 'Would you like to specify a whitelisted domain',
},
{
type: 'input',
name: 'whitelistedDomain',
message: 'Whitelisted Domain',
when: ({inputWhitelistedDomains}) => inputWhitelistedDomains,
validate: (input) => {
when: ({ inputWhitelistedDomains }) => inputWhitelistedDomains,
validate: input => {
if (input && input.length > 0) {
return true;
}
return 'Whitelisted Domain cannot be empty.';
}
}
},
},
]);
if (answers.inputWhitelistedDomains) {
settings.domains.whitelist = [answers.whitelistedDomain];
}
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
console.log("\nWe'll ask you some questions about your first admin user.\n");
let user = await inquirer.prompt([
{
type: 'input',
name: 'username',
message: 'Username',
filter: (username) => {
return UsersService
.isValidUsername(username, false)
.catch((err) => {
throw err.message;
});
}
filter: username => {
return UsersService.isValidUsername(username, false).catch(err => {
throw err.message;
});
},
},
{
name: 'email',
message: 'Email',
format: 'email',
validate: (value) => {
validate: value => {
if (value && value.length >= 3) {
return true;
}
return 'Email is required';
}
},
},
{
name: 'password',
message: 'Password',
type: 'password',
filter: (password) => {
return UsersService
.isValidPassword(password)
.catch((err) => {
throw err.message;
});
}
filter: password => {
return UsersService.isValidPassword(password).catch(err => {
throw err.message;
});
},
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword, {password}) => {
filter: (confirmPassword, { password }) => {
if (password !== confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
throw err.message;
});
}
return UsersService.isValidPassword(confirmPassword).catch(err => {
throw err.message;
});
},
},
]);
let {user: newUser} = await SetupService.setup({
let { user: newUser } = await SetupService.setup({
settings: settings.toObject(),
user: {
email: user.email,
username: user.username,
password: user.password
}
password: user.password,
},
});
console.log('Settings created.');
console.log(`User ${newUser.id} created.`);
console.log('\nTalk is now installed!');
console.log('\nWe recommend adding TALK_INSTALL_LOCK=TRUE to your environment to turn off the dynamic setup.');
console.log(
'\nWe recommend adding TALK_INSTALL_LOCK=TRUE to your environment to turn off the dynamic setup.'
);
};
// Start tthe setup process.
// Start the setup process.
performSetup()
.then(() => {
util.shutdown();
})
.catch((e) => {
.catch(e => {
console.error(e);
util.shutdown(1);
});
+7 -19
View File
@@ -4,35 +4,25 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const mongoose = require('../services/mongoose');
const TokensService = require('../services/tokens');
const util = require('./util');
const Table = require('cli-table');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
util.onshutdown([() => mongoose.disconnect()]);
async function listTokens(userID) {
try {
let tokens = await TokensService.list(userID);
let table = new Table({
head: [
'ID',
'Name',
'Status'
]
head: ['ID', 'Name', 'Status'],
});
tokens.forEach((token) => {
table.push([
token.id,
token.name,
token.active ? 'Active' : 'Revoked'
]);
tokens.forEach(token => {
table.push([token.id, token.name, token.active ? 'Active' : 'Revoked']);
});
console.log(table.toString());
@@ -46,7 +36,6 @@ async function listTokens(userID) {
async function revokeToken(tokenID) {
try {
await TokensService.revoke(null, tokenID);
console.log(`Revoked Token[${tokenID}]`);
@@ -60,8 +49,7 @@ async function revokeToken(tokenID) {
async function createToken(userID, tokenName) {
try {
let {pat: {id}, jwt} = await TokensService.create(userID, tokenName);
let { pat: { id }, jwt } = await TokensService.create(userID, tokenName);
console.log(`Created Token[${id}] for User[${userID}] = ${jwt}`);
+236 -422
View File
@@ -4,160 +4,135 @@
* Module dependencies.
*/
const program = require('./commander');
const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const { graphql } = require('graphql');
const helpers = require('../services/migration/helpers');
const { stripIndent } = require('common-tags');
const Table = require('cli-table');
// Make things colorful!
require('colors');
// Register the autocomplete plugin.
inquirer.registerPrompt(
'autocomplete',
require('inquirer-autocomplete-prompt')
);
const schema = require('../graph/schema');
const Context = require('../graph/context');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const CommentModel = require('../models/comment');
const ActionModel = require('../models/action');
const USER_ROLES = require('../models/enum/user_roles');
const mongoose = require('../services/mongoose');
const util = require('./util');
const Table = require('cli-table');
const databaseVerifications = require('./verifications/database');
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
if (input && input.length >= len) {
return true;
}
return msg;
};
// Regeister the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
function getUserCreateAnswers(options) {
if (options.flag_mode) {
let user = {
email: options.email,
password: options.password,
confirmPassword: options.password,
username: options.name,
roles: []
};
if (options.role && USER_ROLES.indexOf(options.role) > -1) {
user.roles = [options.role];
}
return Promise.resolve(user);
}
return inquirer.prompt([
{
name: 'email',
message: 'Email',
format: 'email',
validate: validateRequired('Email is required')
},
{
name: 'password',
message: 'Password',
type: 'password',
filter: (password) => {
return UsersService
.isValidPassword(password)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword) => {
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'username',
message: 'Username',
filter: (username) => {
return UsersService
.isValidUsername(username)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'roles',
message: 'User Role',
type: 'checkbox',
choices: USER_ROLES
}
]);
}
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
/**
* Prompts for input and registers a user based on those.
* transforms a specific action to a removal action on the target model.
*/
async function createUser(options) {
try {
const answers = await getUserCreateAnswers(options);
if (answers.password !== answers.confirmPassword) {
throw new Error('Passwords do not match');
}
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}.`);
});
}));
}
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
const actionDecrTransformer = ({ item_id, action_type, group_id }) => ({
query: { id: item_id },
update: {
$inc: {
[`action_counts.${action_type.toLowerCase()}`]: -1,
[`action_counts.${action_type.toLowerCase()}_${group_id.toLowerCase()}`]: -1,
},
},
});
/**
* Deletes a user.
* Deletes a user and cleans up their associated verifications.
*/
async function deleteUser(userID) {
try {
// Find the user we're removing.
const user = await UserModel.findOne({id: userID});
const user = await UserModel.findOne({ id: userID });
if (!user) {
throw new Error(`user with id ${userID} not found`);
}
printUserAsTable(user);
console.warn(stripIndent`
This will delete the above user.
This might take a long time if there is a lot of data, please confirm that
you want to continue.
`);
const { confirm } = await inquirer.prompt({
type: 'confirm',
name: 'confirm',
message: 'Continue',
default: false,
});
if (!confirm) {
return util.shutdown();
}
const { transformSingleWithCursor } = helpers({
queryBatchSize: 10000,
updateBatchSize: 10000,
});
console.warn("Removing user's actions");
// Remove all actions against comments.
await transformSingleWithCursor(
ActionModel.collection.find({ user_id: user.id, item_type: 'COMMENTS' }),
actionDecrTransformer,
CommentModel
);
// Remove all actions against users.
await transformSingleWithCursor(
ActionModel.collection.find({ user_id: user.id, item_type: 'USERS' }),
actionDecrTransformer,
UserModel
);
// Remove all the user's actions.
await ActionModel
.where({user_id: user.id})
.setOptions({multi: true})
await ActionModel.where({ user_id: user.id })
.setOptions({ multi: true })
.remove();
console.warn("Removing user's comments");
// Removes all the user's reply counts on each of the comments that they
// have commented on.
await transformSingleWithCursor(
CommentModel.collection.aggregate([
{ $match: { author_id: user.id } },
{
$group: {
_id: '$parent_id',
count: { $sum: 1 },
},
},
]),
({ _id: parent_id, count }) => ({
query: { id: parent_id },
update: {
$inc: {
reply_count: -1 * count,
},
},
}),
CommentModel
);
// Remove all the user's comments.
await CommentModel
.where({author_id: user.id})
.setOptions({multi: true})
await CommentModel.where({ author_id: user.id })
.setOptions({ multi: true })
.remove();
// Update the counts that might have changed.
for (const verification of databaseVerifications) {
await verification({fix: true, limit: Infinity, batch: 1000});
}
console.warn('Removing the user');
// Remove the user.
await user.remove();
@@ -169,146 +144,94 @@ async function deleteUser(userID) {
}
}
/**
* Changes the password for a user.
*/
function passwd(userID) {
inquirer.prompt([
function printUserAsTable(user) {
let table = new Table({});
table.push(
{ ID: user.id.gray },
{ Username: user.username },
{ Emails: user.emails },
{ Tags: user.tags ? user.tags.map(({ tag: { name } }) => name) : '' },
{ Role: user.role },
{ Verified: user.hasVerifiedEmail },
{ Username: user.status.username.status },
{ Banned: user.banned },
{
name: 'password',
message: 'Password',
type: 'password',
validate: validateRequired('Password is required')
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
validate: validateRequired('Confirm Password is required')
Suspension: user.suspended
? `Until ${user.status.suspension.until}`
: false,
}
])
.then((answers) => {
if (answers.password !== answers.confirmPassword) {
throw new Error('Password mismatch');
}
);
return UsersService.changePassword(userID, answers.password);
})
.then(() => {
console.log('Password changed.');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
console.log(table.toString());
}
/**
* Updates the user from the options array.
* Searches for users based on their username and email address.
*/
function updateUser(userID, options) {
const updates = [];
if (options.email && typeof options.email === 'string' && options.email.length > 0) {
let q = UserModel.update({
'id': userID,
'profiles.provider': 'local'
}, {
$set: {
'profiles.$.id': options.email
async function searchUsers() {
const ctx = Context.forSystem();
const searchQuery = `
query SearchUsers($value: String) {
users(query: {value: $value}) {
nodes {
id
username
role
profiles {
id
provider
}
}
}
});
}
`;
updates.push(q);
}
if (options.name && typeof options.name === 'string' && options.name.length > 0) {
let q = UserModel.update({
'id': userID
}, {
$set: {
username: options.name
}
});
updates.push(q);
}
Promise
.all(updates.map((q) => q.exec()))
.then(() => {
console.log(`User ${userID} updated.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Lists all the users registered in the database.
*/
function listUsers() {
UsersService
.all()
.then((users) => {
let table = new Table({
head: [
'ID',
'Username',
'Profiles',
'Roles',
'Status',
'State'
]
});
users.forEach((user) => {
let state = user.disabled ? 'Disabled' : 'Enabled';
const profile = user.profiles.find(({provider}) => provider === 'local');
if (profile && profile.metadata && profile.metadata.confirmed_at) {
state += ', Verified';
} else {
state += ', Unverified';
try {
const answers = await inquirer.prompt({
type: 'autocomplete',
name: 'userID',
message: 'Search for a user',
source: async (answers, value) => {
if (value === null) {
value = '';
}
table.push([
user.id,
user.username,
user.profiles.map((p) => p.provider).join(', '),
user.roles.join(', '),
user.status,
state
]);
});
const { data, errors } = await graphql(schema, searchQuery, {}, ctx, {
value,
});
if (errors && errors.length > 0) {
throw errors[0];
}
console.log(table.toString());
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
if (data.users === null) {
return [];
}
/**
* Merges two users using the specified ID's.
* @param {String} dstUserID id of the user to which is the target of the merge
* @param {String} srcUserID id of the user to which is the source of the merge
*/
function mergeUsers(dstUserID, srcUserID) {
UsersService
.mergeUsers(dstUserID, srcUserID)
.then(() => {
console.log(`User ${srcUserID} was merged into user ${dstUserID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
return data.users.nodes.map(user => {
const emails = user.profiles
.filter(({ provider }) => provider === 'local')
.map(({ id }) => id)
.join(', ');
return {
name: `${user.username} (${emails}) ${user.id.gray} - ${
user.role.gray
}`,
value: user.id,
};
});
},
});
const { userID } = answers;
const user = await UserModel.findOne({ id: userID });
printUserAsTable(user);
util.shutdown(0);
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
/**
@@ -316,127 +239,70 @@ 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 setUserRole(userID) {
try {
const { role } = 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(', ')}.`);
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);
});
}
/**
* Verifies an email address for a user.
*
* @param userID the user's id
* @param email the user's email address to be verified
* @param email the user's email address to be verified, otherwise verifies the
* first email if there is one, if there are multiple, you get a
* prompt.
*/
async function verify(userID, email) {
async function verifyUserEmail(userID, email) {
try {
// Get the user.
const user = await UserModel.findOne({ id: userID });
if (!user) {
throw new Error(`user with ID ${userID} cannot be found`);
}
// Get all the user's email addresses.
const emails = user.emails;
if (emails.length === 0) {
throw new Error('user did not have any email addresses');
}
if (!email && emails.length === 1) {
// The email wasn't passed, and there is only one option.
email = emails[0];
} else if (!emails.includes(email)) {
// The email passed doesn't belong to this user.
throw new Error(`user does not have the email ${email}`);
} else if (emails.length > 1) {
// The email wasn't passed, and there is more than one choice.
const answers = await inquirer.prompt([
{
name: 'email',
message: 'Select Email to Verify',
type: 'list',
choices: emails,
},
]);
email = answers.email;
}
// Verify the email.
await UsersService.confirmEmail(userID, email);
console.log(`User ${userID} had their email ${email} verified.`);
util.shutdown();
@@ -450,77 +316,25 @@ async function verify(userID, email) {
// Setting up the program command line arguments.
//==============================================================================
program
.command('create')
.option('--email [email]', 'Email to use')
.option('--password [password]', 'Password to use')
.option('--name [name]', 'Name to use')
.option('--role [role]', 'Role to add')
.option('-f, --flag_mode', 'Source from flags instead of prompting')
.description('create a new user')
.action(createUser);
program
.command('delete <userID>')
.description('delete a user')
.action(deleteUser);
program
.command('passwd <userID>')
.description('change a password for a user')
.action(passwd);
program
.command('update <userID>')
.option('--email [email]', 'Email to use')
.option('--name [name]', 'Name to use')
.description('update a user')
.action(updateUser);
program
.command('list')
.description('list all the users in the database')
.action(listUsers);
.description('searches for a user based on their stored username and email')
.action(searchUsers);
program
.command('merge <dstUserID> <srcUserID>')
.description('merge srcUser into the dstUser')
.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('set-role <userID> <role>')
.description('sets the role on a user')
.action(setUserRole);
program
.command('verify <userID> <email>')
.description('verifies the given user\'s email address')
.action(verify);
.description("verifies the given user's email address")
.action(verifyUserEmail);
program.parse(process.argv);
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('./commander');
const mongoose = require('../services/mongoose');
const util = require('./util');
const databaseVerifications = require('./verifications/database');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
async function database({fix = false, limit = Infinity, batch = 1000}) {
try {
for (const verification of databaseVerifications) {
await verification({fix, limit, batch});
}
} catch (err) {
console.error(`Failed to process all the ${databaseVerifications.length} verifications`, err);
util.shutdown(1);
return;
}
util.shutdown();
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('db')
.description('verifies the database integrity')
.option('-f, --fix', 'fix the problems found with database inconsistencies')
.option('-l, --limit [size]', 'limit the amount of documents to process in a single pass, this will ensure only a maximum number of batch operations are issued [default: inf]', parseInt)
.option('-b, --batch [size]', 'batch size to process verifications and repairs of documents [default: 1000]', parseInt)
.action(database);
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
-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');
@@ -1,12 +1,12 @@
import React from 'react';
import {CoralLogo} from 'plugin-api/beta/client/components/ui';
import { CoralLogo } from 'plugin-api/beta/client/components/ui';
import styles from './MyPluginComponent.css';
class MyPluginComponent extends React.Component {
render() {
return (
<div className={styles.myPluginContainer}>
<CoralLogo className={styles.logo}/>
<CoralLogo className={styles.logo} />
<div className={styles.description}>
<h3>Plugin created by Talk CLI</h3>
+2 -3
View File
@@ -1,4 +1,3 @@
/**
This is a client index example file and it could look like this:
@@ -21,6 +20,6 @@ import MyPluginComponent from './components/MyPluginComponent';
export default {
slots: {
stream: [MyPluginComponent]
}
stream: [MyPluginComponent],
},
};
+22 -48
View File
@@ -1,7 +1,9 @@
const debug = require('debug')('talk:util');
const fs = require('fs');
// Setup the environment.
require('../services/env');
const util = module.exports = {};
const debug = require('debug')('talk:util');
const util = (module.exports = {});
/**
* Stores an array of functions that should be executed in the event that the
@@ -15,20 +17,22 @@ util.toshutdown = [];
* @param {Number} [defaultCode=0] default return code upon sucesfull shutdown.
*/
util.shutdown = (defaultCode = 0, signal = null) => {
if (signal) {
debug(`Reached ${signal} signal`);
}
debug(`${util.toshutdown.length} jobs now being called`);
Promise
.all(util.toshutdown.map((func) => func ? func(signal) : null).filter((func) => func))
Promise.all(
util.toshutdown
.map(func => (func ? func(signal) : null))
.filter(func => func)
)
.then(() => {
debug('Shutdown complete, now exiting');
process.exit(defaultCode);
})
.catch((err) => {
.catch(err => {
console.error(err);
process.exit(1);
@@ -41,54 +45,24 @@ util.shutdown = (defaultCode = 0, signal = null) => {
* @param {Array} jobs Array of promise capable shutdown functions that are
* executed.
*/
util.onshutdown = (jobs) => {
util.onshutdown = jobs => {
debug(`${jobs.length} jobs registered to be called during shutdown`);
// Add the new jobs to shutdown to the object reference.
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.
process.on('SIGTERM', () => util.shutdown(0, 'SIGTERM'));
process.on('SIGINT', () => util.shutdown(0, 'SIGINT'));
process.on('SIGTERM', () => util.shutdown(0, 'SIGTERM'));
process.on('SIGINT', () => util.shutdown(0, 'SIGINT'));
process.once('SIGUSR2', () => util.shutdown(0, 'SIGUSR2'));
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
console.error(err);
process.exit(1);
});
-193
View File
@@ -1,193 +0,0 @@
const CommentModel = require('../../../models/comment');
const ActionsService = require('../../../services/actions');
const {arrayJoinBy, singleJoinBy} = require('../../../graph/loaders/util');
const sc = require('snake-case');
const debug = require('debug')('talk:cli:verify');
const getBatch = async (limit, offset) => CommentModel
.find({})
.select({'id': 1, 'action_counts': 1, 'reply_count': 1})
.limit(limit)
.skip(offset)
.sort('created_at');
module.exports = async ({fix, limit, batch}) => {
let operations = [];
// Count how many comments there are to process.
const totalCount = await CommentModel.count();
let offset = 0;
let comments = [];
let commentIDs = [];
console.log(`Processing ${totalCount} comments in batches of ${limit}...`);
// Keep processing documents until there are is none left.
while (offset < totalCount) {
// Get a batch of comments.
comments = await getBatch(batch, offset);
commentIDs = comments.map(({id}) => id);
// Get their reply counts.
let allReplyCounts = await CommentModel
.aggregate([
{
$match: {
parent_id: {
$in: commentIDs,
},
status: {
$in: ['NONE', 'ACCEPTED']
}
}
},
{
$group: {
_id: '$parent_id',
count: {
$sum: 1
}
}
}
])
.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
// currently set for the comments.
let commentOperations = [];
// If the reply count needs to be updated, then update it!
if (comment.reply_count !== replyCount) {
commentOperations.push({
reply_count: replyCount,
});
}
// 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) {
operations.push({
updateOne: {
filter: {
id: comment.id
},
update: {
$set: Object.assign({}, ...commentOperations),
},
},
});
}
}
debug(`Processed batch of ${comments.length} comments.`);
if (operations.length >= limit) {
debug(`Queued operations are ${operations.length}, reached limit of ${limit}, not processing any more.`);
if (operations.length > limit) {
debug(`${operations.length - limit} operations have been truncated to enforce the limit`);
}
break;
}
offset += batch;
}
const OPERATIONS_LENGTH = operations.length;
if (limit < Infinity && offset + comments.length < totalCount) {
console.log(`Processed ${offset + comments.length}/${totalCount} comments because we reached the update limit of ${limit}.`);
} else {
console.log(`Processed all ${totalCount} comments.`);
}
console.log(`${OPERATIONS_LENGTH} documents need fixing.`);
// If fix was enabled, execute the batch writes.
if (OPERATIONS_LENGTH > 0) {
if (fix) {
debug(`Fixing ${OPERATIONS_LENGTH} documents...`);
while (operations.length) {
let batchOperations = operations.splice(0, batch);
let result = await CommentModel.collection.bulkWrite(batchOperations);
debug(`Fixed batch of ${result.modifiedCount} documents.`);
}
console.log(`Applied all ${OPERATIONS_LENGTH} fixes.`);
} else {
console.warn('Skipping fixing, --fix was not enabled, pass --fix to fix these errors');
}
}
};
-12
View File
@@ -1,12 +0,0 @@
// This will import all the verifications that should be run by the:
//
// cli verify database
//
// command. They exist in the form:
//
// async ({fix = false, batch = 1000}) => {}
//
// where their options are derrived.
module.exports = [
require('./comments'),
];
-65
View File
@@ -1,65 +0,0 @@
machine:
node:
version: 8
services:
- docker
- redis
environment:
PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin"
NODE_ENV: "test"
pre:
# TODO: use the following to add in support for MongoDB 3.4.
# # Upgrade the database version to 3.4.
# - sudo apt-get purge mongodb-org*
# - sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 0C49F3730359A14518585931BC711F9BA15703C6
# - echo "deb [ arch=amd64 ] http://repo.mongodb.org/apt/ubuntu precise/mongodb-org/3.4 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.4.list
# - sudo apt-get update
# - sudo apt-get install -y mongodb-org
# - sudo service mongod restart
# Install chromium for e2e and remove old google-chrome
- sudo rm -rf /opt/google/chrome
- sudo rm -f /usr/bin/google-chrome*
- sudo apt-get update
- sudo apt-get install chromium-browser
dependencies:
override:
# Install node dependencies.
- yarn --version
- yarn global add node-gyp nsp --force
- yarn
post:
# Build the static assets.
- yarn build
# Lint the project here, before tests are ran.
- yarn lint
database:
post:
# Initialize the settings in the database, this will create indicies for the
# database.
- ./bin/cli setup --defaults
- sleep 2
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
# Check dependancies using nsp.
- nsp check
- yarn e2e-ci
deployment:
release:
tag: /v[0-9]+(\.[0-9]+)*/
commands:
- bash ./scripts/docker.sh deploy
latest:
branch: master
owner: coralproject
commands:
- bash ./scripts/docker.sh deploy
+18 -18
View File
@@ -1,44 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Router, Route, IndexRedirect, IndexRoute} from 'react-router';
import { Router, Route, IndexRedirect, IndexRoute } from 'react-router';
import Configure from 'routes/Configure';
import Install from 'routes/Install';
import Stories from 'routes/Stories';
import Community from 'routes/Community/containers/Community';
import {ModerationLayout, Moderation} from 'routes/Moderation';
import Community from 'routes/Community';
import { ModerationLayout, Moderation } from 'routes/Moderation';
import Layout from 'containers/Layout';
const routes = (
<div>
<Route exact path="/admin/install" component={Install}/>
<Route path='/admin' component={Layout}>
<IndexRedirect to='/admin/moderate' />
<Route path='configure' component={Configure} />
<Route path='stories' component={Stories} />
<Route exact path="/admin/install" component={Install} />
<Route path="/admin" component={Layout}>
<IndexRedirect to="/admin/moderate" />
<Route path="configure" component={Configure} />
<Route path="stories" component={Stories} />
{/* Community Routes */}
<Route path='community'>
<Route path='flagged' components={Community}>
<Route path=':id' components={Community} />
<Route path="community">
<Route path="flagged" components={Community}>
<Route path=":id" components={Community} />
</Route>
<Route path='people' components={Community}>
<Route path=':id' components={Community} />
<Route path="people" components={Community}>
<Route path=":id" components={Community} />
</Route>
<IndexRedirect to='flagged' />
<IndexRedirect to="flagged" />
</Route>
{/* Moderation Routes */}
<Route path='moderate' component={ModerationLayout}>
<Route path="moderate" component={ModerationLayout}>
<IndexRoute components={Moderation} />
<Route path=':tabOrId' components={Moderation} />
<Route path=":tabOrId" components={Moderation} />
<Route path=':tab' components={Moderation}>
<Route path=':id' components={Moderation} />
<Route path=":tab" components={Moderation}>
<Route path=":id" components={Moderation} />
</Route>
</Route>
</Route>
-165
View File
@@ -1,165 +0,0 @@
import bowser from 'bowser';
import * as actions from '../constants/auth';
import t from 'coral-framework/services/i18n';
import jwtDecode from 'jwt-decode';
//==============================================================================
// SIGN IN
//==============================================================================
export const handleLogin = (email, password, recaptchaResponse) => (dispatch, _, {rest, client, storage}) => {
dispatch({type: actions.LOGIN_REQUEST});
const params = {
method: 'POST',
body: {
email,
password
}
};
if (recaptchaResponse) {
params.headers = {
'X-Recaptcha-Response': recaptchaResponse
};
}
return rest('/auth/local', params)
.then(({user, token}) => {
if (!user) {
if (!bowser.safari && !bowser.ios && storage) {
storage.removeItem('token');
}
return dispatch(checkLoginFailure('not logged in'));
}
dispatch(handleAuthToken(token));
client.resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
if (error.translation_key === 'NOT_AUTHORIZED') {
// invalid credentials
dispatch({
type: actions.LOGIN_FAILURE,
message: t('error.email_password')
});
}
else if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') {
dispatch({
type: actions.LOGIN_MAXIMUM_EXCEEDED,
message: t(`error.${error.translation_key}`),
});
} else {
dispatch({
type: actions.LOGIN_FAILURE,
message: errorMessage,
});
}
});
};
//==============================================================================
// FORGOT PASSWORD
//==============================================================================
const forgotPasswordRequest = () => ({
type: actions.FETCH_FORGOT_PASSWORD_REQUEST
});
const forgotPasswordSuccess = () => ({
type: actions.FETCH_FORGOT_PASSWORD_SUCCESS
});
const forgotPasswordFailure = (error) => ({
type: actions.FETCH_FORGOT_PASSWORD_FAILURE,
error,
});
export const requestPasswordReset = (email) => (dispatch, _, {rest}) => {
dispatch(forgotPasswordRequest(email));
const redirectUri = location.href;
return rest('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}})
.then(() => dispatch(forgotPasswordSuccess()))
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(forgotPasswordFailure(errorMessage));
});
};
//==============================================================================
// CHECK LOGIN
//==============================================================================
const checkLoginRequest = () => ({
type: actions.CHECK_LOGIN_REQUEST
});
const checkLoginSuccess = (user, isAdmin) => ({
type: actions.CHECK_LOGIN_SUCCESS,
user,
isAdmin
});
const checkLoginFailure = (error) => ({
type: actions.CHECK_LOGIN_FAILURE,
error
});
export const checkLogin = () => (dispatch, _, {rest, client, storage}) => {
dispatch(checkLoginRequest());
return rest('/auth')
.then(({user}) => {
if (!user) {
if (!bowser.safari && !bowser.ios && storage) {
storage.removeItem('token');
}
return dispatch(checkLoginFailure('not logged in'));
}
client.resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(checkLoginFailure(errorMessage));
});
};
//==============================================================================
// LOGOUT
//==============================================================================
export const logout = () => (dispatch, _, {rest, client, storage}) => {
return rest('/auth', {method: 'DELETE'}).then(() => {
if (storage) {
storage.removeItem('token');
}
// Reset the websocket.
client.resetWebsocket();
dispatch({type: actions.LOGOUT});
});
};
//==============================================================================
// AUTH TOKEN
//==============================================================================
export const handleAuthToken = (token) => (dispatch, _, {storage}) => {
if (storage) {
storage.setItem('exp', jwtDecode(token).exp);
storage.setItem('token', token);
}
dispatch({type: 'HANDLE_AUTH_TOKEN'});
};
@@ -1,7 +1,19 @@
import {SHOW_BAN_USER_DIALOG, HIDE_BAN_USER_DIALOG} from '../constants/banUserDialog';
import {
SHOW_BAN_USER_DIALOG,
HIDE_BAN_USER_DIALOG,
} from '../constants/banUserDialog';
export const showBanUserDialog = ({userId, username, commentId, commentStatus}) =>
({type: SHOW_BAN_USER_DIALOG, userId, username, commentId, commentStatus});
export const hideBanUserDialog = () => ({type: HIDE_BAN_USER_DIALOG});
export const showBanUserDialog = ({
userId,
username,
commentId,
commentStatus,
}) => ({
type: SHOW_BAN_USER_DIALOG,
userId,
username,
commentId,
commentStatus,
});
export const hideBanUserDialog = () => ({ type: HIDE_BAN_USER_DIALOG });
+30 -32
View File
@@ -7,73 +7,71 @@ import {
SORT_UPDATE,
SET_PAGE,
SET_SEARCH_VALUE,
SET_ROLE,
SET_COMMENTER_STATUS,
SHOW_BANUSER_DIALOG,
HIDE_BANUSER_DIALOG,
SHOW_REJECT_USERNAME_DIALOG,
HIDE_REJECT_USERNAME_DIALOG
HIDE_REJECT_USERNAME_DIALOG,
SET_INDICATOR_TRACK,
} from '../constants/community';
import t from 'coral-framework/services/i18n';
export const fetchUsers = (query = {}) => (dispatch, _, {rest}) => {
export const fetchUsers = (query = {}) => (dispatch, _, { rest }) => {
dispatch(requestFetchUsers());
rest(`/users?${queryString.stringify(query)}`)
.then(({result, page, count, limit, totalPages}) =>{
.then(({ result, page, count, limit, totalPages }) => {
dispatch({
type: FETCH_USERS_SUCCESS,
users: result,
page,
count,
limit,
totalPages
totalPages,
});
})
.catch((error) => {
.catch(error => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: FETCH_USERS_FAILURE, error: errorMessage});
const errorMessage = error.translation_key
? t(`error.${error.translation_key}`)
: error.toString();
dispatch({ type: FETCH_USERS_FAILURE, error: errorMessage });
});
};
const requestFetchUsers = () => ({
type: FETCH_USERS_REQUEST
type: FETCH_USERS_REQUEST,
});
export const updateSorting = (sort) => ({
export const updateSorting = sort => ({
type: SORT_UPDATE,
sort
sort,
});
export const setPage = (page) => ({
export const setPage = page => ({
type: SET_PAGE,
page,
});
export const setSearchValue = (value) => ({
export const setSearchValue = value => ({
type: SET_SEARCH_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});
export const showBanUserDialog = user => ({ type: SHOW_BANUSER_DIALOG, user });
export const hideBanUserDialog = () => ({ type: HIDE_BANUSER_DIALOG });
// Reject Username Dialog
export const showRejectUsernameDialog = (user) => ({type: SHOW_REJECT_USERNAME_DIALOG, user});
export const hideRejectUsernameDialog = () => ({type: HIDE_REJECT_USERNAME_DIALOG});
export const showRejectUsernameDialog = user => ({
type: SHOW_REJECT_USERNAME_DIALOG,
user,
});
export const hideRejectUsernameDialog = () => ({
type: HIDE_REJECT_USERNAME_DIALOG,
});
// Enable or disable the activity indicator subscriptions.
export const setIndicatorTrack = track => ({
type: SET_INDICATOR_TRACK,
track,
});
-7
View File
@@ -1,7 +0,0 @@
export const CONFIG_UPDATED = 'CONFIG_UPDATED';
export const fetchConfig = () => (dispatch) => {
let json = document.getElementById('data');
let data = JSON.parse(json.textContent);
dispatch({type: CONFIG_UPDATED, data});
};
+5 -5
View File
@@ -1,13 +1,13 @@
import * as actions from 'constants/configure';
export const updatePending = ({updater, errorUpdater}) => {
return {type: actions.UPDATE_PENDING, updater, errorUpdater};
export const updatePending = ({ updater, errorUpdater }) => {
return { type: actions.UPDATE_PENDING, updater, errorUpdater };
};
export const clearPending = () => {
return {type: actions.CLEAR_PENDING};
return { type: actions.CLEAR_PENDING };
};
export const setActiveSection = (section) => {
return {type: actions.SET_ACTIVE_SECTION, section};
export const setActiveSection = section => {
return { type: actions.SET_ACTIVE_SECTION, section };
};
+77 -60
View File
@@ -3,17 +3,17 @@ import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
import t from 'coral-framework/services/i18n';
export const nextStep = () => ({type: actions.NEXT_STEP});
export const previousStep = () => ({type: actions.PREVIOUS_STEP});
export const goToStep = (step) => ({type: actions.GO_TO_STEP, step});
export const nextStep = () => ({ type: actions.NEXT_STEP });
export const previousStep = () => ({ type: actions.PREVIOUS_STEP });
export const goToStep = step => ({ type: actions.GO_TO_STEP, step });
const installRequest = () => ({type: actions.INSTALL_REQUEST});
const installSuccess = () => ({type: actions.INSTALL_SUCCESS});
const installFailure = (error) => ({type: actions.INSTALL_FAILURE, error});
const installRequest = () => ({ type: actions.INSTALL_REQUEST });
const installSuccess = () => ({ type: actions.INSTALL_SUCCESS });
const installFailure = error => ({ type: actions.INSTALL_FAILURE, error });
const addError = (name, error) => ({type: actions.ADD_ERROR, name, error});
const hasError = (error) => ({type: actions.HAS_ERROR, error});
const clearErrors = () => ({type: actions.CLEAR_ERRORS});
const addError = (name, error) => ({ type: actions.ADD_ERROR, name, error });
const hasError = error => ({ type: actions.HAS_ERROR, error });
const clearErrors = () => ({ type: actions.CLEAR_ERRORS });
const validation = (formData, dispatch, next) => {
if (!(formData != null)) {
@@ -21,24 +21,21 @@ const validation = (formData, dispatch, next) => {
return;
}
const validKeys = Object.keys(formData)
.filter((name) => name !== 'domains');
const validKeys = Object.keys(formData).filter(name => name !== 'domains');
// Required Validation
const empty = validKeys
.filter((name) => {
const cond = !formData[name].length;
const empty = validKeys.filter(name => {
const cond = !formData[name].length;
if (cond) {
if (cond) {
// Adding Error
dispatch(addError(name, 'This field is required.'));
} else {
dispatch(addError(name, ''));
}
// Adding Error
dispatch(addError(name, 'This field is required.'));
} else {
dispatch(addError(name, ''));
}
return cond;
});
return cond;
});
if (empty.length) {
dispatch(hasError());
@@ -46,19 +43,17 @@ const validation = (formData, dispatch, next) => {
}
// RegExp Validation
const validation = validKeys
.filter((name) => {
const cond = !validate[name](formData[name]);
if (cond) {
const validation = validKeys.filter(name => {
const cond = !validate[name](formData[name]);
if (cond) {
// Adding Error
dispatch(addError(name, errorMsj[name]));
} else {
dispatch(addError(name, ''));
}
dispatch(addError(name, errorMsj[name]));
} else {
dispatch(addError(name, ''));
}
return cond;
});
return cond;
});
if (validation.length) {
dispatch(hasError());
@@ -67,20 +62,21 @@ const validation = (formData, dispatch, next) => {
// Confirm Validation
const prefixLength = 'confirm'.length;
const confirm = validKeys
.filter((name) => {
if (!name.startsWith('confirm')) {
return false;
}
const confirm = validKeys.filter(name => {
if (!name.startsWith('confirm')) {
return false;
}
// Check that 'confirmX' equals 'X'.
const other = name.substr(prefixLength, 1).toLowerCase() + name.substr(prefixLength + 1);
const cond = formData[other] !== formData[name];
if (cond) {
dispatch(addError(name, errorMsj[name]));
}
return cond;
});
// Check that 'confirmX' equals 'X'.
const other =
name.substr(prefixLength, 1).toLowerCase() +
name.substr(prefixLength + 1);
const cond = formData[other] !== formData[name];
if (cond) {
dispatch(addError(name, errorMsj[name]));
}
return cond;
});
if (confirm.length) {
dispatch(hasError());
@@ -105,41 +101,62 @@ export const submitUser = () => (dispatch, getState) => {
});
};
export const finishInstall = () => (dispatch, getState, {rest}) => {
export const finishInstall = () => (dispatch, getState, { rest }) => {
const data = getState().install.data;
dispatch(installRequest());
return rest('/setup', {method: 'POST', body: data})
return rest('/setup', { method: 'POST', body: data })
.then(() => {
dispatch(installSuccess());
dispatch(nextStep());
})
.catch((error) => {
.catch(error => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
const errorMessage = error.translation_key
? t(`error.${error.translation_key}`)
: error.toString();
dispatch(installFailure(errorMessage));
});
};
export const updateSettingsFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_SETTINGS, name, value});
export const updateUserFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_USER, name, value});
export const updatePermittedDomains = (value) => ({type: actions.UPDATE_PERMITTED_DOMAINS_SETTINGS, value});
export const updateSettingsFormData = (name, value) => ({
type: actions.UPDATE_FORMDATA_SETTINGS,
name,
value,
});
export const updateUserFormData = (name, value) => ({
type: actions.UPDATE_FORMDATA_USER,
name,
value,
});
export const updatePermittedDomains = value => ({
type: actions.UPDATE_PERMITTED_DOMAINS_SETTINGS,
value,
});
const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST});
const checkInstallSuccess = (installed) => ({type: actions.CHECK_INSTALL_SUCCESS, installed});
const checkInstallFailure = (error) => ({type: actions.CHECK_INSTALL_FAILURE, error});
const checkInstallRequest = () => ({ type: actions.CHECK_INSTALL_REQUEST });
const checkInstallSuccess = installed => ({
type: actions.CHECK_INSTALL_SUCCESS,
installed,
});
const checkInstallFailure = error => ({
type: actions.CHECK_INSTALL_FAILURE,
error,
});
export const checkInstall = (next) => async (dispatch, _, {rest}) => {
export const checkInstall = next => async (dispatch, _, { rest }) => {
dispatch(checkInstallRequest());
try {
const {installed} = await rest('/setup');
const { installed } = await rest('/setup');
dispatch(checkInstallSuccess(installed));
if (installed) {
next();
}
} catch (error) {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
const errorMessage = error.translation_key
? t(`error.${error.translation_key}`)
: error.toString();
dispatch(checkInstallFailure(errorMessage));
}
};
+21 -16
View File
@@ -1,41 +1,46 @@
import * as actions from 'constants/moderation';
export const toggleModal = (open) => ({type: actions.TOGGLE_MODAL, open});
export const singleView = () => ({type: actions.SINGLE_VIEW});
export const toggleModal = open => ({ type: actions.TOGGLE_MODAL, open });
export const singleView = () => ({ type: actions.SINGLE_VIEW });
// hide shortcuts note
export const hideShortcutsNote = () => (dispatch, _, {storage}) => {
export const hideShortcutsNote = () => (dispatch, _, { localStorage }) => {
try {
if (storage) {
storage.setItem('coral:shortcutsNote', 'hide');
if (localStorage) {
localStorage.setItem('coral:shortcutsNote', 'hide');
}
} catch (e) {
// above will fail in Safari private mode
}
dispatch({type: actions.HIDE_SHORTCUTS_NOTE});
dispatch({ type: actions.HIDE_SHORTCUTS_NOTE });
};
export const setSortOrder = (order) => ({
export const setSortOrder = order => ({
type: actions.SET_SORT_ORDER,
order
order,
});
export const toggleStorySearch = (active) => ({
type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH
export const toggleStorySearch = active => ({
type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH,
});
export const storySearchChange = (value) => ({
export const storySearchChange = value => ({
type: actions.STORY_SEARCH_CHANGE_VALUE,
value
value,
});
export const clearState = () => ({
type: actions.MODERATION_CLEAR_STATE
type: actions.CLEAR_STATE,
});
export const selectCommentId = (id) => ({
type: actions.MODERATION_SELECT_COMMENT,
export const selectCommentId = id => ({
type: actions.SELECT_COMMENT,
id,
});
// Enable or disable the activity indicator subscriptions.
export const setIndicatorTrack = track => ({
type: actions.SET_INDICATOR_TRACK,
track,
});
+27 -22
View File
@@ -10,7 +10,7 @@ import {
UPDATE_ASSET_STATE_REQUEST,
UPDATE_ASSET_STATE_SUCCESS,
UPDATE_ASSET_STATE_FAILURE,
UPDATE_ASSETS
UPDATE_ASSETS,
} from '../constants/stories';
import t from 'coral-framework/services/i18n';
@@ -21,53 +21,58 @@ import t from 'coral-framework/services/i18n';
// Fetch a page of assets
// Get comments to fill each of the three lists on the mod queue
export const fetchAssets = (query = {}) => (dispatch, _, {rest}) => {
dispatch({type: FETCH_ASSETS_REQUEST});
export const fetchAssets = (query = {}) => (dispatch, _, { rest }) => {
dispatch({ type: FETCH_ASSETS_REQUEST });
return rest(`/assets?${queryString.stringify(query)}`)
.then(({result, page, count, limit, totalPages}) =>
dispatch({type: FETCH_ASSETS_SUCCESS,
.then(({ result, page, count, limit, totalPages }) =>
dispatch({
type: FETCH_ASSETS_SUCCESS,
assets: result,
page,
count,
limit,
totalPages,
}))
.catch((error) => {
})
)
.catch(error => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: FETCH_ASSETS_FAILURE, error: errorMessage});
const errorMessage = error.translation_key
? t(`error.${error.translation_key}`)
: error.toString();
dispatch({ type: FETCH_ASSETS_FAILURE, error: errorMessage });
});
};
// Update an asset state
// Get comments to fill each of the three lists on the mod queue
export const updateAssetState = (id, closedAt) => (dispatch, _, {rest}) => {
dispatch({type: UPDATE_ASSET_STATE_REQUEST, id, closedAt});
return rest(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
.then(() => dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
.catch((error) => {
export const updateAssetState = (id, closedAt) => (dispatch, _, { rest }) => {
dispatch({ type: UPDATE_ASSET_STATE_REQUEST, id, closedAt });
return rest(`/assets/${id}/status`, { method: 'PUT', body: { closedAt } })
.then(() => dispatch({ type: UPDATE_ASSET_STATE_SUCCESS }))
.catch(error => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: UPDATE_ASSET_STATE_FAILURE, error: errorMessage});
const errorMessage = error.translation_key
? t(`error.${error.translation_key}`)
: error.toString();
dispatch({ type: UPDATE_ASSET_STATE_FAILURE, error: errorMessage });
});
};
export const updateAssets = (assets) => (dispatch) => {
dispatch({type: UPDATE_ASSETS, assets});
export const updateAssets = assets => dispatch => {
dispatch({ type: UPDATE_ASSETS, assets });
};
export const setPage = (page) => ({
export const setPage = page => ({
type: SET_PAGE,
page,
});
export const setSearchValue = (value) => ({
export const setSearchValue = value => ({
type: SET_SEARCH_VALUE,
value,
});
export const setCriteria = (criteria) => ({
export const setCriteria = criteria => ({
type: SET_CRITERIA,
criteria,
});
@@ -1,7 +1,19 @@
import {SHOW_SUSPEND_USER_DIALOG, HIDE_SUSPEND_USER_DIALOG} from '../constants/suspendUserDialog.js';
import {
SHOW_SUSPEND_USER_DIALOG,
HIDE_SUSPEND_USER_DIALOG,
} from '../constants/suspendUserDialog.js';
export const showSuspendUserDialog = ({userId, username, commentId, commentStatus}) =>
({type: SHOW_SUSPEND_USER_DIALOG, userId, username, commentId, commentStatus});
export const hideSuspendUserDialog = () => ({type: HIDE_SUSPEND_USER_DIALOG});
export const showSuspendUserDialog = ({
userId,
username,
commentId,
commentStatus,
}) => ({
type: SHOW_SUSPEND_USER_DIALOG,
userId,
username,
commentId,
commentStatus,
});
export const hideSuspendUserDialog = () => ({ type: HIDE_SUSPEND_USER_DIALOG });
+18 -9
View File
@@ -1,28 +1,37 @@
import * as actions from 'constants/userDetail';
export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId});
export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL});
export const viewUserDetail = userId => ({
type: actions.VIEW_USER_DETAIL,
userId,
});
export const hideUserDetail = () => ({ type: actions.HIDE_USER_DETAIL });
export const changeUserDetailStatuses = (tab) => {
export const changeTab = tab => {
let statuses = null;
if (tab === 'rejected') {
statuses = ['REJECTED'];
}
return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses};
return { type: actions.CHANGE_TAB_USER_DETAIL, tab, statuses };
};
export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS});
export const clearUserDetailSelections = () => ({
type: actions.CLEAR_USER_DETAIL_SELECTIONS,
});
export const toggleSelectCommentInUserDetail = (id, active) => {
return {
type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT,
id
type: active
? actions.SELECT_USER_DETAIL_COMMENT
: actions.UNSELECT_USER_DETAIL_COMMENT,
id,
};
};
export const toggleSelectAllCommentInUserDetail = (ids, active) => {
return {
type: active ? actions.SELECT_ALL_USER_DETAIL_COMMENT : actions.CLEAR_USER_DETAIL_SELECTIONS,
ids
type: active
? actions.SELECT_ALL_USER_DETAIL_COMMENT
: actions.CLEAR_USER_DETAIL_SELECTIONS,
ids,
};
};
+39 -21
View File
@@ -6,38 +6,56 @@ import t from 'coral-framework/services/i18n';
*/
// change status of a user
export const userStatusUpdate = (status, userId, commentId) => {
return (dispatch, _, {rest}) => {
dispatch({type: userTypes.UPDATE_STATUS_REQUEST});
return rest(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then((res) => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res}))
.catch((error) => {
return (dispatch, _, { rest }) => {
dispatch({ type: userTypes.UPDATE_STATUS_REQUEST });
return rest(`/users/${userId}/status`, {
method: 'POST',
body: { status: status, comment_id: commentId },
})
.then(res => dispatch({ type: userTypes.UPDATE_STATUS_SUCCESS, res }))
.catch(error => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error: errorMessage});
const errorMessage = error.translation_key
? t(`error.${error.translation_key}`)
: error.toString();
dispatch({
type: userTypes.UPDATE_STATUS_FAILURE,
error: errorMessage,
});
});
};
};
// change status of a user
export const sendNotificationEmail = (userId, subject, body) => {
return (dispatch, _, {rest}) => {
return rest(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: userTypes.USER_EMAIL_FAILURE, error: errorMessage});
});
return (dispatch, _, { rest }) => {
return rest(`/users/${userId}/email`, {
method: 'POST',
body: { subject, body },
}).catch(error => {
console.error(error);
const errorMessage = error.translation_key
? t(`error.${error.translation_key}`)
: error.toString();
dispatch({ type: userTypes.USER_EMAIL_FAILURE, error: errorMessage });
});
};
};
// let a user edit their username
export const enableUsernameEdit = (userId) => {
return (dispatch, _, {rest}) => {
return rest(`/users/${userId}/username-enable`, {method: 'POST'})
.catch((error) => {
export const enableUsernameEdit = userId => {
return (dispatch, _, { rest }) => {
return rest(`/users/${userId}/username-enable`, { method: 'POST' }).catch(
error => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error: errorMessage});
});
const errorMessage = error.translation_key
? t(`error.${error.translation_key}`)
: error.toString();
dispatch({
type: userTypes.USERNAME_ENABLE_FAILURE,
error: errorMessage,
});
}
);
};
};
@@ -0,0 +1,34 @@
.table {
flex-direction: column;
}
.table, .headerRow, .row {
display: flex;
}
.headerRowItem, .item {
flex: 1;
padding: 20px;
&:nth-child(2) {
flex: 2;
}
}
.username {
word-break: break-all;
}
.headerRowItem {
color: #595959;
font-weight: bold;
}
.headerRow, .row {
border-bottom: 1px solid #e0e0e0;
}
.action {
color: black;
font-weight: bold;
}
@@ -0,0 +1,137 @@
import React from 'react';
import PropTypes from 'prop-types';
import { murmur3 } from 'murmurhash-js';
import styles from './AccountHistory.css';
import cn from 'classnames';
import flatten from 'lodash/flatten';
import orderBy from 'lodash/orderBy';
import has from 'lodash/has';
import moment from 'moment';
import t from 'coral-framework/services/i18n';
import { Icon } from 'coral-ui';
const buildUserHistory = (userState = {}) => {
return orderBy(
flatten(
Object.keys(userState.status)
.filter(k => k !== '__typename')
.map(k => userState.status[k].history)
),
'created_at',
'desc'
);
};
/** readableDuration returns a readable duration of the suspension/ban in hours or days
* @param {} startDate
* @param {} endDate
*/
const readableDuration = (startDate, endDate) => {
const dur = moment.duration(moment(endDate).diff(moment(startDate)));
const durAsDays = dur.asDays().toFixed(0);
const durAsHours = dur.asHours().toFixed(0);
return durAsHours > 23
? durAsDays > 1
? t('suspenduser.days', durAsDays)
: t('suspenduser.day', durAsDays)
: durAsHours > 1
? t('suspenduser.hours', durAsHours)
: t('suspenduser.hour', durAsHours);
};
const buildActionResponse = (typename, created_at, until, status) => {
switch (typename) {
case 'UsernameStatusHistory':
return t('account_history.username_status', status);
case 'BannedStatusHistory':
return status
? t('account_history.user_banned')
: t('account_history.ban_removed');
case 'SuspensionStatusHistory':
return until
? t('account_history.suspended', readableDuration(created_at, until))
: t('account_history.suspension_removed');
default:
return '-';
}
};
const getModerationValue = assignedBy =>
has(assignedBy, 'username') ? (
assignedBy.username
) : (
<span>
<Icon name="computer" /> {t('account_history.system')}
</span>
);
class AccountHistory extends React.Component {
render() {
const { user } = this.props;
const userHistory = buildUserHistory(user.state);
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}>
{t('account_history.date')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.action')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.taken_by')}
</div>
</div>
{userHistory.map(
({ __typename, created_at, assigned_by, until, status }) => (
<div
className={cn(styles.row, 'talk-admin-account-history-row')}
key={`${__typename}_${murmur3(created_at)}`}
>
<div
className={cn(
styles.item,
'talk-admin-account-history-row-date'
)}
>
{moment(new Date(created_at)).format('MMM DD, YYYY')}
</div>
<div
className={cn(
styles.item,
styles.action,
'talk-admin-account-history-row-status'
)}
>
{buildActionResponse(__typename, created_at, until, status)}
</div>
<div
className={cn(
styles.item,
styles.username,
'talk-admin-account-history-row-assigned-by'
)}
>
{getModerationValue(assigned_by)}
</div>
</div>
)
)}
</div>
</div>
);
}
}
AccountHistory.propTypes = {
user: PropTypes.object.isRequired,
};
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;
@@ -1,9 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Button, Icon} from 'coral-ui';
import {Menu} from 'react-mdl';
import { Button, Icon } from 'coral-ui';
import ClickOutside from 'coral-framework/components/ClickOutside';
import { Menu } from 'react-mdl';
import cn from 'classnames';
import {findDOMNode} from 'react-dom';
import { findDOMNode } from 'react-dom';
import styles from './ActionsMenu.css';
import t from 'coral-framework/services/i18n';
@@ -13,46 +14,60 @@ let count = 0;
class ActionsMenu extends React.Component {
id = `actions-dropdown-${count++}`;
menu = null;
state = {open: false};
state = { open: false };
timeout = null;
componentWillUnmount() {
clearTimeout(this.timeout);
}
handleRef = (ref) => {
handleRef = ref => {
this.menu = ref ? findDOMNode(ref).parentNode : null;
}
};
syncOpenState = () => {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.setState({open: this.menu.className.indexOf('is-visible') >= 0});
this.setState({ open: this.menu.className.indexOf('is-visible') >= 0 });
}, 150);
};
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})}
disabled={false}
id={this.id}
<ClickOutside onClickOutside={this.syncOpenState}>
<div
className={cn(styles.root, className)}
onBlur={this.syncOpenState}
onClick={this.syncOpenState}
icon={this.props.icon}
raised>
{t('modqueue.actions')}
<Icon
name={this.state.open ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
className={styles.arrowIcon}
/>
</Button>
<Menu target={this.id} className={styles.menu} ref={this.handleRef}>
{this.props.children}
</Menu>
</div>
onKeyUp={this.syncOpenState}
>
<Button
cStyle="actions"
className={cn(
styles.button,
{ [styles.buttonOpen]: this.state.open },
buttonClassNames
)}
disabled={false}
id={this.id}
onClick={this.syncOpenState}
icon={this.props.icon}
raised
>
{label ? label : t('modqueue.actions')}
<Icon
name={
this.state.open ? 'keyboard_arrow_up' : 'keyboard_arrow_down'
}
className={styles.arrowIcon}
/>
</Button>
<Menu target={this.id} className={styles.menu} ref={this.handleRef}>
{this.props.children}
</Menu>
</div>
</ClickOutside>
);
}
}
@@ -61,6 +76,8 @@ ActionsMenu.propTypes = {
icon: PropTypes.string,
children: PropTypes.node,
className: PropTypes.string,
label: PropTypes.string,
buttonClassNames: PropTypes.string,
};
export default ActionsMenu;
@@ -1,13 +1,18 @@
import React from 'react';
import cn from 'classnames';
import {MenuItem} from 'react-mdl';
import { MenuItem } from 'react-mdl';
import PropTypes from 'prop-types';
import styles from './ActionsMenu.css';
import camelCase from 'lodash/camelCase';
const ActionsMenuItem = (props) =>
<MenuItem className={cn(styles.menuItem, props.className, 'action-menu-item')} {...props} id={camelCase(props.children)}/>;
const ActionsMenuItem = props => (
<MenuItem
className={cn(styles.menuItem, props.className, 'action-menu-item')}
{...props}
id={camelCase(props.children)}
/>
);
ActionsMenuItem.propTypes = {
className: PropTypes.string,
children: PropTypes.string,
+81 -55
View File
@@ -1,103 +1,129 @@
import React from 'react';
import PropTypes from 'prop-types';
import Layout from 'coral-admin/src/components/ui/Layout';
import Layout from 'coral-admin/src/components/Layout';
import styles from './NotFound.css';
import {Button, TextField, Alert, Success} from 'coral-ui';
import { Button, TextField, Alert, Success } from 'coral-ui';
import Recaptcha from 'react-recaptcha';
import cn from 'classnames';
class AdminLogin extends React.Component {
constructor (props) {
constructor(props) {
super(props);
this.state = {email: '', password: '', requestPassword: false};
this.state = { email: '', password: '', requestPassword: false };
}
handleSignIn = (e) => {
handleSignIn = e => {
e.preventDefault();
this.props.handleLogin(this.state.email, this.state.password);
}
};
onRecaptchaLoad = () => {
// do something?
}
};
onRecaptchaVerify = (recaptchaResponse) => {
this.props.handleLogin(this.state.email, this.state.password, recaptchaResponse);
}
onRecaptchaVerify = recaptchaResponse => {
this.props.handleLogin(
this.state.email,
this.state.password,
recaptchaResponse
);
};
handleRequestPassword = (e) => {
handleRequestPassword = e => {
e.preventDefault();
this.props.requestPasswordReset(this.state.email);
}
};
render () {
const {errorMessage, loginMaxExceeded, recaptchaPublic} = this.props;
render() {
const { errorMessage, loginMaxExceeded, recaptchaPublic } = this.props;
const signInForm = (
<form className="talk-admin-login-sign-in" onSubmit={this.handleSignIn}>
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
id="email"
label='Email Address'
label="Email Address"
value={this.state.email}
onChange={(e) => this.setState({email: e.target.value})} />
onChange={e => this.setState({ email: e.target.value })}
/>
<TextField
id="password"
label='Password'
label="Password"
value={this.state.password}
onChange={(e) => this.setState({password: e.target.value})}
type='password' />
<div style={{height: 10}}></div>
onChange={e => this.setState({ password: e.target.value })}
type="password"
/>
<div style={{ height: 10 }} />
<Button
className="talk-admin-login-sign-in-button"
type='submit'
cStyle='black'
type="submit"
cStyle="black"
full
onClick={this.handleSignIn}>Sign In</Button>
onClick={this.handleSignIn}
>
Sign In
</Button>
<p className={styles.forgotPasswordCTA}>
Forgot your password? <a href="#" className={styles.forgotPasswordLink} onClick={(e) => {
e.preventDefault();
this.setState({requestPassword: true});
}}>Request a new one.</a>
Forgot your password?{' '}
<a
href="#"
className={styles.forgotPasswordLink}
onClick={e => {
e.preventDefault();
this.setState({ requestPassword: true });
}}
>
Request a new one.
</a>
</p>
{
loginMaxExceeded &&
{loginMaxExceeded && (
<Recaptcha
sitekey={recaptchaPublic}
render='explicit'
theme='dark'
render="explicit"
theme="dark"
onloadCallback={this.onRecaptchaLoad}
verifyCallback={this.onRecaptchaVerify} />
}
verifyCallback={this.onRecaptchaVerify}
/>
)}
</form>
);
const requestPasswordForm = (
this.props.passwordRequestSuccess
? <p className={styles.passwordRequestSuccess} onClick={() => {
const requestPasswordForm = this.props.passwordRequestSuccess ? (
<p
className={styles.passwordRequestSuccess}
onClick={() => {
location.href = location.href;
}}>
{this.props.passwordRequestSuccess} <a className={styles.signInLink} href="#">Sign in</a>
<Success />
</p>
: <form onSubmit={this.handleRequestPassword}>
<TextField
label='Email Address'
value={this.state.email}
onChange={(e) => this.setState({email: e.target.value})} />
<Button
type='submit'
cStyle='black'
full
onClick={this.handleRequestPassword}>Reset Password</Button>
</form>
}}
>
{this.props.passwordRequestSuccess}{' '}
<a className={styles.signInLink} href="#">
Sign in
</a>
<Success />
</p>
) : (
<form onSubmit={this.handleRequestPassword}>
<TextField
label="Email Address"
value={this.state.email}
onChange={e => this.setState({ email: e.target.value })}
/>
<Button
type="submit"
cStyle="black"
full
onClick={this.handleRequestPassword}
>
Reset Password
</Button>
</form>
);
return (
<Layout fixedDrawer restricted={true}>
<div className={cn(styles.loginLayout, 'talk-admin-login')}>
<h1 className={styles.loginHeader}>Team sign in</h1>
<p className={styles.loginCTA}>Sign in to interact with your community.</p>
{ this.state.requestPassword ? requestPasswordForm : signInForm }
<p className={styles.loginCTA}>
Sign in to interact with your community.
</p>
{this.state.requestPassword ? requestPasswordForm : signInForm}
</div>
</Layout>
);
+1 -1
View File
@@ -5,7 +5,7 @@ import 'material-design-lite';
import AppRouter from '../AppRouter';
export default class App extends React.Component {
render () {
render() {
return (
<div>
<ToastContainer />
@@ -5,23 +5,22 @@
background: white;
padding: 10px 12px;
box-sizing: border-box;
vertical-align: middle;
line-height: 24px;
font-size: 17px;
height: 47px;
border-radius: 3px;
text-transform: capitalize;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09);
width: 129px;
transform: scale(.8);
margin: 0;
width: 100%;
margin: 0 0 .5em;
&:hover {
&:not(:disabled):hover {
box-shadow: none;
color: white;
background-color: #519954;
cursor: pointer;
}
&:disabled {
cursor: not-allowed;
}
}
.active {
@@ -3,16 +3,21 @@ import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ApproveButton.css';
import {Icon} from 'coral-ui';
import { Icon } from 'coral-ui';
import t from 'coral-framework/services/i18n';
const ApproveButton = ({active, minimal, onClick, className}) => {
const ApproveButton = ({ active, minimal, onClick, className, disabled }) => {
const text = active ? t('modqueue.approved') : t('modqueue.approve');
return (
<button
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active}, className)}
className={cn(
styles.root,
{ [styles.minimal]: minimal, [styles.active]: active },
className
)}
onClick={onClick}
disabled={disabled || active}
>
<Icon name={'done'} className={styles.icon} />
{!minimal && text}
@@ -24,8 +29,8 @@ ApproveButton.propTypes = {
className: PropTypes.string,
active: PropTypes.bool,
minimal: PropTypes.bool,
disabled: PropTypes.bool,
onClick: PropTypes.func,
};
export default ApproveButton;
@@ -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;
}
@@ -1,45 +1,130 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import {Dialog} from 'coral-ui';
import { Dialog } from 'coral-ui';
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,
@@ -1,15 +1,11 @@
import React from 'react';
import {Button} from 'coral-ui';
import { Button } from 'coral-ui';
import t from 'coral-framework/services/i18n';
import {withCopyToClipboard} from 'coral-framework/hocs';
import { withCopyToClipboard } from 'coral-framework/hocs';
class ButtonCopyToClipboard extends React.Component {
render () {
return (
<Button {...this.props} >
{t('common.copy')}
</Button>
);
render() {
return <Button {...this.props}>{t('common.copy')}</Button>;
}
}
@@ -1,10 +1,10 @@
import React from 'react';
import {murmur3} from 'murmurhash-js';
import {CSSTransitionGroup} from 'react-transition-group';
import { murmur3 } from 'murmurhash-js';
import { CSSTransitionGroup } from 'react-transition-group';
import styles from './CommentAnimatedEdit.css';
import PropTypes from 'prop-types';
const CommentBodyHighlighter = ({children, body}) => {
const CommentAnimatedEdit = ({ children, body }) => {
return (
<CSSTransitionGroup
component={'div'}
@@ -20,14 +20,16 @@ const CommentBodyHighlighter = ({children, body}) => {
transitionEnterTimeout={3600}
transitionLeaveTimeout={2800}
>
{React.cloneElement(React.Children.only(children), {key: murmur3(body)})}
{React.cloneElement(React.Children.only(children), {
key: murmur3(body),
})}
</CSSTransitionGroup>
);
};
CommentBodyHighlighter.propTypes = {
CommentAnimatedEdit.propTypes = {
children: PropTypes.node,
body: PropTypes.string,
};
export default CommentBodyHighlighter;
export default CommentAnimatedEdit;
@@ -1,86 +0,0 @@
import React from 'react';
import {matchLinks} from '../utils';
import memoize from 'lodash/memoize';
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
// generate a regulare expression that catches the `phrases`.
function generateRegExp(phrases) {
const inner = phrases
.map((phrase) =>
phrase.split(/\s+/)
.map((word) => escapeRegExp(word))
.join('[\\s"?!.]+')
).join('|');
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
try {
return new RegExp(pattern, 'iu');
}
catch (_err) {
// IE does not support unicode support, so we'll create one without.
return new RegExp(pattern, 'i');
}
}
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
function getPhrasesRegexp(suspectWords, bannedWords) {
return generateRegExp([...suspectWords, ...bannedWords]);
}
// Memoized version as arguments rarely change.
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
// an array of React Elements.
function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
const tokens = body.split(regexp);
return tokens.map((token, i) =>
i % 3 === 2
? <mark key={`${keyPrefix}_${i}`}>{token}</mark>
: token
);
}
// markLinks looks for links inside `body` and highlights them by returning
// an array of React Elements.
function markLinks(body) {
const matches = matchLinks(body);
const content = [];
let index = 0;
if (matches) {
matches
.forEach((match, i) => {
content.push(body.substring(index, match.index));
content.push(<mark key={i}>{match.text}</mark>);
index = match.lastIndex;
});
}
content.push(body.substring(index));
return content;
}
export default ({suspectWords, bannedWords, body, ...rest}) => {
// First highlight links.
const content = markLinks(body)
.map((element, index) => {
// Keep highlighted links.
if (typeof element !== 'string') {
return element;
}
// Highlight suspect and banned phrase inside this part of text.
return markPhrases(element, suspectWords, bannedWords, index);
});
return (
<div {...rest}>
{content}
</div>
);
};
@@ -5,7 +5,7 @@
.moreDetail {
position: absolute;
font-size: 12px;
font-size: 13px;
font-weight: 500;
color: black;
right: 16px;
@@ -1,4 +1,4 @@
import React, {Component} from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import styles from './CommentDetails.css';
import t from 'coral-framework/services/i18n';
@@ -7,26 +7,26 @@ import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
class CommentDetails extends Component {
state = {
showDetail: false
showDetail: false,
};
constructor () {
constructor() {
super();
this.state = {
showDetail: false
showDetail: false,
};
}
toggleDetail = () => {
this.setState((state) => ({
showDetail: !state.showDetail
this.setState(state => ({
showDetail: !state.showDetail,
}));
this.props.clearHeightCache && this.props.clearHeightCache();
}
};
render() {
const {data, root, comment, clearHeightCache} = this.props;
const {showDetail} = this.state;
const { data, root, comment, clearHeightCache } = this.props;
const { showDetail } = this.state;
const queryData = {
root,
comment,
@@ -49,12 +49,14 @@ class CommentDetails extends Component {
queryData={queryData}
more={showDetail}
/>
{showDetail && <Slot
fill="adminCommentMoreDetails"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>}
{showDetail && (
<Slot
fill="adminCommentMoreDetails"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
)}
</div>
);
}
@@ -0,0 +1,109 @@
import React from 'react';
import PropTypes from 'prop-types';
import { matchLinks } from '../utils';
import memoize from 'lodash/memoize';
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
// generate a regulare expression that catches the `phrases`.
function generateRegExp(phrases) {
const inner = phrases
.map(phrase =>
phrase
.split(/\s+/)
.map(word => escapeRegExp(word))
.join('[\\s"?!.]+')
)
.join('|');
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
try {
return new RegExp(pattern, 'iu');
} catch (_err) {
// IE does not support unicode support, so we'll create one without.
return new RegExp(pattern, 'i');
}
}
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
function getPhrasesRegexp(suspectWords, bannedWords) {
return generateRegExp([...suspectWords, ...bannedWords]);
}
// Memoized version as arguments rarely change.
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
// an array of React Elements.
function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
const tokens = body.split(regexp);
return tokens.map(
(token, i) =>
i % 3 === 2 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : token
);
}
// markLinks looks for links inside `body` and highlights them by returning
// an array of React Elements.
function markLinks(body) {
const matches = matchLinks(body);
const content = [];
let index = 0;
if (matches) {
matches.forEach((match, i) => {
content.push(body.substring(index, match.index));
content.push(<mark key={i}>{match.text}</mark>);
index = match.lastIndex;
});
}
content.push(body.substring(index));
return content;
}
const CommentFormatter = ({
body,
suspectWords,
bannedWords,
className = 'comment',
...rest
}) => {
// Breaking the body by line break
const textbreaks = body.split('\n');
return (
<span className={`${className}-text`} {...rest}>
{textbreaks.map((line, i) => {
const content = markLinks(line).map((element, index) => {
// Keep highlighted links.
if (typeof element !== 'string') {
return element;
}
// Highlight suspect and banned phrase inside this part of text.
return markPhrases(element, suspectWords, bannedWords, index);
});
return (
<span key={i} className={`${className}-line`}>
{content}
{i !== textbreaks.length - 1 && (
<br className={`${className}-linebreak`} />
)}
</span>
);
})}
</span>
);
};
CommentFormatter.propTypes = {
className: PropTypes.string,
bannedWords: PropTypes.array,
suspectWords: PropTypes.array,
body: PropTypes.string,
};
export default CommentFormatter;
@@ -5,41 +5,72 @@ import Slot from 'coral-framework/components/Slot';
import FlagLabel from 'coral-ui/components/FlagLabel';
import cn from 'classnames';
import styles from './CommentLabels.css';
import { ADMIN, MODERATOR, STAFF } from 'coral-framework/constants/roles';
const staffRoles = ['ADMIN', 'STAFF', 'MODERATOR'];
const staffRoles = [ADMIN, MODERATOR, STAFF];
function isUserFlagged(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.user);
return actions.some(
action => action.__typename === 'FlagAction' && action.user
);
}
function getUserFlaggedType(actions) {
return actions
.some((action) =>
return actions.some(
action =>
action.__typename === 'FlagAction' &&
action.user &&
action.user.roles.some((role) => staffRoles.includes(role))
) ? 'Staff' : 'User';
staffRoles.includes(action.user.role)
)
? 'Staff'
: 'User';
}
function hasSuspectedWords(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'Matched suspect word filter');
return actions.some(
action =>
action.__typename === 'FlagAction' && action.reason === 'SUSPECT_WORD'
);
}
function hasHistoryFlag(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'TRUST');
return actions.some(
action => action.__typename === 'FlagAction' && action.reason === 'TRUST'
);
}
const CommentLabels = ({comment, comment: {className, status, actions, hasParent}}) => {
const CommentLabels = ({
comment,
comment: { className, status, actions, hasParent },
}) => {
return (
<div className={cn(className, styles.root)}>
<div className={styles.coreLabels}>
{hasParent && <Label iconName="reply" className={styles.replyLabel}>reply</Label>}
{status === 'PREMOD' && <Label iconName="query_builder" className={styles.premodLabel}>Pre-Mod</Label>}
{isUserFlagged(actions) && <FlagLabel iconName="person">{getUserFlaggedType(actions)}</FlagLabel>}
{hasSuspectedWords(actions) && <FlagLabel iconName="sms_failed">Suspect</FlagLabel>}
{hasHistoryFlag(actions) && <FlagLabel iconName="sentiment_very_dissatisfied">History</FlagLabel>}
{hasParent && (
<Label iconName="reply" className={styles.replyLabel}>
reply
</Label>
)}
{status === 'PREMOD' && (
<Label iconName="query_builder" className={styles.premodLabel}>
Pre-Mod
</Label>
)}
{isUserFlagged(actions) && (
<FlagLabel iconName="person">{getUserFlaggedType(actions)}</FlagLabel>
)}
{hasSuspectedWords(actions) && (
<FlagLabel iconName="sms_failed">Suspect</FlagLabel>
)}
{hasHistoryFlag(actions) && (
<FlagLabel iconName="sentiment_very_dissatisfied">History</FlagLabel>
)}
</div>
<Slot className={styles.slot} fill="adminCommentLabels" queryData={{comment}} />
<Slot
className={styles.slot}
fill="adminCommentLabels"
queryData={{ comment }}
/>
</div>
);
};
@@ -4,7 +4,7 @@ import styles from './CountBadge.css';
import t from 'coral-framework/services/i18n';
const CountBadge = ({count}) => {
const CountBadge = ({ count }) => {
let number = count;
// shorten large counts to abbreviations
@@ -16,13 +16,11 @@ const CountBadge = ({count}) => {
number = `${(number / 1e3).toFixed(1)}${t('modqueue.thousand')}`;
}
return (
<span className={styles.count}>{number}</span>
);
return <span className={styles.count}>{number}</span>;
};
CountBadge.propTypes = {
count: PropTypes.number.isRequired
count: PropTypes.number.isRequired,
};
export default CountBadge;
+31 -31
View File
@@ -1,61 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Navigation, Drawer} from 'react-mdl';
import {IndexLink, Link} from 'react-router';
import { Navigation, Drawer } from 'react-mdl';
import { IndexLink, Link } from 'react-router';
import styles from './Drawer.css';
import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
import { can } from 'coral-framework/services/perms';
import cn from 'classnames';
const CoralDrawer = ({handleLogout, auth = {}}) => (
const CoralDrawer = ({ handleLogout, currentUser }) => (
<Drawer className={cn('talk-admin-drawer-nav', styles.drawer)}>
{ auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
{currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
<div>
<Navigation className={styles.nav}>
{
can(auth.user, 'MODERATE_COMMENTS') && (
<IndexLink
className={cn('talk-admin-nav-moderate', styles.navLink)}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
</IndexLink>
)
}
{can(currentUser, 'MODERATE_COMMENTS') && (
<IndexLink
className={cn('talk-admin-nav-moderate', styles.navLink)}
to="/admin/moderate"
activeClassName={styles.active}
>
{t('configure.moderate')}
</IndexLink>
)}
<Link
className={cn('talk-admin-nav-stories', styles.navLink)}
to="/admin/stories"
activeClassName={styles.active}>
activeClassName={styles.active}
>
{t('configure.stories')}
</Link>
<Link
className={cn('talk-admin-nav-community', styles.navLink)}
to="/admin/community"
activeClassName={styles.active}>
activeClassName={styles.active}
>
{t('configure.community')}
</Link>
{
can(auth.user, 'UPDATE_CONFIG') &&
(
<Link
className={cn('talk-admin-nav-configure', styles.navLink)}
to="/admin/configure"
activeClassName={styles.active}>
{t('configure.configure')}
</Link>
)
}
<a onClick={handleLogout}>Sign Out</a>
{can(currentUser, 'UPDATE_CONFIG') && (
<Link
className={cn('talk-admin-nav-configure', styles.navLink)}
to="/admin/configure"
activeClassName={styles.active}
>
{t('configure.configure')}
</Link>
)}
<a onClick={handleLogout}>{t('configure.sign_out')}</a>
<span>{`v${process.env.VERSION}`}</span>
</Navigation>
</div> : null }
</div>
) : null}
</Drawer>
);
CoralDrawer.propTypes = {
handleLogout: PropTypes.func.isRequired,
restricted: PropTypes.bool, // hide app elements from a logged out user
auth: PropTypes.object
currentUser: PropTypes.object,
};
export default CoralDrawer;
@@ -1,15 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Card} from 'coral-ui';
import { Card } from 'coral-ui';
const EmptyCard = (props) => (
<Card style={{textAlign: 'center', maxWidth: 400, margin: '0 auto'}}>
const EmptyCard = props => (
<Card style={{ textAlign: 'center', maxWidth: 400, margin: '0 auto' }}>
{props.children}
</Card>
);
EmptyCard.propTypes = {
children: PropTypes.node.isRequired
children: PropTypes.node.isRequired,
};
export default EmptyCard;
@@ -0,0 +1,20 @@
.header, .cta, .success {
text-align: center;
font-size: 16px;
}
.success {
cursor: pointer;
padding: 8px 14px;
}
.signInLink {
color: blue;
font-weight: normal;
text-decoration: none;
}
.signInLink:hover {
text-decoration: underline;
}
@@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ForgotPassword.css';
import { Button, TextField, Alert, Success } from 'coral-ui';
import t from 'coral-framework/services/i18n';
class ForgotPassword extends React.Component {
constructor(props) {
super(props);
}
handleEmailChange = e => this.props.onEmailChange(e.target.value);
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleSignInLink = e => {
e.preventDefault();
this.props.onSignInLink();
};
renderSuccess() {
return (
<div className={styles.success} onClick={this.handleSignInLink}>
{t('password_reset.mail_sent')}{' '}
<a
className={styles.signInLink}
href="#"
onClick={this.handleSignInLink}
>
Sign in
</a>
<Success />
</div>
);
}
renderForm() {
const { email, errorMessage } = this.props;
return (
<form onSubmit={this.handleSubmit}>
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
label="Email Address"
value={email}
onChange={this.handleEmailChange}
/>
<Button type="submit" cStyle="black" full>
Reset Password
</Button>
<p className={styles.cta}>
Go back to{' '}
<a
href="#"
className={styles.signInLink}
onClick={this.handleSignInLink}
>
Sign In
</a>
.
</p>
</form>
);
}
render() {
return this.props.success ? this.renderSuccess() : this.renderForm();
}
}
ForgotPassword.propTypes = {
success: PropTypes.bool.isRequired,
email: PropTypes.string.isRequired,
onEmailChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
errorMessage: PropTypes.string,
onSignInLink: PropTypes.func.isRequired,
};
export default ForgotPassword;
@@ -1,11 +1,11 @@
import React from 'react';
import {Layout} from 'react-mdl';
import { Layout } from 'react-mdl';
import styles from './FullLoading.css';
import {CoralLogo} from 'coral-ui';
import { CoralLogo } from 'coral-ui';
export const FullLoading = () => (
<Layout fixedDrawer>
<div className={styles.layout} >
<div className={styles.layout}>
<h1>Loading</h1>
<CoralLogo />
</div>
+128
View File
@@ -0,0 +1,128 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import { Navigation, Header, IconButton, MenuItem, Menu } from 'react-mdl';
import { Link, IndexLink } from 'react-router';
import styles from './Header.css';
import t from 'coral-framework/services/i18n';
import { Logo } from './Logo';
import { can } from 'coral-framework/services/perms';
import ModerationIndicator from '../routes/Moderation/containers/Indicator';
import CommunityIndicator from '../routes/Community/containers/Indicator';
const CoralHeader = ({
handleLogout,
showShortcuts = () => {},
currentUser,
root,
data,
}) => {
return (
<div className={styles.headerWrapper}>
<Header className={styles.header}>
<Logo className={styles.logo} />
<div>
{currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
<Navigation className={styles.nav}>
{can(currentUser, 'MODERATE_COMMENTS') && (
<IndexLink
id="moderateNav"
className={cn('talk-admin-nav-moderate', styles.navLink)}
to="/admin/moderate"
activeClassName={styles.active}
>
{t('configure.moderate')}
<ModerationIndicator root={root} data={data} />
</IndexLink>
)}
<Link
id="storiesNav"
className={cn('talk-admin-nav-stories', styles.navLink)}
to="/admin/stories"
activeClassName={styles.active}
>
{t('configure.stories')}
</Link>
<Link
id="communityNav"
className={cn('talk-admin-nav-community', styles.navLink)}
to="/admin/community"
activeClassName={styles.active}
>
{t('configure.community')}
<CommunityIndicator root={root} data={data} />
</Link>
{can(currentUser, 'UPDATE_CONFIG') && (
<Link
id="configureNav"
className={cn('talk-admin-nav-configure', styles.navLink)}
to="/admin/configure"
activeClassName={styles.active}
>
{t('configure.configure')}
</Link>
)}
</Navigation>
) : null}
<div className={styles.rightPanel}>
<ul>
<li className={cn(styles.settings, 'talk-admin-header-settings')}>
<div>
<IconButton
name="settings"
id="menu-settings"
className="talk-admin-header-settings-button"
/>
<Menu target="menu-settings" align="right">
<MenuItem onClick={() => showShortcuts(true)}>
{t('configure.shortcuts')}
</MenuItem>
<MenuItem>
<a
href="https://github.com/coralproject/talk/releases"
target="_blank"
rel="noopener noreferrer"
>
View latest version
</a>
</MenuItem>
<MenuItem>
<a
href="https://support.coralproject.net"
target="_blank"
rel="noopener noreferrer"
>
Report a bug or give feedback
</a>
</MenuItem>
{currentUser && (
<MenuItem
onClick={handleLogout}
className="talk-admin-header-sign-out"
>
{t('configure.sign_out')}
</MenuItem>
)}
</Menu>
</div>
</li>
<li>{`v${process.env.VERSION}`}</li>
</ul>
</div>
</div>
</Header>
</div>
);
};
CoralHeader.propTypes = {
currentUser: PropTypes.object,
showShortcuts: PropTypes.func,
handleLogout: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
};
export default CoralHeader;
@@ -1,7 +1,7 @@
import React from 'react';
import {matchLinks} from '../utils';
import { matchLinks } from '../utils';
export default ({text, children}) => {
export default ({ text, children }) => {
const hasLinks = !!matchLinks(text);
if (!hasLinks) {
@@ -1,7 +1,6 @@
import React from 'react';
import styles from './Indicator.css';
const Indicator = () =>
<span className={styles.indicator}></span>;
const Indicator = () => <span className={styles.indicator} />;
export default Indicator;
@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Layout as LayoutMDL} from 'react-mdl';
import Header from '../../containers/Header';
import Drawer from '../Drawer';
import { Layout as LayoutMDL } from 'react-mdl';
import Header from '../containers/Header';
import Drawer from './Drawer';
import styles from './Layout.css';
const Layout = ({
@@ -10,31 +10,29 @@ const Layout = ({
handleLogout = () => {},
toggleShortcutModal = () => {},
restricted = false,
auth,
currentUser,
}) => (
<LayoutMDL className={styles.layout} fixedDrawer>
<Header
handleLogout={handleLogout}
showShortcuts={toggleShortcutModal}
auth={auth}
currentUser={currentUser}
/>
<Drawer
<Drawer
handleLogout={handleLogout}
restricted={restricted}
auth={auth}
currentUser={currentUser}
/>
<div className={styles.layout}>
{children}
</div>
<div className={styles.layout}>{children}</div>
</LayoutMDL>
);
Layout.propTypes = {
children: PropTypes.node,
auth: PropTypes.object,
currentUser: PropTypes.object,
handleLogout: PropTypes.func,
toggleShortcutModal: PropTypes.func,
restricted: PropTypes.bool // hide elements from a user that's logged out
restricted: PropTypes.bool, // hide elements from a user that's logged out
};
export default Layout;
@@ -1,23 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Button} from 'coral-ui';
import { Button } from 'coral-ui';
import styles from './LoadMore.css';
import cn from 'classnames';
const LoadMore = ({loadMore, showLoadMore, className, ...rest}) =>
const LoadMore = ({ loadMore, showLoadMore, className = '', ...rest }) => (
<div {...rest} className={cn(className, styles.loadMoreContainer)}>
{
showLoadMore && <Button
className={styles.loadMore}
onClick={loadMore}>
{showLoadMore && (
<Button className={styles.loadMore} onClick={loadMore}>
Load More
</Button>
}
</div>;
)}
</div>
);
LoadMore.propTypes = {
className: PropTypes.string,
loadMore: PropTypes.func.isRequired,
showLoadMore: PropTypes.bool.isRequired
showLoadMore: PropTypes.bool.isRequired,
};
export default LoadMore;
@@ -0,0 +1,18 @@
.layout {
max-width: 400px;
margin: 0 auto;
}
.header, .cta {
text-align: center;
font-size: 16px;
}
.layout h1 {
font-size: 40px;
}
.header {
font-size: 30px;
}
@@ -0,0 +1,37 @@
import React, { Component } from 'react';
import SignIn from '../containers/SignIn';
import ForgotPassword from '../containers/ForgotPassword';
import PropTypes from 'prop-types';
import styles from './Login.css';
import Layout from 'coral-admin/src/components/Layout';
import cn from 'classnames';
class LoginContainer extends Component {
renderForm() {
return this.props.forgotPassword ? (
<ForgotPassword onSignInLink={this.props.onSignInLink} />
) : (
<SignIn onForgotPasswordLink={this.props.onForgotPasswordLink} />
);
}
render() {
return (
<Layout fixedDrawer restricted={true}>
<div className={cn(styles.layout, 'talk-admin-login')}>
<h1 className={styles.header}>Team sign in</h1>
<p className={styles.cta}>Sign in to interact with your community.</p>
{this.renderForm()}
</div>
</Layout>
);
}
}
LoginContainer.propTypes = {
forgotPassword: PropTypes.bool.isRequired,
onForgotPasswordLink: PropTypes.func.isRequired,
onSignInLink: PropTypes.func.isRequired,
};
export default LoginContainer;
@@ -1,9 +1,9 @@
import React from 'react';
import styles from './Logo.css';
import {CoralLogo} from 'coral-ui';
import { CoralLogo } from 'coral-ui';
import PropTypes from 'prop-types';
export const Logo = ({className = ''}) => (
export const Logo = ({ className = '' }) => (
<div className={`${styles.logo} ${className}`}>
<h1>
<CoralLogo className={styles.base} />
@@ -13,5 +13,5 @@ export const Logo = ({className = ''}) => (
);
Logo.propTypes = {
className: PropTypes.string
className: PropTypes.string,
};
+5 -3
View File
@@ -1,11 +1,13 @@
import React from 'react';
import {Button, Icon} from 'react-mdl';
import { Button, Icon } from 'react-mdl';
import styles from './Modal.css';
export default ({open, children, onClose}) => (
export default ({ open, children, onClose }) => (
<div className={`${styles.container} ${!open ? styles.hide : ''}`}>
<div className={styles.inner}>
<Button className={styles.close} onClick={onClose}><Icon name='close' /></Button>
<Button className={styles.close} onClick={onClose}>
<Icon name="close" />
</Button>
{children}
</div>
</div>
@@ -28,7 +28,7 @@
padding: 15px;
box-sizing: border-box;
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
z-index: 10;
z-index: 4;
.ctaHeader {
font-size: 16px;
@@ -5,52 +5,75 @@ import styles from './ModerationKeysModal.css';
import t from 'coral-framework/services/i18n';
export default class ModerationKeysModal extends React.Component {
static propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
hideShortcutsNote: PropTypes.func.isRequired,
shortcutsNoteVisible: PropTypes.string.isRequired,
queueCount: PropTypes.number.isRequired
}
queueCount: PropTypes.number.isRequired,
};
buildShortcuts = () => {
return [
{
title: 'modqueue.navigation',
shortcuts: {
'j': 'modqueue.next_comment',
'k': 'modqueue.prev_comment',
j: 'modqueue.next_comment',
k: 'modqueue.prev_comment',
'ctrl+f': 'modqueue.toggle_search',
't': 'modqueue.next_queue',
t: 'modqueue.next_queue',
[`1...${this.props.queueCount}`]: 'modqueue.jump_to_queue',
's': 'modqueue.singleview',
'?': 'modqueue.thismenu'
}
z: 'modqueue.singleview',
'?': 'modqueue.thismenu',
},
},
{
title: 'modqueue.actions',
shortcuts: {
'd': 'modqueue.approve',
'f': 'modqueue.reject'
}
}
d: 'modqueue.approve',
f: 'modqueue.reject',
},
},
];
}
};
render () {
const {open, onClose, hideShortcutsNote, shortcutsNoteVisible} = this.props;
render() {
const {
open,
onClose,
hideShortcutsNote,
shortcutsNoteVisible,
} = this.props;
return (
<div>
<div className={styles.callToAction} style={{display: shortcutsNoteVisible === 'show' ? 'block' : 'none'}}>
<div onClick={hideShortcutsNote} className={styles.closeButton}>×</div>
<div
className={styles.callToAction}
style={{
display: shortcutsNoteVisible === 'show' ? 'block' : 'none',
}}
>
<div onClick={hideShortcutsNote} className={styles.closeButton}>
×
</div>
<p className={styles.ctaHeader}>{t('modqueue.mod_faster')}</p>
<p><strong>{t('modqueue.try_these')}:</strong></p>
<p>
<strong>{t('modqueue.try_these')}:</strong>
</p>
<ul>
<li><span>{t('modqueue.approve')}</span> <span className={styles.smallKey}>d</span></li>
<li><span>{t('modqueue.reject')}</span> <span className={styles.smallKey}>f</span></li>
<li>
<span>{t('modqueue.approve')}</span>{' '}
<span className={styles.smallKey}>d</span>
</li>
<li>
<span>{t('modqueue.reject')}</span>{' '}
<span className={styles.smallKey}>f</span>
</li>
</ul>
<p><span>{t('modqueue.view_more_shortcuts')}</span> <span className={styles.smallKey}>{t('modqueue.shift_key')}</span> + <span className={styles.smallKey}>/</span></p>
<p>
<span>{t('modqueue.view_more_shortcuts')}</span>{' '}
<span className={styles.smallKey}>{t('modqueue.shift_key')}</span> +{' '}
<span className={styles.smallKey}>/</span>
</p>
</div>
<Modal open={open} onClose={onClose}>
<h3>{t('modqueue.shortcuts')}</h3>
@@ -63,9 +86,11 @@ export default class ModerationKeysModal extends React.Component {
</tr>
</thead>
<tbody>
{Object.keys(shortcut.shortcuts).map((key) => (
{Object.keys(shortcut.shortcuts).map(key => (
<tr key={`${key}tr`}>
<td className={styles.shortcut}><span className={styles.key}>{key}</span></td>
<td className={styles.shortcut}>
<span className={styles.key}>{key}</span>
</td>
<td>{t(shortcut.shortcuts[key])}</td>
</tr>
))}
@@ -3,35 +3,3 @@
margin: 0 auto;
}
.loginLayout {
max-width: 400px;
margin: 0 auto;
}
.loginHeader, .loginCTA, .forgotPasswordCTA, .passwordRequestSuccess {
text-align: center;
font-size: 16px;
}
.forgotPasswordLink, .signInLink {
color: blue;
font-weight: normal;
text-decoration: none;
}
.forgotPasswordLink:hover, .signInLink:hover {
text-decoration: underline;
}
.layout h1 {
font-size: 40px;
}
.loginHeader {
font-size: 30px;
}
.passwordRequestSuccess {
cursor: pointer;
padding: 8px 14px;
}
@@ -1,13 +1,16 @@
import React from 'react';
import {Layout} from 'react-mdl';
import { Layout } from 'react-mdl';
import styles from './NotFound.css';
export const NotFound = () => (
<Layout fixedDrawer>
<div className={styles.layout} >
<div className={styles.layout}>
<h1>Page Not Found</h1>
<p>The communicorn feels your pain.</p>
<img src="https://coralproject.net/images/communicorn.jpg" alt="Communicorn"/>
<img
src="https://coralproject.net/images/communicorn.jpg"
alt="Communicorn"
/>
</div>
</Layout>
);
@@ -5,23 +5,22 @@
background: white;
padding: 10px 11px;
box-sizing: border-box;
vertical-align: middle;
line-height: 24px;
font-size: 17px;
height: 47px;
border-radius: 3px;
text-transform: capitalize;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09);
width: 129px;
transform: scale(.8);
margin: 0;
width: 100%;
margin: 0 0 .5em;
&:hover {
&:not(:disabled):hover {
color: white;
background-color: #D03235;
box-shadow: none;
cursor: pointer;
}
&:disabled {
cursor: not-allowed;
}
}
.active {
@@ -3,16 +3,21 @@ import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './RejectButton.css';
import {Icon} from 'coral-ui';
import { Icon } from 'coral-ui';
import t from 'coral-framework/services/i18n';
const RejectButton = ({active, minimal, onClick, className}) => {
const RejectButton = ({ active, minimal, onClick, className, disabled }) => {
const text = active ? t('modqueue.rejected') : t('modqueue.reject');
return (
<button
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active}, className)}
className={cn(
styles.root,
{ [styles.minimal]: minimal, [styles.active]: active },
className
)}
onClick={onClick}
disabled={disabled || active}
>
<Icon name={'close'} className={styles.icon} />
{!minimal && text}
@@ -25,7 +30,7 @@ RejectButton.propTypes = {
active: PropTypes.bool,
minimal: PropTypes.bool,
onClick: PropTypes.func,
disabled: PropTypes.bool,
};
export default RejectButton;
@@ -0,0 +1,23 @@
.forgotPasswordCTA {
text-align: center;
font-size: 16px;
}
.forgotPasswordLink:hover {
text-decoration: underline;
}
.forgotPasswordLink {
color: blue;
font-weight: normal;
text-decoration: none;
}
.recaptcha {
margin-top: 16px;
margin-bottom: 6px;
}
.signInButton {
margin-top: 10px;
}
@@ -0,0 +1,94 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './SignIn.css';
import { Button, TextField, Alert } from 'coral-ui';
import cn from 'classnames';
import Recaptcha from 'coral-framework/components/Recaptcha';
class SignIn extends React.Component {
recaptcha = null;
handleForgotPasswordLink = e => {
e.preventDefault();
this.props.onForgotPasswordLink();
};
handleEmailChange = e => this.props.onEmailChange(e.target.value);
handlePasswordChange = e => this.props.onPasswordChange(e.target.value);
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
// Reset recaptcha because each response can only
// be used once.
if (this.recaptcha) {
this.recaptcha.reset();
}
};
handleRecaptchaRef = ref => {
this.recaptcha = ref;
};
render() {
const { email, password, errorMessage, requireRecaptcha } = this.props;
return (
<form className="talk-admin-login-sign-in" onSubmit={this.handleSubmit}>
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
id="email"
label="Email Address"
value={email}
onChange={this.handleEmailChange}
/>
<TextField
id="password"
label="Password"
value={password}
onChange={this.handlePasswordChange}
type="password"
/>
{requireRecaptcha && (
<div className={styles.recaptcha}>
<Recaptcha
ref={this.handleRecaptchaRef}
onVerify={this.props.onRecaptchaVerify}
/>
</div>
)}
<Button
className={cn(styles.signInButton, 'talk-admin-login-sign-in-button')}
type="submit"
cStyle="black"
full
>
Sign In
</Button>
<p className={styles.forgotPasswordCTA}>
Forgot your password?{' '}
<a
href="#"
className={styles.forgotPasswordLink}
onClick={this.handleForgotPasswordLink}
>
Request a new one.
</a>
</p>
</form>
);
}
}
SignIn.propTypes = {
email: PropTypes.string.isRequired,
password: PropTypes.string.isRequired,
onEmailChange: PropTypes.func.isRequired,
onPasswordChange: PropTypes.func.isRequired,
onForgotPasswordLink: PropTypes.func.isRequired,
onRecaptchaVerify: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
errorMessage: PropTypes.string,
requireRecaptcha: PropTypes.bool.isRequired,
};
export default SignIn;
@@ -1,25 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Dialog} from 'coral-ui';
import {RadioGroup, Radio} from 'react-mdl';
import { Dialog } from 'coral-ui';
import { RadioGroup, Radio } from 'react-mdl';
import styles from './SuspendUserDialog.css';
import cn from 'classnames';
import Button from 'coral-ui/components/Button';
import t, {timeago} from 'coral-framework/services/i18n';
import {dateAdd} from 'coral-framework/utils';
import t, { timeago } from 'coral-framework/services/i18n';
import { dateAdd } from 'coral-framework/utils';
const initialState = {step: 0, duration: '3'};
const initialState = { step: 0, duration: '3' };
function durationsToDate(hours) {
// Add 1 minute more to help `timeago.js` to display the correct duration.
return dateAdd(new Date(), 'minute', hours * 60 + 1);
}
class SuspendUserDialog extends React.Component {
state = initialState;
componentWillReceiveProps(next) {
@@ -28,13 +26,13 @@ class SuspendUserDialog extends React.Component {
}
}
handleDurationChange = (event) => {
this.setState({duration: event.target.value});
}
handleDurationChange = event => {
this.setState({ duration: event.target.value });
};
handleMessageChange = (event) => {
this.setState({message: event.target.value});
}
handleMessageChange = event => {
this.setState({ message: event.target.value });
};
goToStep1 = () => {
this.setState({
@@ -43,13 +41,12 @@ class SuspendUserDialog extends React.Component {
'suspenduser.email_message_suspend',
this.props.username,
this.props.organizationName,
timeago(durationsToDate(this.state.duration)),
timeago(durationsToDate(this.state.duration))
),
});
}
};
handlePerform = () => {
this.props.onPerform({
message: this.state.message,
@@ -59,36 +56,49 @@ class SuspendUserDialog extends React.Component {
};
renderStep0() {
const {onCancel, username} = this.props;
const {duration} = this.state;
const { onCancel, username } = this.props;
const { duration } = this.state;
return (
<section>
<h1 className={styles.header}>
{t('suspenduser.title_suspend')}
</h1>
<section className="talk-admin-suspend-user-dialog-step-0">
<h1 className={styles.header}>{t('suspenduser.title_suspend')}</h1>
<p className={styles.description}>
{t('suspenduser.description_suspend', username)}
</p>
<fieldset>
<legend className={styles.legend}>{t('suspenduser.select_duration')}</legend>
<legend className={styles.legend}>
{t('suspenduser.select_duration')}
</legend>
<RadioGroup
name='status filter'
name="status filter"
value={duration}
childContainer='div'
childContainer="div"
onChange={this.handleDurationChange}
className={styles.radioGroup}
>
<Radio value='1'>{t('suspenduser.one_hour')}</Radio>
<Radio value='3'>{t('suspenduser.hours', 3)}</Radio>
<Radio value='24'>{t('suspenduser.hours', 24)}</Radio>
<Radio value='168'>{t('suspenduser.days', 7)}</Radio>
<Radio value="1">{t('suspenduser.one_hour')}</Radio>
<Radio value="3">{t('suspenduser.hours', 3)}</Radio>
<Radio value="24">{t('suspenduser.hours', 24)}</Radio>
<Radio value="168">{t('suspenduser.days', 7)}</Radio>
</RadioGroup>
</fieldset>
<div className={styles.buttons}>
<Button cStyle="white" className={styles.cancel} onClick={onCancel} raised>
<Button
cStyle="white"
className={styles.cancel}
onClick={onCancel}
raised
>
{t('suspenduser.cancel')}
</Button>
<Button cStyle="black" className={cn(styles.perform, 'talk-admin-suspend-user-dialog-confirm')} onClick={this.goToStep1} raised>
<Button
cStyle="black"
className={cn(
styles.perform,
'talk-admin-suspend-user-dialog-confirm'
)}
onClick={this.goToStep1}
raised
>
{t('suspenduser.suspend_user')}
</Button>
</div>
@@ -97,31 +107,40 @@ class SuspendUserDialog extends React.Component {
}
renderStep1() {
const {onCancel, username} = this.props;
const {message} = this.state;
const { message } = this.state;
const { onCancel, username } = this.props;
return (
<section>
<h1 className={styles.header}>
{t('suspenduser.title_notify')}
</h1>
<section className="talk-admin-suspend-user-dialog-step-1">
<h1 className={styles.header}>{t('suspenduser.title_notify')}</h1>
<p className={styles.description}>
{t('suspenduser.description_notify', username)}
</p>
<fieldset>
<legend className={styles.legend}>{t('suspenduser.write_message')}</legend>
<legend className={styles.legend}>
{t('suspenduser.write_message')}
</legend>
<textarea
rows={5}
className={styles.messageInput}
value={message}
onChange={this.handleMessageChange} />
onChange={this.handleMessageChange}
/>
</fieldset>
<div className={styles.buttons}>
<Button cStyle="white" className={styles.cancel} onClick={onCancel} raised>
<Button
cStyle="white"
className={styles.cancel}
onClick={onCancel}
raised
>
{t('suspenduser.cancel')}
</Button>
<Button
cStyle="black"
className={cn(styles.perform, 'talk-admin-suspend-user-dialog-send')}
className={cn(
styles.perform,
'talk-admin-suspend-user-dialog-send'
)}
onClick={this.handlePerform}
disabled={this.state.message.length === 0}
raised
@@ -134,8 +153,8 @@ class SuspendUserDialog extends React.Component {
}
render() {
const {open, onCancel} = this.props;
const {step} = this.state;
const { open, onCancel } = this.props;
const { step } = this.state;
return (
<Dialog
className={cn(styles.dialog, 'talk-admin-suspend-user-dialog')}
@@ -143,7 +162,13 @@ class SuspendUserDialog extends React.Component {
open={open}
>
<div className={styles.close}>
<button aria-label="Close" onClick={onCancel} className={styles.closeButton}>×</button>
<button
aria-label="Close"
onClick={onCancel}
className={styles.closeButton}
>
×
</button>
</div>
{step === 0 && this.renderStep0()}
{step === 1 && this.renderStep1()}
@@ -5,8 +5,9 @@ import AutosizeInput from 'react-input-autosize';
import PropTypes from 'prop-types';
import cn from 'classnames';
const autosizingRenderInput = ({onChange, value, addTag: _, ...other}) =>
<AutosizeInput type='text' onChange={onChange} value={value} {...other} />;
const autosizingRenderInput = ({ onChange, value, addTag: _, ...other }) => (
<AutosizeInput type="text" onChange={onChange} value={value} {...other} />
);
autosizingRenderInput.propTypes = {
onChange: PropTypes.func,
@@ -14,17 +15,16 @@ autosizingRenderInput.propTypes = {
addTag: PropTypes.func,
};
const TagsInputComponent = ({className = '', ...props}) => {
const TagsInputComponent = ({ className = '', ...props }) => {
return (
<TagsInput
addOnBlur={true}
addOnPaste={true}
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
pasteSplit={data => data.split(',').map(d => d.trim())}
className={cn(styles.root, 'tags-input', className)}
focusedClassName={styles.rootFocus}
renderInput={autosizingRenderInput}
{...props}
tagProps={{
className: styles.tag,
classNameRemove: styles.tagRemove,
@@ -207,7 +207,7 @@
.toastify__body {
color: white;
overflow-x: scroll;
overflow-x: auto;
font-size: 15px;
font-weight: 400;
}
@@ -1,6 +1,6 @@
import './ToastContainer.css';
import {defaultProps} from 'recompose';
import {ToastContainer} from 'react-toastify';
import { defaultProps } from 'recompose';
import { ToastContainer } from 'react-toastify';
export default defaultProps({
autoClose: 5000,
@@ -94,73 +94,52 @@
width: calc(100% - 90px);
}
.commentStatuses {
.tabBar {
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;
}
border: none;
}
.active {
.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;
}
.bulkActionGroup {
height: 52px;
background-color: #efefef;
padding: 0 0 0 10px;
display: flex;
i {
margin-right: 0;
}
.bulkAction {
display: inline-block;
width: 48px;
height: 48px;
transform: scale(.7);
min-width: 0;
}
.bulkAction:last-child {
margin-left: -10px;
}
.username {
display: inline-block;
vertical-align: middle;
}
.selectedCommentsInfo {
align-self: center;
font-weight: 500;
margin-left: 15px;
.actionsMenu {
display: inline-block;
}
.loadMore>button {
background-color: #696969;
&:hover {
background-color: #404040;
color: white;
}
.actionsMenuSuspended {
background-color: #F29336;
border-color: #F29336;
color: white;
}
.toggleAll {
padding: 0 10px 0 0;
align-self: center;
.actionsMenuBanned {
background-color: #E45241;
border-color: #E45241;
color: white;
}
.commentList {
clear: both;
}
.bulkActionHeader {
display: flex;
justify-content: space-between;
height: 52px;
&.selected {
background-color: #efefef;
}
}
+297 -158
View File
@@ -1,94 +1,68 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import Comment from '../containers/UserDetailComment';
import capitalize from 'lodash/capitalize';
import styles from './UserDetail.css';
import {Icon, Drawer, Spinner} from 'coral-ui';
import {Slot} from 'coral-framework/components';
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';
import t from 'coral-framework/services/i18n';
export default class UserDetail extends React.Component {
class UserDetail extends React.Component {
rejectThenReload = async info => {
await this.props.rejectComment(info);
this.props.data.refetch();
};
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
}
rejectThenReload = async (info) => {
try {
await this.props.rejectComment(info);
this.props.data.refetch();
} catch (err) {
console.error(err);
this.props.notify('error', getErrorMessages(err));
}
}
acceptThenReload = async (info) => {
try {
await this.props.acceptComment(info);
this.props.data.refetch();
} catch (err) {
console.error(err);
this.props.notify('error', getErrorMessages(err));
}
}
acceptThenReload = async info => {
await this.props.acceptComment(info);
this.props.data.refetch();
};
bulkAcceptThenReload = async () => {
try {
await this.props.bulkAccept();
this.props.data.refetch();
} catch (err) {
console.error(err);
this.props.notify('error', getErrorMessages(err));
}
}
await this.props.bulkAccept();
this.props.data.refetch();
};
bulkRejectThenReload = async () => {
try {
await this.props.bulkReject();
this.props.data.refetch();
} catch (err) {
await this.props.bulkReject();
this.props.data.refetch();
};
console.error(err);
this.props.notify('error', getErrorMessages(err));
}
}
changeTab = tab => {
this.props.changeTab(tab);
};
showAll = () => {
this.props.changeStatus('all');
}
showSuspenUserDialog = () =>
this.props.showSuspendUserDialog({
userId: this.props.root.user.id,
username: this.props.root.user.username,
});
showRejected = () => {
this.props.changeStatus('rejected');
}
showBanUserDialog = () =>
this.props.showBanUserDialog({
userId: this.props.root.user.id,
username: this.props.root.user.username,
});
renderLoading() {
return (
@@ -100,68 +74,170 @@ export default class UserDetail extends React.Component {
);
}
renderError() {
return (
<ClickOutside onClickOutside={this.props.hideUserDetail}>
<Drawer onClose={this.props.hideUserDetail}>
<div>{this.props.data.error.message}</div>
</Drawer>
</ClickOutside>
);
}
getActionMenuLabel() {
const { root: { user } } = this.props;
if (isBanned(user)) {
return 'Banned';
} else if (isSuspended(user)) {
return 'Suspended';
}
return '';
}
renderLoaded() {
const {
data,
root,
root: {
user,
totalComments,
rejectedComments,
comments: {nodes, hasNextPage}
},
root: { me, user, totalComments, rejectedComments },
activeTab,
selectedCommentIds,
toggleSelect,
hideUserDetail,
viewUserDetail,
loadMore,
toggleSelectAll
toggleSelectAll,
unbanUser,
unsuspendUser,
modal,
} = this.props;
let rejectedPercent = (rejectedComments / totalComments) * 100;
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
// if totalComments is 0, you're dividing by zero
let rejectedPercent = rejectedComments / totalComments * 100;
// 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>
<ClickOutside onClickOutside={modal ? null : hideUserDetail}>
<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 })}>
{t('user_detail.remove_suspension')}
</ActionsMenuItem>
) : (
<ActionsMenuItem
disabled={me.id === user.id}
onClick={this.showSuspenUserDialog}
>
{t('user_detail.suspend')}
</ActionsMenuItem>
)}
{banned ? (
<ActionsMenuItem onClick={() => unbanUser({ id: user.id })}>
{t('user_detail.remove_ban')}
</ActionsMenuItem>
) : (
<ActionsMenuItem
disabled={me.id === user.id}
onClick={this.showBanUserDialog}
>
{t('user_detail.ban')}
</ActionsMenuItem>
)}
</ActionsMenu>
)}
{(banned || suspended) && (
<UserInfoTooltip
user={user}
banned={banned}
suspended={suspended}
/>
)}
<div>
<ul className={styles.userDetailList}>
<li>
<Icon name="assignment_ind" />
<span className={styles.userDetailItem}>Member Since:</span>
<span className={styles.userDetailItem}>
{t('user_detail.member_since')}:
</span>
{new Date(user.created_at).toLocaleString()}
</li>
{user.profiles.map(({id}) =>
{user.profiles.map(({ id }) => (
<li key={id}>
<Icon name="email" />
<span className={styles.userDetailItem}>Email:</span>
{id} <ButtonCopyToClipboard className={styles.copyButton} icon="content_copy" copyText={id} />
<span className={styles.userDetailItem}>
{t('user_detail.email')}:
</span>
{id}{' '}
<ButtonCopyToClipboard
className={styles.copyButton}
icon="content_copy"
copyText={id}
/>
</li>
)}
))}
</ul>
<ul className={styles.stats}>
<li className={styles.stat}>
<span className={styles.statItem}>Total Comments</span>
<span className={styles.statItem}>
{t('user_detail.total_comments')}
</span>
<span className={styles.statResult}>{totalComments}</span>
</li>
<li className={styles.stat}>
<span className={styles.statItem}>Reject Rate</span>
<span className={styles.statItem}>
{t('user_detail.reject_rate')}
</span>
<span className={styles.statResult}>
{rejectedPercent.toFixed(1)}%
</span>
</li>
<li className={styles.stat}>
<span className={styles.statItem}>Reports</span>
<span className={cn(styles.statReportResult, styles[getReliability(user.reliable.flagger)])}>
<span className={styles.statItem}>
{t('user_detail.reports')}
</span>
<span
className={cn(
styles.statReportResult,
styles[getReliability(user.reliable.flagger)]
)}
>
{capitalize(getReliability(user.reliable.flagger))}
</span>
</li>
@@ -171,76 +247,139 @@ export default class UserDetail extends React.Component {
<Slot
fill="userProfile"
data={this.props.data}
queryData={{root, user}}
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>
)
: (
<div className={styles.bulkActionGroup}>
<ApproveButton
onClick={this.bulkAcceptThenReload}
minimal
/>
<RejectButton
onClick={this.bulkRejectThenReload}
minimal
/>
<span className={styles.selectedCommentsInfo}> {selectedCommentIds.length} comments selected</span>
</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>
</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}
/>
<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'
)}
>
{t('user_detail.all')}
</Tab>
<Tab
tabId={'rejected'}
className={cn(styles.tab, 'talk-admin-user-detail-rejected-tab')}
>
{t('user_detail.rejected')}
</Tab>
<Tab
tabId={'history'}
className={cn(
styles.tab,
styles.button,
'talk-admin-user-detail-history-tab'
)}
>
{t('user_detail.account_history')}
</Tab>
</TabBar>
<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}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
/>
</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}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
/>
</TabPane>
<TabPane
tabId={'history'}
className={'talk-admin-user-detail-history-tab-pane'}
>
<AccountHistory user={user} />
</TabPane>
</TabContent>
</Drawer>
</ClickOutside>
);
}
render() {
if (this.props.data.error) {
return this.renderError();
}
if (this.props.loading) {
return this.renderLoading();
}
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,
changeTab: 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,
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
unbanUser: PropTypes.func.isRequired,
unsuspendUser: PropTypes.func.isRequired,
modal: PropTypes.bool,
};
export default UserDetail;
@@ -78,9 +78,17 @@
font-weight: 300;
font-size: 16px;
max-width: 360px;
blockquote {
font-size: inherit;
line-height: inherit;
letter-spacing: inherit;
}
}
.sideActions {
/* IE 11 needs this... */
width: 105px;
}
.editedMarker {
@@ -1,11 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Link} from 'react-router';
import {Icon} from 'coral-ui';
import Slot from 'coral-framework/components/Slot';
import { Link } from 'react-router';
import { Icon } from 'coral-ui';
import CommentDetails from './CommentDetails';
import styles from './UserDetailComment.css';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import CommentAnimatedEdit from './CommentAnimatedEdit';
@@ -13,19 +13,18 @@ import CommentLabels from '../containers/CommentLabels';
import ApproveButton from './ApproveButton';
import RejectButton from 'coral-admin/src/components/RejectButton';
import t, {timeago} from 'coral-framework/services/i18n';
import t, { timeago } from 'coral-framework/services/i18n';
class UserDetailComment extends React.Component {
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 });
render() {
const {
@@ -34,30 +33,43 @@ class UserDetailComment extends React.Component {
toggleSelect,
className,
data,
root: {settings: {wordlist: {banned, suspect}}},
root: { settings },
} = this.props;
const queryData = { root, comment };
const formatterSettings = {
suspectWords: settings.wordlist.suspect,
bannedWords: settings.wordlist.banned,
body: comment.body,
};
return (
<li
tabIndex={0}
className={cn(className, styles.root, {[styles.rootSelected]: selected})}
className={cn(className, styles.root, {
[styles.rootSelected]: selected,
})}
>
<div className={styles.container}>
<div className={styles.header}>
<input
className={styles.bulkSelectInput}
type='checkbox'
type="checkbox"
value={comment.id}
checked={selected}
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
onChange={e => toggleSelect(e.target.value, e.target.checked)}
/>
<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
}
{comment.editing && comment.editing.edited ? (
<span>
&nbsp;<span className={styles.editedMarker}>
({t('comment.edited')})
</span>
</span>
) : null}
<div className={styles.labels}>
<CommentLabels comment={comment} />
@@ -65,17 +77,27 @@ class UserDetailComment extends React.Component {
</div>
<div className={styles.story}>
Story: {comment.asset.title}
{<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
{
<Link to={`/admin/moderate/${comment.asset.id}`}>
{t('modqueue.moderate')}
</Link>
}
</div>
<CommentAnimatedEdit body={comment.body}>
<div className={styles.bodyContainer}>
<div className={styles.body}>
<CommentBodyHighlighter
suspectWords={suspect}
bannedWords={banned}
body={comment.body}
<Slot
fill="userDetailCommentContent"
data={data}
className={cn(
styles.commentContent,
'talk-admin-user-detail-comment'
)}
queryData={queryData}
slotSize={1}
defaultComponent={CommentFormatter}
{...formatterSettings}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
@@ -106,17 +128,15 @@ class UserDetailComment extends React.Component {
</div>
</CommentAnimatedEdit>
</div>
<CommentDetails
data={data}
root={root}
comment={comment}
/>
<CommentDetails data={data} root={root} comment={comment} />
</li>
);
}
}
UserDetailComment.propTypes = {
selected: PropTypes.bool,
data: PropTypes.object,
user: PropTypes.object.isRequired,
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
@@ -140,7 +160,7 @@ UserDetailComment.propTypes = {
asset: PropTypes.shape({
title: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string
id: PropTypes.string,
}),
}),
};
@@ -0,0 +1,56 @@
.commentList {
clear: both;
}
.loadMore > button {
background-color: #696969;
&:hover {
background-color: #404040;
color: white;
}
}
.bulkActionHeader {
display: flex;
justify-content: space-between;
height: 52px;
justify-content: flex-end;
&.selected {
background-color: #efefef;
}
}
.bulkActionGroup {
height: 52px;
background-color: #efefef;
padding: 0 0 0 10px;
display: flex;
flex: 1;
i {
margin-right: 0;
}
.bulkAction {
display: inline-block;
width: 48px;
height: 48px;
transform: scale(.7);
min-width: 0;
}
.bulkAction:last-child {
margin-left: -10px;
}
}
.selectedCommentsInfo {
align-self: center;
font-weight: 500;
margin-left: 15px;
}
.toggleAll {
padding-right: 14px;
align-self: center;
}
@@ -0,0 +1,106 @@
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';
import RejectButton from './RejectButton';
import ApproveButton from './ApproveButton';
const UserDetailCommentList = props => {
const {
data,
root,
root: { user, comments: { nodes, hasNextPage } },
acceptComment,
rejectComment,
selectedCommentIds,
toggleSelect,
viewUserDetail,
loadMore,
toggleSelectAll,
bulkAcceptThenReload,
bulkRejectThenReload,
} = props;
return (
<div
className={cn(styles.commentList, 'talk-admin-user-detail-comment-list')}
>
<div
className={
selectedCommentIds.length > 0
? cn(styles.bulkActionHeader, styles.selected)
: styles.bulkActionHeader
}
>
{selectedCommentIds.length > 0 && (
<div className={styles.bulkActionGroup}>
<ApproveButton onClick={bulkAcceptThenReload} minimal />
<RejectButton onClick={bulkRejectThenReload} minimal />
<span className={styles.selectedCommentsInfo}>
{' '}
{selectedCommentIds.length} comments selected
</span>
</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>
</div>
{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,
toggleSelectAll: PropTypes.func.isRequired,
bulkAcceptThenReload: PropTypes.func.isRequired,
bulkRejectThenReload: 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;
left: -100px;
width: 200px;
text-align: left;
color: #616161;
}
.menu::before{
content: '';
border: 10px solid transparent;
border-top-color: #999;
position: absolute;
left: 96px;
top: -20px;
transform: rotate(180deg);
}
.menu::after{
content: '';
border: 10px solid transparent;
border-top-color: white;
position: absolute;
left: 96px;
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,177 @@
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('MMM 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 />
</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('MMM 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('MMM 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;

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