diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..9e1ee91e0 --- /dev/null +++ b/.circleci/config.yml @@ -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 \ No newline at end of file diff --git a/scripts/e2e-ci.sh b/.circleci/e2e.sh similarity index 65% rename from scripts/e2e-ci.sh rename to .circleci/e2e.sh index a509ccee7..2d9fc25af 100755 --- a/scripts/e2e-ci.sh +++ b/.circleci/e2e.sh @@ -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 diff --git a/.eslintignore b/.eslintignore index fd85e4523..a9543d933 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 + diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 079766d25..0801532f0 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,5 +1,34 @@ -### Expected behavior + + +#### Do you want to request a **feature** or report a **bug**? + + +#### Intended outcome: + + +#### Actual outcome: + + +#### How to reproduce the issue: + + +#### Version and environment + diff --git a/.gitignore b/.gitignore index d30e961bd..a6630d22e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 000000000..9c9500651 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,13 @@ +{ + "linters": { + "*.js": [ + "git-exec-and-restage eslint --fix --" + ], + "bin/cli*": [ + "git-exec-and-restage eslint --fix --" + ], + "*.yml": [ + "yamllint" + ] + } +} diff --git a/.nodemon.json b/.nodemon.json index 7f7fd3d59..101104f4a 100644 --- a/.nodemon.json +++ b/.nodemon.json @@ -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" + ] } diff --git a/.nsprc b/.nsprc index 583560bdd..da6cb9865 100644 --- a/.nsprc +++ b/.nsprc @@ -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" ] } diff --git a/Dockerfile b/Dockerfile index 95aa74d8a..0b7c8caa4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Dockerfile.onbuild b/Dockerfile.onbuild index 20d34ffb9..3e837aad6 100644 --- a/Dockerfile.onbuild +++ b/Dockerfile.onbuild @@ -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 \ No newline at end of file diff --git a/LICENSE b/LICENSE index e0a687532..5beee04b4 100644 --- a/LICENSE +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 89474bf6d..db9f65c3d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Talk · [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) · [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8/badge)](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) · [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests) +# Talk · [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) · [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946/badge)](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946) · [![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). diff --git a/app.js b/app.js index c180d2c04..c2bf9648d 100644 --- a/app.js +++ b/app.js @@ -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 diff --git a/bin/cli b/bin/cli index f618b24be..314f33dd4 100755 --- a/bin/cli +++ b/bin/cli @@ -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'); - } -}); diff --git a/bin/cli-assets b/bin/cli-assets index 4805bead0..78229d91b 100755 --- a/bin/cli-assets +++ b/bin/cli-assets @@ -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 ') - .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 ') + .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. diff --git a/bin/cli-jobs b/bin/cli-jobs index 6048a69db..a9599213d 100755 --- a/bin/cli-jobs +++ b/bin/cli-jobs @@ -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; diff --git a/bin/cli-migration b/bin/cli-migration index 036ac8b02..63e16f97f 100755 --- a/bin/cli-migration +++ b/bin/cli-migration @@ -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 ', + 'change the size of queried documents that are batched at a time', + parse10, + 10000 + ) + .option( + '-u, --update-batch-size ', + '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); diff --git a/bin/cli-plugins b/bin/cli-plugins index b7e301fce..f2e76de21 100755 --- a/bin/cli-plugins +++ b/bin/cli-plugins @@ -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); diff --git a/bin/cli-serve b/bin/cli-serve index 723558651..b8dbd4594 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -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); }); - diff --git a/bin/cli-settings b/bin/cli-settings index f8768046c..7f449e0d5 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -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) { diff --git a/bin/cli-setup b/bin/cli-setup index c7a44c453..282a8a2d7 100755 --- a/bin/cli-setup +++ b/bin/cli-setup @@ -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); }); diff --git a/bin/cli-token b/bin/cli-token index fa5d2e350..b131728e6 100755 --- a/bin/cli-token +++ b/bin/cli-token @@ -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}`); diff --git a/bin/cli-users b/bin/cli-users index 89bf667a7..ea308ae44 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -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 ') .description('delete a user') .action(deleteUser); -program - .command('passwd ') - .description('change a password for a user') - .action(passwd); - -program - .command('update ') - .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 ') - .description('merge srcUser into the dstUser') - .action(mergeUsers); - -program - .command('addrole ') - .description('adds a role to a given user') - .action(addRole); - -program - .command('removerole ') - .description('removes a role from a given user') - .action(removeRole); - -program - .command('ban ') - .description('ban a given user') - .action(ban); - -program - .command('uban ') - .description('unban a given user') - .action(unban); - -program - .command('disable ') - .description('disable a given user from logging in') - .action(disableUser); - -program - .command('enable ') - .description('enable a given user from logging in') - .action(enableUser); + .command('set-role ') + .description('sets the role on a user') + .action(setUserRole); program .command('verify ') - .description('verifies the given user\'s email address') - .action(verify); + .description("verifies the given user's email address") + .action(verifyUserEmail); program.parse(process.argv); diff --git a/bin/cli-verify b/bin/cli-verify deleted file mode 100755 index 6e67e0016..000000000 --- a/bin/cli-verify +++ /dev/null @@ -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(); -} diff --git a/bin/commander.js b/bin/commander.js deleted file mode 100644 index 328a25b29..000000000 --- a/bin/commander.js +++ /dev/null @@ -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'); diff --git a/bin/templates/plugin/client/components/MyPluginComponent.js b/bin/templates/plugin/client/components/MyPluginComponent.js index 89e274e1a..98488b133 100644 --- a/bin/templates/plugin/client/components/MyPluginComponent.js +++ b/bin/templates/plugin/client/components/MyPluginComponent.js @@ -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 (
- +

