diff --git a/.eslintignore b/.eslintignore index 53c37a166..a4865e1f6 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ -dist \ No newline at end of file +dist +client/lib diff --git a/.eslintrc.json b/.eslintrc.json index 6ac5a08e6..035a86189 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,25 +5,15 @@ }, "extends": "eslint:recommended", "rules": { - "indent": [ - "error", + "indent": ["error", 2 ], "no-console": [ 0 ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single" - ], - "semi": [ - "error", - "always" - ], + "linebreak-style": ["error", "unix"], + "quotes": ["error", "single"], + "semi": ["error", "always"], "no-template-curly-in-string": [1], "no-unsafe-negation": [1], "array-callback-return": [1], @@ -35,7 +25,6 @@ "no-throw-literal": [2], "yoda": [1], "no-path-concat": [2], - "no-process-exit": [2], "eol-last": [1], "no-continue": [1], "no-nested-ternary": [1], @@ -46,20 +35,20 @@ "no-const-assign": [2], "no-duplicate-imports": [2], "prefer-template": [1], - "comma-spacing": [ - "error", - { + "comma-spacing": ["error", { "after": true - } - ], + }], "no-var": [2], "no-lonely-if": [2], "curly": [2], - "no-unused-vars": ["error", { "argsIgnorePattern": "next" }], - "no-multiple-empty-lines": [ - "error", - {"max": 1} - ], - "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 2 }] + "no-unused-vars": ["error", { + "argsIgnorePattern": "next" + }], + "no-multiple-empty-lines": ["error", { + "max": 1 + }], + "newline-per-chained-call": ["error", { + "ignoreChainWithDepth": 2 + }] } } diff --git a/.gitignore b/.gitignore index 3b59ff2f5..c76651868 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ node_modules -npm-debug.log +npm-debug.log* dist !dist/coral-admin dist/coral-admin/bundle.js +tests/e2e/reports .DS_Store *.iml +*.swp +dump.rdb .env gaba.cfg .idea/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..9fa7985c0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# Contribution Guide + +We're very excited that you're interested in contributing to Talk! There is much to do. Before you begin, please review this document to get a sense of the practices and philosophies that hold this project together. + + +## Doing the Work + +We are here to make it as seamless as possible to contribute to Talk. The following lists are meant to make it straightforward to perform the mechanics of working on the project so you can focus your energy toward writing and reviewing content. + + +### Code Reviews + +One of the most valuable aspects of working in software. It is something that should challenge the reviewer and author alike. It is a way of focusing knowledge, experience and opinions for the benefit of the project and the participants. + +Code reviews are a collaboration to make _the work_ as good as it can be. Code reviews are not a good venue for providing direct instruction to _the author._ Focus on positive, incremental improvements that can be made on the work at hand. + +Please take your time when writing and reviewing code. Here are some fundamental questions to open up a reviewing headspace. + +**Is the code clear, efficient and a pleasure to read?** + +Somewhere at the intersection of good variable names, well laid out file structures, consistent formatting and appropriate comments lies beautiful code. Code is language spoken to at least two very distinct audiences, the computer that interprets it and the developer who encounters it. Both should be at the front of your mind when reviewing code. + +Thinking like a computer, you could ask: + +* Is the code using memory efficiently? +* Is data being moved around unnecessarily? +* Are multiple network requests being made where fewer would do? +* Is there excess processing happening in a synchronous flow that may disrupt user experience? +* Are there large libraries included for small gains? + +Then, returning to your human roots... Is the code readable? + +* Can I understand what is happening here (and maybe even why) by simply opening up the file, starting at the top and reading downward? +* Do comments convey clear, full thoughts in a narrative language that provides background for the code choices? +* Are the files separated logically such that each one contains a clear concept of code? + + +**Is the API documentation up to date? Are all client calls written against the docs?** + +We use [swagger](https://github.com/coralproject/talk/blob/master/swagger.yaml) to track our API documentation. + +* If APIs are created or updated, is the swagger.yml file up to date? There's nothing more frustrating than trying to develop against docs that are out of date or wrong. We need to be meticulous here as it's the little differences that can cause the most frustration and tricky bugs. +* If client code calls APIs, are they written against the swagger.yml file? Are all return codes handled? + +**Is there sufficient test coverage?** + +Our tests folder is set up to mirror the code folders: [https://github.com/coralproject/talk/tree/master/tests](https://github.com/coralproject/talk/tree/master/tests) + +* Can you a sense of the logic behind the code by reading the tests? +* Can you see both what should happen and what should _never, ever_ be allowed to happen? +* Are there future cases that are guarded against via the creation of unit tests (aka, making sure things are typed, specifically checking for all values that will be used, etc...)? + + +### Forking, Branching and Merging + +Talk follows the _master as tip_ repo structure. `master` is the bleeding edge. It should be _as stable as possible_ but may suffer instabilities, generally during times that fundamental architectural elements are added. + +Releases are _tagged_ off the master branch. + +Contributions to Talk follow this process. There are a lot of steps, but mechanically following these steps will standardize communication, help stop errors and let you focus on your contribution. + +* At the outset of a piece of work, a branch or fork is made from master. +* The work is done in that fork. +* As soon as the work has taken shape, a PR is created for discussion. (If the PR is created for review before it's ready to merge, please make that clear in the description/title.) +* At least one other contributor to the project must review all code (see Code Reviews below.) +* If there are merge conflicts with master, merge master into the branch. +* Ensure that [circleci](https://circleci.com/) passes all tests for your branch. (If you have forked and do not have circleci set up, you and the reviewer should independently ensure that all the of Continuous Integration steps pass before merging.) +* If merge conflicts exist with `master`, merge `master` into your branch and re-run CI before merging into master. +* Merge to master, but _you're not quite done yet!_ +* Deploy master to staging (or have a core member do so.) +* Ensure that all your changes are working on staging. +* Have your reviewer verify the same. +* ... aaaand the work is delivered! + + +## Continuous Integration + +We use circleci to run our ci: [https://circleci.com/gh/coralproject/talk](https://circleci.com/gh/coralproject/talk) + +Our pipeline will _test_, _lint_, and _build_ all pushes to the repo. + +Any branch not passing CI will not be merged into master. + +If you're working in a fork, please run each of the steps locally before submitting a PR. + + +## Coding Style + +### API Design + +When building APIs, we follow these principles: + +* Follow [RESTful](https://en.wikipedia.org/wiki/Representational_state_transfer) principles for basic operations. +* Avoid routing yourself into a corner, for example, by putting a variable other than an object's id directly after an object. +* Put non-required, flexible variables into query params, required/identity based values in request params. diff --git a/README.md b/README.md index 0f217d89c..0b77e95b7 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,14 @@ A commenting platform from The Coral Project. [https://coralproject.net](https:/ ## Contributing to Talk +### Product Roadmap +You can view what the Coral Team is working on next here: https://www.pivotaltracker.com/n/projects/1863625 + +You can view product ideas and our longer term roadmap here: https://trello.com/b/ILND751a/talk + ### Local Dependencies Node + Mongo ### Getting Started @@ -19,13 +25,19 @@ Runs Talk. The Talk application requires specific configuration options to be available inside the environment in order to run, those variables are listed here: -- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to - secure cookies +- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to +secure cookies. - `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook - Login enabled app. +Login enabled app. - `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your - Facebook Login enabled app. -- `TALK_ROOT_URL` (*required*) - Root url of the installed application externally available in the format: `://` without the path. +Facebook Login enabled app. +- `TALK_ROOT_URL` (*required*) - root url of the installed application externally +available in the format: `://` without the path. +- `TALK_SMTP_PROVIDER` (*required*) - SMTP provider name. +- `TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using. +- `TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using. +- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`. +- `TALK_SMTP_PORT` (*required*) - SMTP port. ### Running with Docker Make sure you have Docker running first and then run `docker-compose up -d` @@ -37,9 +49,11 @@ Make sure you have Docker running first and then run `docker-compose up -d` `npm run lint` ### Helpful URLs -Bare comment stream: http://localhost:5000/client/coral-embed-stream/ -Comment stream embedded on sample article: http://localhost:5000/client/coral-embed-stream/samplearticle.html -Moderator view: http://localhost:5000/admin/ +Comment stream: http://localhost:3000/ + +Comment stream embedded on sample article: http://localhost:3000/assets/samplearticle.html + +Moderator view: http://localhost:3000/admin ### Docs `swagger.yaml` diff --git a/app.js b/app.js index 8008cb01e..fe392932b 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,10 @@ if (app.get('env') !== 'test') { //============================================================================== app.set('trust proxy', 1); -app.use(helmet()); +// We disable frameward on helmet to allow crossdomain injection of the embed +app.use(helmet({ + frameguard: false +})); app.use(bodyParser.json()); app.use('/client', express.static(path.join(__dirname, 'dist'))); app.set('views', path.join(__dirname, 'views')); @@ -45,7 +48,7 @@ const session_opts = { }, store: new RedisStore({ ttl: 1800, - client: redis, + client: redis.createClient(), }) }; @@ -91,7 +94,9 @@ app.use((req, res, next) => { // returning a status code that makes sense. app.use('/api', (err, req, res, next) => { if (err !== ErrNotFound) { - console.error(err); + if (app.get('env') !== 'test') { + console.error(err); + } } res.status(err.status || 500); diff --git a/architecture.png b/architecture.png new file mode 100644 index 000000000..0096ccd41 Binary files /dev/null and b/architecture.png differ diff --git a/architecture.xml b/architecture.xml new file mode 100644 index 000000000..ce2b09a19 --- /dev/null +++ b/architecture.xml @@ -0,0 +1 @@ +7Vpbc6M2FP41nrYP6wFksP1oO0k7nd1O2nTa7qMMCtZGICpE4vTXVwIJkCVnnQbbWU/z4MDRBen7zk0HRmCVbX9ksNh8ogkio8BLtiNwNQqC6dwTv1Lw3AjCYNoIUoaTRuR3gjv8D1JCNS6tcIJKoyOnlHBcmMKY5jmKuSGDjNEns9s9JeZTC5giS3AXQ2JL/8QJ3zTSWeh18p8QTjf6yb6nWtYwfkgZrXL1vFEA7uu/pjmDei7Vv9zAhD71ROB6BFaMUt5cZdsVIhJaDVsz7mZPa7tuhnJ+yIBJ1Ix4hKRCesn1wvizBkOMELiLm6VYbSGFMaGVmGD5tMEc3RUwlsInoQlCtuEZEXe+uFRTI8bRdu/6/HbXQpkQzRBnz6KLGhBpjdCK1Nw9daQEGvpNn5C56giVIqTtzB0Y4kLh4cYGOKBIhJqoW8r4hqY0h+S6ky5r7pGcwTPBQFvM/5LicajuPuuWXCys1yRvP6sJSg4ZX0iNrmGHZYljLb7BpJ08T+xOQtjr8gVx/qwsDVacClG3g4+UFqpfyRl9QCtKKKv3DLz6r23R9tARbLEpIKMVixVmCkWx4hSpXoHSOgnni1rAEIEcP5pW+RZOw/kxOPV7jHrjaWiQalA6Bt8IqTvU/BeWQ+9EpEb+8UkNTFLHfmTyGrbtt4hhsQXEviETHoDtJpKcgO3AiliLohCCO8REqBkFERErWib4UVym8vL7X0R6MgpWcp/bgqGyHH8pf9AdxeN6fS1FOijEOdCyot7eEAemZojzdZbRC3L+xBXkZt7b0Zx8Pf4fH4I2fVIQABcEMxcEkwEgiCbHdx8vhYTatVyil/AnjtB/Ii/huxTbcg2LJMP5SObIN+JXHmOYWAbN9zkHewLpet6JI/F1dqwdSTCzrShwWFE0hBGF5zWi6cUaUXhGIwotI1pRBmWX62yNkvIdaDmYOWLFsbTcn9vJRyWeGni3jD5i4T7OAUmkE2Bt+L59TPY9YEMCBoCkXW4HySeIiUrIsNj1OQAJDECmDhVxlQ2GwCME53WEl+oHdc3gLCfMwC6T/UzXwta9XyskJI5Dx0OF3tcxY7aTYx/qN6cDGAVwBBKCxWa+kxjeib1biIhtcRMEU8dymkvLuRe6uiOCBKe51GkxvzyFLyVIOIZkoRoynCS12blQ34Pra4qWhxxnpg6kgyGQjo7hfuyi5WW4mDe4DjX0lmIxY0t9sHOYB7ucNi5Njepoff1EjR+0Jqr1o93PYSWAo5TPTJVpg5Sjzn056mRGLDC3I1ZzLD1BxJrYdZz/X14ci9TwRKQC+/hxw2jtNq7zRJ5CSJXivHTlIwzBWHS8YSiptoemJfqFXxOLE1qt6xbf0BV/TyQd9k3g7rkvmJzw3DeZWsCrYpFQNFU+imAmd52vy8JdK1rRLKtyzJ/39f1qtekTzGHa1bgPJK/KyCKWC+0yoY9wjcgtLXFd7AJXa8o5zUQHIhuW7UvsniWp19h2NsXpDtntO21pd4V+GyKVA+epEg+gEMAMkD6w9QEcSx1mdjrbI9eiDWXrujLwrmkbnhLgOHcfixKdYvco+Q0l2C7HaHgTyGEp4N13Dhj4O4adigSIbGjmcxsa3x8AG/2sgXMBqyRxifE+1J8fnaH8GjpKazRP6dV+p3FqrTYNPnAWHl3v7QZR68CCx3C+PXyivyv5RdVSFhU+qPrAQvQg6J53rbuR975JrexpZMOHslZPOYs/Kbb2LLpS/jskD+LfgsWSj5hXjfgKw5TBrBcUmseZS3ghJTjz9v4QLWNfUkiKDXzlNi6p1mNmIbZbb2VvrDTL3L39LrA55nffXoLrfwE= \ No newline at end of file diff --git a/bin/cli b/bin/cli index bec5ea84f..d8148282f 100755 --- a/bin/cli +++ b/bin/cli @@ -19,7 +19,10 @@ const pkg = require('../package.json'); program .version(pkg.version) + .command('serve', 'serve the application') + .command('assets', 'interact with assets') .command('settings', 'work with the application settings') + .command('jobs', 'work with the job queues') .command('users', 'work with the application auth') .parse(process.argv); diff --git a/bin/cli-assets b/bin/cli-assets new file mode 100755 index 000000000..9ab36685a --- /dev/null +++ b/bin/cli-assets @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +/** + * Setup the debug paramater. + */ + +process.env.DEBUG = process.env.TALK_DEBUG; + +/** + * Module dependencies. + */ + +const program = require('commander'); +const pkg = require('../package.json'); +const parseDuration = require('parse-duration'); +const Table = require('cli-table'); +const Asset = require('../models/asset'); +const mongoose = require('../mongoose'); +const scraper = require('../services/scraper'); +const util = require('../util'); + +// Register the shutdown criteria. +util.onshutdown([ + () => mongoose.disconnect() +]); + +/** + * Lists all the assets registered in the database. + */ +function listAssets() { + Asset + .find({}) + .sort({'created_at': 1}) + .then((asset) => { + let table = new Table({ + head: [ + 'ID', + 'Title', + 'URL' + ] + }); + + asset.forEach((asset) => { + table.push([ + asset.id, + asset.title ? asset.title : '', + asset.url ? asset.url : '' + ]); + }); + + console.log(table.toString()); + util.shutdown(); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); + }); +} + +function refreshAssets(ageString) { + const now = new Date().getTime(); + const ageMs = parseDuration(ageString); + const age = new Date(now - ageMs); + + Asset.find({ + $or: [ + { + scraped: { + $lte: age + } + }, + { + scraped: null + } + ] + }) + + // Queue all the assets for scraping. + .then((assets) => Promise.all(assets.map(scraper.create))) + + .then(() => { + console.log('Assets were queued to be scraped'); + util.shutdown(); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); + }); +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .version(pkg.version); + +program + .command('list') + .description('list all the assets in the database') + .action(listAssets); + +program + .command('refresh ') + .description('queues the assets that exceed the age requested') + .action(refreshAssets); + +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/cli-jobs b/bin/cli-jobs new file mode 100755 index 000000000..bc320d14b --- /dev/null +++ b/bin/cli-jobs @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +/** + * Setup the debug paramater. + */ + +process.env.DEBUG = process.env.TALK_DEBUG; + +/** + * Module dependencies. + */ + +const program = require('commander'); +const scraper = require('../services/scraper'); +const util = require('../util'); +const mongoose = require('../mongoose'); +const kue = require('../kue'); + +util.onshutdown([ + () => mongoose.disconnect() +]); + +/** + * Starts the job processor. + */ +function processJobs() { + + // Start the processor. + scraper.process(); + + // The scraper only needs to shutdown when the scraper has actually been + // started. + util.onshutdown([ + () => scraper.shutdown() + ]); +} + +/** + * Removes a single job. + * @param {Object} job the job to be removed + * @return {Promise} + */ +function removeJob(job) { + return new Promise((resolve, reject) => job.remove((err) => { + if (err) { + return reject(err); + } + + return resolve(job); + })); +} + +/** + * Removes the jobs passed in and returns a promise. + * @param {Array} jobs array of jobs + * @return {Promise} + */ +function removeJobs(jobs) { + return Promise.all(jobs.map(removeJob)); +} + +/** + * Get the top n jobs with a specific state. + * @param {String} [state='complete'] state to list jobs by + * @param {Number} limit limit of jobs to load + * @return {Promise} + */ +function rangeJobsByState(state = 'complete', limit) { + return new Promise((resolve, reject) => { + kue.Job.rangeByState(state, 0, limit, 'asc', (err, jobs) => { + if (err) { + return reject(err); + } + + resolve(jobs); + }); + }); +} + +/** + * Cleans up the jobs that are in the queue. + */ +function cleanupJobs(options) { + const n = 100; + + Promise.all([ + rangeJobsByState('complete', n), + options.stuck ? rangeJobsByState('failed', n) : false + ]) + .then((joblists) => joblists.filter((jobs) => jobs).map(removeJobs)) + .then(() => { + util.shutdown(); + console.log('Removed old jobs'); + }); +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('process') + .description('starts job processing') + .action(processJobs); + +program + .command('cleanup') + .option('-s, --stuck', 'cleans up jobs that have been stuck', false) + .description('cleans up inactive jobs') + .action(cleanupJobs); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (process.argv.length <= 2) { + program.outputHelp(); + util.shutdown(); +} diff --git a/bin/cli-serve b/bin/cli-serve new file mode 100755 index 000000000..dd682f5ad --- /dev/null +++ b/bin/cli-serve @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +/** + * Setup the debug paramater. + */ + +process.env.DEBUG = process.env.TALK_DEBUG; + +const app = require('../app'); +const debug = require('debug')('talk:server'); +const http = require('http'); +const init = require('../init'); +const scraper = require('../services/scraper'); +const mongoose = require('../mongoose'); +const util = require('../util'); + +/** +* Get port from environment and store in Express. +*/ + +const port = normalizePort(process.env.TALK_PORT || (process.env.NODE_ENV === 'test' ? '3011' : '3000')); + +app.set('port', port); + +/** +* Create HTTP server. +*/ +const server = http.createServer(app); + +/** + * Event listener for HTTP server "error" event. + */ +function onError(error) { + if (error.syscall !== 'listen') { + throw error; + } + + let bind = typeof port === 'string' + ? `Pipe ${port}` + : `Port ${port}`; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(`${bind} requires elevated privileges`); + break; + case 'EADDRINUSE': + console.error(`${bind} is already in use`); + break; + } + + throw error; +} + +/** + * Normalize a port into a number, string, or false. + */ + +function normalizePort(val) { + let port = parseInt(val, 10); + + if (isNaN(port)) { + // named pipe + return val; + } + + if (port >= 0) { + // port number + return port; + } + + return false; +} + +/** + * Event listener for HTTP server "listening" event. + */ + +function onListening() { + let addr = server.address(); + let bind = typeof addr === 'string' + ? `pipe ${ addr}` + : `port ${ addr.port}`; + debug(`Listening on ${ bind}`); +} + +/** + * Start the app. + */ +function startApp() { + init().then(() => { + + /** + * Listen on provided port, on all network interfaces. + */ + server.listen(port); + server.on('error', onError); + server.on('listening', onListening); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); + }); +} + +/** + * Module dependencies. + */ + +const program = require('commander'); + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .option('-j, --jobs', 'enable job processing on this thread') + .parse(process.argv); + +// Start the application serving. +startApp(); + +// Enable job processing on the thread if enabled. +if (program.jobs) { + + // Start the processor. + scraper.process(); +} + +// Define a safe shutdown function to call in the event we need to shutdown +// because the node hooks are below which will interrupt the shutdown process. +// Shutdown the mongoose connection, the app server, and the scraper. +util.onshutdown([ + () => program.jobs ? scraper.shutdown() : null, + () => mongoose.disconnect(), + () => server.close() +]); diff --git a/bin/cli-settings b/bin/cli-settings index cff6c04ed..e7ba30151 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -11,6 +11,14 @@ process.env.DEBUG = process.env.TALK_DEBUG; */ const program = require('commander'); +const mongoose = require('../mongoose'); +const Setting = require('../models/setting'); +const util = require('../util'); + +// Register the shutdown criteria. +util.onshutdown([ + () => mongoose.disconnect() +]); //============================================================================== // Setting up the program command line arguments. @@ -20,19 +28,17 @@ program .command('init') .description('initilizes the talk settings') .action(() => { - const mongoose = require('../mongoose'); - const Setting = require('../models/setting'); const defaults = {id: '1', moderation: 'pre'}; Setting .update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}) .then(() => { console.log('Created settings object.'); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(`failed to create the settings object ${JSON.stringify(err)}`); - throw new Error(err); // just to be safe + util.shutdown(1); }); }); @@ -41,4 +47,5 @@ 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/cli-users b/bin/cli-users index 54d7f2927..adfe3bf23 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -13,14 +13,20 @@ process.env.DEBUG = process.env.TALK_DEBUG; const program = require('commander'); const pkg = require('../package.json'); const prompt = require('prompt'); +const User = require('../models/user'); +const mongoose = require('../mongoose'); +const util = require('../util'); +const Table = require('cli-table'); + +// Regeister the shutdown criteria. +util.onshutdown([ + () => mongoose.disconnect() +]); /** * Prompts for input and registers a user based on those. */ function createUser(options) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - return new Promise((resolve, reject) => { if (options.flag_mode) { @@ -28,6 +34,7 @@ function createUser(options) { email: options.email, password: options.password, displayName: options.name, + role: options.role }); } @@ -56,6 +63,11 @@ function createUser(options) { name: 'displayName', description: 'Display Name', required: true + }, + { + name: 'role', + description: 'User Role', + required: false } ], (err, result) => { if (err) { @@ -70,15 +82,21 @@ function createUser(options) { }); }) .then((result) => { - return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim()); - }) - .then((user) => { - console.log(`Created user ${user.id}.`); - mongoose.disconnect(); - }) - .catch((err) => { - console.error(err); - mongoose.disconnect(); + return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim()) + .then((user) => { + console.log(`Created user ${user.id}.`); + + return User + .addRoleToUser(user.id, result.role.trim()) + .then(() => { + console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`); + util.shutdown(); + }); + }) + .catch((err) => { + console.error(err); + util.shutdown(); + }); }); } @@ -86,20 +104,17 @@ function createUser(options) { * Deletes a user. */ function deleteUser(userID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .findOneAndRemove({ id: userID }) .then(() => { console.log('Deleted user'); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(); }); } @@ -107,9 +122,6 @@ function deleteUser(userID) { * Changes the password for a user. */ function passwd(userID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - prompt.start(); prompt.get([ @@ -128,13 +140,13 @@ function passwd(userID) { ], (err, result) => { if (err) { console.error(err); - mongoose.disconnect(); + util.shutdown(); return; } if (result.password !== result.confirmPassword) { console.error(new Error('Password mismatch')); - mongoose.disconnect(); + util.shutdown(1); return; } @@ -142,11 +154,11 @@ function passwd(userID) { .changePassword(userID, result.password) .then(() => { console.log('Password changed.'); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); }); } @@ -155,9 +167,6 @@ function passwd(userID) { * Updates the user from the options array. */ function updateUser(userID, options) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - const updates = []; if (options.email && typeof options.email === 'string' && options.email.length > 0) { @@ -189,11 +198,11 @@ function updateUser(userID, options) { .all(updates.map((q) => q.exec())) .then(() => { console.log(`User ${userID} updated.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -201,10 +210,6 @@ function updateUser(userID, options) { * Lists all the users registered in the database. */ function listUsers() { - const Table = require('cli-table'); - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .all() .then((users) => { @@ -214,6 +219,7 @@ function listUsers() { 'Display Name', 'Profiles', 'Roles', + 'Status', 'State' ] }); @@ -224,16 +230,17 @@ function listUsers() { user.displayName, user.profiles.map((p) => p.provider).join(', '), user.roles.join(', '), + user.status, user.disabled ? 'Disabled' : 'Enabled' ]); }); console.log(table.toString()); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -243,18 +250,15 @@ function listUsers() { * @param {String} srcUserID id of the user to which is the source of the merge */ function mergeUsers(dstUserID, srcUserID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .mergeUsers(dstUserID, srcUserID) .then(() => { console.log(`User ${srcUserID} was merged into user ${dstUserID}.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -264,18 +268,15 @@ function mergeUsers(dstUserID, srcUserID) { * @param {String} role the role to add */ function addRole(userID, role) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .addRoleToUser(userID, role) .then(() => { console.log(`Added the ${role} role to User ${userID}.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -285,18 +286,49 @@ function addRole(userID, role) { * @param {String} role the role to remove */ function removeRole(userID, role) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .removeRoleFromUser(userID, role) .then(() => { console.log(`Removed the ${role} role from User ${userID}.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); + }); +} + +/** + * Ban a user + * @param {String} userID id of the user to ban + */ +function ban(userID) { + User + .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) { + User + .setStatus(userID, 'active', '') + .then(() => { + console.log(`Unban the User ${userID}.`); + util.shutdown(); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); }); } @@ -305,18 +337,15 @@ function removeRole(userID, role) { * @param {String} userID the ID of a user to disable */ function disableUser(userID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .disableUser(userID) .then(() => { console.log(`User ${userID} was disabled.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -325,18 +354,15 @@ function disableUser(userID) { * @param {String} userID the ID of a user to enable */ function enableUser(userID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .enableUser(userID) .then(() => { console.log(`User ${userID} was enabled.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -352,6 +378,7 @@ program .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); @@ -393,6 +420,16 @@ program .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') @@ -408,4 +445,5 @@ 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/www b/bin/www deleted file mode 100755 index 3e9e20918..000000000 --- a/bin/www +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env node - -/** - * Setup the debug paramater. - */ - -process.env.DEBUG = process.env.TALK_DEBUG; - -/** - * Module dependencies. - */ - -const app = require('../app'); -const debug = require('debug')('talk:server'); -const http = require('http'); -const init = require('../init'); -const port = normalizePort(process.env.TALK_PORT || '3000'); - -let server; - -init().then(() => { - - /** - * Get port from environment and store in Express. - */ - app.set('port', port); - - /** - * Create HTTP server. - */ - server = http.createServer(app); - - /** - * Listen on provided port, on all network interfaces. - */ - server.listen(port); - server.on('error', onError); - server.on('listening', onListening); -}); - -/** - * Normalize a port into a number, string, or false. - */ - -function normalizePort(val) { - let port = parseInt(val, 10); - - if (isNaN(port)) { - // named pipe - return val; - } - - if (port >= 0) { - // port number - return port; - } - - return false; -} - -/** - * Event listener for HTTP server "error" event. - */ - -function onError(error) { - if (error.syscall !== 'listen') { - throw error; - } - - let bind = typeof port === 'string' - ? `Pipe ${ port}` - : `Port ${ port}`; - - // handle specific listen errors with friendly messages - switch (error.code) { - case 'EACCES': - console.error(`${bind} requires elevated privileges`); - break; - case 'EADDRINUSE': - console.error(`${bind} is already in use`); - break; - } - - throw error; -} - -/** - * Event listener for HTTP server "listening" event. - */ - -function onListening() { - let addr = server.address(); - let bind = typeof addr === 'string' - ? `pipe ${ addr}` - : `port ${ addr.port}`; - debug(`Listening on ${ bind}`); -} diff --git a/cache.js b/cache.js index efe689f9c..1d090e46d 100644 --- a/cache.js +++ b/cache.js @@ -1,6 +1,8 @@ const redis = require('./redis'); -const cache = module.exports = {}; +const cache = module.exports = { + client: redis.createClient() +}; /** * This collects a key that may either be an array or a string and creates a @@ -51,7 +53,7 @@ cache.wrap = (key, expiry, work) => { * @return {Promise} */ cache.get = (key) => new Promise((resolve, reject) => { - redis.get(keyfunc(key), (err, reply) => { + cache.client.get(keyfunc(key), (err, reply) => { if (err) { return reject(err); } @@ -74,6 +76,21 @@ cache.get = (key) => new Promise((resolve, reject) => { }); }); +/** + * This invalidates a cached entry in the cache. + * @param {Mixed} key Either an array of items composing a key or a string + * @return {Promise} + */ +cache.invalidate = (key) => new Promise((resolve, reject) => { + cache.client.del(keyfunc(key), (err) => { + if (err) { + return reject(err); + } + + resolve(); + }); +}); + /** * This sets a value on the key with the expiry and then resolves once it is * done. @@ -87,7 +104,7 @@ cache.set = (key, value, expiry) => new Promise((resolve, reject) => { // Serialize the value as JSON. let reply = JSON.stringify(value); - redis.set(keyfunc(key), reply, 'EX', expiry, (err) => { + cache.client.set(keyfunc(key), reply, 'EX', expiry, (err) => { if (err) { return reject(err); } diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index a0b43361d..6d55d7b18 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -1,9 +1,9 @@ import React from 'react'; import {Router, Route, IndexRoute, browserHistory} from 'react-router'; -import ModerationQueue from 'containers/ModerationQueue'; -import CommentStream from 'containers/CommentStream'; -import Configure from 'containers/Configure'; +import ModerationQueue from 'containers/ModerationQueue/ModerationQueue'; +import CommentStream from 'containers/CommentStream/CommentStream'; +import Configure from 'containers/Configure/Configure'; import CommunityContainer from 'containers/Community/CommunityContainer'; import LayoutContainer from 'containers/LayoutContainer'; diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index c5a03132f..54763259d 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -1,5 +1,5 @@ import * as actions from '../constants/auth'; -import {base, handleResp, getInit} from '../helpers/response'; +import coralApi from '../../../coral-framework/helpers/response'; // Check Login @@ -9,11 +9,23 @@ const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error}); export const checkLogin = () => dispatch => { dispatch(checkLoginRequest()); - fetch(`${base}/auth`, getInit('GET')) - .then(handleResp) + coralApi('/auth') .then(user => { const isAdmin = !!user.roles.filter(i => i === 'admin').length; dispatch(checkLoginSuccess(user, isAdmin)); }) .catch(error => dispatch(checkLoginFailure(error))); }; + +// LogOut Actions + +const logOutRequest = () => ({type: actions.LOGOUT_REQUEST}); +const logOutSuccess = () => ({type: actions.LOGOUT_SUCCESS}); +const logOutFailure = () => ({type: actions.LOGOUT_FAILURE}); + +export const logout = () => dispatch => { + dispatch(logOutRequest()); + coralApi('/auth', {method: 'DELETE'}) + .then(() => dispatch(logOutSuccess())) + .catch(error => dispatch(logOutFailure(error))); +}; diff --git a/client/coral-admin/src/actions/comments.js b/client/coral-admin/src/actions/comments.js index e4a55a893..d4aee034e 100644 --- a/client/coral-admin/src/actions/comments.js +++ b/client/coral-admin/src/actions/comments.js @@ -1,4 +1,3 @@ - /** * Action disptacher related to comments */ @@ -16,3 +15,16 @@ export const flagComment = id => (dispatch, getState) => { export const createComment = (name, body) => dispatch => { dispatch({type: 'COMMENT_CREATE', name, body}); }; + +// Dialog Actions +export const showBanUserDialog = (userId, userName, commentId) => { + return dispatch => { + dispatch({type: 'SHOW_BANUSER_DIALOG', userId, userName, commentId}); + }; +}; + +export const hideBanUserDialog = (showDialog) => { + return dispatch => { + dispatch({type: 'HIDE_BANUSER_DIALOG', showDialog}); + }; +}; diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 7a4112f8b..c4712835a 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -6,15 +6,15 @@ import { FETCH_COMMENTERS_FAILURE, SORT_UPDATE, COMMENTERS_NEW_PAGE, - SET_ROLE + SET_ROLE, + SET_COMMENTER_STATUS } from '../constants/community'; -import {base, getInit, handleResp} from '../helpers/response'; +import coralApi from '../../../coral-framework/helpers/response'; export const fetchCommenters = (query = {}) => dispatch => { dispatch(requestFetchCommenters()); - fetch(`${base}/user?${qs.stringify(query)}`, getInit('GET')) - .then(handleResp) + coralApi(`/users?${qs.stringify(query)}`) .then(({result, page, count, limit, totalPages}) => dispatch({ type: FETCH_COMMENTERS_SUCCESS, @@ -42,8 +42,15 @@ export const newPage = () => ({ }); export const setRole = (id, role) => dispatch => { - return fetch(`${base}/user/${id}/role`, getInit('POST', {role})) + return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}}) .then(() => { return dispatch({type: SET_ROLE, id, role}); }); }; + +export const setCommenterStatus = (id, status) => dispatch => { + return coralApi(`/users/${id}/status`, {method: 'POST', body: {status}}) + .then(() => { + return dispatch({type: SET_COMMENTER_STATUS, id, status}); + }); +}; diff --git a/client/coral-admin/src/actions/settings.js b/client/coral-admin/src/actions/settings.js index f71730663..71106e1f7 100644 --- a/client/coral-admin/src/actions/settings.js +++ b/client/coral-admin/src/actions/settings.js @@ -1,3 +1,5 @@ +import coralApi from '../../../coral-framework/helpers/response'; + export const SETTINGS_LOADING = 'SETTINGS_LOADING'; export const SETTINGS_RECEIVED = 'SETTINGS_RECEIVED'; export const SETTINGS_FETCH_ERROR = 'SETTINGS_FETCH_ERROR'; @@ -8,38 +10,9 @@ export const SAVE_SETTINGS_LOADING = 'SAVE_SETTINGS_LOADING'; export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS'; export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED'; -const base = '/api/v1'; - -const getInit = (method, body) => { - const headers = new Headers({ - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }); - - const init = {method, headers}; - if (method.toLowerCase() !== 'get') { - init.body = JSON.stringify(body); - } - - return init; -}; - -const handleResp = res => { - if (res.status === 401) { - throw new Error('Not Authorized to make this request'); - } else if (res.status > 399) { - throw new Error('Error! Status ', res.status); - } else if (res.status === 204) { - return res.text(); - } else { - return res.json(); - } -}; - export const fetchSettings = () => dispatch => { dispatch({type: SETTINGS_LOADING}); - fetch(`${base}/settings`, getInit('GET')) - .then(handleResp) + coralApi('/settings') .then(settings => { dispatch({type: SETTINGS_RECEIVED, settings}); }) @@ -55,8 +28,7 @@ export const updateSettings = settings => { export const saveSettingsToServer = () => (dispatch, getState) => { const settings = getState().settings.toJS().settings; dispatch({type: SAVE_SETTINGS_LOADING}); - fetch(`${base}/settings`, getInit('PUT', settings)) - .then(handleResp) + coralApi('/settings', {method: 'PUT', body: settings}) .then(() => { dispatch({type: SAVE_SETTINGS_SUCCESS, settings}); }) diff --git a/client/coral-admin/src/actions/users.js b/client/coral-admin/src/actions/users.js new file mode 100644 index 000000000..f2ff37cbd --- /dev/null +++ b/client/coral-admin/src/actions/users.js @@ -0,0 +1,14 @@ + +/** + * Action disptacher related to users + */ +// +// export const banUser = (status, author_id) => (dispatch) => { +// dispatch({type: 'USER_STATUS_UPDATE', author_id, status}); +// }; +export const banUser = (status, userId, commentId) => { + return dispatch => { + dispatch({type: 'USER_BAN', status, userId, commentId}); + dispatch({type: 'COMMENTS_MODERATION_QUEUE_FETCH'}); + }; +}; diff --git a/client/coral-admin/src/components/BanUserDialog.css b/client/coral-admin/src/components/BanUserDialog.css new file mode 100644 index 000000000..dfac4f194 --- /dev/null +++ b/client/coral-admin/src/components/BanUserDialog.css @@ -0,0 +1,147 @@ +.dialog { + border: none; + box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2); + width: 280px; + top: 10px; +} + +.header { + margin-bottom: 20px; +} + +.header h1, .separator h1{ + text-align: center; + font-size: 1.2em; +} + +.formField { + margin-top: 15px; +} + +.formField label { + font-size: 1.08em; + font-weight: bold; + margin-bottom: 5px; +} + +.formField input { + width: 100%; + display: block; + border: none; + outline: none; + border: 1px solid rgba(0,0,0,.12); + padding: 10px 6px; + box-sizing: border-box; + border-radius: 2px; + margin: 5px auto; +} + +.footer { + margin: 20px auto 10px; + text-align: center; +} + +.footer span { + display: block; + margin-bottom: 5px; +} + +.footer a { + color: #2c69b6; + cursor: pointer; + margin: 0 5px; +} + +.socialConnections { + margin-bottom: 20px; +} + +.signInButton { + margin-top: 10px; +} + +.close { + font-size: 20px; + line-height: 14px; + top: 10px; + right: 10px; + position: absolute; + display: block; + font-weight: bold; + color: #363636; + cursor: pointer; +} + +.close:hover { + color: #6b6b6b; +} + +input.error{ + border: solid 2px #f44336; +} + +.errorMsg, .hint { + color: grey; + font-weight: 600; + padding: 3px 0 16px; +} + +.alert { + padding: 10px; + margin-bottom: 20px; + border-radius: 2px; +} + +.alert--success { + border: solid 1px #1ec00e; + background: #cbf1b8; + color: #006900; +} + +.alert--error { + background: #FFEBEE; + color: #B71C1C; +} + +.userBox a { + color: #2c69b6; + cursor: pointer; + margin: 0px; +} + +.attention { + display: inline-block; + width: 15px; + height: 15px; + background: #B71C1C; + color: #FFEBEE; + font-weight: bolder; + padding: 4px; + vertical-align: middle; + border-radius: 20px; + box-sizing: border-box; + font-size: 9px; + line-height: 7px; + text-align: center; + margin-right: 5px; +} + +.action { + margin-top: 15px; +} + +.passwordRequestSuccess { + border: 1px solid green; + background-color: lightgreen; + padding: 10px; +} + +.passwordRequestFailure { + border: 1px solid orange; + background-color: 1px solid coral; + padding: 10px; +} + +.cancel { + margin: 10px 0; +} diff --git a/client/coral-admin/src/components/BanUserDialog.js b/client/coral-admin/src/components/BanUserDialog.js new file mode 100644 index 000000000..1867b9ac2 --- /dev/null +++ b/client/coral-admin/src/components/BanUserDialog.js @@ -0,0 +1,45 @@ +import React from 'react'; + +import {Dialog} from 'coral-ui'; +import Button from 'coral-ui/components/Button'; + +import styles from './BanUserDialog.css'; + +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations'; +const lang = new I18n(translations); + +const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => { + const {userName = '', userId = '', commentId = ''} = user; + + return ( + handleClose()} onCancel={() => handleClose()} title={lang.t('bandialog.ban_user')}> + handleClose()}>× +
+
+

