diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 66ae8ade8..72c75ce27 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,9 +1,10 @@
# Contributor's Guide
-Welcome! We are very excited that you are interested in contributing to Talk.
+Welcome! We are very excited that you are interested in contributing to Talk.
This document is a companion to help you approach contributing. If it does not do so, please [let us know how we can improve it](https://github.com/coralproject/talk/issues)!
+By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
## Product Roadmap
@@ -13,7 +14,7 @@ You can view product ideas and our longer term roadmap here https://trello.com/b
## Contribute to the documentation
-Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
+Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
We are looking for _documentarians_ to:
@@ -40,7 +41,7 @@ Talk is designed to integrate into existing environments in a variety of ways:
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 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.
+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.
## Write some code
@@ -48,9 +49,9 @@ First, [set up a dev environment](https://github.com/coralproject/talk/blob/mast
### Build a New Feature / Plugin
-Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
+Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
-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.
+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.
To get an idea of where the Coral Team is going, see:
@@ -66,13 +67,10 @@ Examples:
### Work on the Core
-There is always more work to be done to make an application more stable, scaleable and secure.
+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).
-
-
-
diff --git a/INSTALL.md b/INSTALL.md
index f5f82f294..008c4cba3 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -1,95 +1,94 @@
-# Installing a dev environment
-
-By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
+# Installation
## Requirements
### System
-- Any flavor of Linux, OSX or Windows
+- Any flavour of Linux, OSX or Windows
- 1GB memory (minimum)
- 5GB storage (minimum)
-### Software
+## Installation From Source
-* [Node](https://nodejs.org/es/download/package-manager) v7 or later
-* Mongo v3.2 or later
-* Redis v3.2 or later
+### Requirements
+
+There are some runtime requirements for running Talk from source:
+
+- [Node](https://nodejs.org/) v7 or later
+- [MongoDB](https://www.mongodb.com/) v3.2 or later
+- [Redis](https://redis.io/) v3.2 or later
+- [Yarn](https://yarnpkg.com/) v0.19.1 or later
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
-## First time setup
+### Installing
-### Installation
+```bash
+# Download the tarball containing the repository
+curl -L https://github.com/coralproject/talk/tarball/master -o coralproject-talk.tar.gz
-Navigate to a directory.
+# Untar that file and change to that directory
+tar xpf coralproject-talk.tar.gz
+mv coralproject-talk-* coralproject-talk
+cd coralproject-talk
-```
-git clone https://github.com/coralproject/talk
-cd talk
-yarn install
-```
+# Install package dependancies
+yarn
-### Environmental Variables
-
-Talk uses environmental variables for configuration. You can learn about them in the [README file](README.md).
-
-
-## Workflows
-
-### The server
-
-Starting the server:
-
-```
-yarn start
-```
-
-Browse to `http://localhost:3000` (or your custom port.)
-
-### Building the front end
-
-Our build process will build all front end components registered [here](https://github.com/coralproject/talk/blob/6052cac1d3494f8060325a88bb2ce03c88c2f94c/webpack.config.dev.js#L9-L15).
-
-One time build:
-
-```
+# Build static files
yarn build
```
-Build, then rebuild when a file is updated (development build):
+### Running
-```
-yarn build-watch
+Refer to the `README.md` file for required configuration variables to add to the
+environment.
+
+You can start the server after configuring the server using the command:
+
+```bash
+yarn start
```
+You can see other scripts we've made available by consulting the `package.json`
+file under the `scripts` key including:
-### Testing
+- `yarn test` run unit tests
+- `yarn e2e` run end to end tests
+- `yarn build-watch` watch for changes to client files and build static assets
+- `yarn dev-start` watch for changes to server files and reload the server
-Run all tests once:
+## Installation From Docker Hub
-`
-yarn test
-`
+### Requirements
-Run our end to end tests (will install Selenium and nightwatch):
+There are some runtime requirements for running Talk for Docker:
-`
-yarn e2e
-`
+- [MongoDB](https://www.mongodb.com/) v3.2 or later
+- [Redis](https://redis.io/) v3.2 or later
+- [Docker](https://www.docker.com/) v1.13.0 or later
+- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later
-_Please ensure all tests are passing before submitting a PR!_
+_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
-## Troubleshooting
+### Installing
+```bash
+# Create a directory for talk
+mkdir coralproject-talk
+cd coralproject-talk
-##### Can't ping the redis server!
+# Download the docker-compose.yml file from the repository
+curl -LO https://raw.githubusercontent.com/coralproject/talk/master/docker-compose.yml
+```
-- Check that Redis Server is running.
-- Check that TALK_REDIS_URL is set.
-
-##### Authenticaiton doesn't work!
-
-- Make sure Redis is the correct version.
+At this stage, you should refer to the `README.md` file for required
+configuration variables to add to the environment key for the `talk` service
+listed in the `docker-compose.yml` file.
+### Running
+```bash
+# Start the services using compose
+docker-compose up -d
+```
diff --git a/README.md b/README.md
index 274fd7f3d..f47fbd2f6 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CO
To set up a development environment or build from source, see [INSTALL.md](https://github.com/coralproject/talk/blob/master/INSTALL.md).
-To launch a Talk server of your own from your browser without any need to muck about in a terminal or think about engineering concepts, stay tuned. We will launch [our installer](https://github.com/coralproject/talk-install) shortly!!
+To launch a Talk server of your own from your browser without any need to muck about in a terminal or think about engineering concepts, stay tuned. We will launch [our installer](https://github.com/coralproject/talk-install) shortly!
### Configuration
@@ -37,6 +37,7 @@ Facebook Login enabled app.
- `TALK_SMTP_PASSWORD` (*required for email*) - password for the SMTP provider you are using.
- `TALK_SMTP_HOST` (*required for email*) - SMTP host url with format `smtp.domain.com`.
- `TALK_SMTP_PORT` (*required for email*) - SMTP port.
+- `TALK_INSTALL_LOCK` (_optional for dynamic setup_) - Defaults to `FALSE`. When `TRUE`, disables the dynamic setup endpoint.
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
alternative methods of loading configuration during development.
diff --git a/app.js b/app.js
index c04e0cb4a..c2601ad5a 100644
--- a/app.js
+++ b/app.js
@@ -94,6 +94,11 @@ if (app.get('env') !== 'production') {
endpointURL: '/api/v1/graph/ql'
}));
+ // GraphQL documention.
+ app.get('/admin/docs', (req, res) => {
+ res.render('admin/docs');
+ });
+
}
//==============================================================================
diff --git a/bin/cli b/bin/cli
index 5b52d96b9..c0690e455 100755
--- a/bin/cli
+++ b/bin/cli
@@ -4,7 +4,7 @@
* Module dependencies.
*/
-const util = require('../util');
+// const util = require('./util');
const program = require('./commander');
program
@@ -15,18 +15,14 @@ program
.command('users', 'work with the application auth')
.parse(process.argv);
-// If there is no command listed, output help.
-if (!process.argv.slice(2).length) {
- program.outputHelp();
- return;
-}
-
-// The ensures that the child process that is created here is always cleaned up
-// properly when the parent process dies.
-util.onshutdown([
- (signal) => {
- if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
- program.runningCommand.kill(signal);
- }
+/**
+ * When this provess exists, check to see if we have a running command, if we do
+ * check to see if it is still running. If it is, then kill it with a SIGINT
+ * signal. This is for the use case where we want to kill the process that is
+ * labled with the PID written out by the parent process.
+ */
+process.once('exit', () => {
+ if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
+ program.runningCommand.kill('SIGINT');
}
-]);
+});
diff --git a/bin/cli-assets b/bin/cli-assets
index 76ca5735b..166152baf 100755
--- a/bin/cli-assets
+++ b/bin/cli-assets
@@ -10,7 +10,7 @@ const Table = require('cli-table');
const AssetModel = require('../models/asset');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
-const util = require('../util');
+const util = require('./util');
// Register the shutdown criteria.
util.onshutdown([
diff --git a/bin/cli-jobs b/bin/cli-jobs
index 4d348fabb..f22e1068b 100755
--- a/bin/cli-jobs
+++ b/bin/cli-jobs
@@ -7,7 +7,7 @@
const program = require('./commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
-const util = require('../util');
+const util = require('./util');
const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
diff --git a/bin/cli-serve b/bin/cli-serve
index 0427dcf34..08b451904 100755
--- a/bin/cli-serve
+++ b/bin/cli-serve
@@ -2,13 +2,12 @@
const app = require('../app');
const program = require('./commander');
-const debug = require('debug')('talk:server');
const http = require('http');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const kue = require('../services/kue');
const mongoose = require('../services/mongoose');
-const util = require('../util');
+const util = require('./util');
/**
* Get port from environment and store in Express.
@@ -79,7 +78,7 @@ function onListening() {
let bind = typeof addr === 'string'
? `pipe ${ addr}`
: `port ${ addr.port}`;
- debug(`Listening on ${ bind}`);
+ console.log(`Listening on ${ bind}`);
}
/**
diff --git a/bin/cli-setup b/bin/cli-setup
index eaa2b90b1..185b04f01 100755
--- a/bin/cli-setup
+++ b/bin/cli-setup
@@ -9,7 +9,10 @@ const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingModel = require('../models/setting');
const SettingsService = require('../services/settings');
-const util = require('../util');
+const SetupService = require('../services/setup');
+const UsersService = require('../services/users');
+const util = require('./util');
+const errors = require('../errors');
// Register the shutdown criteria.
util.onshutdown([
@@ -29,60 +32,165 @@ program
// Setup the application
//==============================================================================
-SettingsService
- .init()
- .then((settings) => {
- if (program.defaults) {
- return settings.save();
- }
+const performSetup = () => {
- 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];
- }
+ 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);
});
+ }
- return settings.save();
+ // Get the current settings, we are expecing an error here.
+ return SettingsService
+ .retrieve()
+ .then(() => {
+
+ // We should NOT have gotten a settings object, this means that the
+ // application is already setup. Error out here.
+ throw errors.ErrSettingsInit;
+
+ })
+ .catch((err) => {
+
+ // If the error is `not init`, then we're good, otherwise, it's something
+ // else.
+ if (err !== errors.ErrSettingsNotInit) {
+ throw err;
+ }
+
+ })
+ .then(() => {
+
+ // Create the base settings model.
+ let settings = new SettingModel();
+
+ 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: 'displayName',
+ message: 'Display Name',
+ filter: (displayName) => {
+ return UsersService
+ .isValidDisplayName(displayName, 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'));
+ }
+
+ return SetupService.setup({
+ settings: settings.toObject(),
+ user: {
+ email: user.email,
+ displayName: user.displayName,
+ password: user.password
+ }
+ });
+ });
+ })
+ .then(({user}) => {
+ console.log('Settings created.');
+ console.log(`User ${user.id} created.`);
+ console.log('\nTalk is now installed!');
+ util.shutdown();
+ })
+ .catch((err) => {
+ console.error(err);
+ util.shutdown(1);
});
- })
- .then(() => {
- console.log('Talk is now installed!');
- util.shutdown();
- })
- .catch((err) => {
- console.error(err);
- util.shutdown(1);
- });
+};
+
+// Start tthe setup process.
+performSetup();
diff --git a/bin/cli-users b/bin/cli-users
index 072a143ee..01f46ad2e 100755
--- a/bin/cli-users
+++ b/bin/cli-users
@@ -9,7 +9,7 @@ const inquirer = require('inquirer');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const mongoose = require('../services/mongoose');
-const util = require('../util');
+const util = require('./util');
const Table = require('cli-table');
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
diff --git a/bin/commander.js b/bin/commander.js
index 2a3d1e363..ad346a911 100644
--- a/bin/commander.js
+++ b/bin/commander.js
@@ -2,7 +2,6 @@ const pkg = require('../package.json');
const dotenv = require('dotenv');
const fs = require('fs');
const program = require('commander');
-const util = require('../util');
// Perform rewrites to the runtime environment variables based on the contents
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
@@ -27,6 +26,10 @@ const parseArgs = require('minimist')(process.argv.slice(2), {
}
});
+/**
+ * If the config flag is present, then we have to load the configuration from
+ * the file specified. We will then load those values into the environment.
+ */
if (parseArgs.config) {
let envConfig = dotenv.parse(fs.readFileSync(parseArgs.config, {encoding: 'utf8'}));
@@ -35,8 +38,15 @@ if (parseArgs.config) {
});
}
-// If the `--pid` flag is used, put the current pid there.
+/**
+ * If the pid flag is present, then we have to create a pid file at the location
+ * specified.
+ */
if (parseArgs.pid) {
+ const util = require('./util');
+
+ console.log('Wrote PID');
+
util.pid(parseArgs.pid);
}
diff --git a/util.js b/bin/util.js
similarity index 100%
rename from util.js
rename to bin/util.js
diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js
index 9c0f271cc..9721123fa 100644
--- a/client/coral-admin/src/AppRouter.js
+++ b/client/coral-admin/src/AppRouter.js
@@ -1,21 +1,25 @@
import React from 'react';
import {Router, Route, IndexRoute, browserHistory} from 'react-router';
-import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
-import CommentStream from 'containers/CommentStream/CommentStream';
-import Configure from 'containers/Configure/Configure';
import Streams from 'containers/Streams/Streams';
-import CommunityContainer from 'containers/Community/CommunityContainer';
+import Configure from 'containers/Configure/Configure';
import LayoutContainer from 'containers/LayoutContainer';
+import CommentStream from 'containers/CommentStream/CommentStream';
+import InstallContainer from 'containers/Install/InstallContainer';
+import CommunityContainer from 'containers/Community/CommunityContainer';
+import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
const routes = (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
const AppRouter = () => ;
diff --git a/client/coral-admin/src/actions/install.js b/client/coral-admin/src/actions/install.js
new file mode 100644
index 000000000..a74a1929f
--- /dev/null
+++ b/client/coral-admin/src/actions/install.js
@@ -0,0 +1,109 @@
+import coralApi from 'coral-framework/helpers/response';
+import * as actions from '../constants/install';
+import validate from 'coral-framework/helpers/validate';
+import errorMsj from 'coral-framework/helpers/error';
+
+export const nextStep = () => ({type: actions.NEXT_STEP});
+export const previousStep = () => ({type: actions.PREVIOUS_STEP});
+export const goToStep = step => ({type: actions.GO_TO_STEP, step});
+
+const installRequest = () => ({type: actions.INSTALL_REQUEST});
+const installSuccess = () => ({type: actions.INSTALL_SUCCESS});
+const installFailure = error => ({type: actions.INSTALL_FAILURE, error});
+
+const addError = (name, error) => ({type: actions.ADD_ERROR, name, error});
+const hasError = error => ({type: actions.HAS_ERROR, error});
+const clearErrors = () => ({type: actions.CLEAR_ERRORS});
+
+const validation = (formData, dispatch, next) => {
+ if (!(formData != null)) {
+ return dispatch(hasError());
+ }
+
+ // Required Validation
+ const empty = Object.keys(formData).filter(name => {
+ const cond = !formData[name].length;
+
+ if (cond) {
+
+ // Adding Error
+ dispatch(addError(name, 'This field is required.'));
+ } else {
+ dispatch(addError(name, ''));
+ }
+
+ return cond;
+ });
+
+ if (empty.length) {
+ return dispatch(hasError());
+ }
+
+ // RegExp Validation
+ const validation = Object.keys(formData).filter(name => {
+ const cond = !validate[name](formData[name]);
+ if (cond) {
+
+ // Adding Error
+ dispatch(addError(name, errorMsj[name]));
+ } else {
+ dispatch(addError(name, ''));
+ }
+
+ return cond;
+ });
+
+ if (validation.length) {
+ return dispatch(hasError());
+ }
+
+ dispatch(clearErrors());
+ next();
+};
+
+export const submitSettings = () => (dispatch, getState) => {
+ const settingsFormData = getState().install.toJS().data.settings;
+ validation(settingsFormData, dispatch, function() {
+ dispatch(nextStep());
+ });
+};
+
+export const submitUser = () => (dispatch, getState) => {
+ const userFormData = getState().install.toJS().data.user;
+ validation(userFormData, dispatch, function() {
+ const data = getState().install.toJS().data;
+ dispatch(installRequest());
+ coralApi('/setup', {method: 'POST', body: data})
+ .then(result => {
+ console.log(result);
+ dispatch(installSuccess());
+ dispatch(nextStep());
+ })
+ .catch(error => {
+ console.error(error);
+ dispatch(installFailure(`${error.message}`));
+ });
+ });
+};
+
+export const updateSettingsFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_SETTINGS, name, value});
+export const updateUserFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_USER, name, value});
+
+const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST});
+const checkInstallSuccess = installed => ({type: actions.CHECK_INSTALL_SUCCESS, installed});
+const checkInstallFailure = error => ({type: actions.CHECK_INSTALL_FAILURE, error});
+
+export const checkInstall = next => dispatch => {
+ dispatch(checkInstallRequest());
+ coralApi('/setup')
+ .then(({installed}) => {
+ dispatch(checkInstallSuccess(installed));
+ if (installed) {
+ next();
+ }
+ })
+ .catch(error => {
+ console.error(error);
+ dispatch(checkInstallFailure(`${error.message}`));
+ });
+};
diff --git a/client/coral-admin/src/actions/settings.js b/client/coral-admin/src/actions/settings.js
index 85ad5149e..32f56e111 100644
--- a/client/coral-admin/src/actions/settings.js
+++ b/client/coral-admin/src/actions/settings.js
@@ -11,6 +11,7 @@ export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS';
export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED';
export const WORDLIST_UPDATED = 'WORDLIST_UPDATED';
+export const DOMAINLIST_UPDATED = 'DOMAINLIST_UPDATED';
export const fetchSettings = () => dispatch => {
dispatch({type: SETTINGS_LOADING});
@@ -33,6 +34,10 @@ export const updateWordlist = (listName, list) => {
return {type: WORDLIST_UPDATED, listName, list};
};
+export const updateDomainlist = (listName, list) => {
+ return {type: DOMAINLIST_UPDATED, listName, list};
+};
+
export const saveSettingsToServer = () => (dispatch, getState) => {
let settings = getState().settings.toJS().settings;
if (settings.charCount) {
diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js
index 4e2b391c8..3e6598d6e 100644
--- a/client/coral-admin/src/components/ui/Header.js
+++ b/client/coral-admin/src/components/ui/Header.js
@@ -6,34 +6,49 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import {Logo} from './Logo';
-export default ({handleLogout}) => (
+export default ({handleLogout, restricted = false}) => (
-
- {lang.t('configure.moderate')}
- {lang.t('configure.community')}
- {lang.t('configure.configure')}
+ {
+ !restricted ?
+
+
+
+ {lang.t('configure.moderate')}
+
+
+ {lang.t('configure.community')}
+
+
+ {lang.t('configure.configure')}
+
{lang.t('configure.streams')}
-
-
-
-
-
-
-
- Sign Out
-
-
-
-
- {`v${process.env.VERSION}`}
-
-
-
+ activeClassName={styles.active}>
+ {lang.t('configure.streams')}
+
+
+
+
+
+
+
+
+ Sign Out
+
+
+
+
+ {`v${process.env.VERSION}`}
+
+
+
+
+ :
+ null
+ }
);
diff --git a/client/coral-admin/src/constants/install.js b/client/coral-admin/src/constants/install.js
new file mode 100644
index 000000000..b3fd7e0d0
--- /dev/null
+++ b/client/coral-admin/src/constants/install.js
@@ -0,0 +1,16 @@
+export const NEXT_STEP = 'NEXT_STEP';
+export const GO_TO_STEP = 'GO_TO_STEP';
+export const PREVIOUS_STEP = 'PREVIOUS_STEP';
+export const ADD_ERROR = 'ADD_ERROR';
+export const HAS_ERROR = 'HAS_ERROR';
+export const SHOW_ERRORS = 'SHOW_ERRORS';
+export const CLEAR_ERRORS = 'CLEAR_ERRORS';
+export const INSTALL_REQUEST = 'INSTALL_REQUEST';
+export const INSTALL_SUCCESS = 'INSTALL_SUCCESS';
+export const INSTALL_FAILURE = 'INSTALL_FAILURE';
+export const UPDATE_FORMDATA_USER = 'UPDATE_FORMDATA_USER';
+export const UPDATE_FORMDATA_SETTINGS = 'UPDATE_FORMDATA_SETTINGS';
+
+export const CHECK_INSTALL_REQUEST = 'CHECK_INSTALL_REQUEST';
+export const CHECK_INSTALL_SUCCESS = 'CHECK_INSTALL_SUCCESS';
+export const CHECK_INSTALL_FAILURE = 'CHECK_INSTALL_FAILURE';
diff --git a/client/coral-admin/src/containers/Configure/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js
index 8ad6cacfd..c9c88eeee 100644
--- a/client/coral-admin/src/containers/Configure/Configure.js
+++ b/client/coral-admin/src/containers/Configure/Configure.js
@@ -5,6 +5,7 @@ import {
updateSettings,
saveSettingsToServer,
updateWordlist,
+ updateDomainlist
} from '../../actions/settings';
import {Button, List, Item} from 'coral-ui';
@@ -14,6 +15,7 @@ import translations from '../../translations.json';
import EmbedLink from './EmbedLink';
import CommentSettings from './CommentSettings';
import Wordlist from './Wordlist';
+import Domainlist from './Domainlist';
import has from 'lodash/has';
class Configure extends Component {
@@ -47,6 +49,11 @@ class Configure extends Component {
this.props.dispatch(updateWordlist(listName, list));
}
+ onChangeDomainlist = (listName, list) => {
+ this.setState({changed: true});
+ this.props.dispatch(updateDomainlist(listName, list));
+ }
+
onSettingUpdate = (setting) => {
this.setState({changed: true});
this.props.dispatch(updateSettings(setting));
@@ -73,7 +80,14 @@ class Configure extends Component {
errors={this.state.errors}
settingsError={this.onSettingError}/>;
case 'embed':
- return ;
+ return has(this, 'props.settings.domains.whitelist')
+ ?
+
+
+
+ : ;
case 'wordlist':
return has(this, 'props.settings.wordlist')
? (
+
+
{lang.t('configure.domain-list-title')}
+
+ {lang.t('configure.domain-list-text')}
+ data.split(',').map(d => d.trim())}
+ onChange={tags => onChangeDomainlist('whitelist', tags)}
+ />
+
+
+);
+
+export default Domainlist;
+
+const lang = new I18n(translations);
diff --git a/client/coral-admin/src/containers/Install/InstallContainer.js b/client/coral-admin/src/containers/Install/InstallContainer.js
new file mode 100644
index 000000000..7a8634180
--- /dev/null
+++ b/client/coral-admin/src/containers/Install/InstallContainer.js
@@ -0,0 +1,93 @@
+import React, {Component} from 'react';
+import {connect} from 'react-redux';
+import styles from './style.css';
+import {Wizard, WizardNav} from 'coral-ui';
+import {Layout} from '../../components/ui/Layout';
+
+import {
+ nextStep,
+ previousStep,
+ goToStep,
+ updateUserFormData,
+ updateSettingsFormData,
+ submitSettings,
+ submitUser,
+ checkInstall
+} from '../../actions/install';
+
+import InitialStep from './components/Steps/InitialStep';
+import AddOrganizationName from './components/Steps/AddOrganizationName';
+import CreateYourAccount from './components/Steps/CreateYourAccount';
+import FinalStep from './components/Steps/FinalStep';
+
+class InstallContainer extends Component {
+ componentDidMount() {
+ const {checkInstall} = this.props;
+ checkInstall(() => {
+ this.context.router.push('/admin');
+ });
+ }
+
+ render() {
+ const {install} = this.props;
+
+ return (
+
+
+ {
+ !install.alreadyInstalled ? (
+
+
Welcome to the Coral Project
+ { install.step !== 0 ?
: null }
+
+
+
+
+
+
+
+ ) : (
+
Talk is already installed
+ )
+ }
+
+
+ );
+ }
+}
+
+InstallContainer.contextTypes = {
+ router: React.PropTypes.object
+};
+
+const mapStateToProps = state => ({
+ install: state.install.toJS()
+});
+
+const mapDispatchToProps = dispatch => ({
+ checkInstall: next => dispatch(checkInstall(next)),
+ nextStep: () => dispatch(nextStep()),
+ previousStep: () => dispatch(previousStep()),
+ goToStep: step => dispatch(goToStep(step)),
+ handleSettingsChange: e => {
+ const {name, value} = e.currentTarget;
+ dispatch(updateSettingsFormData(name, value));
+ },
+ handleUserChange: e => {
+ const {name, value} = e.currentTarget;
+ dispatch(updateUserFormData(name, value));
+ },
+ handleSettingsSubmit: e => {
+ e.preventDefault();
+ dispatch(submitSettings());
+ },
+ handleUserSubmit: e => {
+ e.preventDefault();
+ dispatch(submitUser());
+ }
+});
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(InstallContainer);
diff --git a/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js
new file mode 100644
index 000000000..0f85bbad4
--- /dev/null
+++ b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import styles from './style.css';
+import {FormField, Button} from 'coral-ui';
+
+const AddOrganizationName = props => {
+ const {handleSettingsChange, handleSettingsSubmit, install} = props;
+ return (
+
+
+ Please tell us the name of your organization. This will appear in emails when
+ inviting new team members
+
+
+
+
+
+ );
+};
+
+export default AddOrganizationName;
diff --git a/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js b/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js
new file mode 100644
index 000000000..91c664c37
--- /dev/null
+++ b/client/coral-admin/src/containers/Install/components/Steps/CreateYourAccount.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import styles from './style.css';
+import {FormField, Button, Spinner} from 'coral-ui';
+
+const InitialStep = props => {
+ const {handleUserChange, handleUserSubmit, install} = props;
+ return (
+
+ );
+};
+
+export default InitialStep;
diff --git a/client/coral-admin/src/containers/Install/components/Steps/FinalStep.js b/client/coral-admin/src/containers/Install/components/Steps/FinalStep.js
new file mode 100644
index 000000000..e549ebe63
--- /dev/null
+++ b/client/coral-admin/src/containers/Install/components/Steps/FinalStep.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import styles from './style.css';
+import {Button} from 'coral-ui';
+import {Link} from 'react-router';
+
+const InitialStep = () => {
+ return (
+
+
+ Thanks for installing Talk! We sent an email to verify your email
+ address. While you finish setting the account, you can start engaging
+ with your readers now.
+
+
Launch Talk
+
Close this Installer
+
+ );
+};
+
+export default InitialStep;
diff --git a/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js b/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js
new file mode 100644
index 000000000..9f9770fe9
--- /dev/null
+++ b/client/coral-admin/src/containers/Install/components/Steps/InitialStep.js
@@ -0,0 +1,19 @@
+import React from 'react';
+import styles from './style.css';
+import {Button} from 'coral-ui';
+
+const InitialStep = props => {
+ const {nextStep} = props;
+ return (
+
+
+ The remainder of the Talk installation will take about ten minutes.
+ Once you complete the following two steps, you will have a free
+ installation and provision of Mongo and Redis.
+
+
Get Started
+
+ );
+};
+
+export default InitialStep;
diff --git a/client/coral-admin/src/containers/Install/components/Steps/InviteTeamMembers.js b/client/coral-admin/src/containers/Install/components/Steps/InviteTeamMembers.js
new file mode 100644
index 000000000..156916ab0
--- /dev/null
+++ b/client/coral-admin/src/containers/Install/components/Steps/InviteTeamMembers.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import styles from './style.css';
+import {Button, Select, Option, FormField} from 'coral-ui';
+
+const InviteTeamMembers = props => {
+ const {nextStep} = props;
+ return (
+
+
Invite Team Members
+
+ Once registered, new team members will receive an email to Create
+ their password.
+
+
+
+ );
+};
+
+export default InviteTeamMembers;
diff --git a/client/coral-admin/src/containers/Install/components/Steps/style.css b/client/coral-admin/src/containers/Install/components/Steps/style.css
new file mode 100644
index 000000000..161c72685
--- /dev/null
+++ b/client/coral-admin/src/containers/Install/components/Steps/style.css
@@ -0,0 +1,77 @@
+.step {
+ padding: 20px 0;
+
+ h3 {
+ max-width: 450px;
+ margin: 0 auto;
+ text-align: left;
+ font-size: 1.4em;
+ font-weight: bold;
+ }
+
+ p {
+ max-width: 450px;
+ margin: 0 auto 30px;
+ font-size: 1.1em;
+ text-align: left;
+ }
+
+ > button {
+ min-width: 145px;
+ a {
+ text-decoration: none;
+ color: inherit;
+ }
+ }
+
+ .form {
+ max-width: 300px;
+ margin: 30px auto;
+ text-align: left;
+
+ form > button {
+ margin: 30px 0;
+ }
+
+ .formField {
+ text-align: left;
+
+ label {
+ text-align: left;
+ display: block;
+ margin: 10px 0;
+ font-weight: 400;
+ font-size: 1.08em;
+ }
+
+ > input {
+ border: solid 1px rgba(0, 0, 0, 0.23);
+ padding: 10px;
+ border-radius: 3px;
+ width: 100%;
+ box-sizing: border-box;
+ transition: border-color 0.2s ease;
+ font-size: 1em;
+
+ &:focus {
+ border-color: black;
+ }
+ }
+ }
+ }
+}
+
+.finalStep {
+ button {
+ width: 225px;
+ margin-right: 10px;
+ }
+}
+
+.error {
+ background: #FFEBEE;
+ color: #B71C1C;
+ padding: 10px;
+ margin-bottom: 20px;
+ border-radius: 2px;
+}
diff --git a/client/coral-admin/src/containers/Install/style.css b/client/coral-admin/src/containers/Install/style.css
new file mode 100644
index 000000000..62f7ffe04
--- /dev/null
+++ b/client/coral-admin/src/containers/Install/style.css
@@ -0,0 +1,12 @@
+.Install {
+ max-width: 900px;
+ margin: 0 auto;
+ text-align: center;
+ padding: 50px 0;
+
+ h2 {
+ font-size: 2em;
+ font-weight: 500;
+ margin: 0;
+ }
+}
diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js
index 84d1b84ad..306461dd0 100644
--- a/client/coral-admin/src/index.js
+++ b/client/coral-admin/src/index.js
@@ -1,6 +1,15 @@
import React from 'react';
-import ReactDOM from 'react-dom';
+import {render} from 'react-dom';
+import {ApolloProvider} from 'react-apollo';
+
+import {client} from './services/client';
+import store from './services/store';
+
import App from './components/App';
-// Render the application into the DOM
-ReactDOM.render( , document.querySelector('#root'));
+render(
+
+
+
+ , document.querySelector('#root')
+);
diff --git a/client/coral-admin/src/reducers/index.js b/client/coral-admin/src/reducers/index.js
index a19a5a087..f12de96ba 100644
--- a/client/coral-admin/src/reducers/index.js
+++ b/client/coral-admin/src/reducers/index.js
@@ -1,19 +1,19 @@
-import {combineReducers} from 'redux';
+import auth from 'reducers/auth';
+import users from 'reducers/users';
+import assets from 'reducers/assets';
+import actions from 'reducers/actions';
+import install from 'reducers/install';
import comments from 'reducers/comments';
import settings from 'reducers/settings';
import community from 'reducers/community';
-import users from 'reducers/users';
-import auth from 'reducers/auth';
-import actions from 'reducers/actions';
-import assets from 'reducers/assets';
-// Combine all reducers into a main one
-export default combineReducers({
+export default {
settings,
comments,
community,
auth,
actions,
assets,
- users
-});
+ users,
+ install
+};
diff --git a/client/coral-admin/src/reducers/install.js b/client/coral-admin/src/reducers/install.js
new file mode 100644
index 000000000..a49f76b5e
--- /dev/null
+++ b/client/coral-admin/src/reducers/install.js
@@ -0,0 +1,91 @@
+import {Map} from 'immutable';
+
+import * as actions from '../constants/install';
+
+const initialState = Map({
+ isLoading: false,
+ data: Map({
+ settings: Map({
+ organizationName: ''
+ }),
+ user: Map({
+ displayName: '',
+ email: '',
+ password: '',
+ confirmPassword: ''
+ })
+ }),
+ errors: Map({
+ organizationName: '',
+ displayName: '',
+ email: '',
+ password: '',
+ confirmPassword: ''
+ }),
+ showErrors: false,
+ hasError: false,
+ error: null,
+ step: 0,
+ navItems: [{
+ text: '1. Add Organization Name',
+ step: 1
+ },
+ {
+ text: '2. Create your account',
+ step: 2
+ }],
+ installRequest: null,
+ installRequestError: null,
+ alreadyInstalled: false
+});
+
+export default function install (state = initialState, action) {
+ switch (action.type) {
+ case actions.NEXT_STEP:
+ return state
+ .set('step', state.get('step') + 1);
+ case actions.PREVIOUS_STEP:
+ return state
+ .set('step', state.get('step') - 1);
+ case actions.GO_TO_STEP:
+ return state
+ .set('step', action.step);
+ case actions.UPDATE_FORMDATA_SETTINGS:
+ return state
+ .setIn(['data', 'settings', action.name], action.value);
+ case actions.UPDATE_FORMDATA_USER:
+ return state
+ .setIn(['data', 'user', action.name], action.value);
+ case actions.HAS_ERROR:
+ return state
+ .merge({
+ hasError: true,
+ showErrors: true
+ });
+ case actions.ADD_ERROR:
+ return state
+ .setIn(['errors', action.name], action.error);
+ case actions.CLEAR_ERRORS:
+ return state
+ .set('errors', Map());
+ case actions.INSTALL_REQUEST:
+ return state
+ .set('isLoading', true);
+ case actions.INSTALL_SUCCESS:
+ return state
+ .set('isLoading', false)
+ .set('installRequest', 'SUCCESS');
+ case actions.INSTALL_FAILURE:
+ return state
+ .merge({
+ isLoading: false,
+ installRequest: 'FAILURE',
+ installRequestError: action.error
+ });
+ case actions.CHECK_INSTALL_SUCCESS:
+ return state
+ .set('alreadyInstalled', action.installed);
+ default :
+ return state;
+ }
+}
diff --git a/client/coral-admin/src/reducers/settings.js b/client/coral-admin/src/reducers/settings.js
index 12b16d9ad..4f743bc0a 100644
--- a/client/coral-admin/src/reducers/settings.js
+++ b/client/coral-admin/src/reducers/settings.js
@@ -6,6 +6,9 @@ const initialState = Map({
wordlist: Map({
banned: List(),
suspect: List()
+ }),
+ domains: Map({
+ whitelist: List()
})
}),
saveSettingsError: null,
@@ -24,6 +27,7 @@ export default (state = initialState, action) => {
case types.SAVE_SETTINGS_SUCCESS: return saveComplete(state, action);
case types.SAVE_SETTINGS_FAILED: return settingsSaveFailed(state, action);
case types.WORDLIST_UPDATED: return updateWordlist(state, action);
+ case types.DOMAINLIST_UPDATED: return updateDomainlist(state, action);
default: return state;
}
};
@@ -40,6 +44,10 @@ const updateWordlist = (state, action) => {
return state.setIn(['settings', 'wordlist', action.listName], action.list);
};
+const updateDomainlist = (state, action) => {
+ return state.setIn(['settings', 'domains', action.listName], action.list);
+};
+
const saveComplete = (state, action) => {
const s = state.set('fetchingSettings', false).set('saveSettingsError', null);
const settings = s.get('settings').merge(action.settings);
diff --git a/client/coral-framework/client.js b/client/coral-admin/src/services/client.js
similarity index 100%
rename from client/coral-framework/client.js
rename to client/coral-admin/src/services/client.js
diff --git a/client/coral-admin/src/services/store.js b/client/coral-admin/src/services/store.js
index 0700dc579..bf2735e45 100644
--- a/client/coral-admin/src/services/store.js
+++ b/client/coral-admin/src/services/store.js
@@ -1,17 +1,24 @@
-
-import {createStore, applyMiddleware} from 'redux';
+import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
-import mainReducer from 'reducers';
+import mainReducer from '../reducers';
+import {client} from './client';
-/**
- * Create the store by merging the app reducers with
- * the talk adapter. The talk adapter is the wire between
- * this client and the coral backend. The idea is we can
- * write different adapters for other platforms if we want
- */
+const middlewares = [
+ applyMiddleware(client.middleware()),
+ applyMiddleware(thunk)
+];
+
+if (window.devToolsExtension) {
+
+ // we can't have the last argument of compose() be undefined
+ middlewares.push(window.devToolsExtension());
+}
export default createStore(
- mainReducer,
- window.devToolsExtension && window.devToolsExtension(),
- applyMiddleware(thunk)
+ combineReducers({
+ ...mainReducer,
+ apollo: client.reducer()
+ }),
+ {},
+ compose(...middlewares)
);
diff --git a/client/coral-framework/subscriptions.js b/client/coral-admin/src/services/subscriptions.js
similarity index 100%
rename from client/coral-framework/subscriptions.js
rename to client/coral-admin/src/services/subscriptions.js
diff --git a/client/coral-framework/transport.js b/client/coral-admin/src/services/transport.js
similarity index 100%
rename from client/coral-framework/transport.js
rename to client/coral-admin/src/services/transport.js
diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json
index 6dffeae42..0eb1ab98d 100644
--- a/client/coral-admin/src/translations.json
+++ b/client/coral-admin/src/translations.json
@@ -79,7 +79,9 @@
"comment-count-header": "Limit Comment Length",
"comment-count-text-pre": "Comments will be limited to ",
"comment-count-text-post": " characters.",
- "comment-count-error": "Please enter a valid number."
+ "comment-count-error": "Please enter a valid number.",
+ "domain-list-title": "Domain Whitelist",
+ "domain-list-text": "Some instructions on how to type the urls."
},
"bandialog": {
"ban_user": "Ban User?",
@@ -187,7 +189,12 @@
"comment-count-header": "Limitar el largo del comentario",
"comment-count-text-pre": "El largo de comentarios será ",
"comment-count-text-post": " caracteres",
- "comment-count-error": "Por favor escribe un número válido."
+ "comment-count-error": "Por favor escribe un número válido.",
+ "domain-list-title": "Lista de Dominios Permitidos",
+ "domain-list-text": "Instrucciones de como ingresar las URLs."
+ },
+ "embedlink": {
+ "copy": "Copiar"
},
"bandialog": {
"ban_user": "Quieres suspender el Usuario?",
diff --git a/client/coral-docs/src/index.js b/client/coral-docs/src/index.js
new file mode 100644
index 000000000..5d8c49d9d
--- /dev/null
+++ b/client/coral-docs/src/index.js
@@ -0,0 +1,8 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {GraphQLDocs} from 'graphql-docs';
+
+import fetcher from './services/fetcher';
+
+// Render the application into the DOM
+ReactDOM.render( , document.querySelector('#root'));
diff --git a/client/coral-docs/src/services/fetcher.js b/client/coral-docs/src/services/fetcher.js
new file mode 100644
index 000000000..e4d6f091d
--- /dev/null
+++ b/client/coral-docs/src/services/fetcher.js
@@ -0,0 +1,10 @@
+export default function fetcher(query) {
+ return fetch(`${window.location.host}/api/v1/graph/ql`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({query}),
+ }).then((res) => res.json());
+}
diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js
index c0c0d57d1..3bf787a9c 100644
--- a/client/coral-embed-stream/src/Comment.js
+++ b/client/coral-embed-stream/src/Comment.js
@@ -10,6 +10,8 @@ import React, {PropTypes} from 'react';
import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
import AuthorName from 'coral-plugin-author-name/AuthorName';
+
+import TagLabel from 'coral-plugin-tag-label/TagLabel';
import Content from 'coral-plugin-commentcontent/CommentContent';
import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
@@ -18,7 +20,9 @@ import LikeButton from 'coral-plugin-likes/LikeButton';
import styles from './Comment.css';
-const getAction = (type, comment) => comment.actions.filter((a) => a.type === type)[0];
+const getActionSummary = (type, comment) => comment.action_summaries
+ .filter((a) => a.__typename === type)[0];
+const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF') ;
class Comment extends React.Component {
@@ -35,7 +39,8 @@ class Comment extends React.Component {
setActiveReplyBox: PropTypes.func.isRequired,
refetch: PropTypes.func.isRequired,
showSignInDialog: PropTypes.func.isRequired,
- postAction: PropTypes.func.isRequired,
+ postFlag: PropTypes.func.isRequired,
+ postLike: PropTypes.func.isRequired,
deleteAction: PropTypes.func.isRequired,
parentId: PropTypes.string,
addNotification: PropTypes.func.isRequired,
@@ -51,15 +56,19 @@ class Comment extends React.Component {
}),
comment: PropTypes.shape({
depth: PropTypes.number,
- actions: PropTypes.array.isRequired,
+ action_summaries: PropTypes.array.isRequired,
body: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string
+ })
+ ),
replies: PropTypes.arrayOf(
PropTypes.shape({
body: PropTypes.string.isRequired,
id: PropTypes.string.isRequired
- })
- ),
+ })),
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
@@ -78,14 +87,15 @@ class Comment extends React.Component {
refetch,
addNotification,
showSignInDialog,
- postAction,
+ postLike,
+ postFlag,
setActiveReplyBox,
activeReplyBox,
deleteAction
} = this.props;
- const like = getAction('LIKE', comment);
- const flag = getAction('FLAG', comment);
+ const like = getActionSummary('LikeActionSummary', comment);
+ const flag = getActionSummary('FlagActionSummary', comment);
return (
+ { isStaff(comment.tags)
+ ?
+ : null }
@@ -117,7 +130,7 @@ class Comment extends React.Component {
flag={flag}
id={comment.id}
author_id={comment.user.id}
- postAction={postAction}
+ postFlag={postFlag}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
@@ -150,7 +163,8 @@ class Comment extends React.Component {
depth={depth + 1}
asset={asset}
currentUser={currentUser}
- postAction={postAction}
+ postLike={postLike}
+ postFlag={postFlag}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
reactKey={reply.id}
@@ -158,7 +172,6 @@ class Comment extends React.Component {
comment={reply} />;
})
}
-
);
}
diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js
index ef1760e12..3acbc30d9 100644
--- a/client/coral-embed-stream/src/Embed.js
+++ b/client/coral-embed-stream/src/Embed.js
@@ -10,7 +10,7 @@ const {addNotification, clearNotification} = notificationActions;
const {fetchAssetSuccess} = assetActions;
import {queryStream} from 'coral-framework/graphql/queries';
-import {postComment, postAction, deleteAction} from 'coral-framework/graphql/mutations';
+import {postComment, postFlag, postLike, deleteAction} from 'coral-framework/graphql/mutations';
import {editName} from 'coral-framework/actions/user';
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
@@ -105,7 +105,7 @@ class Embed extends Component {
minHeight: document.body.scrollHeight + 200
} : {};
- if (loading) {
+ if (loading || !asset) {
return ;
}
@@ -160,7 +160,8 @@ class Embed extends Component {
postItem={this.props.postItem}
asset={asset}
currentUser={user}
- postAction={this.props.postAction}
+ postLike={this.props.postLike}
+ postFlag={this.props.postFlag}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments} />
@@ -227,7 +228,8 @@ const mapDispatchToProps = dispatch => ({
export default compose(
connect(mapStateToProps, mapDispatchToProps),
postComment,
- postAction,
+ postFlag,
+ postLike,
deleteAction,
queryStream
)(Embed);
diff --git a/client/coral-embed-stream/src/Stream.js b/client/coral-embed-stream/src/Stream.js
index 1b586706f..1695cd578 100644
--- a/client/coral-embed-stream/src/Stream.js
+++ b/client/coral-embed-stream/src/Stream.js
@@ -37,7 +37,8 @@ class Stream extends React.Component {
asset,
postItem,
addNotification,
- postAction,
+ postFlag,
+ postLike,
deleteAction,
showSignInDialog,
refetch
@@ -56,7 +57,8 @@ class Stream extends React.Component {
postItem={postItem}
asset={asset}
currentUser={currentUser}
- postAction={postAction}
+ postLike={postLike}
+ postFlag={postFlag}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js
index f75146f0a..3dc69400c 100644
--- a/client/coral-embed-stream/src/index.js
+++ b/client/coral-embed-stream/src/index.js
@@ -2,8 +2,8 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
-import {client} from 'coral-framework/client';
-import store from 'coral-framework/store';
+import {client} from 'coral-framework/services/client';
+import store from 'coral-framework/services/store';
import Embed from './Embed';
diff --git a/client/coral-framework/graphql/fragments/actionSummaryView.graphql b/client/coral-framework/graphql/fragments/actionSummaryView.graphql
new file mode 100644
index 000000000..4ac232bf6
--- /dev/null
+++ b/client/coral-framework/graphql/fragments/actionSummaryView.graphql
@@ -0,0 +1,8 @@
+fragment actionSummaryView on ActionSummary {
+ __typename
+ count
+ current_user {
+ id
+ created_at
+ }
+}
diff --git a/client/coral-framework/graphql/fragments/commentView.graphql b/client/coral-framework/graphql/fragments/commentView.graphql
index 29f9b3bfe..123ce9a09 100644
--- a/client/coral-framework/graphql/fragments/commentView.graphql
+++ b/client/coral-framework/graphql/fragments/commentView.graphql
@@ -1,18 +1,18 @@
+#import "../fragments/actionSummaryView.graphql"
+
fragment commentView on Comment {
id
body
created_at
status
+ tags {
+ name
+ }
user {
id
name: displayName
}
- actions {
- type: action_type
- count
- current: current_user {
- id
- created_at
- }
+ action_summaries {
+ ...actionSummaryView
}
}
diff --git a/client/coral-framework/graphql/mutations/deleteAction.graphql b/client/coral-framework/graphql/mutations/deleteAction.graphql
index bfce8cf6a..f8adf371c 100644
--- a/client/coral-framework/graphql/mutations/deleteAction.graphql
+++ b/client/coral-framework/graphql/mutations/deleteAction.graphql
@@ -1,3 +1,7 @@
mutation deleteAction ($id: ID!) {
- deleteAction(id:$id)
+ deleteAction(id:$id) {
+ errors {
+ translation_key
+ }
+ }
}
diff --git a/client/coral-framework/graphql/mutations/index.js b/client/coral-framework/graphql/mutations/index.js
index b97e5ff22..40ca0a7a3 100644
--- a/client/coral-framework/graphql/mutations/index.js
+++ b/client/coral-framework/graphql/mutations/index.js
@@ -1,6 +1,7 @@
import {graphql} from 'react-apollo';
import POST_COMMENT from './postComment.graphql';
-import POST_ACTION from './postAction.graphql';
+import POST_FLAG from './postFlag.graphql';
+import POST_LIKE from './postLike.graphql';
import DELETE_ACTION from './deleteAction.graphql';
import commentView from '../fragments/commentView.graphql';
@@ -21,12 +22,23 @@ export const postComment = graphql(POST_COMMENT, {
}}),
});
-export const postAction = graphql(POST_ACTION, {
+export const postLike = graphql(POST_LIKE, {
props: ({mutate}) => ({
- postAction: (action) => {
+ postLike: (like) => {
return mutate({
variables: {
- action
+ like
+ }
+ });
+ }}),
+});
+
+export const postFlag = graphql(POST_FLAG, {
+ props: ({mutate}) => ({
+ postFlag: (flag) => {
+ return mutate({
+ variables: {
+ flag
}
});
}}),
diff --git a/client/coral-framework/graphql/mutations/postAction.graphql b/client/coral-framework/graphql/mutations/postAction.graphql
deleted file mode 100644
index fff737fa8..000000000
--- a/client/coral-framework/graphql/mutations/postAction.graphql
+++ /dev/null
@@ -1,5 +0,0 @@
-mutation CreateAction ($action: CreateActionInput!) {
- createAction(action:$action) {
- id
- }
-}
diff --git a/client/coral-framework/graphql/mutations/postComment.graphql b/client/coral-framework/graphql/mutations/postComment.graphql
index 499871766..6ce46eaa8 100644
--- a/client/coral-framework/graphql/mutations/postComment.graphql
+++ b/client/coral-framework/graphql/mutations/postComment.graphql
@@ -2,6 +2,11 @@
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
- ...commentView
+ comment {
+ ...commentView
+ }
+ errors {
+ translation_key
+ }
}
}
diff --git a/client/coral-framework/graphql/mutations/postFlag.graphql b/client/coral-framework/graphql/mutations/postFlag.graphql
new file mode 100644
index 000000000..cabc2feef
--- /dev/null
+++ b/client/coral-framework/graphql/mutations/postFlag.graphql
@@ -0,0 +1,10 @@
+mutation CreateFlag($flag: CreateFlagInput!) {
+ createFlag(flag:$flag) {
+ flag {
+ id
+ }
+ errors {
+ translation_key
+ }
+ }
+}
diff --git a/client/coral-framework/graphql/mutations/postLike.graphql b/client/coral-framework/graphql/mutations/postLike.graphql
new file mode 100644
index 000000000..350904352
--- /dev/null
+++ b/client/coral-framework/graphql/mutations/postLike.graphql
@@ -0,0 +1,10 @@
+mutation CreateLike ($like: CreateLikeInput!) {
+ createLike(like:$like) {
+ like {
+ id
+ }
+ errors {
+ translation_key
+ }
+ }
+}
diff --git a/client/coral-framework/graphql/queries/index.js b/client/coral-framework/graphql/queries/index.js
index aa22feae5..a08f87c3c 100644
--- a/client/coral-framework/graphql/queries/index.js
+++ b/client/coral-framework/graphql/queries/index.js
@@ -14,7 +14,7 @@ function getQueryVariable(variable) {
}
// If no query is included, return a default string for development
- return 'http://dev.default.stream';
+ return 'http://localhost/default/stream';
}
export const queryStream = graphql(STREAM_QUERY, {
diff --git a/client/coral-framework/helpers/error.js b/client/coral-framework/helpers/error.js
index 34ba253ba..882486f6a 100644
--- a/client/coral-framework/helpers/error.js
+++ b/client/coral-framework/helpers/error.js
@@ -6,5 +6,6 @@ export default {
email: lang.t('error.email'),
password: lang.t('error.password'),
displayName: lang.t('error.displayName'),
- confirmPassword: lang.t('error.confirmPassword')
+ confirmPassword: lang.t('error.confirmPassword'),
+ organizationName: lang.t('error.organizationName'),
};
diff --git a/client/coral-framework/helpers/validate.js b/client/coral-framework/helpers/validate.js
index b267fb7c0..69d81966f 100644
--- a/client/coral-framework/helpers/validate.js
+++ b/client/coral-framework/helpers/validate.js
@@ -2,5 +2,6 @@ export default {
email: email => (/^([A-Za-z0-9_\-\.\+])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
password: pass => (/^(?=.{8,}).*$/.test(pass)),
confirmPassword: () => true,
- displayName: displayName => (/^[a-zA-Z0-9_]+$/.test(displayName))
+ displayName: displayName => (/^[a-zA-Z0-9_]+$/.test(displayName)),
+ organizationName: org => (/^[a-zA-Z0-9_ ]+$/).test(org)
};
diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js
index d2cd4197b..56a2e522c 100644
--- a/client/coral-framework/index.js
+++ b/client/coral-framework/index.js
@@ -1,5 +1,5 @@
-import store from './store';
-import pym from './PymConnection';
+import store from './services/store';
+import pym from './services/PymConnection';
import I18n from './modules/i18n/i18n';
import * as authActions from './actions/auth';
import * as assetActions from './actions/asset';
diff --git a/client/coral-framework/PymConnection.js b/client/coral-framework/services/PymConnection.js
similarity index 77%
rename from client/coral-framework/PymConnection.js
rename to client/coral-framework/services/PymConnection.js
index ca592b824..6c492f2b8 100644
--- a/client/coral-framework/PymConnection.js
+++ b/client/coral-framework/services/PymConnection.js
@@ -1,4 +1,4 @@
-import Pym from '../../node_modules/pym.js';
+import Pym from '../../../node_modules/pym.js';
const pym = new Pym.Child({polling: 100});
export default pym;
diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js
new file mode 100644
index 000000000..b4a7a38df
--- /dev/null
+++ b/client/coral-framework/services/client.js
@@ -0,0 +1,14 @@
+import ApolloClient, {addTypename} from 'apollo-client';
+import getNetworkInterface from './transport';
+
+export const client = new ApolloClient({
+ connectToDevTools: true,
+ queryTransformer: addTypename,
+ dataIdFromObject: (result) => {
+ if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
+ return result.__typename + result.id; // eslint-disable-line no-underscore-dangle
+ }
+ return null;
+ },
+ networkInterface: getNetworkInterface()
+});
diff --git a/client/coral-framework/store.js b/client/coral-framework/services/store.js
similarity index 93%
rename from client/coral-framework/store.js
rename to client/coral-framework/services/store.js
index 439569ee1..bf2735e45 100644
--- a/client/coral-framework/store.js
+++ b/client/coral-framework/services/store.js
@@ -1,6 +1,6 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
-import mainReducer from './reducers';
+import mainReducer from '../reducers';
import {client} from './client';
const middlewares = [
diff --git a/client/coral-framework/services/subscriptions.js b/client/coral-framework/services/subscriptions.js
new file mode 100644
index 000000000..818a6fb33
--- /dev/null
+++ b/client/coral-framework/services/subscriptions.js
@@ -0,0 +1,16 @@
+import {print} from 'graphql-tag/printer';
+
+// quick way to add the subscribe and unsubscribe functions to the network interface
+const addGraphQLSubscriptions = (networkInterface, wsClient) => {
+ return Object.assign(networkInterface, {
+ subscribe: (request, handler) => wsClient.subscribe({
+ query: print(request.query),
+ variables: request.variables,
+ }, handler),
+ unsubscribe: (id) => {
+ wsClient.unsubscribe(id);
+ },
+ });
+};
+
+export default addGraphQLSubscriptions;
diff --git a/client/coral-framework/services/transport.js b/client/coral-framework/services/transport.js
new file mode 100644
index 000000000..2bd6ac636
--- /dev/null
+++ b/client/coral-framework/services/transport.js
@@ -0,0 +1,11 @@
+import {createNetworkInterface} from 'apollo-client';
+
+export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) {
+ return new createNetworkInterface({
+ uri: apiUrl,
+ opts: {
+ credentials: 'same-origin',
+ headers,
+ },
+ });
+}
diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json
index 989a41cc4..9b56ba92c 100644
--- a/client/coral-framework/translations.json
+++ b/client/coral-framework/translations.json
@@ -16,6 +16,7 @@
"password": "Password must be at least 8 characters",
"displayName": "Display names can contain letters, numbers and _ only",
"confirmPassword": "Passwords don't match. Please, check again",
+ "organizationName": "Organization name must only contain letters or numbers.",
"emailPasswordError": "Email and/or password combination incorrect.",
"EMAIL_REQUIRED": "An email address is required",
"PASSWORD_REQUIRED": "Must input a password",
@@ -39,6 +40,7 @@
"email": "No es un email válido",
"password": "La contraseña debe tener por lo menos 8 caracteres",
"displayName": "Los nombres pueden contener letras, números y _",
+ "organizationName": "El nombre de la organización debe contener letras y/o números.",
"confirmPassword": "Las contraseñas no coinciden",
"emailPasswordError": "Email y/o contraseña incorrecta.",
"EMAIL_REQUIRED": "Se requiere una dirección de correo electrónico",
diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js
index 41c8f75fb..344ade150 100644
--- a/client/coral-plugin-commentbox/CommentBox.js
+++ b/client/coral-plugin-commentbox/CommentBox.js
@@ -8,9 +8,6 @@ const name = 'coral-plugin-commentbox';
class CommentBox extends Component {
static propTypes = {
-
- // updateItem: PropTypes.func,
- // comments: PropTypes.array,
commentPostedHandler: PropTypes.func,
postItem: PropTypes.func.isRequired,
cancelButtonClicked: PropTypes.func,
@@ -29,10 +26,6 @@ class CommentBox extends Component {
postComment = () => {
const {
-
- // child_id,
- // updateItem,
- // appendItemArray,
commentPostedHandler,
postItem,
assetId,
@@ -48,28 +41,13 @@ class CommentBox extends Component {
parent_id: parentId
};
- // let related;
- // let parent_type;
- // if (parent_id) {
- // comment.parent_id = parent_id;
- // related = 'children';
- // parent_type = 'comments';
- // } else {
- // related = 'comments';
- // parent_type = 'assets';
- // }
- // if (child_id || parent_id) {
- // updateItem(child_id || parent_id, 'showReply', false, 'comments');
- // }
-
if (this.props.charCount && this.state.body.length > this.props.charCount) {
return;
}
postItem(comment, 'comments')
.then(({data}) => {
- const postedComment = data.createComment;
+ const postedComment = data.createComment.comment;
- // const commentId = postedComment.id;
if (postedComment.status === 'REJECTED') {
addNotification('error', lang.t('comment-post-banned-word'));
} else if (postedComment.status === 'PREMOD') {
diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js
index ec62f5396..e94dfc5fc 100644
--- a/client/coral-plugin-flags/FlagButton.js
+++ b/client/coral-plugin-flags/FlagButton.js
@@ -12,7 +12,7 @@ class FlagButton extends Component {
showMenu: false,
itemType: '',
reason: '',
- note: '',
+ message: '',
step: 0,
posted: false,
localPost: null,
@@ -23,7 +23,7 @@ class FlagButton extends Component {
onReportClick = () => {
const {currentUser, flag, deleteAction} = this.props;
const {localPost, localDelete} = this.state;
- const flagged = (flag && flag.current && !localDelete) || localPost;
+ const flagged = (flag && flag.current_user && !localDelete) || localPost;
if (!currentUser) {
const offset = document.getElementById(`c_${this.props.id}`).getBoundingClientRect().top - 75;
this.props.showSignInDialog(offset);
@@ -31,15 +31,15 @@ class FlagButton extends Component {
}
if (flagged) {
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
- deleteAction(localPost || flag.current.id);
+ deleteAction(localPost || flag.current_user.id);
} else {
this.setState({showMenu: !this.state.showMenu});
}
}
onPopupContinue = () => {
- const {postAction, id, author_id} = this.props;
- const {itemType, reason, step, posted} = this.state;
+ const {postFlag, id, author_id} = this.props;
+ const {itemType, reason, step, posted, message} = this.state;
// Proceed to the next step or close the menu if we've reached the end
if (step + 1 >= this.props.getPopupMenu.length) {
@@ -67,13 +67,14 @@ class FlagButton extends Component {
if (itemType === 'COMMENTS') {
this.setState({localPost: 'temp'});
}
- postAction({
+ postFlag({
item_id,
item_type: itemType,
- action_type: 'FLAG'
+ reason,
+ message
}).then(({data}) => {
if (itemType === 'COMMENTS') {
- this.setState({localPost: data.createAction.id});
+ this.setState({localPost: data.createFlag.flag.id});
}
});
}
@@ -99,7 +100,7 @@ class FlagButton extends Component {
}
onNoteTextChange = (e) => {
- this.setState({note: e.target.value});
+ this.setState({message: e.target.value});
}
handleClickOutside () {
@@ -109,7 +110,7 @@ class FlagButton extends Component {
render () {
const {flag, getPopupMenu} = this.props;
const {localPost, localDelete} = this.state;
- const flagged = (flag && flag.current && !localDelete) || localPost;
+ const flagged = (flag && flag.current_user && !localDelete) || localPost;
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return
@@ -150,15 +151,15 @@ class FlagButton extends Component {
}
{
this.state.reason &&
-
+
{lang.t('flag-reason')}
}
diff --git a/client/coral-plugin-likes/LikeButton.js b/client/coral-plugin-likes/LikeButton.js
index cc2c6d83e..145080469 100644
--- a/client/coral-plugin-likes/LikeButton.js
+++ b/client/coral-plugin-likes/LikeButton.js
@@ -12,7 +12,7 @@ class LikeButton extends Component {
count: PropTypes.number
}),
id: PropTypes.string,
- postAction: PropTypes.func.isRequired,
+ postLike: PropTypes.func.isRequired,
deleteAction: PropTypes.func.isRequired,
showSignInDialog: PropTypes.func.isRequired,
currentUser: PropTypes.shape({
@@ -26,9 +26,9 @@ class LikeButton extends Component {
}
render() {
- const {like, id, postAction, deleteAction, showSignInDialog, currentUser} = this.props;
+ const {like, id, postLike, deleteAction, showSignInDialog, currentUser} = this.props;
const {localPost, localDelete} = this.state;
- const liked = (like && like.current && !localDelete) || localPost;
+ const liked = (like && like.current_user && !localDelete) || localPost;
let count = like ? like.count : 0;
if (localPost) {count += 1;}
if (localDelete) {count -= 1;}
@@ -42,28 +42,23 @@ class LikeButton extends Component {
if (currentUser.banned) {
return;
}
- if (!liked) {
- this.setState({localPost: 'temp', localDelete: false});
- postAction({
+ if (!liked) { // this comment has not yet been liked by this user.
+ this.setState({localPost: 'temp'});
+ postLike({
item_id: id,
- item_type: 'COMMENTS',
- action_type: 'LIKE'
+ item_type: 'COMMENTS'
}).then(({data}) => {
- this.setState({localPost: data.createAction.id});
+ this.setState({localPost: data.createLike.like.id});
});
} else {
this.setState((prev) => prev.localPost ? {...prev, localPost: null} : {...prev, localDelete: true});
- deleteAction(localPost || like.current.id);
+ deleteAction(localPost || like.current_user.id);
}
};
return
- {
- liked
- ? {lang.t('liked')}
- : {lang.t('like')}
- }
+ {lang.t(liked ? 'liked' : 'like')}
thumb_up
{count > 0 && count}
diff --git a/client/coral-plugin-stream/Stream.js b/client/coral-plugin-stream/Stream.js
index 5add6b753..0b35f408f 100644
--- a/client/coral-plugin-stream/Stream.js
+++ b/client/coral-plugin-stream/Stream.js
@@ -57,6 +57,9 @@ const StreamQuery = gql`fragment commentView on Comment {
user {
name: displayName
}
+ tags {
+ name
+ }
actions {
type: action_type
count
diff --git a/client/coral-plugin-tag-label/TagLabel.js b/client/coral-plugin-tag-label/TagLabel.js
new file mode 100644
index 000000000..0bceca225
--- /dev/null
+++ b/client/coral-plugin-tag-label/TagLabel.js
@@ -0,0 +1,9 @@
+import React from 'react';
+
+import styles from './styles.css';
+
+const TagLabel = ({isStaff}) =>
+ {isStaff ? 'Staff' : ''}
+
;
+
+export default TagLabel;
diff --git a/client/coral-plugin-tag-label/styles.css b/client/coral-plugin-tag-label/styles.css
new file mode 100644
index 000000000..03dc3dbc8
--- /dev/null
+++ b/client/coral-plugin-tag-label/styles.css
@@ -0,0 +1,7 @@
+.staff {
+ background-color: #4C1066;
+ color: white;
+ display: inline-block;
+ margin: 10px 10px;
+ padding: 5px 5px;
+}
diff --git a/client/coral-settings/containers/SettingsContainer.js b/client/coral-settings/containers/SettingsContainer.js
index 4398ed5c6..9e72cdadc 100644
--- a/client/coral-settings/containers/SettingsContainer.js
+++ b/client/coral-settings/containers/SettingsContainer.js
@@ -5,7 +5,7 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import {myCommentHistory} from 'coral-framework/graphql/queries';
-import {link} from 'coral-framework/PymConnection';
+import {link} from 'coral-framework/services/PymConnection';
import NotLoggedIn from '../components/NotLoggedIn';
import {Spinner} from 'coral-ui';
import SettingsHeader from '../components/SettingsHeader';
diff --git a/client/coral-ui/components/Button.css b/client/coral-ui/components/Button.css
index ece3779d6..4cdd7ef74 100644
--- a/client/coral-ui/components/Button.css
+++ b/client/coral-ui/components/Button.css
@@ -30,6 +30,12 @@
font-size: 18px;
vertical-align: middle;
}
+
+ &:disabled {
+ background: #E0E0E0;
+ color: #4f5c67;
+ font-weight: bold;
+ }
}
.type--black {
diff --git a/client/coral-ui/components/Option.css b/client/coral-ui/components/Option.css
new file mode 100644
index 000000000..54784d8fc
--- /dev/null
+++ b/client/coral-ui/components/Option.css
@@ -0,0 +1,3 @@
+.Option {
+
+}
diff --git a/client/coral-ui/components/Option.js b/client/coral-ui/components/Option.js
new file mode 100644
index 000000000..d322ef0ef
--- /dev/null
+++ b/client/coral-ui/components/Option.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import {Option as OptionMDL} from 'react-mdl-selectfield';
+import styles from './Option.css';
+
+const Option = props => {
+ const {children, ...attrs} = props;
+ return (
+
+ {children}
+
+ );
+};
+
+export default Option;
diff --git a/client/coral-ui/components/Select.css b/client/coral-ui/components/Select.css
new file mode 100644
index 000000000..ac93a843c
--- /dev/null
+++ b/client/coral-ui/components/Select.css
@@ -0,0 +1,33 @@
+.Select {
+ position: relative;
+ width: 100%;
+ height: 40px;
+ background: #2c2c2c;
+ padding: 10px 15px;
+ box-sizing: border-box;
+ color: white;
+ border-radius: 2px;
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
+
+ > div {
+ padding: 0;
+ }
+
+ i {
+ position: absolute;
+ top: 7px;
+ right: 7px;
+ }
+
+ input {
+ padding: 0;
+ font-size: 13px;
+ letter-spacing: 0.7px;
+ font-weight: 400;
+ }
+
+ &:hover {
+ cursor: pointer;
+ }
+
+}
diff --git a/client/coral-ui/components/Select.js b/client/coral-ui/components/Select.js
new file mode 100644
index 000000000..731c50ddc
--- /dev/null
+++ b/client/coral-ui/components/Select.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import {SelectField} from 'react-mdl-selectfield';
+import styles from './Select.css';
+
+const Select = props => {
+ const {children, ...attrs} = props;
+ return (
+
+ {children}
+
+ );
+};
+
+export default Select;
diff --git a/client/coral-ui/components/Wizard.js b/client/coral-ui/components/Wizard.js
new file mode 100644
index 000000000..eefa2a0b1
--- /dev/null
+++ b/client/coral-ui/components/Wizard.js
@@ -0,0 +1,27 @@
+import React, {PropTypes} from 'react';
+
+const Wizard = (props) => {
+ const {children, currentStep, ...rest} = props;
+ return (
+
+ {React.Children.toArray(children)
+ .filter((child, i) => i === currentStep)
+ .map((child, i) =>
+ React.cloneElement(child, {
+ i,
+ currentStep,
+ ...rest
+ })
+ )}
+
+ );
+};
+
+Wizard.propTypes = {
+ currentStep: PropTypes.number.isRequired,
+ nextStep: PropTypes.func.isRequired,
+ previousStep: PropTypes.func.isRequired,
+ goToStep: PropTypes.func.isRequired
+};
+
+export default Wizard;
diff --git a/client/coral-ui/components/WizardNav.css b/client/coral-ui/components/WizardNav.css
new file mode 100644
index 000000000..001ee8603
--- /dev/null
+++ b/client/coral-ui/components/WizardNav.css
@@ -0,0 +1,107 @@
+.WizardNav {
+ ul {
+ list-style: none;
+ }
+
+ li {
+ border: solid 1px #DFDFDF;
+ background-color: #F0F0F0;
+ display: inline-block;
+ padding: 10px;
+ margin-right: 10px;
+ color: #A7A7A7;
+ position: relative;
+ padding-left: 40px;
+ border-radius: 1px;
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ &.done {
+ border-color: #00796B;
+ background-color: #00796B;
+ color: #ffffff;
+ span {
+ &::after {
+ border-color: transparent transparent transparent #00796B;
+ }
+ }
+
+ i {
+ opacity: 1;
+ }
+ }
+
+ &.active {
+ background-color: #FFFFFF;
+ color: #636363;
+ font-weight: 500;
+
+ span {
+ &::after {
+ border-color: transparent transparent transparent #FFFFFF;
+ }
+ }
+ }
+
+ i {
+ transition: opacity 0.2s ease;
+ opacity: 0;
+ vertical-align: middle;
+ font-size: 16px;
+ margin-top: -5px;
+ margin-left: 8px;
+ }
+
+ span {
+ padding: 10px 20px;
+ margin-right: 10px;
+ position: absolute;
+ top: -1px;
+ right: -51px;
+ z-index: 10;
+
+ &::before {
+ content: "";
+ display: inline-block;
+ position: absolute;
+ border: 23px solid #DFDFDF;
+ border-color: transparent transparent transparent #DFDFDF;
+ top: 0;
+ left: 0;
+ }
+
+ &::after {
+ content: "";
+ display: inline-block;
+ position: absolute;
+ border: 23px solid white;
+ border-color: transparent transparent transparent #f0f0f0;
+ top: 0;
+ left: -1px;
+ }
+ }
+
+ &::before {
+ content: "";
+ display: inline-block;
+ position: absolute;
+ border: 23px solid #DFDFDF;
+ border-color: transparent transparent transparent #DFDFDF;
+ top: -1px;
+ left: 0;
+ }
+
+ &::after {
+ content: "";
+ display: inline-block;
+ position: absolute;
+ border: 23px solid white;
+ border-color: transparent transparent transparent #fafafa;
+ top: -1px;
+ left: -1px;
+ }
+
+ }
+}
diff --git a/client/coral-ui/components/WizardNav.js b/client/coral-ui/components/WizardNav.js
new file mode 100644
index 000000000..84f7d30ac
--- /dev/null
+++ b/client/coral-ui/components/WizardNav.js
@@ -0,0 +1,31 @@
+import React, {PropTypes} from 'react';
+import styles from './WizardNav.css';
+import Icon from './Icon';
+
+const WizardNav = props => {
+ const {goToStep = () => {}, currentStep, items, icon} = props;
+ return (
+
+
+ {
+ items.map((item, i) => (
+ goToStep(item.step)}>
+ {item.text}
+ {icon && }
+
+
+ ))
+ }
+
+
+ );
+};
+
+WizardNav.propTypes = {
+ currentStep: PropTypes.number.isRequired
+};
+
+export default WizardNav;
diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js
index c5a087737..beb2396f5 100644
--- a/client/coral-ui/index.js
+++ b/client/coral-ui/index.js
@@ -16,4 +16,8 @@ export {default as Card} from './components/Card';
export {default as FormField} from './components/FormField';
export {default as Success} from './components/Success';
export {default as Pager} from './components/Pager';
+export {default as Wizard} from './components/Wizard';
+export {default as WizardNav} from './components/WizardNav';
+export {default as Select} from './components/Select';
+export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..76c28d20f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,25 @@
+version: '2'
+services:
+ talk:
+ image: coralproject/talk:latest
+ restart: always
+ ports:
+ - "5000:5000"
+ depends_on:
+ - mongo
+ - redis
+ mongo:
+ image: mongo:3.2
+ restart: always
+ volumes:
+ - mongo:/data/db
+ redis:
+ image: redis:3.2
+ restart: always
+ volumes:
+ - redis:/data
+volumes:
+ mongo:
+ external: false
+ redis:
+ external: false
diff --git a/errors.js b/errors.js
index 08a5c3ae2..7debe6a43 100644
--- a/errors.js
+++ b/errors.js
@@ -121,6 +121,7 @@ const ErrInvalidAssetURL = new APIError('asset_url is invalid', {
// ErrNotAuthorized is an error that is returned in the event an operation is
// deemed not authorized.
const ErrNotAuthorized = new APIError('not authorized', {
+ translation_key: 'NOT_AUTHORIZED',
status: 401
});
@@ -128,6 +129,18 @@ const ErrNotAuthorized = new APIError('not authorized', {
// initialized.
const ErrSettingsNotInit = new Error('settings not initialized, run `./bin/cli setup` to setup the application first');
+// ErrSettingsInit is returned when the setup endpoint is hit and we are already
+// initialized.
+const ErrSettingsInit = new APIError('settings are already initialized', {
+ status: 500
+});
+
+// ErrInstallLock is returned when the setup endpoint is hit and the install
+// lock is present.
+const ErrInstallLock = new APIError('install lock active', {
+ status: 500
+});
+
module.exports = {
ExtendableError,
APIError,
@@ -145,5 +158,7 @@ module.exports = {
ErrNotFound,
ErrInvalidAssetURL,
ErrAuthentication,
- ErrNotAuthorized
+ ErrNotAuthorized,
+ ErrSettingsInit,
+ ErrInstallLock
};
diff --git a/graph/loaders/actions.js b/graph/loaders/actions.js
index c41f17219..2dd6472a0 100644
--- a/graph/loaders/actions.js
+++ b/graph/loaders/actions.js
@@ -5,6 +5,15 @@ const util = require('./util');
const ActionsService = require('../../services/actions');
const ActionModel = require('../../models/action');
+/**
+ * Gets actions based on their item id's.
+ */
+const genActionsByItemID = (_, item_ids) => {
+ return ActionsService
+ .findByItemIdArray(item_ids)
+ .then(util.arrayJoinBy(item_ids, 'item_id'));
+};
+
/**
* Looks up actions based on the requested id's all bounded by the user.
* @param {Object} context the context of the request
@@ -35,6 +44,7 @@ const getItemIdsByActionTypeAndItemType = (_, action_type, item_type) => {
*/
module.exports = (context) => ({
Actions: {
+ getByID: new DataLoader((ids) => genActionsByItemID(context, ids)),
getSummariesByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)),
getByTypes: ({action_type, item_type}) => getItemIdsByActionTypeAndItemType(context, action_type, item_type)
}
diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js
index 0b6ad976d..23f7eb29d 100644
--- a/graph/loaders/comments.js
+++ b/graph/loaders/comments.js
@@ -1,4 +1,5 @@
const util = require('./util');
+const DataLoader = require('dataloader');
const CommentModel = require('../../models/comment');
@@ -135,6 +136,95 @@ const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_
.limit(limit);
};
+const genRecentReplies = (_, ids) => {
+ return CommentModel.aggregate([
+
+ // get all the replies for the comments in question
+ {$match: {
+ parent_id: {
+ $in: ids
+ }
+ }},
+
+ // sort these by their created at timestamp, CHRONOLOGICAL'y as these are
+ // replies
+ {$sort: {
+ created_at: 1
+ }},
+
+ // group these replies by their parent_id
+ {$group: {
+ _id: '$parent_id',
+ replies: {
+ $push: '$$ROOT'
+ }
+ }},
+
+ // project it so that we only retain the first 3 replies of each parent
+ // comment
+ {$project: {
+ _id: '$_id',
+ replies: {
+ $slice: [
+ '$replies',
+ 0,
+ 3
+ ]
+ }
+ }},
+
+ {$unwind: '$replies'},
+
+ ])
+ .then((replies) => replies.map((reply) => reply.replies))
+ .then(util.arrayJoinBy(ids, 'parent_id'));
+};
+
+const genRecentComments = (_, ids) => {
+ return CommentModel.aggregate([
+
+ // get all the replies for the comments in question
+ {$match: {
+ asset_id: {
+ $in: ids
+ }
+ }},
+
+ // sort these by their created at timestamp, CHRONOLOGICAL'y as these are
+ // replies
+ {$sort: {
+ created_at: 1
+ }},
+
+ // group these replies by their parent_id
+ {$group: {
+ _id: '$asset_id',
+ comments: {
+ $push: '$$ROOT'
+ }
+ }},
+
+ // project it so that we only retain the first 3 replies of each parent
+ // comment
+ {$project: {
+ _id: '$_id',
+ comments: {
+ $slice: [
+ '$comments',
+ 0,
+ 3
+ ]
+ }
+ }},
+
+ // Unwind these comments.
+ {$unwind: '$comments'},
+
+ ])
+ .then((replies) => replies.map((reply) => reply.comments))
+ .then(util.arrayJoinBy(ids, 'asset_id'));
+};
+
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
@@ -144,6 +234,8 @@ module.exports = (context) => ({
Comments: {
getByQuery: (query) => getCommentsByQuery(context, query),
countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)),
- countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids))
+ countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)),
+ genRecentReplies: new DataLoader((ids) => genRecentReplies(context, ids)),
+ genRecentComments: new DataLoader((ids) => genRecentComments(context, ids))
}
});
diff --git a/graph/mutators/action.js b/graph/mutators/action.js
index b5264fd49..3499719e1 100644
--- a/graph/mutators/action.js
+++ b/graph/mutators/action.js
@@ -1,6 +1,7 @@
const ActionModel = require('../../models/action');
const ActionsService = require('../../services/actions');
const UsersService = require('../../services/users');
+const errors = require('../../errors');
/**
* Creates an action on a item. If the item is a user flag, sets the user's status to
@@ -11,11 +12,12 @@ const UsersService = require('../../services/users');
* @param {String} action_type type of the action
* @return {Promise} resolves to the action created
*/
-const createAction = ({user = {}}, {item_id, item_type, action_type, metadata = {}}) => {
+const createAction = ({user = {}}, {item_id, item_type, action_type, group_id, metadata = {}}) => {
return ActionsService.insertUserAction({
item_id,
item_type,
user_id: user.id,
+ group_id,
action_type,
metadata
}).then((action) => {
@@ -58,8 +60,8 @@ module.exports = (context) => {
return {
Action: {
- create: () => {},
- delete: () => {}
+ create: () => Promise.reject(errors.ErrNotAuthorized),
+ delete: () => Promise.reject(errors.ErrNotAuthorized)
}
};
};
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
index 3c35b0e60..6214c9df8 100644
--- a/graph/mutators/comment.js
+++ b/graph/mutators/comment.js
@@ -36,6 +36,12 @@ const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id =
}
}
+ if (user.hasRoles('ADMIN')) {
+ return CommentsService
+ .addTag(comment.id, 'STAFF', user.id)
+ .then(() => comment);
+ }
+
return comment;
});
};
@@ -171,7 +177,7 @@ module.exports = (context) => {
return {
Comment: {
- create: () => {}
+ create: () => Promise.reject(errors.ErrNotAuthorized)
}
};
};
diff --git a/graph/resolvers/action.js b/graph/resolvers/action.js
index f8aa60245..0962f59af 100644
--- a/graph/resolvers/action.js
+++ b/graph/resolvers/action.js
@@ -1,4 +1,12 @@
const Action = {
+ __resolveType({action_type}) {
+ switch (action_type) {
+ case 'FLAG':
+ return 'FlagAction';
+ case 'LIKE':
+ return 'LikeAction';
+ }
+ },
// This will load the user for the specific action. We'll limit this to the
// admin users only or the current logged in user.
diff --git a/graph/resolvers/action_summary.js b/graph/resolvers/action_summary.js
index 48eff50c7..93848d285 100644
--- a/graph/resolvers/action_summary.js
+++ b/graph/resolvers/action_summary.js
@@ -1,3 +1,12 @@
-const ActionSummary = {};
+const ActionSummary = {
+ __resolveType({action_type}) {
+ switch (action_type) {
+ case 'FLAG':
+ return 'FlagActionSummary';
+ case 'LIKE':
+ return 'LikeActionSummary';
+ }
+ },
+};
module.exports = ActionSummary;
diff --git a/graph/resolvers/asset.js b/graph/resolvers/asset.js
index 60b44fa62..2077b9e7a 100644
--- a/graph/resolvers/asset.js
+++ b/graph/resolvers/asset.js
@@ -1,4 +1,7 @@
const Asset = {
+ recentComments({id}, _, {loaders: {Comments}}) {
+ return Comments.genRecentComments.load(id);
+ },
comments({id}, {sort, limit}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id: id,
diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js
index b3d79225e..ef77ff6b5 100644
--- a/graph/resolvers/comment.js
+++ b/graph/resolvers/comment.js
@@ -2,6 +2,9 @@ const Comment = {
user({author_id}, _, {loaders: {Users}}) {
return Users.getByID.load(author_id);
},
+ recentReplies({id}, _, {loaders: {Comments}}) {
+ return Comments.genRecentReplies.load(id);
+ },
replies({id, asset_id}, {sort, limit}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id,
@@ -13,7 +16,16 @@ const Comment = {
replyCount({id}, _, {loaders: {Comments}}) {
return Comments.countByParentID.load(id);
},
- actions({id}, _, {loaders: {Actions}}) {
+ actions({id}, _, {user, loaders: {Actions}}) {
+
+ // Only return the actions if the user is not an admin.
+ if (user && user.hasRoles('ADMIN')) {
+ return Actions.getByID.load(id);
+ }
+
+ return null;
+ },
+ action_summaries({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
},
asset({asset_id}, _, {loaders: {Assets}}) {
diff --git a/graph/resolvers/flag_action.js b/graph/resolvers/flag_action.js
new file mode 100644
index 000000000..44cf7a410
--- /dev/null
+++ b/graph/resolvers/flag_action.js
@@ -0,0 +1,9 @@
+const FlagAction = {
+
+ // Stored in the metadata, extract and return.
+ reason({metadata: {reason}}) {
+ return reason;
+ }
+};
+
+module.exports = FlagAction;
diff --git a/graph/resolvers/flag_action_summary.js b/graph/resolvers/flag_action_summary.js
new file mode 100644
index 000000000..58dda689e
--- /dev/null
+++ b/graph/resolvers/flag_action_summary.js
@@ -0,0 +1,7 @@
+const FlagActionSummary = {
+ reason({group_id}) {
+ return group_id;
+ }
+};
+
+module.exports = FlagActionSummary;
diff --git a/graph/resolvers/generic_user_error.js b/graph/resolvers/generic_user_error.js
new file mode 100644
index 000000000..45b656f5b
--- /dev/null
+++ b/graph/resolvers/generic_user_error.js
@@ -0,0 +1,3 @@
+const GenericUserError = {};
+
+module.exports = GenericUserError;
diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js
index f25664f34..84dd10fdc 100644
--- a/graph/resolvers/index.js
+++ b/graph/resolvers/index.js
@@ -1,21 +1,33 @@
-const Action = require('./action');
const ActionSummary = require('./action_summary');
+const Action = require('./action');
const Asset = require('./asset');
const Comment = require('./comment');
const Date = require('./date');
+const FlagActionSummary = require('./flag_action_summary');
+const FlagAction = require('./flag_action');
+const GenericUserError = require('./generic_user_error');
+const LikeAction = require('./like_action');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
+const UserError = require('./user_error');
const User = require('./user');
+const ValidationUserError = require('./validation_user_error');
module.exports = {
- Action,
ActionSummary,
+ Action,
Asset,
Comment,
Date,
+ FlagActionSummary,
+ FlagAction,
+ GenericUserError,
+ LikeAction,
RootMutation,
RootQuery,
Settings,
- User
+ UserError,
+ User,
+ ValidationUserError,
};
diff --git a/graph/resolvers/like_action.js b/graph/resolvers/like_action.js
new file mode 100644
index 000000000..12d10f81e
--- /dev/null
+++ b/graph/resolvers/like_action.js
@@ -0,0 +1,5 @@
+const LikeAction = {
+
+};
+
+module.exports = LikeAction;
diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js
index ef46b839a..8369b7281 100644
--- a/graph/resolvers/root_mutation.js
+++ b/graph/resolvers/root_mutation.js
@@ -1,12 +1,31 @@
+/**
+ * Wraps up a promise to return an object with the resolution of the promise
+ * keyed at `key` or an error caught at `errors`.
+ */
+const wrapResponse = (key) => (promise) => {
+ return promise.then((value) => {
+ let res = {};
+ if (key) {
+ res[key] = value;
+ }
+ return res;
+ }).catch((err) => ({
+ errors: [err]
+ }));
+};
+
const RootMutation = {
createComment(_, {asset_id, parent_id, body}, {mutators: {Comment}}) {
- return Comment.create({asset_id, parent_id, body});
+ return wrapResponse('comment')(Comment.create({asset_id, parent_id, body}));
},
- createAction(_, {action}, {mutators: {Action}}) {
- return Action.create(action);
+ createLike(_, {like: {item_id, item_type}}, {mutators: {Action}}) {
+ return wrapResponse('like')(Action.create({item_id, item_type, action_type: 'LIKE'}));
+ },
+ createFlag(_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) {
+ return wrapResponse('flag')(Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}));
},
deleteAction(_, {id}, {mutators: {Action}}) {
- return Action.delete({id});
+ return wrapResponse(null)(Action.delete({id}));
},
};
diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js
index c75cdbf54..13efe29e9 100644
--- a/graph/resolvers/user.js
+++ b/graph/resolvers/user.js
@@ -1,7 +1,15 @@
const User = {
- actions({id}, _, {loaders: {Actions}}) {
+ action_summaries({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
},
+ actions({id}, _, {user, loaders: {Actions}}) {
+
+ // Only return the actions if the user is not an admin.
+ if (user && user.hasRoles('ADMIN')) {
+ return Actions.getByID.load(id);
+ }
+
+ },
comments({id}, _, {loaders: {Comments}, user}) {
// If the user is not an admin, only return comment list for the owner of
diff --git a/graph/resolvers/user_error.js b/graph/resolvers/user_error.js
new file mode 100644
index 000000000..3716fa126
--- /dev/null
+++ b/graph/resolvers/user_error.js
@@ -0,0 +1,11 @@
+const UserError = {
+ __resolveType({field_name}) {
+ if (field_name) {
+ return 'ValidationUserError';
+ }
+
+ return 'GenericUserError';
+ }
+};
+
+module.exports = UserError;
diff --git a/graph/resolvers/validation_user_error.js b/graph/resolvers/validation_user_error.js
new file mode 100644
index 000000000..211a0e505
--- /dev/null
+++ b/graph/resolvers/validation_user_error.js
@@ -0,0 +1,3 @@
+const ValidationUserError = {};
+
+module.exports = ValidationUserError;
diff --git a/graph/schema.js b/graph/schema.js
index b4b42b809..2fca1a87f 100644
--- a/graph/schema.js
+++ b/graph/schema.js
@@ -1,8 +1,15 @@
const tools = require('graphql-tools');
+const maskErrors = require('graphql-errors').maskErrors;
const resolvers = require('./resolvers');
const typeDefs = require('./typeDefs');
const schema = tools.makeExecutableSchema({typeDefs, resolvers});
+if (process.env.NODE_ENV === 'production') {
+
+ // Mask errors that are thrown if we are in a production environment.
+ maskErrors(schema);
+}
+
module.exports = schema;
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index b1f9b82f7..ddffcd708 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -1,16 +1,94 @@
-# Establishes the ordering of the content by their created_at time stamp.
-enum SORT_ORDER {
- # newest to oldest order.
- REVERSE_CHRONOLOGICAL
-
- # oldest to newer order.
- CHRONOLOGICAL
-}
+################################################################################
+## Custom Scalar Types
+################################################################################
# Date represented as an ISO8601 string.
scalar Date
+
+################################################################################
+## Users
+################################################################################
+
+# Roles that a user can have, these can be combined.
+enum USER_ROLES {
+
+ # an administrator of the site
+ ADMIN
+
+ # a moderator of the site
+ MODERATOR
+}
+
+# Any person who can author comments, create actions, and view comments on a
+# stream.
+type User {
+
+ # The ID of the User.
+ id: ID!
+
+ # display name of a user.
+ displayName: String!
+
+ # Action summaries against the user.
+ action_summaries: [ActionSummary]
+
+ # Actions completed on the parent.
+ actions: [Action]
+
+ # the current roles of the user.
+ roles: [USER_ROLES]
+
+ # determines whether the user can edit their username
+ canEditName: Boolean
+
+ # returns all comments based on a query.
+ comments(query: CommentsQuery): [Comment]
+}
+
+type Tag {
+ # the actual tag for the comment.
+ name: String!
+
+ # the user that assigned the tag. If NULL then the system automatically tagged it.
+ assigned_by: String
+
+ # the time when the tag was assigned.
+ created_at: Date!
+}
+
+################################################################################
+## Comments
+################################################################################
+
+# The statuses that a comment may have.
+enum COMMENT_STATUS {
+
+ # The comment has been accepted by a moderator.
+ ACCEPTED
+
+ # The comment has been rejected by a moderator.
+ REJECTED
+
+ # The comment was created while the asset's premoderation option was on, and
+ # new comments that haven't been moderated yet are referred to as
+ # "premoderated" or "premod" comments.
+ PREMOD
+}
+
+# The types of action there are as enum's.
+enum ACTION_TYPE {
+
+ # Represents a LikeAction.
+ LIKE
+
+ # Represents a FlagAction.
+ FLAG
+}
+
+# CommentsQuery allows the ability to query comments by a specific methods.
input CommentsQuery {
+
# current status of a comment.
statuses: [COMMENT_STATUS]
@@ -30,113 +108,181 @@ input CommentsQuery {
# skip results from the last created_at timestamp.
cursor: Date
+ # filter by a specific tag name.
+ tag: [String]
+
# sort the results by created_at.
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
}
-enum USER_ROLES {
- # an administrator of the site
- ADMIN
-
- # a moderator of the site
- MODERATOR
-}
-
-# Any person who can author comments, create actions, and view comments on a
-# stream.
-type User {
- id: ID!
-
- # display name of a user.
- displayName: String!
-
- # actions against a specific user.
- actions: [ActionSummary]
-
- # the current roles of the user.
- roles: [USER_ROLES]
-
- # determines whether the user can edit their username
- canEditName: Boolean
-
- # returns all comments based on a query.
- comments(query: CommentsQuery): [Comment]
-}
-
+# Comment is the base representation of user interaction in Talk.
type Comment {
+
+ # The ID of the comment.
id: ID!
- # the actual comment data.
+ # The actual comment data.
body: String!
+ # the tags on the comment
+ tags: [Tag]
+
# the user who authored the comment.
user: User
+ # the recent replies made against this comment.
+ recentReplies: [Comment]
+
# the replies that were made to the comment.
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3): [Comment]
- # the count of replies on a comment
+ # The count of replies on a comment.
replyCount: Int
- # the actions made against a comment.
- actions: [ActionSummary]
+ # Actions completed on the parent.
+ actions: [Action]
- # the asset that a comment was made on.
+ # Action summaries against a comment.
+ action_summaries: [ActionSummary]
+
+ # The asset that a comment was made on.
asset: Asset
- # the current status of a comment.
+ # The current status of a comment.
status: COMMENT_STATUS
- # the time when the comment was created
+ # The time when the comment was created
created_at: Date!
}
-enum ITEM_TYPE {
- ASSETS
- COMMENTS
- USERS
-}
+################################################################################
+## Actions
+################################################################################
-enum ACTION_TYPE {
- LIKE
- FLAG
-}
+# An action rendered against a parent enity item.
+interface Action {
-type Action {
+ # The ID of the action.
id: ID!
- action_type: ACTION_TYPE!
- item_id: ID!
- item_type: ITEM_TYPE!
+ # The author of the action.
+ user: User
- user: User!
+ # The time when the Action was updated.
updated_at: Date
+
+ # The time when the Action was created.
created_at: Date
}
-type ActionSummary {
- action_type: ACTION_TYPE!
- item_type: ITEM_TYPE!
+# A summary of actions based on the specific grouping of the group_id.
+interface ActionSummary {
+
+ # The count of actions with this group.
count: Int
+
+ # The current user's action.
current_user: Action
}
+# LikeAction is used by users who "like" a specific entity.
+type LikeAction implements Action {
+
+ # The ID of the action.
+ id: ID!
+
+ # The author of the action.
+ user: User
+
+ # The time when the Action was updated.
+ updated_at: Date
+
+ # The time when the Action was created.
+ created_at: Date
+}
+
+# LikeActionSummary is counts the amount of "likes" that a specific entity has.
+type LikeActionSummary implements ActionSummary {
+
+ # The count of likes against the parent entity.
+ count: Int!
+
+ current_user: LikeAction
+}
+
+# A FLAG action that contains flag metadata.
+type FlagAction implements Action {
+
+ # The ID of the Flag Action.
+ id: ID!
+
+ # The reason for which the Flag Action was created.
+ reason: String
+
+ # An optional message sent with the flagging action by the user.
+ message: String
+
+ # The user who created the action.
+ user: User
+
+ # The time when the Flag Action was updated.
+ updated_at: Date
+
+ # The time when the Flag Action was created.
+ created_at: Date
+}
+
+# Summary for Flag Action with a a unique reason.
+type FlagActionSummary implements ActionSummary {
+
+ # The total count of flags with this reason.
+ count: Int!
+
+ # The reason for which the Flag Action was created.
+ reason: String
+
+ # The flag by the current user against the parent entity with this reason.
+ current_user: FlagAction
+}
+
+################################################################################
+## Settings
+################################################################################
+
+# The moderation mode of the site.
enum MODERATION_MODE {
+
+ # Comments posted while in `PRE` mode will be labeled with a `PREMOD`
+ # status and will require a moderator decision before being visible.
PRE
+
+ # Comments posted while in `POST` will be visible immediately.
POST
}
+# Site wide global settings.
type Settings {
+
+ # Moderation mode for the site.
moderation: MODERATION_MODE!
+
+ # Enables a requirement for email confirmation before a user can login.
+ requireEmailConfirmation: Boolean
+
infoBoxEnable: Boolean
infoBoxContent: String
closeTimeout: Int
closedMessage: String
charCountEnable: Boolean
charCount: Int
- requireEmailConfirmation: Boolean
+
}
+################################################################################
+## Assets
+################################################################################
+
+# Where comments are made on.
type Asset {
# The current ID of the asset.
@@ -148,6 +294,9 @@ type Asset {
# The URL that the asset is located on.
url: String
+ # Returns recent comments
+ recentComments: [Comment]
+
# The top level comments that are attached to the asset.
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10): [Comment]
@@ -165,53 +314,178 @@ type Asset {
created_at: Date
}
-enum COMMENT_STATUS {
- ACCEPTED
- REJECTED
- PREMOD
+################################################################################
+## Errors
+################################################################################
+
+# Any error rendered due to the user's input.
+interface UserError {
+
+ # Translation key relating to a translatable string containing details to be
+ # displayed to the end user.
+ translation_key: String!
}
+# A generic error not related to validation reasons.
+type GenericUserError implements UserError {
+
+ # Translation key relating to a translatable string containing details to be
+ # displayed to the end user.
+ translation_key: String!
+}
+
+# A validation error that affects the input.
+type ValidationUserError implements UserError {
+
+ # Translation key relating to a translatable string containing details to be
+ # displayed to the end user.
+ translation_key: String!
+
+ # The field in question that caused the error.
+ field_name: String!
+}
+
+################################################################################
+## Queries
+################################################################################
+
+# Establishes the ordering of the content by their created_at time stamp.
+enum SORT_ORDER {
+
+ # newest to oldest order.
+ REVERSE_CHRONOLOGICAL
+
+ # oldest to newer order.
+ CHRONOLOGICAL
+}
+
+# All queries that can be executed.
type RootQuery {
- # retrieves site wide settings and defaults.
+ # Site wide settings and defaults.
settings: Settings
- # retrieves all assets.
+ # All assets.
assets: [Asset]
- # retrieves a specific asset.
+ # Find or create an asset by url, or just find with the ID.
asset(id: ID, url: String): Asset
- # retrieves comments based on the input query.
+ # Comments returned based on a query.
comments(query: CommentsQuery!): [Comment]
- # retrieves the current logged in user.
+ # The currently logged in user based on the request.
me: User
}
-input CreateActionInput {
- # the type of action.
- action_type: ACTION_TYPE!
+################################################################################
+## Mutations
+################################################################################
- # the type of the item.
- item_type: ITEM_TYPE!
+# Response defines what can be expected from any response to a mutation action.
+interface Response {
- # the id of the item that is related to the action.
+ # An array of errors relating to the mutation that occured.
+ errors: [UserError]
+}
+
+# CreateCommentResponse is returned with the comment that was created and any
+# errors that may have occured in the attempt to create it.
+type CreateCommentResponse implements Response {
+
+ # The comment that was created.
+ comment: Comment
+
+ # An array of errors relating to the mutation that occured.
+ errors: [UserError]
+}
+
+# Used to represent the item type for an action.
+enum ACTION_ITEM_TYPE {
+
+ # The action references a entity of type Asset.
+ ASSETS
+
+ # The action references a entity of type Comment.
+ COMMENTS
+
+ # The action references a entity of type User.
+ USERS
+}
+
+input CreateLikeInput {
+
+ # The item's id for which we are to create a like.
item_id: ID!
+
+ # The type of the item for which we are to create the like.
+ item_type: ACTION_ITEM_TYPE!
}
+type CreateLikeResponse implements Response {
+
+ # The like that was created.
+ like: LikeAction
+
+ # An array of errors relating to the mutation that occured.
+ errors: [UserError]
+}
+
+input CreateFlagInput {
+
+ # The item's id for which we are to create a flag.
+ item_id: ID!
+
+ # The type of the item for which we are to create the flag.
+ item_type: ACTION_ITEM_TYPE!
+
+ # The reason for flagging the item.
+ reason: String!
+
+ # An optional message sent with the flagging action by the user.
+ message: String
+}
+
+# CreateFlagResponse is the response returned with possibly some errors
+# relating to the creating the flag action attempt and possibly the flag that
+# was created.
+type CreateFlagResponse implements Response {
+
+ # The like that was created.
+ flag: FlagAction
+
+ # An array of errors relating to the mutation that occured.
+ errors: [UserError]
+}
+
+# DeleteActionResponse is the response returned with possibly some errors
+# relating to the delete action attempt.
+type DeleteActionResponse implements Response {
+
+ # An array of errors relating to the mutation that occured.
+ errors: [UserError]
+}
+
+# All mutations for the application are defined on this object.
type RootMutation {
- # creates a comment on the asset.
- createComment(asset_id: ID!, parent_id: ID, body: String!): Comment
- # creates an action based on an input.
- createAction(action: CreateActionInput!): Action
+ # Creates a comment on the asset.
+ createComment(asset_id: ID!, parent_id: ID, body: String!): CreateCommentResponse
- # delete an action based on the action id.
- deleteAction(id: ID!): Boolean
+ # Creates a like on an entity.
+ createLike(like: CreateLikeInput!): CreateLikeResponse
+ # Creates a flag on an entity.
+ createFlag(flag: CreateFlagInput!): CreateFlagResponse
+
+ # Delete an action based on the action id.
+ deleteAction(id: ID!): DeleteActionResponse
}
+################################################################################
+## Schema
+################################################################################
+
schema {
query: RootQuery
mutation: RootMutation
diff --git a/models/action.js b/models/action.js
index ed871f56b..36d83574e 100644
--- a/models/action.js
+++ b/models/action.js
@@ -29,6 +29,12 @@ const ActionSchema = new Schema({
},
item_id: String,
user_id: String,
+
+ // The element that summaries will additionally group on in addtion to their action_type, item_type, and
+ // item_id.
+ group_id: String,
+
+ // Additional metadata stored on the field.
metadata: Schema.Types.Mixed
}, {
timestamps: {
diff --git a/models/comment.js b/models/comment.js
index b656f9238..ac44d904e 100644
--- a/models/comment.js
+++ b/models/comment.js
@@ -30,6 +30,24 @@ const StatusSchema = new Schema({
_id: false
});
+/**
+ * The Mongo schema for a Comment Tag.
+ * @type {Schema}
+ */
+const TagSchema = new Schema({
+ name: String,
+
+ // The User ID of the user that assigned the status.
+ assigned_by: {
+ type: String,
+ default: null
+ },
+
+ created_at: Date
+}, {
+ _id: false
+});
+
/**
* The Mongo schema for a Comment.
* @type {Schema}
@@ -49,6 +67,7 @@ const CommentSchema = new Schema({
author_id: String,
status_history: [StatusSchema],
status: {type: String, default: null},
+ tags: [TagSchema],
parent_id: String
}, {
timestamps: {
diff --git a/models/setting.js b/models/setting.js
index 25882b3f8..adbf60d83 100644
--- a/models/setting.js
+++ b/models/setting.js
@@ -62,6 +62,12 @@ const SettingSchema = new Schema({
requireEmailConfirmation: {
type: Boolean,
default: false
+ },
+ domains: {
+ whitelist: {
+ type: Array,
+ default: ['localhost']
+ }
}
}, {
timestamps: {
diff --git a/package.json b/package.json
index 048d487be..09ce50aa9 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"express": "^4.14.0",
"express-session": "^1.14.2",
"graphql": "^0.8.2",
+ "graphql-errors": "^2.1.0",
"graphql-server-express": "^0.5.0",
"graphql-tag": "^1.2.3",
"graphql-tools": "^0.9.0",
@@ -120,6 +121,7 @@
"eslint-plugin-standard": "^2.0.1",
"exports-loader": "^0.6.3",
"fetch-mock": "^5.5.0",
+ "graphql-docs": "^0.2.0",
"hammerjs": "^2.0.8",
"ignore-styles": "^5.0.1",
"immutable": "^3.8.1",
diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js
index 19346274d..96f3911d4 100644
--- a/routes/api/comments/index.js
+++ b/routes/api/comments/index.js
@@ -127,21 +127,4 @@ router.put('/:comment_id/status', authorization.needed('ADMIN'), (req, res, next
});
});
-router.post('/:comment_id/actions', (req, res, next) => {
-
- const {
- action_type,
- metadata
- } = req.body;
-
- CommentsService
- .addAction(req.params.comment_id, req.user.id, action_type, metadata)
- .then((action) => {
- res.status(201).json(action);
- })
- .catch((err) => {
- next(err);
- });
-});
-
module.exports = router;
diff --git a/routes/api/index.js b/routes/api/index.js
index c40c428b4..9913d8ad2 100644
--- a/routes/api/index.js
+++ b/routes/api/index.js
@@ -13,6 +13,7 @@ router.use('/actions', authorization.needed(), require('./actions'));
router.use('/auth', require('./auth'));
router.use('/users', require('./users'));
router.use('/account', require('./account'));
+router.use('/setup', require('./setup'));
// Bind the kue handler to the /kue path.
router.use('/kue', authorization.needed('ADMIN'), require('../../services/kue').kue.app);
diff --git a/routes/api/setup/index.js b/routes/api/setup/index.js
new file mode 100644
index 000000000..60f7d7fd2
--- /dev/null
+++ b/routes/api/setup/index.js
@@ -0,0 +1,51 @@
+const express = require('express');
+
+const SetupService = require('../../../services/setup');
+
+const router = express.Router();
+
+router.get('/', (req, res, next) => {
+ SetupService
+ .isAvailable()
+ .then(() => {
+ res.json({installed: false});
+ })
+ .catch(() => {
+ res.json({installed: true});
+ });
+});
+
+router.post('/', (req, res, next) => {
+
+ SetupService
+ .isAvailable()
+ .then(() => {
+
+ // Allow the request to keep going here.
+ next();
+ })
+ .catch((err) => {
+ next(err);
+ });
+
+}, (req, res, next) => {
+
+ const {
+ settings,
+ user: {email, password, displayName}
+ } = req.body;
+
+ SetupService
+ .setup({settings, user: {email, password, displayName}})
+ .then(() => {
+
+ // We're setup!
+ res.status(204).end();
+ })
+ .catch((err) => {
+ return next(err);
+ });
+
+});
+
+module.exports = router;
diff --git a/routes/api/users/index.js b/routes/api/users/index.js
index 5d9386d14..1ce429cb4 100644
--- a/routes/api/users/index.js
+++ b/routes/api/users/index.js
@@ -1,7 +1,6 @@
const express = require('express');
const router = express.Router();
const UsersService = require('../../../services/users');
-const SettingsService = require('../../../services/settings');
const CommentsService = require('../../../services/comments');
const mailer = require('../../../services/mailer');
const errors = require('../../../errors');
@@ -97,12 +96,6 @@ router.post('/:user_id/email', authorization.needed('ADMIN'), (req, res, next) =
.catch(next);
});
-// /**
-// * SendEmailConfirmation sends a confirmation email to the user.
-// * @param {Request} req express request object
-// * @param {String} email user email address
-// */
-
/**
* SendEmailConfirmation sends a confirmation email to the user.
* @param {ExpressApp} app the instance of the express app
@@ -134,25 +127,14 @@ router.post('/', (req, res, next) => {
.createLocalUser(email, password, displayName)
.then((user) => {
- // Get the settings from the database to find out if we need to send an
- // email confirmation. The Front end will know about the
+ // Send an email confirmation. The Front end will know about the
// requireEmailConfirmation as it's included in the settings get endpoint.
- return SettingsService.retrieve().then(({requireEmailConfirmation = false}) => {
+ return SendEmailConfirmation(req.app, user.id, email, redirectUri)
+ .then(() => {
- if (requireEmailConfirmation) {
-
- SendEmailConfirmation(req.app, user.id, email, redirectUri)
- .then(() => {
-
- // Then send back the user.
- res.status(201).json(user);
- });
- } else {
-
- // We don't need to confirm the email, let's just send back the user!
+ // Then send back the user.
res.status(201).json(user);
- }
- });
+ });
})
.catch(err => {
next(err);
diff --git a/services/actions.js b/services/actions.js
index c336dd974..0bcb81af0 100644
--- a/services/actions.js
+++ b/services/actions.js
@@ -26,7 +26,8 @@ module.exports = class ActionsService {
action_type: action.action_type,
item_type: action.item_type,
item_id: action.item_id,
- user_id: action.user_id
+ user_id: action.user_id,
+ group_id: action.group_id
};
// Create/Update the action.
@@ -86,68 +87,70 @@ module.exports = class ActionsService {
* @param {String} ids array of user identifiers (uuid)
*/
static getActionSummaries(item_ids, current_user_id = '') {
- return ActionModel.aggregate([
- {
- // only grab items that match the specified item id's
- $match: {
- item_id: {
- $in: item_ids
- }
- }
+ // only grab items that match the specified item id's
+ let $match = {
+ item_id: {
+ $in: item_ids
+ }
+ };
+
+ let $group = {
+
+ // group unique documents by these properties, we are leveraging the
+ // fact that each uuid is completly unique.
+ _id: {
+ item_id: '$item_id',
+ action_type: '$action_type',
+ group_id: '$group_id'
},
- {
- $group: {
- // group unique documents by these properties, we are leveraging the
- // fact that each uuid is completly unique.
- _id: {
- item_id: '$item_id',
- action_type: '$action_type'
- },
-
- // and sum up all actions matching the above grouping criteria
- count: {
- $sum: 1
- },
-
- // we are leveraging the fact that each uuid is completly unique and
- // just grabbing the last instance of the item type here.
- item_type: {
- $last: '$item_type'
- },
-
- current_user: {
- $max: {
- $cond: {
- if: {
- $eq: ['$user_id', current_user_id],
- },
- then: '$$CURRENT',
- else: null
- }
- }
- }
- }
+ // and sum up all actions matching the above grouping criteria
+ count: {
+ $sum: 1
},
- {
- $project: {
- // suppress the _id field
- _id: false,
+ // we are leveraging the fact that each uuid is completly unique and
+ // just grabbing the last instance of the item type here.
+ item_type: {
+ $first: '$item_type'
+ },
- // map the fields from the _id grouping down a level
- item_id: '$_id.item_id',
- action_type: '$_id.action_type',
-
- // map the field directly
- count: '$count',
- item_type: '$item_type',
-
- // set the current user to false here
- current_user: '$current_user'
+ current_user: {
+ $max: {
+ $cond: {
+ if: {
+ $eq: ['$user_id', current_user_id],
+ },
+ then: '$$CURRENT',
+ else: null
+ }
}
}
+ };
+
+ let $project = {
+
+ // suppress the _id field
+ _id: false,
+
+ // map the fields from the _id grouping down a level
+ item_id: '$_id.item_id',
+ action_type: '$_id.action_type',
+ group_id: '$_id.group_id',
+
+ // map the field directly
+ count: '$count',
+ item_type: '$item_type',
+
+ // set the current user to false here
+ current_user: '$current_user'
+ };
+
+ return ActionModel.aggregate([
+ {$match},
+ {$group},
+ {$project}
]);
}
diff --git a/services/assets.js b/services/assets.js
index 24a161865..f8f795263 100644
--- a/services/assets.js
+++ b/services/assets.js
@@ -1,5 +1,7 @@
const AssetModel = require('../models/asset');
const SettingsService = require('./settings');
+const domainlist = require('./domainlist');
+const errors = require('../errors');
module.exports = class AssetsService {
@@ -53,16 +55,24 @@ module.exports = class AssetsService {
* @return {Promise}
*/
static findOrCreateByUrl(url) {
- return AssetModel.findOneAndUpdate({url}, {url}, {
- // Ensure that if it's new, we return the new object created.
- new: true,
+ // Check the URL to confirm that is in the domain whitelist
+ return domainlist.urlCheck(url).then((whitelisted) => {
+ if (!whitelisted) {
+ return Promise.reject(errors.ErrInvalidAssetURL);
+ } else {
+ return AssetModel.findOneAndUpdate({url}, {url}, {
- // Perform an upsert in the event that this doesn't exist.
- upsert: true,
+ // Ensure that if it's new, we return the new object created.
+ new: true,
- // Set the default values if not provided based on the mongoose models.
- setDefaultsOnInsert: true
+ // Perform an upsert in the event that this doesn't exist.
+ upsert: true,
+
+ // Set the default values if not provided based on the mongoose models.
+ setDefaultsOnInsert: true
+ });
+ }
});
}
diff --git a/services/comments.js b/services/comments.js
index 5d39a129e..fc05341cb 100644
--- a/services/comments.js
+++ b/services/comments.js
@@ -3,6 +3,10 @@ const CommentModel = require('../models/comment');
const ActionModel = require('../models/action');
const ActionsService = require('./actions');
+const ALLOWED_TAGS = [
+ {name: 'STAFF'}
+];
+
module.exports = class CommentsService {
/**
@@ -33,6 +37,7 @@ module.exports = class CommentsService {
type: status,
created_at: new Date()
}] : [],
+ tags: [],
status,
author_id
});
@@ -40,6 +45,33 @@ module.exports = class CommentsService {
return comment.save();
}
+ /**
+ * Adds a tag if it doesn't already exist on the comment.
+ */
+ static addTag(id, name, assigned_by) {
+
+ if (ALLOWED_TAGS.find((t) => t.name === name) == null) {
+ return Promise.reject(new Error('tag not allowed'));
+ }
+
+ return CommentModel.update({
+ id,
+ tags: {
+ $ne: {
+ name
+ }
+ }
+ }, {
+ $push: {
+ tags: {
+ name,
+ assigned_by,
+ created_at: new Date()
+ }
+ }
+ });
+ }
+
/**
* Finds a comment by the id.
* @param {String} id identifier of comment (uuid)
diff --git a/services/domainlist.js b/services/domainlist.js
new file mode 100644
index 000000000..29673e2b6
--- /dev/null
+++ b/services/domainlist.js
@@ -0,0 +1,110 @@
+const debug = require('debug')('talk:services:domainlist');
+const _ = require('lodash');
+const SettingsService = require('./settings');
+
+/**
+ * The root domainlist object.
+ * @type {Object}
+ */
+class Domainlist {
+
+ constructor() {
+ this.lists = {
+ whitelist: [],
+ };
+ }
+
+ /**
+ * Loads domains white list in from the database
+ */
+ load() {
+ return SettingsService
+ .retrieve()
+ .then((settings) => {
+
+ // Insert the settings domains whitelist.
+ this.upsert(settings.domains);
+ });
+ }
+
+ /**
+ * Inserts the domains whitelist data
+ * @param {Array} list list of domains to be set to the whitelist
+ */
+ upsert(lists) {
+
+ // Add the domains to this array and also be sure are all unique domains
+ if (!('whitelist' in lists)) {
+ return;
+ }
+
+ this.lists['whitelist'] = Domainlist.parseList(lists['whitelist']);
+ debug(`Added ${lists['whitelist'].length} domains to the whitelist.`);
+
+ return Promise.resolve(this);
+ }
+
+ /**
+ * Tests the url to see if it contains any of the whitelisted domains.
+ * @param {String} url value to match.
+ * @return {Boolean} true if the url contains any of the domains, false otherwise.
+ */
+ match(list, url) {
+
+ const domainToMatch = Domainlist.parseURL(url);
+
+ // This will return true in the event that at least one blockword is found
+ // in the phrase.
+ for (let i = 0; i < list.length; i++) {
+ if (list[i] === domainToMatch) {
+ return true;
+ }
+ }
+
+ // We've walked over all the whitelisted domains, and haven't had a
+ // mismatch... It is not an allowed domain!
+ return false;
+ }
+
+ /**
+ * Parses the list content.
+ * @param {Array} list array of domains to parse for a list.
+ * @return {Array} the parsed list
+ */
+ static parseList(list) {
+ return _.uniq(list.map((domain) => Domainlist.parseURL(domain)));
+ }
+
+ /**
+ * Parses the URL.
+ * @param {String} url url to parse for a domain.
+ * @return {String} the domain
+ */
+ static parseURL(url){
+ let domain;
+
+ // removes protocol and get domain
+ if (url.indexOf('://') > -1) {
+ domain = url.split('/')[2];
+ } else {
+ domain = url.split('/')[0];
+ }
+
+ // remove port number
+ domain = domain.split(':')[0];
+
+ return domain.toLowerCase();
+ }
+
+ static urlCheck(url) {
+ const dl = new Domainlist();
+
+ return dl.load()
+ .then(() => {
+ return dl.match(dl.lists['whitelist'], url);
+ });
+ }
+
+}
+
+module.exports = Domainlist;
diff --git a/services/setup.js b/services/setup.js
new file mode 100644
index 000000000..74ef1d431
--- /dev/null
+++ b/services/setup.js
@@ -0,0 +1,100 @@
+const UsersService = require('./users');
+const SettingsService = require('./settings');
+const SettingsModel = require('../models/setting');
+const errors = require('../errors');
+
+/**
+ * This service is used when we want to setup the application. It is consumed by
+ * the dynamic setup endpoint and by the cli-setup tool.
+ */
+module.exports = class SetupService {
+
+ /**
+ * This returns a promise which resolves if the setup is available.
+ */
+ static isAvailable() {
+
+ // Check if we have an install lock present.
+ if (process.env.TALK_INSTALL_LOCK === 'TRUE') {
+ return Promise.reject(errors.ErrInstallLock);
+ }
+
+ // Get the current settings, we are expecing an error here.
+ return SettingsService
+ .retrieve()
+ .then(() => {
+
+ // We should NOT have gotten a settings object, this means that the
+ // application is already setup. Error out here.
+ return Promise.reject(errors.ErrSettingsInit);
+
+ })
+ .catch((err) => {
+
+ // If the error is `not init`, then we're good, otherwise, it's something
+ // else.
+ if (err !== errors.ErrSettingsNotInit) {
+ return Promise.reject(err);
+ }
+
+ // Allow the request to keep going here.
+ return;
+ });
+ }
+
+ /**
+ * This verifies that the current input for the setup is valid.
+ */
+ static validate({settings, user: {email, displayName, password}}) {
+
+ // Verify the email address of the user.
+ if (!email) {
+ return Promise.reject(errors.ErrMissingEmail);
+ }
+
+ // Create a settings model to use for validation.
+ let settingsModel = new SettingsModel(settings);
+
+ // Verify other properties of the user.
+ return Promise.all([
+ UsersService.isValidDisplayName(displayName, false),
+ UsersService.isValidPassword(password),
+ settingsModel.validate()
+ ]);
+ }
+
+ /**
+ * This will perform the setup.
+ */
+ static setup({settings, user: {email, password, displayName}}) {
+
+ // Validate the settings first.
+ return SetupService
+ .validate({settings, user: {email, password, displayName}})
+ .then(() => {
+ return SettingsService.update(settings);
+ })
+ .then((settings) => {
+
+ // Settings are created! Create the user.
+
+ // Create the user.
+ return UsersService
+ .createLocalUser(email, password, displayName)
+
+ // Grant them administrative privileges and confirm the email account.
+ .then((user) => {
+
+ return Promise.all([
+ UsersService.addRoleToUser(user.id, 'ADMIN'),
+ UsersService.confirmEmail(user.id, email)
+ ])
+ .then(() => ({
+ settings,
+ user
+ }));
+ });
+ });
+ }
+
+};
diff --git a/services/users.js b/services/users.js
index bab7b0d20..408d5c618 100644
--- a/services/users.js
+++ b/services/users.js
@@ -170,10 +170,11 @@ module.exports = class UsersService {
/**
* Check the requested displayname for naughty words (currently in English) and special chars
- * @param {String} displayName word to be checked for profanity
+ * @param {String} displayName word to be checked for profanity
+ * @param {Boolean} checkAgainstWordlist enables cheching against the wordlist
* @return {Promise} rejected if the machine's sensibilites are offended
*/
- static isValidDisplayName(displayName) {
+ static isValidDisplayName(displayName, checkAgainstWordlist = true) {
const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/;
if (!displayName) {
@@ -185,10 +186,19 @@ module.exports = class UsersService {
return Promise.reject(errors.ErrSpecialChars);
}
- // check for profanity
- return Wordlist.displayNameCheck(displayName);
+ if (checkAgainstWordlist) {
+
+ // check for profanity
+ return Wordlist.displayNameCheck(displayName);
+ }
+
+ // No errors found!
+ return Promise.resolve(displayName);
}
+ /**
+ * Performs validations for the password.
+ */
static isValidPassword(password) {
if (!password) {
return Promise.reject(errors.ErrMissingPassword);
@@ -456,6 +466,8 @@ module.exports = class UsersService {
.verifyToken(token, {
subject: PASSWORD_RESET_JWT_SUBJECT
})
+
+ // TODO: add search by __v as well
.then((decoded) => UsersService.findById(decoded.userId));
}
@@ -607,25 +619,33 @@ module.exports = class UsersService {
subject: EMAIL_CONFIRM_JWT_SUBJECT
})
.then(({userID, email, referer}) => {
- return UserModel
- .update({
- id: userID,
- profiles: {
- $elemMatch: {
- id: email,
- provider: 'local'
- }
- }
- }, {
- $set: {
- 'profiles.$.metadata.confirmed_at': new Date()
- }
- })
+ return UsersService
+ .confirmEmail(userID, email)
.then(() => ({userID, email, referer}));
});
}
+ /**
+ * Marks the email on the user as confirmed.
+ */
+ static confirmEmail(id, email) {
+ return UserModel
+ .update({
+ id: id,
+ profiles: {
+ $elemMatch: {
+ id: email,
+ provider: 'local'
+ }
+ }
+ }, {
+ $set: {
+ 'profiles.$.metadata.confirmed_at': new Date()
+ }
+ });
+ }
+
/**
* Returns all users with pending 'ADMIN'ation actions.
* @return {Promise}
diff --git a/test/routes/api/assets/index.js b/test/routes/api/assets/index.js
index 56c20fc78..9f1e4ad66 100644
--- a/test/routes/api/assets/index.js
+++ b/test/routes/api/assets/index.js
@@ -10,24 +10,29 @@ chai.use(require('chai-http'));
const AssetModel = require('../../../../models/asset');
const AssetsService = require('../../../../services/assets');
+const SettingsService = require('../../../../services/settings');
describe('/api/v1/assets', () => {
beforeEach(() => {
- return AssetModel.create([
- {
- url: 'https://coralproject.net/news/asset1',
- title: 'Asset 1',
- description: 'term1',
- closedAt: Date.now()
- },
- {
- url: 'https://coralproject.net/news/asset2',
- title: 'Asset 2',
- description: 'term2',
- closedAt: null
- }
- ]);
+ const settings = {id: '1', moderation: 'PRE', domains: {whitelist: ['test.com']}};
+
+ return SettingsService.init(settings).then(() => {
+ return AssetModel.create([
+ {
+ url: 'https://coralproject.net/news/asset1',
+ title: 'Asset 1',
+ description: 'term1',
+ closedAt: Date.now()
+ },
+ {
+ url: 'https://coralproject.net/news/asset2',
+ title: 'Asset 2',
+ description: 'term2',
+ closedAt: null
+ }
+ ]);
+ });
});
describe('#get', () => {
diff --git a/test/routes/api/comments/index.js b/test/routes/api/comments/index.js
index c305f9f6b..0ee19fc5e 100644
--- a/test/routes/api/comments/index.js
+++ b/test/routes/api/comments/index.js
@@ -267,79 +267,3 @@ describe('/api/v1/comments/:comment_id', () => {
});
});
});
-
-describe('/api/v1/comments/:comment_id/actions', () => {
-
- const comments = [{
- id: 'abc',
- body: 'comment 10',
- asset_id: 'asset',
- author_id: '123',
- status_history: []
- }, {
- id: 'def',
- body: 'comment 20',
- asset_id: 'asset',
- author_id: '456',
- status: 'REJECTED',
- status_history: [{
- type: 'REJECTED'
- }]
- }, {
- id: 'hij',
- body: 'comment 30',
- asset_id: '456',
- status: 'ACCEPTED',
- status_history: [{
- type: 'ACCEPTED'
- }]
- }];
-
- const users = [{
- displayName: 'Ana',
- email: 'ana@gmail.com',
- password: '123456789'
- }, {
- displayName: 'Maria',
- email: 'maria@gmail.com',
- password: '123456789'
- }];
-
- const actions = [{
- action_type: 'FLAG',
- item_type: 'COMMENTS',
- item_id: 'abc'
- }, {
- action_type: 'LIKE',
- item_type: 'COMMENTS',
- item_id: 'hij'
- }];
-
- beforeEach(() => {
- return SettingsService.init(settings).then(() => {
- return Promise.all([
- CommentModel.create(comments),
- UsersService.createLocalUsers(users),
- ActionModel.create(actions)
- ]);
- });
- });
-
- describe('#post', () => {
- it('it should create an action', () => {
- return chai.request(app)
- .post('/api/v1/comments/abc/actions')
- .set(passport.inject({id: '456', roles: ['ADMIN']}))
- .send({'action_type': 'flag', 'metadata': {'reason': 'Comment is too awesome.'}})
- .then((res) => {
- expect(res).to.have.status(201);
- expect(res).to.have.body;
-
- expect(res.body).to.have.property('action_type', 'flag');
- expect(res.body).to.have.property('metadata');
- expect(res.body.metadata).to.deep.equal({'reason': 'Comment is too awesome.'});
- expect(res.body).to.have.property('item_id', 'abc');
- });
- });
- });
-});
diff --git a/test/services/assets.js b/test/services/assets.js
index ff8d9e175..3bbe8ee6e 100644
--- a/test/services/assets.js
+++ b/test/services/assets.js
@@ -1,5 +1,6 @@
const AssetModel = require('../../models/asset');
const AssetsService = require('../../services/assets');
+const SettingsService = require('../../services/settings');
const chai = require('chai');
const expect = chai.expect;
@@ -10,8 +11,12 @@ chai.should();
describe('services.AssetsService', () => {
beforeEach(() => {
+ const settings = {id: '1', moderation: 'PRE', domains: {whitelist: ['new.test.com', 'test.com', 'override.test.com']}};
const defaults = {url:'http://test.com'};
- return AssetModel.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true});
+
+ return SettingsService.init(settings).then(() => {
+ return AssetModel.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true});
+ });
});
describe('#findById', ()=> {
@@ -54,7 +59,7 @@ describe('services.AssetsService', () => {
});
});
- it('should return a new asset when the url does not exist', () => {
+ it('should return a new asset when the url does not exist and its domain is whitelisted', () => {
return AssetsService
.findOrCreateByUrl('http://new.test.com')
.then((asset) => {
@@ -62,6 +67,17 @@ describe('services.AssetsService', () => {
.and.to.not.equal(1);
});
});
+
+ it('should return an error when the url does not exist and its domain is not whitelisted', () => {
+ return AssetsService
+ .findOrCreateByUrl('http://bad.test.com')
+ .then((asset) => {
+ expect(asset).to.be.null;
+ })
+ .catch((error) => {
+ expect(error).to.not.be.null;
+ });
+ });
});
describe('#overrideSettings', () => {
diff --git a/test/services/comments.js b/test/services/comments.js
index fc2105cb1..c27050ae7 100644
--- a/test/services/comments.js
+++ b/test/services/comments.js
@@ -70,11 +70,14 @@ describe('services.CommentsService', () => {
const users = [{
email: 'stampi@gmail.com',
displayName: 'Stampi',
- password: '1Coral!!'
+ password: '1Coral!!',
+ roles: ['ADMIN'],
+ _id: '1'
}, {
email: 'sockmonster@gmail.com',
displayName: 'Sockmonster',
- password: '2Coral!!'
+ password: '2Coral!!',
+ _id : '2'
}];
const actions = [{
@@ -256,4 +259,40 @@ describe('services.CommentsService', () => {
});
});
+
+ describe('#tagByStaff()', () => {
+
+ it('creates a new comment by admin', () => {
+ return UsersService.findLocalUser('stampi@gmail.com', '1Coral!!').then((user) => {
+ return UsersService.addRoleToUser(user.id, 'ADMIN').then(() => {
+ UsersService.findById(user.id).then((u) => {
+ return CommentsService
+ .publicCreate({
+ body: 'This is a comment!',
+ status: 'ACCEPTED',
+ author_id: u.id
+ }).then((c) => {
+ expect(c).to.not.be.null;
+ expect(c.tags).to.not.have.length(0);
+ expect(c.tags[0].name).to.be.equal('STAFF');
+ });
+ });
+ });
+ });
+ });
+
+ it('creates a new comment by non admin', () => {
+ return UsersService.findLocalUser('sockmonster@gmail.com', '2Coral!!').then((user) => {
+ return CommentsService
+ .publicCreate({
+ body: 'This is a comment!',
+ status: 'ACCEPTED',
+ author_id: user.id
+ }).then((c) => {
+ expect(c).to.not.be.null;
+ expect(c.tags).to.have.length(0);
+ });
+ });
+ });
+ });
});
diff --git a/test/services/domainlist.js b/test/services/domainlist.js
new file mode 100644
index 000000000..db53b77b4
--- /dev/null
+++ b/test/services/domainlist.js
@@ -0,0 +1,52 @@
+const expect = require('chai').expect;
+const Domainlist = require('../../services/domainlist');
+const SettingsService = require('../../services/settings');
+
+describe('services.Domainlist', () => {
+
+ const domainlists = {
+ whitelist: [
+ 'nytimes.com',
+ 'wapo.com'
+ ]
+ };
+
+ let domainlist = new Domainlist();
+ const settings = {id: '1', moderation: 'PRE', domainlist: {whitelist: ['nytimes.com', 'wapo.com']}};
+
+ beforeEach(() => SettingsService.init(settings));
+
+ describe('#init', () => {
+
+ before(() => domainlist.upsert(domainlists));
+
+ it('has entries', () => {
+ expect(domainlist.lists.whitelist).to.not.be.empty;
+ });
+
+ });
+
+ describe('#match', () => {
+
+ const whiteList = Domainlist.parseList(domainlists['whitelist']);
+
+ it('does match on an included domain', () => {
+ [
+ 'wapo.com',
+ 'nytimes.com'
+ ].forEach((domain) => {
+ expect(domainlist.match(whiteList, domain)).to.be.true;
+ });
+ });
+
+ it('does not match on a not included domain', () => {
+ [
+ 'badsite.com',
+ 'www.badsite.com',
+ 'otherexample.com'
+ ].forEach((domain) => {
+ expect(domainlist.match(whiteList, domain)).to.be.false;
+ });
+ });
+ });
+});
diff --git a/views/admin/docs.ejs b/views/admin/docs.ejs
new file mode 100644
index 000000000..de288e055
--- /dev/null
+++ b/views/admin/docs.ejs
@@ -0,0 +1,33 @@
+
+
+
+
+ Talk: GraphQL Docs
+
+
+
+
+
+
+
+
diff --git a/views/article.ejs b/views/article.ejs
index 72fc69ce3..3869262ff 100644
--- a/views/article.ejs
+++ b/views/article.ejs
@@ -8,6 +8,7 @@
+