Plugin created by Talk CLI

diff --git a/bin/templates/plugin/client/index.js b/bin/templates/plugin/client/index.js index acd0910be..72a7fe6a7 100644 --- a/bin/templates/plugin/client/index.js +++ b/bin/templates/plugin/client/index.js @@ -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], + }, }; diff --git a/bin/util.js b/bin/util.js index ab9035fe5..e80f123a5 100644 --- a/bin/util.js +++ b/bin/util.js @@ -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); +}); diff --git a/bin/verifications/database/comments.js b/bin/verifications/database/comments.js deleted file mode 100644 index 231badabb..000000000 --- a/bin/verifications/database/comments.js +++ /dev/null @@ -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'); - } - } -}; diff --git a/bin/verifications/database/index.js b/bin/verifications/database/index.js deleted file mode 100644 index 3dafeb3bf..000000000 --- a/bin/verifications/database/index.js +++ /dev/null @@ -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'), -]; diff --git a/circle.yml b/circle.yml deleted file mode 100644 index 9f35736c6..000000000 --- a/circle.yml +++ /dev/null @@ -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 diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index 362143d38..9d68d592b 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -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 = (
- - - - - + + + + + {/* Community Routes */} - - - + + + - - + + - + {/* Moderation Routes */} - + - + - - + + diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js deleted file mode 100644 index 422258f50..000000000 --- a/client/coral-admin/src/actions/auth.js +++ /dev/null @@ -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'}); -}; - diff --git a/client/coral-admin/src/actions/banUserDialog.js b/client/coral-admin/src/actions/banUserDialog.js index 8b068051c..aac904a2b 100644 --- a/client/coral-admin/src/actions/banUserDialog.js +++ b/client/coral-admin/src/actions/banUserDialog.js @@ -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 }); diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 6a091d214..9a0452ea1 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -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, +}); diff --git a/client/coral-admin/src/actions/config.js b/client/coral-admin/src/actions/config.js deleted file mode 100644 index 4f3528be0..000000000 --- a/client/coral-admin/src/actions/config.js +++ /dev/null @@ -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}); -}; diff --git a/client/coral-admin/src/actions/configure.js b/client/coral-admin/src/actions/configure.js index 128de75bc..acc30be1b 100644 --- a/client/coral-admin/src/actions/configure.js +++ b/client/coral-admin/src/actions/configure.js @@ -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 }; }; diff --git a/client/coral-admin/src/actions/install.js b/client/coral-admin/src/actions/install.js index 02d562d65..2f8c8c250 100644 --- a/client/coral-admin/src/actions/install.js +++ b/client/coral-admin/src/actions/install.js @@ -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)); } }; diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 18e31091f..16c2bd2f7 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -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, +}); diff --git a/client/coral-admin/src/actions/stories.js b/client/coral-admin/src/actions/stories.js index 2524b0c3c..ab610ae20 100644 --- a/client/coral-admin/src/actions/stories.js +++ b/client/coral-admin/src/actions/stories.js @@ -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, }); - diff --git a/client/coral-admin/src/actions/suspendUserDialog.js b/client/coral-admin/src/actions/suspendUserDialog.js index 06913147d..f81b76dd3 100644 --- a/client/coral-admin/src/actions/suspendUserDialog.js +++ b/client/coral-admin/src/actions/suspendUserDialog.js @@ -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 }); diff --git a/client/coral-admin/src/actions/userDetail.js b/client/coral-admin/src/actions/userDetail.js index 3a1de5745..f34f8e7a8 100644 --- a/client/coral-admin/src/actions/userDetail.js +++ b/client/coral-admin/src/actions/userDetail.js @@ -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, }; }; diff --git a/client/coral-admin/src/actions/users.js b/client/coral-admin/src/actions/users.js index 994a79a79..c538d7384 100644 --- a/client/coral-admin/src/actions/users.js +++ b/client/coral-admin/src/actions/users.js @@ -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, + }); + } + ); }; }; diff --git a/client/coral-admin/src/components/AccountHistory.css b/client/coral-admin/src/components/AccountHistory.css new file mode 100644 index 000000000..fbd1027d1 --- /dev/null +++ b/client/coral-admin/src/components/AccountHistory.css @@ -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; +} diff --git a/client/coral-admin/src/components/AccountHistory.js b/client/coral-admin/src/components/AccountHistory.js new file mode 100644 index 000000000..a16b334ca --- /dev/null +++ b/client/coral-admin/src/components/AccountHistory.js @@ -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 + ) : ( + + {t('account_history.system')} + + ); + +class AccountHistory extends React.Component { + render() { + const { user } = this.props; + const userHistory = buildUserHistory(user.state); + return ( +
+
+
+
+ {t('account_history.date')} +
+
+ {t('account_history.action')} +
+
+ {t('account_history.taken_by')} +
+
+ {userHistory.map( + ({ __typename, created_at, assigned_by, until, status }) => ( +
+
+ {moment(new Date(created_at)).format('MMM DD, YYYY')} +
+
+ {buildActionResponse(__typename, created_at, until, status)} +
+
+ {getModerationValue(assigned_by)} +
+
+ ) + )} +
+
+ ); + } +} + +AccountHistory.propTypes = { + user: PropTypes.object.isRequired, +}; + +export default AccountHistory; diff --git a/client/coral-admin/src/components/ActionsMenu.css b/client/coral-admin/src/components/ActionsMenu.css index 1d2f91f74..c8638423f 100644 --- a/client/coral-admin/src/components/ActionsMenu.css +++ b/client/coral-admin/src/components/ActionsMenu.css @@ -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; diff --git a/client/coral-admin/src/components/ActionsMenu.js b/client/coral-admin/src/components/ActionsMenu.js index 6351afda7..81b857a13 100644 --- a/client/coral-admin/src/components/ActionsMenu.js +++ b/client/coral-admin/src/components/ActionsMenu.js @@ -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 ( -
- - - {this.props.children} - -
+ onKeyUp={this.syncOpenState} + > + + + {this.props.children} + +
+ ); } } @@ -61,6 +76,8 @@ ActionsMenu.propTypes = { icon: PropTypes.string, children: PropTypes.node, className: PropTypes.string, + label: PropTypes.string, + buttonClassNames: PropTypes.string, }; export default ActionsMenu; diff --git a/client/coral-admin/src/components/ActionsMenuItem.js b/client/coral-admin/src/components/ActionsMenuItem.js index 2bb9fa645..7370ba466 100644 --- a/client/coral-admin/src/components/ActionsMenuItem.js +++ b/client/coral-admin/src/components/ActionsMenuItem.js @@ -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) => - ; - +const ActionsMenuItem = props => ( + +); + ActionsMenuItem.propTypes = { className: PropTypes.string, children: PropTypes.string, diff --git a/client/coral-admin/src/components/AdminLogin.js b/client/coral-admin/src/components/AdminLogin.js index d195ad72b..99bd61696 100644 --- a/client/coral-admin/src/components/AdminLogin.js +++ b/client/coral-admin/src/components/AdminLogin.js @@ -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 = (
{errorMessage && {errorMessage}} this.setState({email: e.target.value})} /> + onChange={e => this.setState({ email: e.target.value })} + /> this.setState({password: e.target.value})} - type='password' /> -
+ onChange={e => this.setState({ password: e.target.value })} + type="password" + /> +
+ onClick={this.handleSignIn} + > + Sign In +

