diff --git a/.eslintignore b/.eslintignore index 6fecd9d9b..3a259c106 100644 --- a/.eslintignore +++ b/.eslintignore @@ -11,4 +11,6 @@ plugins/* !plugins/coral-plugin-mod !plugins/coral-plugin-love !plugins/coral-plugin-viewing-options +!plugins/coral-plugin-comment-content +!plugins/talk-plugin-permalink node_modules diff --git a/.eslintrc.json b/.eslintrc.json index 2186efd8b..12355ac34 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -53,7 +53,8 @@ "no-lonely-if": [2], "curly": [2], "no-unused-vars": ["error", { - "argsIgnorePattern": "next" + "argsIgnorePattern": "^_|next", + "varsIgnorePattern": "^_" }], "no-multiple-empty-lines": ["error", { "max": 1 diff --git a/.gitignore b/.gitignore index abd490356..0be1266c6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,7 @@ plugins/* !plugins/coral-plugin-mod !plugins/coral-plugin-love !plugins/coral-plugin-viewing-options +!plugins/coral-plugin-comment-content +!plugins/talk-plugin-permalink **/node_modules/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 94e2be437..140105723 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,16 +6,46 @@ This document is a companion to help you approach contributing. If it does not d By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html). -## Product Roadmap +## What should I Contribute? -You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625. +There are at least three ways to contribute to Talk: -If you'd like to see our longer term roadmap, [get in touch.](https://coralproject.net/contact.html) +* Writing Code +* Writing Docs +* Providing Translations +## Writing Code -## Contribute to the documentation +Conversation surrounding contributions begins in [issues](https://github.com/coralproject/talk/issues). -Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally. +### When should I Create an Issue? + +File an issue as soon as you have an idea of something you'd like to contribute. We would love to hear what you're thinking and help refine the idea to make it into the Talk ecosystem. + +Please file issues if: + +* you would like to contribute to Talk Core. +* you are building a Plugin that the current Plugin API doesn't support. +* you are building a Plugin and want advice. + +### What should I include? + +Coral has adopted an iterative, agile development philosophy. All contributions that make it into the Talk repo should start with a story or this form: + +`As a [type of person] I'd like to be able to [do something] so that I can [get some result].` + +This exercise does two things: + +* allows us to ground our technical choices in a clear, simple product need. +* expresses that product need in a way that doesn't imply a specific technical solution allowing for debate as to the best way to solve the problem. + +Please feel free to provide as much detail as possible when filing the issue but please do keep the initial issue specific to one need and try to avoid including technical or design solutions. + +If you have a specific technical or design solution in mind, please submit it as the first comment on the thread. + +## Contributing Documentation + +Clear docs are a prerequisite for a successful open source project. Contributing to the documentation is often more important than contributing to the code! We are looking for _documentarians_ to: @@ -23,55 +53,34 @@ We are looking for _documentarians_ to: * create new / missing sections, and * take the lead in making sections, or the over all structure better. -### But how? +Information about how to update docs can be found in our [FAQ](faq.html#how-do-i-contribute-to-these-docs). -* Our public docs site can be updated [here](https://github.com/coralproject/docs). -* [Let us know](https://github.com/coralproject/talk/wiki/Contact-Us) if you'd like permission to update our wiki. -* Update any of our .md docs files by following [this guide](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging). -* Our API docs need to be kept honest. [Update them here](https://github.com/coralproject/talk/blob/master/docs/swagger.yaml). +If you'd like to discuss a contribution, please [file an issue](https://github.com/coralproject/talk/issues) describing the changes you would like to see. -## Integrate into your environment +## Contributing Translations -First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes! +Talk's tranlations are stored in `.yml` files [here](https://github.com/coralproject/talk/tree/master/locales). -Talk is designed to integrate into existing environments in a variety of ways: +Translations can be submitted via pull request. If you do not use github, you can use 'en.yml' as a template and [email](https://coralproject.net/contact.html) the translations to us. We can import it into the repo. -* [Auth integrations](https://github.com/coralproject/talk/wiki/Security#authentication-strategies) -* [Push assets into Talk](https://github.com/coralproject/talk/blob/master/routes/api/assets/index.js) -* Monitoring Hooks (coming in 2017) +## I want to contribute but I'm not sure what to do! -If you're considering deploying Talk, [please let us know](https://github.com/coralproject/talk/wiki/Contact-Us)! We are quite literally doing this for you and want to help you succeed any way we can. +If you want to contribute but don't have a clear idea of exactly what that may be, here are some resources that may help: -If you are writing custom integration code in your fork of Talk, please consider keeping it generic and filing a Pull Request to contribute it back to the project! See our [forking and merging guidelines](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging) for more info. +### Product Roadmap -## Write some code +Please visit our product roadmap here: https://www.pivotaltracker.com/n/projects/1863625. If you'd like to take on any of our scheduled tasks we'd be forever grateful! -First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes! +### Discussion Forum -### Build a New Feature / Plugin +If you'd like to discuss what we're up to, please visit or [community](https://community.coralproject.net/) or [contact us](https://coralproject.net/contact.html). -Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement. +### Integrations -Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early and often if you'd like to help. We would love to hear your ideas for features and plugins and help you find a way to productively engage the project. +Have a favorite analytics engine? Data science service? CMS? Auth platform? Deployment platform or pipeline? Pet project? Consider building a plugin to integrate them! -To get an idea of where the Coral Team is going, see: +### Favorite Features? -* our [Slack channel](https://coralprojectslackin.herokuapp.com/), where you can talk directly with the Team and other community members, -* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and -* our [issues](https://github.com/coralproject/talk/issues). +Do you have a favorite feature of an existing platform that's not yet been done in Talk? Sounds like Talk needs that feature. - -Examples: - -* [Add An Emoji Button to Comments](https://github.com/coralproject/talk/wiki/Add-An-Emoji-Button-to-Comments) - - -### Work on the Core - -There is always more work to be done to make an application more stable, scaleable and secure. - -If you see issues in the code or have ideas on how we may improve Talk, please consider: - -* [contributing a fix](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging), -* [filing an issue](https://github.com/coralproject/talk/issues), or -* or otherwise [letting us know](https://github.com/coralproject/talk/wiki/Contact-Us). +## Thanks! diff --git a/PLUGINS.md b/PLUGINS.md index cf1eade31..ac34240b1 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -1,503 +1,3 @@ # Talk Plugins -Plugins for Talk can take various forms, currently we are only supporting server -side plugins. - -## Plugin Registration - -The parsing order for the plugin registration is as follows: - -- `TALK_PLUGINS_JSON` environment variable -- `plugins.json` file -- `plugins.default.json` file - -If you need to "disable all plugins", you can simply provide `{}` as the -contents of `process.env.TALK_PLUGINS_JSON` or the `plugins.json`. - -The format for this is thus: - -```json -{ - "server": [ - "people" - ] -} -``` - -Where we have a `server` key with an array of plugins that match the folder -name in the `plugins/` folder. For example, the above config would -require a plugin from `plugins/people`, which must provide a `index.js` file -that returns an object that matches the Plugin Specification. - -If the package is external (available on NPM) you can specify the string for -the version by using an object instead, for example: - -```json -{ - "server": [ - {"people": "^1.2.0"} - ] -} -``` - -External plugins can be resolved by running: - -```bash -./bin/cli plugins reconcile -``` - -This achieves two things: - -1. It will traverse into local plugin folders and install their dependencies. - _Note that if the plugin is already installed and available in the node_modules folder, it will not be - fetched again unless there is a version mismatch._ This will result in the - project `package.json` and `yarn.lock` files to be modified, this is normal as - this ensures that repeated deployments (with the same config) will have the - same config, these changes should not be committed to source control. -2. It will seek out dependencies that are listed in the object notation and try - to install them from npm. - -## Plugin Dependencies - -You may also include additional external dependencies in your local packages by -specifying a `package.json` at your plugin root which will result in a -`node_modules` folder being generated at the plugin root with your specific -dependencies. - -## Deployment Solutions - -Plugins can be deployed with a production instance of Talk. - -### Source - -Source deployments can just modify the `plugins.json` file and include any -local plugins into the `plugins/` directory. After including the config, you -need to reconcile the plugins and build the static assets: - -```bash -# get plugin dependancies and remote plugins -./bin/cli plugins reconcile - -# build staic assets (including enabled client side plugins) -yarn build -``` - -Then the application can be started as is. - -### Docker - -If you deploy using Docker, you can extend from the `*-onbuild` image, an -example `Dockerfile` for your project could be: - -```Dockerfile -FROM coralproject/talk:latest-onbuild -``` - -Where the directory for your instance would contain a `plugins.json` file -describing the plugin requirements and a `plugins` directory containing any -other local plugins that should be included. - -Onbuild triggers will execute when the image is building with your custom -configuration and will ensure that the image is ready to use by building all -assets inside the image as well. - -## Server Plugins - -### API - -You can access any API available inside the talk directory in a plugin by simply -importing the file relative to the talk project root. An example would be if you -wanted to import the `MetadataService`, you would simply write: - -```javascript -const MetadataService = require('services/metadata'); -``` - -### Specification - -Each plugin should export a single object with all hooks available on it. - -_**Note: You will have access to the whole core and other plugin's typeDefs, -context, loaders, mutators, resolvers, hooks. This is intentional, as it -encourages composing plugins to merge functionality, like a Slack plugin which -provides a Slack notify context function as well as having the loader for -comments.**_ - -The following are the hooks available: - -#### Field: `typeDefs` - -```graphql -enum COLOUR { - RED - BLUE -} - -type Person { - name: String! - colour: COLOUR! -} - -type RootMutation { - createPerson(name: String!): Person -} - -type RootQuery { - people: [Person!] -} - -type Subscription { - leader: Person -} -``` - -Thanks to [gql-merge](https://www.npmjs.com/package/gql-merge) the contents of -`typeDefs` should be a string that will be _merged_ with the existing type -definitions. `enum`'s will be appended to, types will be appended, and new types -will be added. - -#### Field: `context` - -```js -{ - Slack: (context) => ({ - notify: (message) => { - // return a promise after we're done sending notifications. - } - }) -} -``` - -Any property provided here will be added to the context parameter available -inside all resolvers, loaders, mutators, and of course, other context based -plugins. - -The top level item must accept a context for the request which it should use to -configure the context plugin before it would be mounted at `context.plugins`. -This plugin above would mount at: `context.plugins.Slack`, or, if you're using -[object destructuring](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), `{plugins: {Slack}}`. - -#### Field: `loaders` - -```js -(context) => ({ - People: { - load: () => db.people.find({user: context.user}) - } -}) -``` - -Loaders should be provided as a function which returns a map which is used in -the resolvers function. These must return a promise or a value. - -#### Field: `mutators` - -```js -(context) => ({ - People: { - create: (name) => { - return db.people.insert({user: context.user, name}); - } - } -}) -``` - -Mutators should be provided as a function which returns a map which is used in -the resolvers function. These must return a promise or a value. - -#### Field: `resolvers` - -```js -{ - Person: { - name(obj, args, context) { - return obj.name; - }, - colour(obj, args, context) { - // Bill likes the colour red, everyone else likes blue. - return obj.name === 'bill' ? 'RED' : 'BLUE'; - } - }, - RootQuery: { - people(obj, args, {loaders: {People}}) { - return People.load(); - } - }, - RootMutation: { - createPerson(obj, {name}, {mutators: {People}}) { - return People.create(name); - } - } -} -``` - -Should return a resolver map as described in the -[Apollo Docs](http://dev.apollodata.com/tools/graphql-tools/resolvers.html#Resolver-map). - -This will merge with the existing resolvers in core and from previous plugins. - -#### Field: `hooks` - -```js -{ - RootMutation: { - createPerson: { - post: async (obj, args, {plugins: {Slack}}, info, person) { - if (!person) { - return person; - } - - await Slack.notify(`A new person just was created with name ${person.name}`); - - return person; - } - } - } -} -``` - -Hooks here are pretty special, for each resolver field, you can specify a -pre/post hook that will execute pre and post field resolution. - -If your post function accepts four parameters, then it can modify the field -result. It is *required* that the function resolves a promise (or returns) with -the modified value or simply the original if you didn't modify it. - -#### Field: `setupFunctions` - -```js -setupFunctions: { - leader: (options, args) => ({ - leader: { - filter: (person) => person.place === 1 - }, - }), -} -``` - -Setup functions allow you to create filters that control which pubsub.publish() events -send data to the client. If the type in question contains args, clients may subscribe using those arguments to further filter their subscription. - -For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions). - -#### Field: `tokenUserNotFound` - -```js -tokenUserNotFound: async ({jwt, token}) => { - let profile = await someExternalService(token); - if (!profile) { - return null; - } - - let user = await UserModel.findOneAndUpdate({ - id: profile.id - }, { - id: profile.id, - username: profile.username, - lowercaseUsername: profile.username.toLowerCase(), - roles: [], - profiles: [] - }, { - setDefaultsOnInsert: true, - new: true, - upsert: true - }); - - return user; -} -``` - -The `tokenUserNotFound` hook allows auth integrations to hook into the event -when a valid token is provided but a user can't be found in the database that -matches the provided id. - -The function is async, and should return the user object that was created in the -database, or null if the user wasn't found. The `jwt` paramenter of the object -is the unpacked token, while `token` is the original jwt token string. - -#### Field: `router` - -```js -(router) => { - router.get('/api/v1/people', (req, res) => { - res.json({people: [{name: 'Bob'}]}); - }); -} -``` - -The Router hook allows you to create a function that accepts the base express -router where you can mount any amount of middleware/routes to do any form of -action needed by external applications. We also provide the authorization -middleware via: - -```js -const authorization = require('middleware/authorization'); - -module.exports = { - router(router) { - router.get('/api/v1/people', authorization.needed('ADMIN'), (req, res) => { - res.json({people: [{name: 'SECRET PEOPLE'}]}); - }); - } -} -``` - -#### Field: `passport` - -```js -const FacebookStrategy = require('passport-facebook').Strategy; -const UsersService = require('services/users'); -const {ValidateUserLogin, HandleAuthPopupCallback} = require('services/passport'); - -module.exports = { - passport(passport) { - passport.use(new FacebookStrategy({ - clientID: process.env.TALK_FACEBOOK_APP_ID, - clientSecret: process.env.TALK_FACEBOOK_APP_SECRET, - callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`, - passReqToCallback: true, - profileFields: ['id', 'displayName', 'picture.type(large)'] - }, async (req, accessToken, refreshToken, profile, done) => { - - let user; - try { - user = await UsersService.findOrCreateExternalUser(profile); - } catch (err) { - return done(err); - } - - return ValidateUserLogin(profile, user, done); - })); - }, - router(router) { - - // Note that we have to import the passport instance here, it is - // instantiated after all the strategies have been mounted. - const {passport} = require('services/passport'); - - /** - * Facebook auth endpoint, this will redirect the user immediatly to facebook - * for authorization. - */ - router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']})); - - /** - * Facebook callback endpoint, this will send the user a html page designed to - * send back the user credentials upon sucesfull login. - */ - router.get('/facebook/callback', (req, res, next) => { - - // Perform the facebook login flow and pass the data back through the opener. - passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next); - }); - } -}; -``` - -This is a full example including the routes hook to add the required components -to the application router to support a different auth strategy. - -### Full Example - -Contents of `plugins.json`: - -```json -{ - "server": [ - "people" - ] -} -``` - -Located in `plugins/people/index.js`: - -```js -module.exports = { - typeDefs: ` - enum COLOUR { - RED - BLUE - } - - type Person { - name: String! - colour: COLOUR! - } - - type RootMutation { - createPerson(name: String!): Person - } - - type RootQuery { - people: [Person!] - } - - type Subscription { - leader: Person - } - `, - context: { - Slack: () => ({ - notify: (message) => { - // return a promise after we're done sending notifications. - } - }) - }, - loaders: ({user}) => ({ - People: { - load: () => db.people.find({user}) - } - }), - mutators: ({user}) => ({ - People: { - create: (name) => { - return db.people.insert({user, name}); - } - } - }), - resolvers: { - Person: { - name(obj, args, context) { - return obj.name; - }, - colour(obj, args, context) { - // Bill likes the colour red, everyone else likes blue. - return obj.name === 'bill' ? 'RED' : 'BLUE'; - } - }, - RootQuery: { - people(obj, args, {loaders: {People}}) { - return People.load(); - } - }, - RootMutation: { - createPerson(obj, {name}, {mutators: {People}}) { - return People.create(name); - } - } - }, - hooks: { - RootMutation: { - createPerson: { - post: async (obj, args, {plugins: {Slack}}, info, person) => { - if (!person) { - return person; - } - - await Slack.notify(`A new person just was created with name ${person.name}`); - - return person; - } - } - } - }, - setupFunctions: { - leader: (options, args) => ({ - leader: { - filter: (person) => person.place === 1 - } - } - } -}; - -``` +Our documentation for Talk has moved! Utilize [this hyperlink](https://coralproject.github.io/talk/plugins.html) via click or tap to navigate your browser to the new location! diff --git a/README.md b/README.md index 48055e326..68547e7a8 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,17 @@ endpoint when the server is running with built assets. ## Contributing to Talk -See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md)! +See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md). + +## Documentation + +### General + +See our [Talk Documentation & Guides](https://coralproject.github.io/talk/index.html). + +### Plugins + +See our guide to using and building [Talk Plugins](https://github.com/coralproject/talk/blob/master/PLUGINS.md). ## Usage @@ -40,10 +50,15 @@ sign and verify tokens via a `HS256` algorithm. - `TALK_RECAPTCHA_SECRET` (*required for reCAPTCHA support*) - server secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout. - `TALK_RECAPTCHA_PUBLIC` (*required for reCAPTCHA support*) - client secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout. - `TALK_PLUGINS_JSON` (_optional_) - used to specify the plugin config via the environment +- `TALK_KEEP_ALIVE` (_optional_) - The keepalive timeout that should be used to send keep alive messages through the websocket to keep the socket alive. (Default `30s`) Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for alternative methods of loading configuration during development. +### Running Migrations + +We have a migration tool that can be run using `bin/cli migration run`. This will detect new migrations available and prompt you to backup your database before proceeding with the migration. Migrations are required with major version releases. + ### Using Trust Talk ships with core components we call "Trust". This allows Talk to automate certain actions based on previous user behavior. diff --git a/bin/cli b/bin/cli index 7e27fa85b..33560c53b 100755 --- a/bin/cli +++ b/bin/cli @@ -12,7 +12,9 @@ program .command('assets', 'interact with assets') .command('setup', 'setup the application') .command('jobs', 'work with the job queues') + .command('token', 'work with the access tokens') .command('users', 'work with the application auth') + .command('migration', 'provides utilities for migrating the database') .command('plugins', 'provides utilities for interacting with the plugin system') .parse(process.argv); diff --git a/bin/cli-assets b/bin/cli-assets index 97b5b043c..4805bead0 100755 --- a/bin/cli-assets +++ b/bin/cli-assets @@ -8,9 +8,12 @@ const program = require('./commander'); const parseDuration = require('ms'); const Table = require('cli-table'); const AssetModel = require('../models/asset'); +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'); // Register the shutdown criteria. util.onshutdown([ @@ -20,65 +23,112 @@ util.onshutdown([ /** * Lists all the assets registered in the database. */ -function listAssets() { - AssetModel - .find({}) - .sort({'created_at': 1}) - .then((asset) => { - let table = new Table({ - head: [ - 'ID', - 'Title', - 'URL' - ] - }); +async function listAssets() { + try { + let assets = await AssetModel.find({}).sort({'created_at': 1}); - 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); + let table = new Table({ + head: [ + 'ID', + 'Title', + 'URL' + ] }); + + assets.forEach((asset) => { + table.push([ + asset.id, + asset.title ? asset.title : '', + asset.url ? asset.url : '' + ]); + }); + + console.log(table.toString()); + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } } -function refreshAssets(ageString) { - const now = new Date().getTime(); - const ageMs = parseDuration(ageString); - const age = new Date(now - ageMs); +async function refreshAssets(ageString) { + try { + const now = new Date().getTime(); + const ageMs = parseDuration(ageString); + const age = new Date(now - ageMs); - AssetModel.find({ - $or: [ - { - scraped: { - $lte: age + let assets = await AssetModel.find({ + $or: [ + { + scraped: { + $lte: age + } + }, + { + scraped: null } - }, - { - scraped: null - } - ] - }) + ] + }); - // Queue all the assets for scraping. - .then((assets) => Promise.all(assets.map(scraper.create))) + // Queue all the assets for scraping. + await Promise.all(assets.map(scraper.create)); - .then(() => { console.log('Assets were queued to be scraped'); util.shutdown(); - }) - .catch((err) => { - console.error(err); + } catch (e) { + console.error(e); util.shutdown(1); - }); + } +} + +async function updateURL(assetID, assetURL) { + try { + await AssetsService.updateURL(assetID, assetURL); + + console.log(`Asset ${assetID} was updated to have url ${assetURL}.`); + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +async function merge(srcID, dstID) { + try { + + // Grab the assets... + let [srcAsset, dstAsset] = await AssetsService.findByIDs([srcID, dstID]); + if (!srcAsset || !dstAsset) { + throw new Error('Not all assets indicated by id exist, cannot merge'); + } + + // Count the affected resources... + let srcCommentCount = await CommentModel.find({asset_id: srcID}).count(); + + console.log(`Now going to update ${srcCommentCount} comments and delete the source Asset[${srcID}].`); + + let {confirm} = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Proceed with merge', + default: false + } + ]); + + if (confirm) { + + // Perform the merge! + await AssetsService.merge(srcID, dstID); + } else { + console.warn('Aborting merge'); + } + + util.shutdown(0); + } catch (e) { + console.error(e); + util.shutdown(1); + } } //============================================================================== @@ -95,6 +145,16 @@ program .description('queues the assets that exceed the age requested') .action(refreshAssets); +program + .command('update-url ') + .description('update the URL of an asset') + .action(updateURL); + +program + .command('merge ') + .description('merges two assets together by moving comments from src to dst and deleting the src asset') + .action(merge); + program.parse(process.argv); // If there is no command listed, output help. diff --git a/bin/cli-migration b/bin/cli-migration new file mode 100755 index 000000000..036ac8b02 --- /dev/null +++ b/bin/cli-migration @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +const program = require('./commander'); +const util = require('./util'); +const inquirer = require('inquirer'); +const mongoose = require('../services/mongoose'); +const MigrationService = require('../services/migration'); + +// Register shutdown hooks. +util.onshutdown([ + () => mongoose.disconnect() +]); + +async function createMigration(name) { + try { + + // Create the migration. + await MigrationService.create(name); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +async function runMigrations() { + + try { + + 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'); + } + + // Get the migrations to run. + let migrations = await MigrationService.listPending(); + + console.log('Now going to run the following migrations:\n'); + + for (let {filename} of migrations) { + console.log(`\tmigrations/${filename}`); + } + + let {confirm} = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Proceed with migrations', + default: false + } + ]); + + if (confirm) { + + // Run the migrations. + await MigrationService.run(migrations); + } else { + console.warn('Skipping migrations'); + } + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('create ') + .description('creates a new migration') + .action(createMigration); + +program + .command('run') + .description('runs all pending migrations') + .action(runMigrations); + +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 index 7978c6c85..f2a6d3592 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -2,9 +2,13 @@ const program = require('./commander'); const app = require('../app'); +const debug = require('debug')('talk:cli:serve'); +const errors = require('../errors'); const {createServer} = require('http'); const scraper = require('../services/scraper'); const mailer = require('../services/mailer'); +const MigrationService = require('../services/migration'); +const SetupService = require('../services/setup'); const kue = require('../services/kue'); const mongoose = require('../services/mongoose'); const util = require('./util'); @@ -87,7 +91,46 @@ function onListening() { /** * Start the app. */ -function startApp(program) { +async function startApp(program) { + + try { + + // Check to see if the application is installed. If the application + // has been installed, then it will throw errors.ErrSettingsNotInit, this + // just means we don't have to check that the migrations have run. + await SetupService.isAvailable(); + + debug('setup is currently available, migrations not being checked'); + + } catch (e) { + + // Check the error. + switch (e) { + case errors.ErrInstallLock, errors.ErrSettingsInit: + + debug('setup is not currently available, migrations now being checked'); + + // The error was expected, just continue. + break; + default: + + // The error was not expected, throw the error! + throw e; + } + + // Now try and check the migration status. + try { + + // Verify that the minimum migration version is met. + await MigrationService.verify(); + + } catch (e) { + console.error(e); + process.exit(1); + } + + debug('migrations do not have to be run'); + } /** * Listen on provided port, on all network interfaces. diff --git a/bin/cli-setup b/bin/cli-setup index 1d6de4090..9ecdecc95 100755 --- a/bin/cli-setup +++ b/bin/cli-setup @@ -8,6 +8,7 @@ const program = require('./commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); const SettingModel = require('../models/setting'); +const MODERATION_OPTIONS = require('../models/enum/moderation_options'); const SettingsService = require('../services/settings'); const SetupService = require('../services/setup'); const UsersService = require('../services/users'); @@ -32,166 +33,155 @@ program // Setup the application //============================================================================== -const performSetup = () => { - - if (program.defaults) { - return SettingsService - .init() - .then(() => { - console.log('Settings created.'); - console.log('\nTalk is now installed!'); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); - } +const performSetup = async () => { // Get the current settings, we are expecing an error here. - return SettingsService - .retrieve() - .then(() => { + try { - // We should NOT have gotten a settings object, this means that the - // application is already setup. Error out here. - throw errors.ErrSettingsInit; + // Try to get the settings. + await SettingsService.retrieve(); - }) - .catch((err) => { + // We should NOT have gotten a settings object, this means that the + // application is already setup. Error out here. + throw errors.ErrSettingsInit; - // If the error is `not init`, then we're good, otherwise, it's something - // else. - if (err !== errors.ErrSettingsNotInit) { - throw err; + } catch (e) { + + // If the error is `not init`, then we're good, otherwise, it's something + // else. + if (e !== errors.ErrSettingsNotInit) { + throw e; + } + } + + if (program.defaults) { + await SettingsService.init(); + + console.log('Settings created.'); + console.log('\nTalk is now installed!'); + + return; + } + + // 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'); + + let answers = await inquirer.prompt([ + { + type: 'input', + name: 'organizationName', + message: 'Organization Name', + default: settings.organizationName, + 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' + }, + { + type: 'confirm', + name: 'requireEmailConfirmation', + default: settings.requireEmailConfirmation, + message: 'Should emails always be confirmed' + } + ]); - }) - .then(() => { + // Update the settings that were changed. + Object.keys(answers).forEach((key) => { + if (answers[key] !== undefined) { + settings[key] = answers[key]; + } + }); - // Create the base settings model. - let settings = new SettingModel(); + console.log('\nWe\'ll ask you some questions about your first admin user.\n'); - console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n'); - - return inquirer.prompt([ - { - type: 'input', - name: 'organizationName', - message: 'Organization Name', - default: settings.organizationName, - validate: (input) => { - if (input && input.length > 0) { - return true; - } - - return 'Organization Name is required.'; - } - }, - { - type: 'list', - choices: SettingModel.MODERATION_OPTIONS, - name: 'moderation', - default: settings.moderation, - message: 'Select a moderation mode' - }, - { - type: 'confirm', - name: 'requireEmailConfirmation', - default: settings.requireEmailConfirmation, - message: 'Should emails always be confirmed' - } - ]) - .then((answers) => { - - // Update the settings that were changed. - Object.keys(answers).forEach((key) => { - if (answers[key] !== undefined) { - settings[key] = answers[key]; - } - }); - - console.log('\nWe\'ll ask you some questions about your first admin user.\n'); - - return inquirer.prompt([ - { - type: 'input', - name: 'username', - message: 'Username', - filter: (username) => { - return UsersService - .isValidUsername(username, false) - .catch((err) => { - throw err.message; - }); - } - }, - { - name: 'email', - message: 'Email', - format: 'email', - 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; - }); - } - }, - { - name: 'confirmPassword', - message: 'Confirm Password', - type: 'password', - filter: (confirmPassword) => { - return UsersService - .isValidPassword(confirmPassword) - .catch((err) => { - throw err.message; - }); - } - }, - ]); - }) - .then((user) => { - - if (user.password !== user.confirmPassword) { - return Promise.reject(new Error('Passwords do not match')); + let user = await inquirer.prompt([ + { + type: 'input', + name: 'username', + message: 'Username', + filter: (username) => { + return UsersService + .isValidUsername(username, false) + .catch((err) => { + throw err.message; + }); + } + }, + { + name: 'email', + message: 'Email', + format: 'email', + validate: (value) => { + if (value && value.length >= 3) { + return true; } - return SetupService.setup({ - settings: settings.toObject(), - user: { - email: user.email, - username: user.username, - password: user.password - } - }); - }); - }) - .then(({user}) => { - console.log('Settings created.'); - console.log(`User ${user.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.'); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); + return '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; + }); + } + }, + ]); + + if (user.password !== user.confirmPassword) { + return Promise.reject(new Error('Passwords do not match')); + } + + let {user: newUser} = await SetupService.setup({ + settings: settings.toObject(), + user: { + email: user.email, + username: user.username, + 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.'); }; // Start tthe setup process. -performSetup(); +performSetup() + .then(() => { + util.shutdown(); + }) + .catch((e) => { + console.error(e); + util.shutdown(1); + }); diff --git a/bin/cli-token b/bin/cli-token new file mode 100755 index 000000000..fa5d2e350 --- /dev/null +++ b/bin/cli-token @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +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() +]); + +async function listTokens(userID) { + try { + let tokens = await TokensService.list(userID); + + let table = new Table({ + head: [ + 'ID', + 'Name', + 'Status' + ] + }); + + tokens.forEach((token) => { + table.push([ + token.id, + token.name, + token.active ? 'Active' : 'Revoked' + ]); + }); + + console.log(table.toString()); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +async function revokeToken(tokenID) { + try { + + await TokensService.revoke(null, tokenID); + + console.log(`Revoked Token[${tokenID}]`); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +async function createToken(userID, tokenName) { + try { + + let {pat: {id}, jwt} = await TokensService.create(userID, tokenName); + + console.log(`Created Token[${id}] for User[${userID}] = ${jwt}`); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('list ') + .description('list tokens for a user') + .action(listTokens); + +program + .command('revoke ') + .description('revokes a token with a given id') + .action(revokeToken); + +program + .command('create ') + .description('create a token for a user with a given name') + .action(createToken); + +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 fccb4b8bb..057c0a806 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -8,6 +8,7 @@ const program = require('./commander'); const inquirer = require('inquirer'); const UsersService = require('../services/users'); const UserModel = require('../models/user'); +const USER_ROLES = require('../models/enum/user_roles'); const mongoose = require('../services/mongoose'); const util = require('./util'); const Table = require('cli-table'); @@ -36,7 +37,7 @@ function getUserCreateAnswers(options) { roles: [] }; - if (options.role && UserModel.USER_ROLES.indexOf(options.role) > -1) { + if (options.role && USER_ROLES.indexOf(options.role) > -1) { user.roles = [options.role]; } @@ -89,7 +90,7 @@ function getUserCreateAnswers(options) { name: 'roles', message: 'User Role', type: 'checkbox', - choices: UserModel.USER_ROLES + choices: USER_ROLES } ]); } @@ -291,8 +292,8 @@ function mergeUsers(dstUserID, srcUserID) { */ function addRole(userID, role) { - if (UserModel.USER_ROLES.indexOf(role) === -1) { - console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`); + if (USER_ROLES.indexOf(role) === -1) { + console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`); util.shutdown(1); return; } @@ -316,8 +317,8 @@ function addRole(userID, role) { */ function removeRole(userID, role) { - if (UserModel.USER_ROLES.indexOf(role) === -1) { - console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`); + if (USER_ROLES.indexOf(role) === -1) { + console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`); util.shutdown(1); return; } diff --git a/circle.yml b/circle.yml index e7427094b..b924028f3 100644 --- a/circle.yml +++ b/circle.yml @@ -52,5 +52,6 @@ deployment: 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 3bde5f931..aee40a3a8 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -1,5 +1,5 @@ import React from 'react'; -import {Router, Route, IndexRedirect, browserHistory} from 'react-router'; +import {Router, Route, IndexRedirect, browserHistory, Redirect} from 'react-router'; import Configure from 'routes/Configure'; import Dashboard from 'routes/Dashboard'; @@ -49,8 +49,8 @@ const routes = ( - - + + diff --git a/client/coral-admin/src/actions/assets.js b/client/coral-admin/src/actions/assets.js index 8a18a8e3f..75bd2477d 100644 --- a/client/coral-admin/src/actions/assets.js +++ b/client/coral-admin/src/actions/assets.js @@ -9,6 +9,7 @@ import { } from '../constants/assets'; import coralApi from '../../../coral-framework/helpers/request'; +import t from 'coral-framework/services/i18n'; /** * Action disptacher related to assets @@ -19,12 +20,16 @@ import coralApi from '../../../coral-framework/helpers/request'; export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch) => { dispatch({type: FETCH_ASSETS_REQUEST}); return coralApi(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`) - .then(({result, count}) => - dispatch({type: FETCH_ASSETS_SUCCESS, - assets: result, - count - })) - .catch((error) => dispatch({type: FETCH_ASSETS_FAILURE, error})); + .then(({result, count}) => + dispatch({type: FETCH_ASSETS_SUCCESS, + assets: result, + count + })) + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: FETCH_ASSETS_FAILURE, error: errorMessage}); + }); }; // Update an asset state @@ -32,9 +37,12 @@ export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filte export const updateAssetState = (id, closedAt) => (dispatch) => { dispatch({type: UPDATE_ASSET_STATE_REQUEST}); return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}}) - .then(() => - dispatch({type: UPDATE_ASSET_STATE_SUCCESS})) - .catch((error) => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error})); + .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}); + }); }; export const updateAssets = (assets) => (dispatch) => { diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index ca48764f1..0c1264e1f 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -3,6 +3,8 @@ import * as actions from '../constants/auth'; import coralApi from 'coral-framework/helpers/request'; import * as Storage from 'coral-framework/helpers/storage'; import {handleAuthToken} from 'coral-framework/actions/auth'; +import {resetWebsocket} from 'coral-framework/services/client'; +import t from 'coral-framework/services/i18n'; //============================================================================== // SIGN IN @@ -36,16 +38,31 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => } dispatch(handleAuthToken(token)); + resetWebsocket(); dispatch(checkLoginSuccess(user)); }) .catch((error) => { - if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') { + 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: error.translation_key + message: t(`error.${error.translation_key}`), }); } else { - dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key}); + dispatch({ + type: actions.LOGIN_FAILURE, + message: errorMessage, + }); } }); }; @@ -54,25 +71,30 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => // FORGOT PASSWORD //============================================================================== -const forgotPassowordRequest = () => ({ +const forgotPasswordRequest = () => ({ type: actions.FETCH_FORGOT_PASSWORD_REQUEST }); -const forgotPassowordSuccess = () => ({ +const forgotPasswordSuccess = () => ({ type: actions.FETCH_FORGOT_PASSWORD_SUCCESS }); -const forgotPassowordFailure = () => ({ - type: actions.FETCH_FORGOT_PASSWORD_FAILURE +const forgotPasswordFailure = (error) => ({ + type: actions.FETCH_FORGOT_PASSWORD_FAILURE, + error, }); export const requestPasswordReset = (email) => (dispatch) => { - dispatch(forgotPassowordRequest(email)); + dispatch(forgotPasswordRequest(email)); const redirectUri = location.href; return coralApi('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}}) - .then(() => dispatch(forgotPassowordSuccess())) - .catch((error) => dispatch(forgotPassowordFailure(error))); + .then(() => dispatch(forgotPasswordSuccess())) + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(forgotPasswordFailure(errorMessage)); + }); }; //============================================================================== @@ -105,10 +127,12 @@ export const checkLogin = () => (dispatch) => { return dispatch(checkLoginFailure('not logged in')); } + resetWebsocket(); dispatch(checkLoginSuccess(user)); }) .catch((error) => { console.error(error); - dispatch(checkLoginFailure(`${error.translation_key}`)); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(checkLoginFailure(errorMessage)); }); }; diff --git a/client/coral-admin/src/actions/banUserDialog.js b/client/coral-admin/src/actions/banUserDialog.js new file mode 100644 index 000000000..8b068051c --- /dev/null +++ b/client/coral-admin/src/actions/banUserDialog.js @@ -0,0 +1,7 @@ +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}); + diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 36b9ffb0a..70c9b7592 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -10,11 +10,12 @@ import { SET_COMMENTER_STATUS, SHOW_BANUSER_DIALOG, HIDE_BANUSER_DIALOG, - SHOW_SUSPENDUSER_DIALOG, - HIDE_SUSPENDUSER_DIALOG + SHOW_REJECT_USERNAME_DIALOG, + HIDE_REJECT_USERNAME_DIALOG } from '../constants/community'; import coralApi from '../../../coral-framework/helpers/request'; +import t from 'coral-framework/services/i18n'; export const fetchAccounts = (query = {}) => (dispatch) => { @@ -30,7 +31,11 @@ export const fetchAccounts = (query = {}) => (dispatch) => { totalPages }); }) - .catch((error) => dispatch({type: FETCH_COMMENTERS_FAILURE, error})); + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: FETCH_COMMENTERS_FAILURE, error: errorMessage}); + }); }; const requestFetchAccounts = () => ({ @@ -47,7 +52,6 @@ export const newPage = () => ({ }); export const setRole = (id, role) => (dispatch) => { - return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}}) .then(() => { return dispatch({type: SET_ROLE, id, role}); @@ -65,6 +69,6 @@ export const setCommenterStatus = (id, status) => (dispatch) => { export const showBanUserDialog = (user) => ({type: SHOW_BANUSER_DIALOG, user}); export const hideBanUserDialog = () => ({type: HIDE_BANUSER_DIALOG}); -// Suspend User Dialog -export const showSuspendUserDialog = (user) => ({type: SHOW_SUSPENDUSER_DIALOG, user}); -export const hideSuspendUserDialog = () => ({type: HIDE_SUSPENDUSER_DIALOG}); +// Reject Username Dialog +export const showRejectUsernameDialog = (user) => ({type: SHOW_REJECT_USERNAME_DIALOG, user}); +export const hideRejectUsernameDialog = () => ({type: HIDE_REJECT_USERNAME_DIALOG}); diff --git a/client/coral-admin/src/actions/install.js b/client/coral-admin/src/actions/install.js index 15413e220..33cefab63 100644 --- a/client/coral-admin/src/actions/install.js +++ b/client/coral-admin/src/actions/install.js @@ -2,6 +2,7 @@ import coralApi from 'coral-framework/helpers/request'; import * as actions from '../constants/install'; 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}); @@ -17,7 +18,8 @@ const clearErrors = () => ({type: actions.CLEAR_ERRORS}); const validation = (formData, dispatch, next) => { if (!(formData != null)) { - return dispatch(hasError()); + dispatch(hasError()); + return; } const validKeys = Object.keys(formData) @@ -40,7 +42,8 @@ const validation = (formData, dispatch, next) => { }); if (empty.length) { - return dispatch(hasError()); + dispatch(hasError()); + return; } // RegExp Validation @@ -59,7 +62,30 @@ const validation = (formData, dispatch, next) => { }); if (validation.length) { - return dispatch(hasError()); + dispatch(hasError()); + return; + } + + // Confirm Validation + const prefixLength = 'confirm'.length; + 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; + }); + + if (confirm.length) { + dispatch(hasError()); + return; } dispatch(clearErrors()); @@ -90,7 +116,8 @@ export const finishInstall = () => (dispatch, getState) => { }) .catch((error) => { console.error(error); - dispatch(installFailure(`${error.translation_key}`)); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(installFailure(errorMessage)); }); }; @@ -113,6 +140,7 @@ export const checkInstall = (next) => (dispatch) => { }) .catch((error) => { console.error(error); - dispatch(checkInstallFailure(`${error.translation_key}`)); + 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 37e09590d..1c803d87d 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -3,16 +3,6 @@ import * as actions from 'constants/moderation'; export const toggleModal = (open) => ({type: actions.TOGGLE_MODAL, open}); export const singleView = () => ({type: actions.SINGLE_VIEW}); -// Ban User Dialog -export const showBanUserDialog = (user, commentId, commentStatus, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, commentStatus, showRejectedNote}); -export const hideBanUserDialog = () => ({type: actions.HIDE_BANUSER_DIALOG}); - -// Suspend User Dialog -export const showSuspendUserDialog = (userId, username, commentId, commentStatus) => - ({type: actions.SHOW_SUSPEND_USER_DIALOG, userId, username, commentId, commentStatus}); - -export const hideSuspendUserDialog = () => ({type: actions.HIDE_SUSPEND_USER_DIALOG}); - // hide shortcuts note export const hideShortcutsNote = () => { try { @@ -51,3 +41,16 @@ export const toggleSelectCommentInUserDetail = (id, active) => { id }; }; + +export const toggleStorySearch = (active) => ({ + type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH +}); + +export const storySearchChange = (value) => ({ + type: actions.STORY_SEARCH_CHANGE_VALUE, + value +}); + +export const clearState = () => ({ + type: actions.MODERATION_CLEAR_STATE +}); diff --git a/client/coral-admin/src/actions/settings.js b/client/coral-admin/src/actions/settings.js index 65c200420..ba8b917ea 100644 --- a/client/coral-admin/src/actions/settings.js +++ b/client/coral-admin/src/actions/settings.js @@ -1,4 +1,5 @@ import coralApi from '../../../coral-framework/helpers/request'; +import t from 'coral-framework/services/i18n'; export const SETTINGS_LOADING = 'SETTINGS_LOADING'; export const SETTINGS_RECEIVED = 'SETTINGS_RECEIVED'; @@ -20,7 +21,9 @@ export const fetchSettings = () => (dispatch) => { dispatch({type: SETTINGS_RECEIVED, settings}); }) .catch((error) => { - dispatch({type: SETTINGS_FETCH_ERROR, error}); + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: SETTINGS_FETCH_ERROR, error: errorMessage}); }); }; @@ -49,6 +52,8 @@ export const saveSettingsToServer = () => (dispatch, getState) => { dispatch({type: SAVE_SETTINGS_SUCCESS, settings}); }) .catch((error) => { - dispatch({type: SAVE_SETTINGS_FAILED, error}); + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: SAVE_SETTINGS_FAILED, error: errorMessage}); }); }; diff --git a/client/coral-admin/src/actions/suspendUserDialog.js b/client/coral-admin/src/actions/suspendUserDialog.js new file mode 100644 index 000000000..06913147d --- /dev/null +++ b/client/coral-admin/src/actions/suspendUserDialog.js @@ -0,0 +1,7 @@ +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}); + diff --git a/client/coral-admin/src/actions/users.js b/client/coral-admin/src/actions/users.js index e888d5b74..7ba051a90 100644 --- a/client/coral-admin/src/actions/users.js +++ b/client/coral-admin/src/actions/users.js @@ -1,5 +1,6 @@ import coralApi from '../../../coral-framework/helpers/request'; import * as userTypes from '../constants/users'; +import t from 'coral-framework/services/i18n'; /** * Action disptacher related to users @@ -10,7 +11,11 @@ export const userStatusUpdate = (status, userId, commentId) => { dispatch({type: userTypes.UPDATE_STATUS_REQUEST}); return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}}) .then((res) => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res})) - .catch((error) => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error})); + .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}); + }); }; }; @@ -18,7 +23,11 @@ export const userStatusUpdate = (status, userId, commentId) => { export const sendNotificationEmail = (userId, subject, body) => { return (dispatch) => { return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}}) - .catch((error) => dispatch({type: userTypes.USER_EMAIL_FAILURE, error})); + .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}); + }); }; }; @@ -26,6 +35,10 @@ export const sendNotificationEmail = (userId, subject, body) => { export const enableUsernameEdit = (userId) => { return (dispatch) => { return coralApi(`/users/${userId}/username-enable`, {method: 'POST'}) - .catch((error) => dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error})); + .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}); + }); }; }; diff --git a/client/coral-admin/src/components/AdminLogin.js b/client/coral-admin/src/components/AdminLogin.js index 9deee53f7..e75e66d23 100644 --- a/client/coral-admin/src/components/AdminLogin.js +++ b/client/coral-admin/src/components/AdminLogin.js @@ -2,7 +2,6 @@ import React, {PropTypes} from 'react'; import Layout from 'coral-admin/src/components/ui/Layout'; import styles from './NotFound.css'; import {Button, TextField, Alert, Success} from 'coral-ui'; -import t from 'coral-framework/services/i18n'; import Recaptcha from 'react-recaptcha'; class AdminLogin extends React.Component { @@ -35,7 +34,7 @@ class AdminLogin extends React.Component { const {errorMessage, loginMaxExceeded, recaptchaPublic} = this.props; const signInForm = (
- {errorMessage && {t(`error.${errorMessage}`)}} + {errorMessage && {errorMessage}} ( + + × +
+
+

{t('bandialog.ban_user')}

+
+
+

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

+ {info} +
+
+ + +
+
+
+); + +BanUserDialog.propTypes = { + open: PropTypes.bool, + onPerform: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + username: PropTypes.string, + info: PropTypes.string, +}; + +export default BanUserDialog; diff --git a/client/coral-admin/src/components/ModerationKeysModal.js b/client/coral-admin/src/components/ModerationKeysModal.js index fabd5098c..c4f3f1e1d 100644 --- a/client/coral-admin/src/components/ModerationKeysModal.js +++ b/client/coral-admin/src/components/ModerationKeysModal.js @@ -16,8 +16,8 @@ const shortcuts = [ { title: 'modqueue.actions', shortcuts: { - 't': 'modqueue.approve', - 'r': 'modqueue.reject' + 'd': 'modqueue.approve', + 'f': 'modqueue.reject' } } ]; @@ -40,8 +40,8 @@ export default class ModerationKeysModal extends React.Component {

{t('modqueue.mod_faster')}

{t('modqueue.try_these')}:

    -
  • {t('modqueue.approve')} t
  • -
  • {t('modqueue.reject')} r
  • +
  • {t('modqueue.approve')} d
  • +
  • {t('modqueue.reject')} f

{t('modqueue.view_more_shortcuts')} {t('modqueue.shift_key')} + /

diff --git a/client/coral-admin/src/components/ModerationList.js b/client/coral-admin/src/components/ModerationList.js index fae85e492..e2b536f2c 100644 --- a/client/coral-admin/src/components/ModerationList.js +++ b/client/coral-admin/src/components/ModerationList.js @@ -8,8 +8,8 @@ import SuspendUserModal from './SuspendUserModal'; // Each action has different meaning and configuration const menuOptionsMap = { - 'reject': {status: 'REJECTED', icon: 'close', key: 'r'}, - 'approve': {status: 'ACCEPTED', icon: 'done', key: 't'}, + 'reject': {status: 'REJECTED', icon: 'close', key: 'f'}, + 'approve': {status: 'ACCEPTED', icon: 'done', key: 'd'}, 'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'}, 'ban': {status: 'BANNED', icon: 'not interested'} }; diff --git a/client/coral-admin/src/routes/Moderation/components/SuspendUserDialog.css b/client/coral-admin/src/components/SuspendUserDialog.css similarity index 100% rename from client/coral-admin/src/routes/Moderation/components/SuspendUserDialog.css rename to client/coral-admin/src/components/SuspendUserDialog.css diff --git a/client/coral-admin/src/routes/Moderation/components/SuspendUserDialog.js b/client/coral-admin/src/components/SuspendUserDialog.js similarity index 98% rename from client/coral-admin/src/routes/Moderation/components/SuspendUserDialog.js rename to client/coral-admin/src/components/SuspendUserDialog.js index 212757f20..d2ef23891 100644 --- a/client/coral-admin/src/routes/Moderation/components/SuspendUserDialog.js +++ b/client/coral-admin/src/components/SuspendUserDialog.js @@ -49,7 +49,6 @@ class SuspendUserDialog extends React.Component { handlePerform = () => { this.props.onPerform({ - id: this.props.userId, message: this.state.message, // Add 1 minute more to help `timeago.js` to display the correct duration. @@ -153,11 +152,10 @@ class SuspendUserDialog extends React.Component { SuspendUserDialog.propTypes = { open: PropTypes.bool.isRequired, - onCancel: PropTypes.func.isRequired, onPerform: PropTypes.func.isRequired, - username: PropTypes.string, - userId: PropTypes.string, + onCancel: PropTypes.func.isRequired, organizationName: PropTypes.string, + username: PropTypes.string, }; export default SuspendUserDialog; diff --git a/client/coral-admin/src/components/TagsInput.css b/client/coral-admin/src/components/TagsInput.css new file mode 100644 index 000000000..6bb630521 --- /dev/null +++ b/client/coral-admin/src/components/TagsInput.css @@ -0,0 +1,51 @@ +.root { + background-color: #fff; + border: 1px solid #ccc; + overflow: hidden; + padding-left: 5px; + padding-top: 5px; + +} + +.rootFocuesd { + border-color: rgb(142, 76, 65); +} + +.tag { + background-color: rgb(255, 220, 214); + border-radius: 2px; + border: 1px solid rgb(244, 126, 107); + color: rgb(244, 126, 107); + display: inline-block; + font-family: sans-serif; + font-size: 13px; + font-weight: 400; + margin-bottom: 5px; + margin-right: 5px; + padding: 5px; +} + +.tagRemove { + cursor: pointer; + font-weight: bold; + color: rgb(101, 24, 23); + + &::before { + content: " ×"; + } +} + +.input > input { + background: transparent; + border: 0; + color: #777; + font-family: sans-serif; + font-size: 13px; + font-weight: 400; + margin-bottom: 6px; + margin-top: 1px; + outline: none; + padding: 5px; +} + + diff --git a/client/coral-admin/src/components/TagsInput.js b/client/coral-admin/src/components/TagsInput.js new file mode 100644 index 000000000..d9854ed86 --- /dev/null +++ b/client/coral-admin/src/components/TagsInput.js @@ -0,0 +1,31 @@ +import React from 'react'; +import TagsInput from 'react-tagsinput'; +import styles from './TagsInput.css'; +import AutosizeInput from 'react-input-autosize'; + +const autosizingRenderInput = ({onChange, value, addTag: _, ...other}) => + ; + +export default (props) => { + return ( + data.split(',').map((d) => d.trim())} + className={styles.root} + focusedClassName={styles.rootFocus} + renderInput={autosizingRenderInput} + {...props} + + tagProps={{ + className: styles.tag, + classNameRemove: styles.tagRemove, + ...props.tagProps, + }} + inputProps={{ + className: styles.input, + ...props.inputProps, + }} + /> + ); +}; diff --git a/client/coral-admin/src/components/ToastContainer.css b/client/coral-admin/src/components/ToastContainer.css index 319872ceb..2fcbd7f7d 100644 --- a/client/coral-admin/src/components/ToastContainer.css +++ b/client/coral-admin/src/components/ToastContainer.css @@ -133,7 +133,7 @@ animation-fill-mode: both; } .toastify { - z-index: 999; + z-index: 99999; position: fixed; padding: 4px; width: 350px; @@ -197,7 +197,7 @@ border-radius: 2px; box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1), 0 3px 20px 0 rgba(0, 0, 0, 0.05); } .toastify-content--info { - background: #2488cb; } + background: #404040; } .toastify-content--success { background: #008577; } .toastify-content--warning { @@ -207,6 +207,7 @@ .toastify__body { color: white; + overflow-x: scroll; font-size: 15px; font-weight: 400; } diff --git a/client/coral-admin/src/components/ui/Header.css b/client/coral-admin/src/components/ui/Header.css index 7cb68ecbf..f85a7cdf9 100644 --- a/client/coral-admin/src/components/ui/Header.css +++ b/client/coral-admin/src/components/ui/Header.css @@ -40,12 +40,12 @@ height: 100%; } -.rightPanel ul { +.rightPanel > ul { list-style: none; margin-right: 20px; } -.rightPanel li { +.rightPanel > ul > li { display: inline-block; float: right; margin-left: 15px; @@ -54,6 +54,12 @@ line-height: 33px; } +.rightPanel a { + color: black; + text-decoration: none; + font-weight: 400; +} + .rightPanel .settings { line-height: 0; } @@ -77,7 +83,7 @@ letter-spacing: .8; &:hover { - background-color: #232323; + background-color: #404040; } &.active { diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index bbc78a321..ca1cc1f30 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -71,12 +71,24 @@ const CoralHeader = ({ showShortcuts(true)}>{t('configure.shortcuts')} - {t('configure.sign_out')} + + + View latest version + + + + + Report a bug or give feedback + + + + {t('configure.sign_out')} + -
  • - {`v${process.env.VERSION}`} +
  • + {`v${process.env.VERSION}`}
  • diff --git a/client/coral-admin/src/components/ui/Layout.css b/client/coral-admin/src/components/ui/Layout.css index 792440d97..a3cc7b7b6 100644 --- a/client/coral-admin/src/components/ui/Layout.css +++ b/client/coral-admin/src/components/ui/Layout.css @@ -1,6 +1,5 @@ .layout { max-width: 1280px; margin: 0 auto; - overflow: hidden; background-color: #FAFAFA; } diff --git a/client/coral-admin/src/components/ui/Layout.js b/client/coral-admin/src/components/ui/Layout.js index 11432e570..10433048e 100644 --- a/client/coral-admin/src/components/ui/Layout.js +++ b/client/coral-admin/src/components/ui/Layout.js @@ -10,7 +10,7 @@ const Layout = ({ toggleShortcutModal, restricted = false, ...props}) => ( - +
    { + const {userId, commentId, commentStatus, setUserStatus, setCommentStatus, hideBanUserDialog} = this.props; + await setUserStatus({userId, status: 'BANNED'}); + hideBanUserDialog(); + if (commentId && commentStatus && commentStatus !== 'REJECTED') { + await setCommentStatus({commentId, status: 'REJECTED'}); + } + } + + getInfo() { + let note = t('bandialog.note_ban_user'); + if (this.props.commentStatus && this.props.commentStatus !== 'REJECTED') { + note = t('bandialog.note_reject_comment'); + } + return t('bandialog.note', note); + } + + render() { + return ( + + ); + } +} + +const mapStateToProps = ({banUserDialog: {open, userId, username, commentId, commentStatus}}) => ({ + open, + userId, + username, + commentId, + commentStatus, +}); + +const mapDispatchToProps = (dispatch) => ({ + ...bindActionCreators({ + hideBanUserDialog, + }, dispatch), +}); + +export default compose( + withSetUserStatus, + withSetCommentStatus, + connect( + mapStateToProps, + mapDispatchToProps, + ), +)(BanUserDialogContainer); diff --git a/client/coral-admin/src/containers/Layout.js b/client/coral-admin/src/containers/Layout.js index 340a18e3b..dacf768c0 100644 --- a/client/coral-admin/src/containers/Layout.js +++ b/client/coral-admin/src/containers/Layout.js @@ -5,6 +5,8 @@ import {fetchConfig} from '../actions/config'; import AdminLogin from '../components/AdminLogin'; import {logout} from 'coral-framework/actions/auth'; import {FullLoading} from '../components/FullLoading'; +import BanUserDialog from './BanUserDialog'; +import SuspendUserDialog from './SuspendUserDialog'; import {toggleModal as toggleShortcutModal} from '../actions/moderation'; import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth'; import {can} from 'coral-framework/services/perms'; @@ -52,7 +54,11 @@ class LayoutContainer extends Component { handleLogout={handleLogout} toggleShortcutModal={toggleShortcutModal} {...this.props} - /> + > + + + {this.props.children} + ); } else if (loggedIn) { return ( diff --git a/client/coral-admin/src/containers/SuspendUserDialog.js b/client/coral-admin/src/containers/SuspendUserDialog.js new file mode 100644 index 000000000..bd95328a3 --- /dev/null +++ b/client/coral-admin/src/containers/SuspendUserDialog.js @@ -0,0 +1,83 @@ +import React, {Component} from 'react'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import SuspendUserDialog from '../components/SuspendUserDialog'; +import {hideSuspendUserDialog} from '../actions/suspendUserDialog'; +import {withSetCommentStatus, withSuspendUser} from 'coral-framework/graphql/mutations'; +import {compose, gql} from 'react-apollo'; +import * as notification from 'coral-admin/src/services/notification'; +import t, {timeago} from 'coral-framework/services/i18n'; +import withQuery from 'coral-framework/hocs/withQuery'; +import get from 'lodash/get'; + +class SuspendUserDialogContainer extends Component { + + suspendUser = async ({message, until}) => { + const {userId, username, commentStatus, commentId, hideSuspendUserDialog, setCommentStatus, suspendUser} = this.props; + hideSuspendUserDialog(); + try { + const result = await suspendUser({id: userId, message, until}); + if (result.data.suspendUser.errors) { + throw result.data.suspendUser.errors; + } + notification.success( + t('suspenduser.notify_suspend_until', username, timeago(until)), + ); + if (commentId && commentStatus && commentStatus !== 'REJECTED') { + return setCommentStatus({commentId, status: 'REJECTED'}) + .then((result) => { + if (result.data.setCommentStatus.errors) { + throw result.data.setCommentStatus.errors; + } + }); + } + } + catch(err) { + notification.showMutationErrors(err); + } + }; + + render() { + return ( + + ); + } +} + +const withOrganizationName = withQuery(gql` + query CoralAdmin_SuspendUserDialog { + settings { + organizationName + } + } +`); + +const mapStateToProps = ({suspendUserDialog: {open, userId, username, commentId, commentStatus}}) => ({ + open, + userId, + username, + commentId, + commentStatus, +}); + +const mapDispatchToProps = (dispatch) => ({ + ...bindActionCreators({ + hideSuspendUserDialog, + }, dispatch), +}); + +export default compose( + connect( + mapStateToProps, + mapDispatchToProps, + ), + withSuspendUser, + withSetCommentStatus, + withOrganizationName, +)(SuspendUserDialogContainer); diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 59a6d7c8f..915fe2989 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -1,6 +1,4 @@ import {add} from 'coral-framework/services/graphqlRegistry'; -import update from 'immutability-helper'; -const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected']; const extension = { mutations: { @@ -10,58 +8,6 @@ const extension = { RejectUsername: () => ({ refetchQueries: ['CoralAdmin_Community'], }), - SetCommentStatus: ({variables: {commentId, status}}) => ({ - updateQueries: { - CoralAdmin_Moderation: (prev) => { - const comment = queues.reduce((comment, queue) => { - return comment ? comment : prev[queue].nodes.find((c) => c.id === commentId); - }, null); - - let acceptedNodes = prev.accepted.nodes; - let acceptedCount = prev.acceptedCount; - let rejectedNodes = prev.rejected.nodes; - let rejectedCount = prev.rejectedCount; - - if (status !== comment.status) { - if (status === 'ACCEPTED') { - comment.status = 'ACCEPTED'; - acceptedCount++; - acceptedNodes = [comment, ...acceptedNodes]; - } - else if (status === 'REJECTED') { - comment.status = 'REJECTED'; - rejectedCount++; - rejectedNodes = [comment, ...rejectedNodes]; - } - } - - const premodNodes = prev.premod.nodes.filter((c) => c.id !== commentId); - const flaggedNodes = prev.flagged.nodes.filter((c) => c.id !== commentId); - const premodCount = premodNodes.length < prev.premod.nodes.length ? prev.premodCount - 1 : prev.premodCount; - const flaggedCount = flaggedNodes.length < prev.flagged.nodes.length ? prev.flaggedCount - 1 : prev.flaggedCount; - - if (status === 'REJECTED') { - acceptedNodes = prev.accepted.nodes.filter((c) => c.id !== commentId); - acceptedCount = acceptedNodes.length < prev.accepted.nodes.length ? prev.acceptedCount - 1 : prev.acceptedCount; - } - else if (status === 'ACCEPTED') { - rejectedNodes = prev.rejected.nodes.filter((c) => c.id !== commentId); - rejectedCount = rejectedNodes.length < prev.rejected.nodes.length ? prev.rejectedCount - 1 : prev.rejectedCount; - } - - return update(prev, { - premodCount: {$set: Math.max(0, premodCount)}, - flaggedCount: {$set: Math.max(0, flaggedCount)}, - acceptedCount: {$set: Math.max(0, acceptedCount)}, - rejectedCount: {$set: Math.max(0, rejectedCount)}, - premod: {nodes: {$set: premodNodes}}, - flagged: {nodes: {$set: flaggedNodes}}, - accepted: {nodes: {$set: acceptedNodes}}, - rejected: {nodes: {$set: rejectedNodes}}, - }); - } - } - }), }, }; diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js new file mode 100644 index 000000000..331fd876a --- /dev/null +++ b/client/coral-admin/src/graphql/utils.js @@ -0,0 +1,137 @@ +import update from 'immutability-helper'; +import * as notification from 'coral-admin/src/services/notification'; + +const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected']; +const limit = 10; + +const ascending = (a, b) => { + const dateA = new Date(a.created_at); + const dateB = new Date(b.created_at); + if (dateA < dateB) { return -1; } + if (dateA > dateB) { return 1; } + return 0; +}; + +const descending = (a, b) => -ascending(a, b); + +function queueHasComment(root, queue, id) { + return root[queue].nodes.find((c) => c.id === id); +} + +function removeCommentFromQueue(root, queue, id) { + if (!queueHasComment(root, queue, id)) { + return root; + } + return update(root, { + [`${queue}Count`]: {$set: root[`${queue}Count`] - 1}, + [queue]: { + nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)}, + }, + }); +} + +function shouldCommentBeAdded(root, queue, comment, sort) { + if (root[`${queue}Count`] < limit) { + + // Adding all comments until first limit has reached. + return true; + } + const cursor = new Date(root[queue].endCursor); + return sort === 'CHRONOLOGICAL' + ? new Date(comment.created_at) <= cursor + : new Date(comment.created_at) >= cursor; +} + +function addCommentToQueue(root, queue, comment, sort) { + if (queueHasComment(root, queue, comment.id)) { + return root; + } + + const sortAlgo = sort === 'CHRONOLOGICAL' ? ascending : descending; + const changes = { + [`${queue}Count`]: {$set: root[`${queue}Count`] + 1}, + }; + + if (shouldCommentBeAdded(root, queue, comment, sort)) { + const nodes = root[queue].nodes.concat(comment).sort(sortAlgo); + changes[queue] = { + nodes: {$set: nodes}, + startCursor: {$set: nodes[0].created_at}, + endCursor: {$set: nodes[nodes.length - 1].created_at}, + }; + } + + return update(root, changes); +} + +function getCommentQueues(comment) { + const queues = ['all']; + if (comment.status === 'ACCEPTED') { + queues.push('accepted'); + } + else if (comment.status === 'REJECTED') { + queues.push('rejected'); + } + else if (comment.status === 'PREMOD') { + queues.push('premod'); + } + if ( + ['NONE', 'PREMOD'].indexOf(comment.status) >= 0 + && comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction') + ) { + queues.push('flagged'); + } + return queues; +} + +/** + * Assimilate comment changes into current store. + * @param {Object} root current state of the store + * @param {Object} comment comment that was changed + * @param {string} sort current sort order of the queues + * @param {Object} [notify] show know notifications if set + * @param {string} notify.activeQueue current active queue + * @param {string} notify.text notification text to show + * @param {bool} notify.anyQueue if true show the notification when the comment is shown + * in the current active queue besides the 'all' queue. + * @return {Object} next state of the store + */ +export function handleCommentChange(root, comment, sort, notify) { + let next = root; + const nextQueues = getCommentQueues(comment); + + let notificationShown = false; + const showNotificationOnce = () => { + if (notificationShown) { + return; + } + notification.info(notify.text); + notificationShown = true; + }; + + queues.forEach((queue) => { + if (nextQueues.indexOf(queue) >= 0) { + if (!queueHasComment(next, queue, comment.id)) { + next = addCommentToQueue(next, queue, comment, sort); + if (notify && notify.activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) { + showNotificationOnce(comment); + } + } + } else if(queueHasComment(next, queue, comment.id)){ + next = removeCommentFromQueue(next, queue, comment.id); + if (notify && notify.activeQueue === queue) { + showNotificationOnce(comment); + } + } + + if ( + notify + && (queue === 'all' || notify.anyQueue) + && queueHasComment(next, queue, comment.id) + && notify.activeQueue === queue + ) { + showNotificationOnce(comment); + } + }); + return next; +} diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index 8cd7685f0..29df3753c 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -1,8 +1,9 @@ import React from 'react'; import {render} from 'react-dom'; import {ApolloProvider} from 'react-apollo'; +import smoothscroll from 'smoothscroll-polyfill'; -import {client} from './services/client'; +import {getClient} from './services/client'; import store from './services/store'; import App from './components/App'; @@ -13,9 +14,10 @@ import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/he loadPluginsTranslations(); injectPluginsReducers(); +smoothscroll.polyfill(); render( - + , document.querySelector('#root') diff --git a/client/coral-admin/src/reducers/banUserDialog.js b/client/coral-admin/src/reducers/banUserDialog.js new file mode 100644 index 000000000..1d66339ec --- /dev/null +++ b/client/coral-admin/src/reducers/banUserDialog.js @@ -0,0 +1,30 @@ +import {SHOW_BAN_USER_DIALOG, HIDE_BAN_USER_DIALOG} from '../constants/banUserDialog'; + +const initialState = { + open: false, + userId: null, + username: '', + commentId: null, + commentStatus: '', +}; + +export default function banUserDialog(state = initialState, action) { + switch (action.type) { + case SHOW_BAN_USER_DIALOG: + return { + ...state, + open: true, + userId: action.userId, + username: action.username, + commentId: action.commentId, + commentStatus: action.commentStatus, + }; + case HIDE_BAN_USER_DIALOG: + return { + ...state, + open: false, + }; + default: + return state; + } +} diff --git a/client/coral-admin/src/reducers/community.js b/client/coral-admin/src/reducers/community.js index 7127c6fbe..3fba504f8 100644 --- a/client/coral-admin/src/reducers/community.js +++ b/client/coral-admin/src/reducers/community.js @@ -9,8 +9,8 @@ import { SET_COMMENTER_STATUS, SHOW_BANUSER_DIALOG, HIDE_BANUSER_DIALOG, - SHOW_SUSPENDUSER_DIALOG, - HIDE_SUSPENDUSER_DIALOG + SHOW_REJECT_USERNAME_DIALOG, + HIDE_REJECT_USERNAME_DIALOG } from '../constants/community'; const initialState = Map({ @@ -24,7 +24,7 @@ const initialState = Map({ pagePeople: 0, user: Map({}), banDialog: false, - suspendDialog: false + rejectUsernameDialog: false }); export default function community (state = initialState, action) { @@ -79,14 +79,14 @@ export default function community (state = initialState, action) { user: Map(action.user), banDialog: true }); - case HIDE_SUSPENDUSER_DIALOG: + case HIDE_REJECT_USERNAME_DIALOG: return state - .set('suspendDialog', false); - case SHOW_SUSPENDUSER_DIALOG: + .set('rejectUsernameDialog', false); + case SHOW_REJECT_USERNAME_DIALOG: return state .merge({ user: Map(action.user), - suspendDialog: true + rejectUsernameDialog: true }); default : return state; diff --git a/client/coral-admin/src/reducers/index.js b/client/coral-admin/src/reducers/index.js index 04c8b6590..f94b50817 100644 --- a/client/coral-admin/src/reducers/index.js +++ b/client/coral-admin/src/reducers/index.js @@ -5,9 +5,13 @@ import community from './community'; import moderation from './moderation'; import install from './install'; import config from './config'; +import banUserDialog from './banUserDialog'; +import suspendUserDialog from './suspendUserDialog'; export default { auth, + banUserDialog, + suspendUserDialog, assets, settings, community, diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index 1b95bdb90..a2c8975ff 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -1,57 +1,23 @@ -import {fromJS, Map, Set} from 'immutable'; +import {fromJS, Set} from 'immutable'; import * as actions from '../constants/moderation'; const initialState = fromJS({ singleView: false, modalOpen: false, - user: {}, - commentId: null, - commentStatus: null, userDetailId: null, userDetailActiveTab: 'all', userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'], userDetailSelectedIds: new Set(), - banDialog: false, + storySearchVisible: false, + storySearchString: '', shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show', sortOrder: 'REVERSE_CHRONOLOGICAL', - suspendUserDialog: { - show: false, - userId: null, - username: '', - commentId: null, - commentStatus: '', - }, }); export default function moderation (state = initialState, action) { switch (action.type) { - case actions.HIDE_BANUSER_DIALOG: - return state - .set('banDialog', false) - .set('commentStatus', null); - case actions.SHOW_BANUSER_DIALOG: - return state - .merge({ - user: Map(action.user), - commentId: action.commentId, - commentStatus: action.commentStatus, - showRejectedNote: action.showRejectedNote, - banDialog: true - }); - case actions.SHOW_SUSPEND_USER_DIALOG: - return state - .mergeDeep({ - suspendUserDialog: { - show: true, - userId: action.userId, - username: action.username, - commentId: action.commentId, - commentStatus: action.commentStatus, - } - }); - case actions.HIDE_SUSPEND_USER_DIALOG: - return state - .setIn(['suspendUserDialog', 'show'], false); + case actions.MODERATION_CLEAR_STATE: + return initialState; case actions.SET_ACTIVE_TAB: return state .set('activeTab', action.activeTab); @@ -80,6 +46,12 @@ export default function moderation (state = initialState, action) { return state.update('userDetailSelectedIds', (set) => set.add(action.id)); case actions.UNSELECT_USER_DETAIL_COMMENT: return state.update('userDetailSelectedIds', (set) => set.delete(action.id)); + case actions.SHOW_STORY_SEARCH: + return state.set('storySearchVisible', true); + case actions.HIDE_STORY_SEARCH: + return state.set('storySearchVisible', false); + case actions.STORY_SEARCH_CHANGE_VALUE: + return state.set('storySearchString', action.value); case actions.SET_SORT_ORDER: return state.set('sortOrder', action.order); default : diff --git a/client/coral-admin/src/reducers/suspendUserDialog.js b/client/coral-admin/src/reducers/suspendUserDialog.js new file mode 100644 index 000000000..733dd337b --- /dev/null +++ b/client/coral-admin/src/reducers/suspendUserDialog.js @@ -0,0 +1,30 @@ +import {SHOW_SUSPEND_USER_DIALOG, HIDE_SUSPEND_USER_DIALOG} from '../constants/suspendUserDialog'; + +const initialState = { + open: false, + userId: null, + username: '', + commentId: null, + commentStatus: '', +}; + +export default function suspendUserDialog(state = initialState, action) { + switch (action.type) { + case SHOW_SUSPEND_USER_DIALOG: + return { + ...state, + open: true, + userId: action.userId, + username: action.username, + commentId: action.commentId, + commentStatus: action.commentStatus, + }; + case HIDE_SUSPEND_USER_DIALOG: + return { + ...state, + open: false, + }; + default: + return state; + } +} diff --git a/client/coral-admin/src/routes/Community/components/ActionButton.js b/client/coral-admin/src/routes/Community/components/ActionButton.js index 67f16acad..b0b68b063 100644 --- a/client/coral-admin/src/routes/Community/components/ActionButton.js +++ b/client/coral-admin/src/routes/Community/components/ActionButton.js @@ -1,23 +1,18 @@ import React from 'react'; import styles from './Community.css'; -import BanUserButton from './BanUserButton'; import {Button} from 'coral-ui'; import {menuActionsMap} from '../../Moderation/helpers/moderationQueueActionsMap'; import t from 'coral-framework/services/i18n'; const ActionButton = ({type = '', user, ...props}) => { - if (type === 'BAN') { - return props.showBanUserDialog(user)} />; - } - return ( ); diff --git a/client/coral-admin/src/routes/Community/components/BanUserButton.css b/client/coral-admin/src/routes/Community/components/BanUserButton.css deleted file mode 100644 index 6e8e50f71..000000000 --- a/client/coral-admin/src/routes/Community/components/BanUserButton.css +++ /dev/null @@ -1,11 +0,0 @@ -.banButton { - -webkit-transform: scale(.8); - transform: scale(.8); - margin: 0; - - i { - vertical-align: middle; - margin-right: 5px; - font-size: 14px; - } -} diff --git a/client/coral-admin/src/routes/Community/components/BanUserButton.js b/client/coral-admin/src/routes/Community/components/BanUserButton.js deleted file mode 100644 index cc4935204..000000000 --- a/client/coral-admin/src/routes/Community/components/BanUserButton.js +++ /dev/null @@ -1,24 +0,0 @@ -import React, {PropTypes} from 'react'; -import {Button, Icon} from 'coral-ui'; -import styles from './BanUserButton.css'; - -import t from 'coral-framework/services/i18n'; - -const BanUserButton = ({user, ...props}) => ( -
    - -
    -); - -BanUserButton.propTypes = { - onClick: PropTypes.func.isRequired -}; - -export default BanUserButton; diff --git a/client/coral-admin/src/routes/Community/components/BanUserDialog.css b/client/coral-admin/src/routes/Community/components/BanUserDialog.css deleted file mode 100644 index a46b9da32..000000000 --- a/client/coral-admin/src/routes/Community/components/BanUserDialog.css +++ /dev/null @@ -1,164 +0,0 @@ -.dialog { - border: none; - box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2); - width: 500px; - top: 50%; - transform: translateY(-50%); - height: 184px; - padding: 20px; - - h2 { - color: black; - font-size: 1.76em; - font-weight: 500; - margin: 0; - } - - h3 { - color: black; - font-size: 1.4em; - font-weight: 500; - margin: 0; - } -} - -.textField { - margin-top: 15px; -} - -.textField label { - font-size: 1.08em; - font-weight: bold; - margin-bottom: 5px; -} - -.textField input { - width: 100%; - display: block; - border: none; - outline: none; - border: 1px solid rgba(0,0,0,.12); - padding: 10px 6px; - box-sizing: border-box; - border-radius: 2px; - margin: 5px auto; -} - -.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-right: 10px; - width: 47%; -} - -.ban { - width: 47%; -} - -.buttons { - margin: 20px 0; -} diff --git a/client/coral-admin/src/routes/Community/components/BanUserDialog.js b/client/coral-admin/src/routes/Community/components/BanUserDialog.js deleted file mode 100644 index 26a885e8b..000000000 --- a/client/coral-admin/src/routes/Community/components/BanUserDialog.js +++ /dev/null @@ -1,51 +0,0 @@ -import React, {PropTypes} from 'react'; -import {Dialog} from 'coral-ui'; -import styles from './BanUserDialog.css'; - -import Button from 'coral-ui/components/Button'; - -import t from 'coral-framework/services/i18n'; - -const BanUserDialog = ({open, handleClose, handleBanUser, user}) => ( - - × -
    -
    -

    {t('community.ban_user')}

    -
    -
    -

    {t('community.are_you_sure', user.username)}

    - {t('community.note')} -
    -
    - - -
    -
    -
    -); - -BanUserDialog.propTypes = { - handleBanUser: PropTypes.func.isRequired, - handleClose: PropTypes.func.isRequired, - user: PropTypes.object.isRequired, -}; - -export default BanUserDialog; diff --git a/client/coral-admin/src/routes/Community/components/Community.css b/client/coral-admin/src/routes/Community/components/Community.css index 28551c8a0..e65c34514 100644 --- a/client/coral-admin/src/routes/Community/components/Community.css +++ b/client/coral-admin/src/routes/Community/components/Community.css @@ -175,7 +175,7 @@ right: 0; height: 100%; top: 0; - padding: 40px 18px; + padding: 50px 18px; box-sizing: border-box; } @@ -310,24 +310,33 @@ .flaggedByCount { display: block; text-align: left; + margin-top: 5px; +} + +.flaggedByCount i { + font-size: 14px; + margin-right: 10px; } .flaggedBy { display: inline; padding: 3px; - font-size: 16px; + font-size: 14px; + margin-left: 5px; } .flaggedByLabel { - font-weight: bold; + font-weight: 600; font-size: 14px; } .flaggedReasons { - padding-top: 15px; margin-left: 24px; + margin-top: 10px; } -.flaggedByReason { +p.flaggedByReason { font-size: 1tpx; + margin: 0px; + line-height: 1.4; } diff --git a/client/coral-admin/src/routes/Community/components/Community.js b/client/coral-admin/src/routes/Community/components/Community.js index f6544b0ac..e87e7ac54 100644 --- a/client/coral-admin/src/routes/Community/components/Community.js +++ b/client/coral-admin/src/routes/Community/components/Community.js @@ -1,10 +1,9 @@ import React, {Component} from 'react'; import CommunityMenu from './CommunityMenu'; -import BanUserDialog from './BanUserDialog'; -import SuspendUserDialog from './SuspendUserDialog'; import People from './People'; import FlaggedAccounts from './FlaggedAccounts'; +import RejectUsernameDialog from './RejectUsernameDialog'; export default class Community extends Component { @@ -80,19 +79,15 @@ export default class Community extends Component { - - diff --git a/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js b/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js index df6368844..3e730e0ac 100644 --- a/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js +++ b/client/coral-admin/src/routes/Community/components/FlaggedAccounts.js @@ -9,8 +9,6 @@ const FlaggedAccounts = ({...props}) => { const {commenters} = props; const hasResults = commenters && !!commenters.length; -// if (commenter.status === 'PENDING' && commenter.actions.length > 0) { - return (
    @@ -24,8 +22,9 @@ const FlaggedAccounts = ({...props}) => { modActionButtons={['APPROVE', 'REJECT']} showBanUserDialog={props.showBanUserDialog} showSuspendUserDialog={props.showSuspendUserDialog} + showRejectUsernameDialog={props.showRejectUsernameDialog} approveUser={props.approveUser} - suspendUser={props.suspendUser} + currentUser={props.currentUser} />; }) : {t('community.no_flagged_accounts')} diff --git a/client/coral-admin/src/routes/Community/components/SuspendUserDialog.css b/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.css similarity index 100% rename from client/coral-admin/src/routes/Community/components/SuspendUserDialog.css rename to client/coral-admin/src/routes/Community/components/RejectUsernameDialog.css diff --git a/client/coral-admin/src/routes/Community/components/SuspendUserDialog.js b/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js similarity index 71% rename from client/coral-admin/src/routes/Community/components/SuspendUserDialog.js rename to client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js index 0db31ccc1..3ff764b2b 100644 --- a/client/coral-admin/src/routes/Community/components/SuspendUserDialog.js +++ b/client/coral-admin/src/routes/Community/components/RejectUsernameDialog.js @@ -1,30 +1,30 @@ import React, {Component, PropTypes} from 'react'; import {Dialog, Button} from 'coral-ui'; -import styles from './SuspendUserDialog.css'; +import styles from './RejectUsernameDialog.css'; import t from 'coral-framework/services/i18n'; const stages = [ { - title: 'suspenduser.title_reject', - description: 'suspenduser.description_reject', + title: 'reject_username.title_reject', + description: 'reject_username.description_reject', options: { - 'j': 'suspenduser.no_cancel', - 'k': 'suspenduser.yes_suspend' + 'j': 'reject_username.no_cancel', + 'k': 'reject_username.yes_suspend' } }, { - title: 'suspenduser.title_notify', - description: 'suspenduser.description_notify', + title: 'reject_username.title_notify', + description: 'reject_username.description_notify', options: { 'j': 'bandialog.cancel', - 'k': 'suspenduser.send' + 'k': 'reject_username.send' } } ]; -class SuspendUserDialog extends Component { +class RejectUsernameDialog extends Component { state = {email: '', stage: 0} @@ -35,7 +35,7 @@ class SuspendUserDialog extends Component { } componentDidMount() { - this.setState({email: t('suspenduser.email_message_reject'), about: t('suspenduser.username')}); + this.setState({email: t('reject_username.email_message_reject'), about: t('reject_username.username')}); } /* @@ -72,22 +72,22 @@ class SuspendUserDialog extends Component { return + title={t('reject_username.suspend_user')}>
    - {t(stages[stage].title, t('suspenduser.username'))} + {t(stages[stage].title, t('reject_username.username'))}
    - {t(stages[stage].description, t('suspenduser.username'))} + {t(stages[stage].description, t('reject_username.username'))}
    { stage === 1 &&
    -
    {t('suspenduser.write_message')}
    +
    {t('reject_username.write_message')}