+ {lang.t('bandialog.ban_user')} +

+
+
+

+ {lang.t('bandialog.are_you_sure', userName)} +

+ + {lang.t('bandialog.note')} + +
+
+ + +
+
+
+ ); +}; + +export default BanUserDialog; diff --git a/client/coral-admin/src/components/Comment.js b/client/coral-admin/src/components/Comment.js index 7cba85788..380a001c8 100644 --- a/client/coral-admin/src/components/Comment.js +++ b/client/coral-admin/src/components/Comment.js @@ -1,46 +1,48 @@ - import React from 'react'; import timeago from 'timeago.js'; +import Linkify from 'react-linkify'; + import styles from './CommentList.css'; + import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations.json'; -import Linkify from 'react-linkify'; -import {FabButton} from 'coral-ui'; + import {Icon} from 'react-mdl'; +import {FabButton, Button} from 'coral-ui'; const linkify = new Linkify(); // Render a single comment for the list export default props => { - const links = linkify.getMatches(props.comment.get('body')); + const authorStatus = props.author.get('status'); + const {comment, author} = props; + const links = linkify.getMatches(comment.get('body')); return (
  • person - {props.comment.get('name') || lang.t('comment.anon')} - {timeago().format(props.comment.get('createdAt') || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} - {props.comment.get('flagged') ?

    {lang.t('comment.flagged')}

    : null} + {author.get('displayName') || lang.t('comment.anon')} + {timeago().format(comment.get('createdAt') || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} + {comment.get('flagged') ?

    {lang.t('comment.flagged')}

    : null}
    {links ? Contains Link : null}
    - {props.actions.map((action, i) => canShowAction(action, props.comment) ? ( - props.onClickAction(props.actionsMap[action].status, props.comment.get('id'))} - /> - ) : null)} + {props.actions.map((action, i) => getActionButton(action, i, props))}
    +
    + {authorStatus === 'banned' ? + {lang.t('comment.banned_user')} : null} +
    - {props.comment.get('body')} + {comment.get('body')}
    @@ -48,15 +50,33 @@ export default props => { ); }; -// Check if an action can be performed over a comment -const canShowAction = (action, comment) => { - const status = comment.get('status'); - const flagged = comment.get('flagged'); +// Get the button of the action performed over a comment if any +const getActionButton = (action, i, props) => { + const status = props.comment.get('status'); + const flagged = props.comment.get('flagged'); + const banned = (props.author.get('status') === 'banned'); if (action === 'flag' && (status || flagged === true)) { - return false; + return null; } - return true; + if (action === 'ban') { + return ( + + ); + } + return ( + props.onClickAction(props.actionsMap[action].status, props.comment.get('id'))} + /> + ); }; const linkStyles = { diff --git a/client/coral-admin/src/components/CommentList.css b/client/coral-admin/src/components/CommentList.css index 2c58c81cf..fddee7553 100644 --- a/client/coral-admin/src/components/CommentList.css +++ b/client/coral-admin/src/components/CommentList.css @@ -122,7 +122,6 @@ } - .hasLinks { color: #f00; text-align: right; @@ -133,3 +132,14 @@ margin-right: 5px; } } + +.banned { + color: #f00; + text-align: left; + display: flex; + align-items: center; + + i { + margin-right: 5px; + } +} diff --git a/client/coral-admin/src/components/CommentList.js b/client/coral-admin/src/components/CommentList.js index e4682252d..b4547335e 100644 --- a/client/coral-admin/src/components/CommentList.js +++ b/client/coral-admin/src/components/CommentList.js @@ -9,7 +9,8 @@ import Comment from 'components/Comment'; const actions = { 'reject': {status: 'rejected', icon: 'close', key: 'r'}, 'approve': {status: 'accepted', icon: 'done', key: 't'}, - 'flag': {status: 'flagged', icon: 'flag', filter: 'Untouched'} + 'flag': {status: 'flagged', icon: 'flag', filter: 'Untouched'}, + 'ban': {status: 'banned', icon: 'not interested'} }; // Renders a comment list and allow performing actions @@ -19,6 +20,7 @@ export default class CommentList extends React.Component { this.state = {active: null}; this.onClickAction = this.onClickAction.bind(this); + this.onClickShowBanDialog = this.onClickShowBanDialog.bind(this); } // remove key handlers before leaving @@ -99,7 +101,8 @@ export default class CommentList extends React.Component { // If we are performing an action over a comment (aka removing from the list) we need to select a new active. // TODO: In the future this can be improved and look at the actual state to // resolve since the content of the list could change externally. For now it works as expected - onClickAction (action, id) { + onClickAction (action, id, author_id) { + // activate the next comment if (id === this.state.active) { const {commentIds} = this.props; if (commentIds.last() === this.state.active) { @@ -108,26 +111,33 @@ export default class CommentList extends React.Component { this.setState({active: commentIds.get(Math.min(commentIds.indexOf(this.state.active) + 1, commentIds.size - 1))}); } } - this.props.onClickAction(action, id); + this.props.onClickAction(action, id, author_id); + } + + onClickShowBanDialog(userId, userName, commentId) { + this.props.onClickShowBanDialog(userId, userName, commentId); } render () { - const {singleView, commentIds, comments, hideActive, key} = this.props; + const {singleView, commentIds, comments, users, hideActive, key} = this.props; const {active} = this.state; return (
      - {commentIds.map((commentId, index) => ( - { + const comment = comments.get(commentId); + return { if (el && commentId === active) { this._active = el; } }} key={index} index={index} onClickAction={this.onClickAction} + onClickShowBanDialog={this.onClickShowBanDialog} actions={this.props.actions} actionsMap={actions} isActive={commentId === active} - hideActive={hideActive} /> - )).toArray()} + hideActive={hideActive} />; + }).toArray()}
    ); } diff --git a/client/coral-admin/src/components/FullLoading.css b/client/coral-admin/src/components/FullLoading.css new file mode 100644 index 000000000..8d850d381 --- /dev/null +++ b/client/coral-admin/src/components/FullLoading.css @@ -0,0 +1,12 @@ +.layout { + max-width: 800px; + margin: 0 auto; +} + +.layout h1 { + font-size: 40px; +} + +.layout img { + width: 100%; +} diff --git a/client/coral-admin/src/components/FullLoading.js b/client/coral-admin/src/components/FullLoading.js new file mode 100644 index 000000000..dee584aed --- /dev/null +++ b/client/coral-admin/src/components/FullLoading.js @@ -0,0 +1,13 @@ +import React from 'react'; +import {Layout} from 'react-mdl'; +import styles from './FullLoading.css'; +import {CoralLogo} from 'coral-ui'; + +export const FullLoading = () => ( + +
    +

    Loading

    + +
    +
    +); diff --git a/client/coral-admin/src/components/ui/Header.css b/client/coral-admin/src/components/ui/Header.css index 3d4e7dc77..a0d7dd30d 100644 --- a/client/coral-admin/src/components/ui/Header.css +++ b/client/coral-admin/src/components/ui/Header.css @@ -1,6 +1,5 @@ .header { background: #505050; - overflow: hidden; } .header > div { @@ -14,8 +13,35 @@ background: #232323; } -.version { +.rightPanel { position: absolute; right: 0; - width: 50px; + width: 170px; +} + +.rightPanel ul { + list-style: none; + line-height: 38px; +} + +.rightPanel li { + display: inline-block; + float: right; + margin-left: 15px; +} + +.rightPanel .settings { + vertical-align: middle; + border-radius: 3px; + border: solid 1px #9e9e9e; + line-height: 10px; +} + +.rightPanel .settings > div { + position: relative; +} + +.rightPanel .settings:hover { + background: rgba(158, 158, 158, 0.69); + cursor: pointer; } diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index 7ba88d25a..e4d151d30 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -1,21 +1,36 @@ import React from 'react'; -import {Navigation, Header} from 'react-mdl'; +import {Navigation, Header, IconButton, MenuItem, Menu} from 'react-mdl'; import {Link, IndexLink} from 'react-router'; import styles from './Header.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; import {Logo} from './Logo'; -export default () => ( +export default ({handleLogout}) => (
    - {lang.t('configure.moderate')} - {lang.t('configure.community')} - {lang.t('configure.configure')} + {lang.t('configure.moderate')} + {lang.t('configure.community')} + {lang.t('configure.configure')} -
    - {`v${process.env.VERSION}`} +
    +
      +
    • +
      + + + Sign Out + +
      +
    • +
    • + {`v${process.env.VERSION}`} +
    • +
    ); diff --git a/client/coral-admin/src/components/ui/Layout.js b/client/coral-admin/src/components/ui/Layout.js index 3e1b9cf2d..46c7aa7fa 100644 --- a/client/coral-admin/src/components/ui/Layout.js +++ b/client/coral-admin/src/components/ui/Layout.js @@ -4,9 +4,9 @@ import Header from './Header'; import Drawer from './Drawer'; import styles from './Layout.css'; -export const Layout = ({children}) => ( +export const Layout = ({children, ...props}) => ( -
    +
    {children} diff --git a/client/coral-admin/src/components/ui/Logo.css b/client/coral-admin/src/components/ui/Logo.css index e764af627..f89bf3d5d 100644 --- a/client/coral-admin/src/components/ui/Logo.css +++ b/client/coral-admin/src/components/ui/Logo.css @@ -1,7 +1,9 @@ .logo h1 { color: #272727; font-size: 20px; - padding: 0 30px; + margin: 0; + line-height: 60px; + padding: 0 20px; } .logo span { @@ -13,6 +15,7 @@ .logo { background: #E5E5E5; + height: 100%; } diff --git a/client/coral-admin/src/constants/comments.js b/client/coral-admin/src/constants/comments.js new file mode 100644 index 000000000..856f619d0 --- /dev/null +++ b/client/coral-admin/src/constants/comments.js @@ -0,0 +1,3 @@ +export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG'; +export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG'; +export const USER_BAN_SUCESS = 'USER_BAN_SUCESS'; diff --git a/client/coral-admin/src/constants/community.js b/client/coral-admin/src/constants/community.js index 2ea77ea77..e3fd88a71 100644 --- a/client/coral-admin/src/constants/community.js +++ b/client/coral-admin/src/constants/community.js @@ -4,3 +4,4 @@ export const FETCH_COMMENTERS_FAILURE = 'FETCH_COMMENTERS_FAILURE'; export const SORT_UPDATE = 'SORT_UPDATE'; export const COMMENTERS_NEW_PAGE = 'COMMENTERS_NEW_PAGE'; export const SET_ROLE = 'SET_ROLE'; +export const SET_COMMENTER_STATUS = 'SET_COMMENTER_STATUS'; diff --git a/client/coral-admin/src/containers/CommentStream.css b/client/coral-admin/src/containers/CommentStream/CommentStream.css similarity index 100% rename from client/coral-admin/src/containers/CommentStream.css rename to client/coral-admin/src/containers/CommentStream/CommentStream.css diff --git a/client/coral-admin/src/containers/CommentStream.js b/client/coral-admin/src/containers/CommentStream/CommentStream.js similarity index 90% rename from client/coral-admin/src/containers/CommentStream.js rename to client/coral-admin/src/containers/CommentStream/CommentStream.js index da4d03a22..113a7bc86 100644 --- a/client/coral-admin/src/containers/CommentStream.js +++ b/client/coral-admin/src/containers/CommentStream/CommentStream.js @@ -31,7 +31,7 @@ class CommentStream extends React.Component { // The only action for now is flagging onClickAction (action, id) { - if (action === 'flagged') { + if (action === 'flag') { this.props.dispatch(flagComment(id)); clearTimeout(this._snackTimeout); this.setState({snackbar: true, snackbarMsg: 'Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.'}); @@ -40,7 +40,7 @@ class CommentStream extends React.Component { } // Render the comment box along with the CommentList - render ({comments}, {snackbar, snackbarMsg}) { + render ({comments, users}, {snackbar, snackbarMsg}) { return (
    @@ -48,6 +48,7 @@ class CommentStream extends React.Component { singleView={false} commentIds={comments.get('ids')} comments={comments.get('byId')} + users={users.get('byId')} onClickAction={this.onClickAction} actions={['flag']} loading={comments.loading} /> @@ -57,4 +58,4 @@ class CommentStream extends React.Component { } } -export default connect(({comments}) => ({comments}))(CommentStream); +export default connect(({comments, users}) => ({comments, users}))(CommentStream); diff --git a/client/coral-admin/src/containers/Community/Community.css b/client/coral-admin/src/containers/Community/Community.css index 63148da7e..b19d0261d 100644 --- a/client/coral-admin/src/containers/Community/Community.css +++ b/client/coral-admin/src/containers/Community/Community.css @@ -1,7 +1,3 @@ -.dataTable { - width: 100%; -} - .roleButton { display: block; } @@ -9,14 +5,13 @@ .searchInput { display: block; padding-left: 40px; - /*border: none;*/ + width: auto; } .searchBox { - /*border: 1px solid rgba(0,0,0,.12);*/ background: white; } .email { display: block; -} \ No newline at end of file +} diff --git a/client/coral-admin/src/containers/Community/Community.js b/client/coral-admin/src/containers/Community/Community.js index f8c24fd3a..e798266f0 100644 --- a/client/coral-admin/src/containers/Community/Community.js +++ b/client/coral-admin/src/containers/Community/Community.js @@ -20,6 +20,10 @@ const tableHeaders = [ title: lang.t('community.account_creation_date'), field: 'created_at' }, + { + title: lang.t('community.status'), + field: 'status' + }, { title: lang.t('community.newsroom_role'), field: 'role' @@ -30,7 +34,7 @@ const Community = ({isFetching, commenters, ...props}) => { const hasResults = !isFetching && !!commenters.length; return ( - +
    - + { isFetching && } { !hasResults && } { hasResults && diff --git a/client/coral-admin/src/containers/Community/CommunityContainer.js b/client/coral-admin/src/containers/Community/CommunityContainer.js index cf67dba4d..e4263cc06 100644 --- a/client/coral-admin/src/containers/Community/CommunityContainer.js +++ b/client/coral-admin/src/containers/Community/CommunityContainer.js @@ -40,8 +40,8 @@ class CommunityContainer extends Component { this.props.dispatch(fetchCommenters({ value: this.state.searchValue, - field: community.get('field'), - asc: community.get('asc'), + field: community.field, + asc: community.asc, ...query })); } @@ -66,15 +66,19 @@ class CommunityContainer extends Component { return ( ); } } -export default connect(({community}) => ({community}))(CommunityContainer); +const mapStateToProps = state => ({ + community: state.community.toJS() +}); + +export default connect(mapStateToProps)(CommunityContainer); diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js index 89737e33e..1d81b1b2e 100644 --- a/client/coral-admin/src/containers/Community/Table.js +++ b/client/coral-admin/src/containers/Community/Table.js @@ -4,7 +4,7 @@ import {SelectField, Option} from 'react-mdl-selectfield'; import styles from './Community.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations'; -import {setRole} from '../../actions/community'; +import {setRole, setCommenterStatus} from '../../actions/community'; const lang = new I18n(translations); @@ -19,6 +19,10 @@ class Table extends Component { this.props.dispatch(setRole(id, role)); } + onCommenterStatusChange (id, status) { + this.props.dispatch(setCommenterStatus(id, status)); + } + render () { const {headers, commenters, onHeaderClickHandler} = this.props; @@ -46,6 +50,14 @@ class Table extends Component { {row.created_at} + + this.onCommenterStatusChange(row.id, status)}> + + + + - - - - - {lang.t('configure.enable-pre-moderation')} - - - - - - - {lang.t('configure.include-comment-stream')} -

    - {lang.t('configure.include-comment-stream-desc')} -

    -
    -
    - - - - - - ; - } - - copyToClipBoard () { - const copyTextarea = document.querySelector(`.${ styles.embedInput}`); - copyTextarea.select(); - - try { - document.execCommand('copy'); - this.setState({copied: true}); - } catch (err) { - console.error('Unable to copy', err); - } - } - - getEmbed () { - const embedText = `
    `; - - return - -

    {lang.t('configure.copy-and-paste')}

    -