- Forgot your password? { - e.preventDefault(); - this.setState({requestPassword: true}); - }}>Request a new one. + Forgot your password?{' '} + { + e.preventDefault(); + this.setState({ requestPassword: true }); + }} + > + Request a new one. +

- { - loginMaxExceeded && + {loginMaxExceeded && ( - } + verifyCallback={this.onRecaptchaVerify} + /> + )} ); - const requestPasswordForm = ( - this.props.passwordRequestSuccess - ?

{ + const requestPasswordForm = this.props.passwordRequestSuccess ? ( +

{ location.href = location.href; - }}> - {this.props.passwordRequestSuccess} Sign in - -

- :
- this.setState({email: e.target.value})} /> - - + }} + > + {this.props.passwordRequestSuccess}{' '} + + Sign in + + +

+ ) : ( +
+ this.setState({ email: e.target.value })} + /> + + ); return (

Team sign in

-

Sign in to interact with your community.

- { this.state.requestPassword ? requestPasswordForm : signInForm } +

+ Sign in to interact with your community. +

+ {this.state.requestPassword ? requestPasswordForm : signInForm}
); diff --git a/client/coral-admin/src/components/App.js b/client/coral-admin/src/components/App.js index 1a15c72c3..52535f91a 100644 --- a/client/coral-admin/src/components/App.js +++ b/client/coral-admin/src/components/App.js @@ -5,7 +5,7 @@ import 'material-design-lite'; import AppRouter from '../AppRouter'; export default class App extends React.Component { - render () { + render() { return (
diff --git a/client/coral-admin/src/components/ApproveButton.css b/client/coral-admin/src/components/ApproveButton.css index 4c42cc233..db3ee63f6 100644 --- a/client/coral-admin/src/components/ApproveButton.css +++ b/client/coral-admin/src/components/ApproveButton.css @@ -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 { diff --git a/client/coral-admin/src/components/ApproveButton.js b/client/coral-admin/src/components/ApproveButton.js index 8ab455510..884ac3da5 100644 --- a/client/coral-admin/src/components/ApproveButton.js +++ b/client/coral-admin/src/components/ApproveButton.js @@ -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 ( - -
- -); +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 ( +
+

{t('bandialog.ban_user')}

+

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

+

{info}

+
+ + +
+
+ ); + } + + renderStep1() { + const { onCancel, onPerform } = this.props; + const { message } = this.state; + + return ( +
+

{t('bandialog.notify_ban_headline')}

+

+ {t('bandialog.notify_ban_description')} +

+
+ + {t('bandialog.write_a_message')} + +