diff --git a/.eslintignore b/.eslintignore index fd85e4523..7ee579147 100644 --- a/.eslintignore +++ b/.eslintignore @@ -28,5 +28,5 @@ plugins/* !plugins/talk-plugin-deep-reply-count !plugins/talk-plugin-subscriber !plugins/talk-plugin-flag-details - +public node_modules diff --git a/.nodemon.json b/.nodemon.json index 36fed8700..44b17e9ee 100644 --- a/.nodemon.json +++ b/.nodemon.json @@ -2,7 +2,7 @@ "exec": "npm-run-all --parallel generate-introspection start:development", "verbose": true, "ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"], - "ext": "js,json,graphql", + "ext": "js,json,graphql,yml", "watch": [ ".", "bin/cli", diff --git a/bin/cli b/bin/cli index e01c9cd64..707f41032 100755 --- a/bin/cli +++ b/bin/cli @@ -4,6 +4,7 @@ * Module dependencies. */ +require('./util'); const program = require('commander'); const {head, map} = require('lodash'); const Matcher = require('did-you-mean'); @@ -18,40 +19,37 @@ program .command('users', 'work with the application auth') .command('migration', 'provides utilities for migrating the database') .command('verify', 'provides utilities for performing data verification') - .command( - 'plugins', - 'provides utilities for interacting with the plugin system' - ) + .command('plugins', 'provides utilities for interacting with the plugin system') .parse(process.argv); // If the command wasn't found, output help. -const cmds = map(program.commands, '_name'); -const cmd = head(program.args); -if (!cmds.includes(cmd)) { - const m = new Matcher(cmds); - const similarCMDs = m.list(cmd); +const commands = map(program.commands, '_name'); +const command = head(program.args); +if (!commands.includes(command)) { + const m = new Matcher(commands); + const similarCommands = m.list(command); - console.error(`cli '${cmd}' is not a talk cli command. See 'cli --help'.`); - if (similarCMDs.length > 0) { - const sc = similarCMDs.map(({value}) => `\t${value}\n`).join(''); + console.error(`cli '${command}' is not a talk cli command. See 'cli --help'.`); + if (similarCommands.length > 0) { + const sc = similarCommands.map(({value}) => `\t${value}\n`).join(''); console.error(`\nThe most similar commands are\n${sc}`); } process.exit(1); } -/** - * When this provess exists, check to see if we have a running command, if we do - * check to see if it is still running. If it is, then kill it with a SIGINT - * signal. This is for the use case where we want to kill the process that is - * labled with the PID written out by the parent process. - */ -process.once('exit', () => { - if ( +// /** +// * When this process 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 +// * labeled with the PID written out by the parent process. +// */ +// process.once('exit', () => { +// if ( - // program.runningCommand && - program.runningCommand.killed === false && - program.runningCommand.exitCode === null - ) { - program.runningCommand.kill('SIGINT'); - } -}); +// // program.runningCommand && +// program.runningCommand.killed === false && +// program.runningCommand.exitCode === null +// ) { +// program.runningCommand.kill('SIGINT'); +// } +// }); diff --git a/bin/cli-assets b/bin/cli-assets index 456f16877..5c1780c5d 100755 --- a/bin/cli-assets +++ b/bin/cli-assets @@ -4,6 +4,7 @@ * Module dependencies. */ +const util = require('./util'); const program = require('commander'); const parseDuration = require('ms'); const Table = require('cli-table'); @@ -12,7 +13,6 @@ const CommentModel = require('../models/comment'); const AssetsService = require('../services/assets'); const mongoose = require('../services/mongoose'); const scraper = require('../services/scraper'); -const util = require('./util'); const inquirer = require('inquirer'); // Register the shutdown criteria. diff --git a/bin/cli-jobs b/bin/cli-jobs index 345b22b56..26eeab147 100755 --- a/bin/cli-jobs +++ b/bin/cli-jobs @@ -4,10 +4,10 @@ * Module dependencies. */ +const util = require('./util'); const program = require('commander'); const scraper = require('../services/scraper'); const mailer = require('../services/mailer'); -const util = require('./util'); const mongoose = require('../services/mongoose'); const kue = require('../services/kue'); diff --git a/bin/cli-migration b/bin/cli-migration index f730b8fbb..18a8c012a 100755 --- a/bin/cli-migration +++ b/bin/cli-migration @@ -4,8 +4,8 @@ * Module dependencies. */ -const program = require('commander'); const util = require('./util'); +const program = require('commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); const MigrationService = require('../services/migration'); diff --git a/bin/cli-plugins b/bin/cli-plugins index 8217b023f..4f293b0c3 100755 --- a/bin/cli-plugins +++ b/bin/cli-plugins @@ -7,6 +7,7 @@ // Interface heavily inspired by the yarn package manager: // https://yarnpkg.com/ +require('./util'); const program = require('commander'); const inquirer = require('inquirer'); diff --git a/bin/cli-serve b/bin/cli-serve index cc00f84ed..3e356d829 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -1,7 +1,7 @@ #!/usr/bin/env node -const program = require('commander'); const util = require('./util'); +const program = require('commander'); const serve = require('../serve'); //============================================================================== diff --git a/bin/cli-settings b/bin/cli-settings index 481e5b2bf..6ffacbeaf 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -1,10 +1,10 @@ #!/usr/bin/env node +const util = require('./util'); const program = require('commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); const SettingsService = require('../services/settings'); -const util = require('./util'); // Register the shutdown criteria. util.onshutdown([() => mongoose.disconnect()]); diff --git a/bin/cli-setup b/bin/cli-setup index 35521e05a..0f14add55 100755 --- a/bin/cli-setup +++ b/bin/cli-setup @@ -4,6 +4,7 @@ * Module dependencies. */ +const util = require('./util'); const program = require('commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); @@ -12,7 +13,6 @@ const MODERATION_OPTIONS = require('../models/enum/moderation_options'); const SettingsService = require('../services/settings'); const SetupService = require('../services/setup'); const UsersService = require('../services/users'); -const util = require('./util'); const errors = require('../errors'); // Register the shutdown criteria. diff --git a/bin/cli-token b/bin/cli-token index 87d955df9..dc1b09e6f 100755 --- a/bin/cli-token +++ b/bin/cli-token @@ -4,10 +4,10 @@ * Module dependencies. */ +const util = require('./util'); const program = require('commander'); const mongoose = require('../services/mongoose'); const TokensService = require('../services/tokens'); -const util = require('./util'); const Table = require('cli-table'); // Register the shutdown criteria. diff --git a/bin/cli-users b/bin/cli-users index c15572fe4..71c51e44d 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -4,124 +4,36 @@ * Module dependencies. */ +const util = require('./util'); const program = require('commander'); const inquirer = require('inquirer'); +const {graphql} = require('graphql'); +const {stripIndent} = require('common-tags'); +const Table = require('cli-table'); + +// Make things colorful! +require('colors'); + +// Register the autocomplete plugin. +inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt')); + +const schema = require('../graph/schema'); +const Context = require('../graph/context'); const UsersService = require('../services/users'); const UserModel = require('../models/user'); const CommentModel = require('../models/comment'); const ActionModel = require('../models/action'); const USER_ROLES = require('../models/enum/user_roles'); const mongoose = require('../services/mongoose'); -const util = require('./util'); -const Table = require('cli-table'); const databaseVerifications = require('./verifications/database'); -const validateRequired = (msg = 'Field is required', len = 1) => (input) => { - if (input && input.length >= len) { - return true; - } - - return msg; -}; - -// Regeister the shutdown criteria. +// Register the shutdown criteria. util.onshutdown([ () => mongoose.disconnect() ]); -function getUserCreateAnswers(options) { - if (options.flag_mode) { - - let user = { - email: options.email, - password: options.password, - confirmPassword: options.password, - username: options.name, - role: 'COMMENTER' - }; - - if (options.role && USER_ROLES.indexOf(options.role) > -1) { - user.roles = options.role; - } - - return Promise.resolve(user); - } - - return inquirer.prompt([ - { - name: 'email', - message: 'Email', - format: 'email', - validate: validateRequired('Email is required') - }, - { - name: 'password', - message: 'Password', - type: 'password', - filter: (password) => { - return UsersService - .isValidPassword(password) - .catch((err) => { - throw err.message; - }); - } - }, - { - name: 'confirmPassword', - message: 'Confirm Password', - type: 'password', - filter: (confirmPassword) => { - return UsersService - .isValidPassword(confirmPassword) - .catch((err) => { - throw err.message; - }); - } - }, - { - name: 'username', - message: 'Username', - filter: (username) => { - return UsersService - .isValidUsername(username) - .catch((err) => { - throw err.message; - }); - } - }, - { - name: 'role', - message: 'User Role', - type: 'list', - choices: USER_ROLES - } - ]); -} - /** - * Prompts for input and registers a user based on those. - */ -async function createUser(options) { - try { - const answers = await getUserCreateAnswers(options); - if (answers.password !== answers.confirmPassword) { - throw new Error('Passwords do not match'); - } - - const user = await UsersService.createLocalUser(answers.email.trim(), answers.password.trim(), answers.username.trim()); - await UsersService.setRole(user.id, answers.role); - await UsersService.sendEmailConfirmation(user, answers.email.trim()); - - console.log(`Created User ${user.id}.`); - util.shutdown(); - } catch (err) { - console.error(err); - util.shutdown(1); - } -} - -/** - * Deletes a user. + * Deletes a user and cleans up their associated verifications. */ async function deleteUser(userID) { @@ -133,23 +45,50 @@ async function deleteUser(userID) { throw new Error(`user with id ${userID} not found`); } + printUserAsTable(user); + + console.warn(stripIndent` + + This will delete the above user. + + This might take a long time if there is a lot of data, please confirm that + you want to continue. + `); + const {confirm} = await inquirer.prompt({ + type: 'confirm', + name: 'confirm', + message: 'Continue', + default: false, + }); + if (!confirm) { + return util.shutdown(); + } + + console.warn('Removing user\'s actions'); + // Remove all the user's actions. await ActionModel .where({user_id: user.id}) .setOptions({multi: true}) .remove(); + console.warn('Removing user\'s comments'); + // Remove all the user's comments. await CommentModel .where({author_id: user.id}) .setOptions({multi: true}) .remove(); + console.warn('Updating the database indexes'); + // Update the counts that might have changed. for (const verification of databaseVerifications) { await verification({fix: true, limit: Infinity, batch: 1000}); } + console.warn('Removing the user'); + // Remove the user. await user.remove(); @@ -160,146 +99,90 @@ async function deleteUser(userID) { } } +function printUserAsTable(user) { + let table = new Table({}); + + table.push( + {'ID': user.id.gray}, + {'Username': user.username}, + {'Emails': user.profiles.filter(({provider}) => provider === 'local').map(({id})=> id) + .join(', ')}, + {'Tags': user.tags ? user.tags.map(({tag: {name}}) => name) : ''}, + {'Role': user.role}, + {'Verified': user.hasVerifiedEmail}, + {'Username': user.status.username.status}, + {'Banned': user.banned}, + {'Suspension': user.suspended ? `Until ${user.status.suspension.until}` : false}, + ); + + console.log(table.toString()); +} + /** - * Changes the password for a user. + * Searches for users based on their username and email address. */ -function passwd(userID) { - inquirer.prompt([ - { - name: 'password', - message: 'Password', - type: 'password', - validate: validateRequired('Password is required') - }, - { - name: 'confirmPassword', - message: 'Confirm Password', - type: 'password', - validate: validateRequired('Confirm Password is required') +async function searchUsers() { + const ctx = Context.forSystem(); + const searchQuery = ` + query SearchUsers($value: String) { + users(query: {value: $value}) { + nodes { + id + username + role + profiles { + id + provider + } + } + } } - ]) - .then((answers) => { - if (answers.password !== answers.confirmPassword) { - throw new Error('Password mismatch'); - } + `; - return UsersService.changePassword(userID, answers.password); - }) - .then(() => { - console.log('Password changed.'); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} - -/** - * Updates the user from the options array. - */ -function updateUser(userID, options) { - const updates = []; - - if (options.email && typeof options.email === 'string' && options.email.length > 0) { - let q = UserModel.update({ - 'id': userID, - 'profiles.provider': 'local' - }, { - $set: { - 'profiles.$.id': options.email - } - }); - - updates.push(q); - } - - if (options.name && typeof options.name === 'string' && options.name.length > 0) { - let q = UserModel.update({ - 'id': userID - }, { - $set: { - username: options.name - } - }); - - updates.push(q); - } - - Promise - .all(updates.map((q) => q.exec())) - .then(() => { - console.log(`User ${userID} updated.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} - -/** - * Lists all the users registered in the database. - */ -function listUsers() { - UsersService - .all() - .then((users) => { - let table = new Table({ - head: [ - 'ID', - 'Username', - 'Profiles', - 'Roles', - 'Status', - 'State' - ] - }); - - users.forEach((user) => { - const profile = user.profiles.find(({provider}) => provider === 'local'); - let state; - if (profile && profile.metadata && profile.metadata.confirmed_at) { - state = 'Verified'; - } else { - state = 'Unverified'; + try { + const answers = await inquirer.prompt({ + type: 'autocomplete', + name: 'userID', + message: 'Search for a user', + source: async (answers, value) => { + if (value === null) { + value = ''; } - table.push([ - user.id, - user.username, - user.profiles.map((p) => p.provider).join(', '), - user.role, - user.status, - state - ]); - }); + const {data, errors} = await graphql(schema, searchQuery, {}, ctx, { + value, + }); + if (errors && errors.length > 0) { + throw errors[0]; + } - console.log(table.toString()); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} + if (data.users === null) { + return []; + } -/** - * Merges two users using the specified ID's. - * @param {String} dstUserID id of the user to which is the target of the merge - * @param {String} srcUserID id of the user to which is the source of the merge - */ -function mergeUsers(dstUserID, srcUserID) { - UsersService - .mergeUsers(dstUserID, srcUserID) - .then(() => { - console.log(`User ${srcUserID} was merged into user ${dstUserID}.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); + return data.users.nodes.map((user) => { + const emails = user.profiles + .filter(({provider}) => provider === 'local') + .map(({id})=> id) + .join(', '); + + return { + name: `${user.username} (${emails}) ${user.id.gray} - ${user.role.gray}`, + value: user.id, + }; + }); + } }); + + const {userID} = answers; + const user = await UserModel.findOne({id: userID}); + + printUserAsTable(user); + util.shutdown(0); + } catch (err) { + console.error(err); + util.shutdown(1); + } } /** @@ -307,20 +190,16 @@ function mergeUsers(dstUserID, srcUserID) { * @param {String} userUD id of the user to add the role to * @param {String} role the role to add */ -async function setRole(userID, role, options) { +async function setUserRole(userID) { try { - if (options.interactive || !role) { - const answers = await inquirer.prompt([ - { - name: 'role', - message: 'User Role', - type: 'list', - choices: USER_ROLES - } - ]); - - role = answers.role; - } + const {role} = await inquirer.prompt([ + { + name: 'role', + message: 'User Role', + type: 'list', + choices: USER_ROLES + } + ]); await UsersService.setRole(userID, role); @@ -337,10 +216,49 @@ async function setRole(userID, role, options) { * Verifies an email address for a user. * * @param userID the user's id - * @param email the user's email address to be verified + * @param email the user's email address to be verified, otherwise verifies the + * first email if there is one, if there are multiple, you get a + * prompt. */ -async function verify(userID, email) { +async function verifyUserEmail(userID, email) { try { + + // Get the user. + const user = await UserModel.findOne({id: userID}); + if (!user) { + throw new Error(`user with ID ${userID} cannot be found`); + } + + // Get all the user's email addresses. + const emails = user.profiles.filter(({provider}) => provider === 'local').map(({id}) => id); + if (!emails || emails.length === 0) { + throw new Error('user did not have any email addresses'); + } + + if (!email && emails.length === 1) { + + // The email wasn't passed, and there is only one option. + email = emails[0]; + } else if (!emails.includes(email)){ + + // The email passed doesn't belong to this user. + throw new Error(`user does not have the email ${email}`); + } else if (emails.length > 1) { + + // The email wasn't passed, and there is more than one choice. + const answers = await inquirer.prompt([ + { + name: 'email', + message: 'Select Email to Verify', + type: 'list', + choices: emails + } + ]); + + email = answers.email; + } + + // Verify the email. await UsersService.confirmEmail(userID, email); console.log(`User ${userID} had their email ${email} verified.`); util.shutdown(); @@ -354,53 +272,25 @@ async function verify(userID, email) { // Setting up the program command line arguments. //============================================================================== -program - .command('create') - .option('--email [email]', 'Email to use') - .option('--password [password]', 'Password to use') - .option('--name [name]', 'Name to use') - .option('--role [role]', 'Role to add') - .option('-f, --flag_mode', 'Source from flags instead of prompting') - .description('create a new user') - .action(createUser); - program .command('delete ') .description('delete a user') .action(deleteUser); program - .command('passwd ') - .description('change a password for a user') - .action(passwd); + .command('search') + .description('searches for a user based on their stored username and email') + .action(searchUsers); program - .command('update ') - .option('--email [email]', 'Email to use') - .option('--name [name]', 'Name to use') - .description('update a user') - .action(updateUser); - -program - .command('list') - .description('list all the users in the database') - .action(listUsers); - -program - .command('merge ') - .description('merge srcUser into the dstUser') - .action(mergeUsers); - -program - .command('setrole [role]') - .option('-i, --interactive', 'Enable interactive mode') + .command('set-role ') .description('sets the role on a user') - .action(setRole); + .action(setUserRole); program .command('verify ') .description('verifies the given user\'s email address') - .action(verify); + .action(verifyUserEmail); program.parse(process.argv); diff --git a/bin/cli-verify b/bin/cli-verify index 9a4c30ab1..5a55f2bd2 100755 --- a/bin/cli-verify +++ b/bin/cli-verify @@ -4,9 +4,9 @@ * Module dependencies. */ +const util = require('./util'); const program = require('commander'); const mongoose = require('../services/mongoose'); -const util = require('./util'); const databaseVerifications = require('./verifications/database'); // Register the shutdown criteria. diff --git a/bin/util.js b/bin/util.js index 572d07a2e..546607c20 100644 --- a/bin/util.js +++ b/bin/util.js @@ -1,3 +1,7 @@ + +// Setup the environment. +require('../services/env'); + const debug = require('debug')('talk:util'); const util = module.exports = {}; @@ -54,3 +58,10 @@ util.onshutdown = (jobs) => { process.on('SIGTERM', () => util.shutdown(0, 'SIGTERM')); process.on('SIGINT', () => util.shutdown(0, 'SIGINT')); process.once('SIGUSR2', () => util.shutdown(0, 'SIGUSR2')); + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', (err) => { + throw err; +}); diff --git a/bin/verifications/database/action_counts.js b/bin/verifications/database/action_counts.js index 133c1a2e3..2aa47a9a7 100644 --- a/bin/verifications/database/action_counts.js +++ b/bin/verifications/database/action_counts.js @@ -3,7 +3,6 @@ const CommentModel = require('../../../models/comment'); const ActionsService = require('../../../services/actions'); const {arrayJoinBy} = require('../../../graph/loaders/util'); const {get} = require('lodash'); -const sc = require('snake-case'); const debug = require('debug')('talk:cli:verify'); const MODELS = [ @@ -38,8 +37,8 @@ async function processBatch(Model, documents) { } // And we generate the group id. - const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase()); - const GROUP_ID = sc(actionSummary.group_id.toLowerCase()); + const ACTION_TYPE = actionSummary.action_type.toLowerCase(); + const GROUP_ID = actionSummary.group_id.toLowerCase(); if (GROUP_ID.length <= 0) { continue; diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index b75793ab7..4b3d4e9bc 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -128,9 +128,9 @@ class UserDetail extends React.Component { const banned = isBanned(user); const suspended = isSuspended(user); - + return ( - +

{user.username} @@ -314,6 +314,7 @@ UserDetail.propTypes = { showBanUserDialog: PropTypes.func, unbanUser: PropTypes.func.isRequired, unsuspendUser: PropTypes.func.isRequired, + modal: PropTypes.bool, }; export default UserDetail; diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index ed4e772cf..80573a2ad 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -56,7 +56,7 @@ export default { proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated}); } }), - UnSuspendUser: ({variables: {input: {id}}}) => ({ + UnsuspendUser: ({variables: {input: {id}}}) => ({ update: (proxy) => { const fragmentId = `User_${id}`; const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId}); @@ -92,7 +92,7 @@ export default { proxy.writeFragment({fragment: userStatusFragment, id: fragmentId, data: updated}); } }), - UnBanUser: ({variables: {input: {id}}}) => ({ + UnbanUser: ({variables: {input: {id}}}) => ({ update: (proxy) => { const fragmentId = `User_${id}`; const data = proxy.readFragment({fragment: userStatusFragment, id: fragmentId}); diff --git a/client/coral-admin/src/routes/Moderation/containers/Comment.js b/client/coral-admin/src/routes/Moderation/containers/Comment.js index 35f11a3fb..72b0c23ab 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Comment.js +++ b/client/coral-admin/src/routes/Moderation/containers/Comment.js @@ -37,19 +37,6 @@ export default withFragments({ user { id username - state { - status { - username { - status - } - banned { - status - } - suspension { - until - } - } - } } asset { id diff --git a/client/coral-embed-stream/src/actions/auth.js b/client/coral-embed-stream/src/actions/auth.js index 64e29a31a..578ca9812 100644 --- a/client/coral-embed-stream/src/actions/auth.js +++ b/client/coral-embed-stream/src/actions/auth.js @@ -5,6 +5,11 @@ import {notify} from 'coral-framework/actions/notification'; import t from 'coral-framework/services/i18n'; import get from 'lodash/get'; +export const updateStatus = (status) => ({ + type: actions.UPDATE_STATUS, + status, +}); + export const showSignInDialog = () => ({ type: actions.SHOW_SIGNIN_DIALOG, }); diff --git a/client/coral-embed-stream/src/constants/auth.js b/client/coral-embed-stream/src/constants/auth.js index bb2ea6c15..3187873fe 100644 --- a/client/coral-embed-stream/src/constants/auth.js +++ b/client/coral-embed-stream/src/constants/auth.js @@ -54,3 +54,4 @@ export const SET_REQUIRE_EMAIL_VERIFICATION = 'SET_REQUIRE_EMAIL_VERIFICATION'; export const SET_REDIRECT_URI = 'SET_REDIRECT_URI'; export const RESET_SIGNIN_DIALOG = 'RESET_SIGNIN_DIALOG'; +export const UPDATE_STATUS = 'UPDATE_STATUS'; diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index e0b144d62..6b51bb99a 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -21,7 +21,14 @@ import t from 'coral-framework/services/i18n'; import PropTypes from 'prop-types'; import {setActiveTab} from '../actions/embed'; -const {logout, checkLogin, focusSignInDialog, blurSignInDialog, hideSignInDialog} = authActions; +const { + logout, + checkLogin, + focusSignInDialog, + blurSignInDialog, + hideSignInDialog, + updateStatus, +} = authActions; const {fetchAssetSuccess} = assetActions; class EmbedContainer extends React.Component { @@ -35,20 +42,23 @@ class EmbedContainer extends React.Component { if (props.auth.loggedIn) { const newSubscriptions = [{ document: USER_BANNED_SUBSCRIPTION, - updateQuery: () => { + updateQuery: (_, {subscriptionData: {data: {userBanned: {state}}}}) => { notify('info', t('your_account_has_been_banned')); + props.updateStatus(state.status); }, }, { document: USER_SUSPENDED_SUBSCRIPTION, - updateQuery: () => { + updateQuery: (_, {subscriptionData: {data: {userSuspended: {state}}}}) => { notify('info', t('your_account_has_been_suspended')); + props.updateStatus(state.status); }, }, { document: USERNAME_REJECTED_SUBSCRIPTION, - updateQuery: () => { + updateQuery: (_, {subscriptionData: {data: {usernameRejected: {state}}}}) => { notify('info', t('your_username_has_been_rejected')); + props.updateStatus(state.status); }, }]; @@ -260,6 +270,7 @@ const mapDispatchToProps = (dispatch) => focusSignInDialog, blurSignInDialog, hideSignInDialog, + updateStatus, }, dispatch ); diff --git a/client/coral-embed-stream/src/reducers/auth.js b/client/coral-embed-stream/src/reducers/auth.js index 156c53515..1f044a8ef 100644 --- a/client/coral-embed-stream/src/reducers/auth.js +++ b/client/coral-embed-stream/src/reducers/auth.js @@ -1,5 +1,6 @@ import * as actions from '../constants/auth'; import pym from 'coral-framework/services/pym'; +import merge from 'lodash/merge'; const initialState = { isLoading: false, @@ -227,38 +228,15 @@ export default function auth (state = initialState, action) { ...state, redirectUri: action.uri, }; - case 'APOLLO_SUBSCRIPTION_RESULT': - - // @TODO: These don't work anymore because apollo store has been decoupled - - if (action.operationName === 'UserBanned' && state.user.id === action.variables.user_id) { - return { - ...state, - user: { - ...state.user, - ...action.result.data.userBanned, - }, - }; - } - if (action.operationName === 'UserSuspended' && state.user.id === action.variables.user_id) { - return { - ...state, - user: { - ...state.user, - ...action.result.data.userSuspended, - }, - }; - } - if (action.operationName === 'UsernameRejected' && state.user.id === action.variables.user_id) { - return { - ...state, - user: { - ...state.user, - ...action.result.data.usernameRejected, - }, - }; - } - return state; + case actions.UPDATE_STATUS: { + return { + ...state, + user: { + ...state.user, + status: merge({}, state.user.status, action.status), + }, + }; + } default : return state; } diff --git a/client/coral-embed-stream/src/reducers/embed.js b/client/coral-embed-stream/src/reducers/embed.js index 06073c54e..8e061827d 100644 --- a/client/coral-embed-stream/src/reducers/embed.js +++ b/client/coral-embed-stream/src/reducers/embed.js @@ -15,23 +15,6 @@ export default function stream(state = initialState, action) { activeTab: action.tab, previousTab: state.activeTab, }; - case 'APOLLO_QUERY_INIT': - if (action.queryString.indexOf('query CoralEmbedStream_Embed(') >= 0) { - return { - ...state, - refetching: action.isRefetch ? true : state.refetching, - refetchRequestId: action.isRefetch ? action.requestId : state.refetchRequestId, - }; - } - return state; - case 'APOLLO_QUERY_RESULT': - if (action.operationName === 'CoralEmbedStream_Embed') { - return { - ...state, - refetching: action.requestId === state.refetchRequestId ? false : state.refetching, - }; - } - return state; default: return state; } diff --git a/client/coral-embed-stream/src/components/ChangeUsername.css b/client/coral-embed-stream/src/tabs/stream/components/ChangeUsername.css similarity index 100% rename from client/coral-embed-stream/src/components/ChangeUsername.css rename to client/coral-embed-stream/src/tabs/stream/components/ChangeUsername.css diff --git a/client/coral-embed-stream/src/components/ChangeUsername.js b/client/coral-embed-stream/src/tabs/stream/components/ChangeUsername.js similarity index 90% rename from client/coral-embed-stream/src/components/ChangeUsername.js rename to client/coral-embed-stream/src/tabs/stream/components/ChangeUsername.js index 6fa7d3c0c..1c21eeb94 100644 --- a/client/coral-embed-stream/src/components/ChangeUsername.js +++ b/client/coral-embed-stream/src/tabs/stream/components/ChangeUsername.js @@ -6,6 +6,7 @@ import styles from './ChangeUsername.css'; import {Button} from 'coral-ui'; import validate from 'coral-framework/helpers/validate'; import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox'; +import {forEachError} from 'plugin-api/beta/client/utils'; class ChangeUsername extends Component { @@ -31,7 +32,9 @@ class ChangeUsername extends Component { changeUsername(user.id, username) .then(() => location.reload()) .catch((error) => { - this.setState({alert: t(`error.${error.translation_key}`)}); + let errorMsg = ''; + forEachError(error, ({msg}) => errorMsg = errorMsg ? `${errorMsg}, ${msg}` : msg); + this.setState({alert: errorMsg}); }); } else { this.setState({alert: t('framework.edit_name.error')}); diff --git a/client/coral-embed-stream/src/tabs/stream/components/Stream.js b/client/coral-embed-stream/src/tabs/stream/components/Stream.js index 16eb7433c..36a4666aa 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Stream.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import {StreamError} from './StreamError'; import Comment from '../containers/Comment'; import BannedAccount from '../../../components/BannedAccount'; -import ChangeUsername from '../../../containers/ChangeUsername'; +import ChangeUsername from '../containers/ChangeUsername'; import Slot from 'coral-framework/components/Slot'; import InfoBox from 'talk-plugin-infobox/InfoBox'; import {can} from 'coral-framework/services/perms'; diff --git a/client/coral-embed-stream/src/containers/ChangeUsername.js b/client/coral-embed-stream/src/tabs/stream/containers/ChangeUsername.js similarity index 100% rename from client/coral-embed-stream/src/containers/ChangeUsername.js rename to client/coral-embed-stream/src/tabs/stream/containers/ChangeUsername.js diff --git a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js index 781fdcb99..62a288a15 100644 --- a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js @@ -211,7 +211,8 @@ class StreamContainer extends React.Component { return ; } - const streamLoading = this.props.refetching || this.props.data.loading; + // @TODO: Detect refetch when we have apollo 2.0. + const streamLoading = this.props.data.loading; return ( ({ auth: state.auth, - refetching: state.embed.refetching, activeReplyBox: state.stream.activeReplyBox, commentId: state.stream.commentId, assetId: state.stream.assetId, diff --git a/client/coral-framework/components/ClickOutside.js b/client/coral-framework/components/ClickOutside.js index 054a9e6fe..ea60247ca 100644 --- a/client/coral-framework/components/ClickOutside.js +++ b/client/coral-framework/components/ClickOutside.js @@ -4,7 +4,8 @@ import {findDOMNode} from 'react-dom'; export default class ClickOutside extends Component { static propTypes = { - onClickOutside: PropTypes.func.isRequired + onClickOutside: PropTypes.func, + children: PropTypes.node, }; static contextTypes = { @@ -16,7 +17,7 @@ export default class ClickOutside extends Component { handleClick = (e) => { const {onClickOutside} = this.props; if (!e || !this.domNode.contains(e.target)) { - onClickOutside(e); + onClickOutside && onClickOutside(e); } }; diff --git a/client/coral-framework/graphql/fragments.js b/client/coral-framework/graphql/fragments.js index 26073676b..9d6ff3f72 100644 --- a/client/coral-framework/graphql/fragments.js +++ b/client/coral-framework/graphql/fragments.js @@ -6,11 +6,11 @@ export default { 'SetUserRoleResponse', 'ChangeUsernameResponse', 'BanUsersResponse', - 'UnBanUserResponse', + 'UnbanUserResponse', 'SetUserSuspensionStatusResponse', 'SetCommentStatusResponse', 'SetUsernameStatusResponse', - 'UnSuspendUserResponse', + 'UnsuspendUserResponse', 'SuspendUserResponse', 'CreateCommentResponse', 'CreateFlagResponse', diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index 89db451ae..27b10669b 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -179,9 +179,9 @@ export const withSuspendUser = withMutation( export const withUnsuspendUser = withMutation( gql` - mutation UnSuspendUser($input: UnSuspendUserInput!) { - unSuspendUser(input: $input) { - ...UnSuspendUserResponse + mutation UnsuspendUser($input: UnsuspendUserInput!) { + unsuspendUser(input: $input) { + ...UnsuspendUserResponse } } `, { @@ -275,9 +275,9 @@ export const withBanUser = withMutation( export const withUnbanUser = withMutation( gql` - mutation UnBanUser($input: UnBanUserInput!) { - unBanUser(input: $input) { - ...UnBanUserResponse + mutation UnbanUser($input: UnbanUserInput!) { + unbanUser(input: $input) { + ...UnbanUserResponse } } `, { diff --git a/client/coral-ui/components/Drawer.css b/client/coral-ui/components/Drawer.css index 88c30425f..1e3055184 100644 --- a/client/coral-ui/components/Drawer.css +++ b/client/coral-ui/components/Drawer.css @@ -23,6 +23,7 @@ } .closeButton { + composes: buttonReset from 'coral-framework/styles/reset.css'; position: absolute; width: 40px; height: 40px; @@ -34,7 +35,6 @@ top: 60px; box-shadow: -1px 3px 4px 0px rgba(0,0,0,0.15); text-align: center; - padding-top: 10px; cursor: pointer; &:hover { diff --git a/config.js b/config.js index 72c24c70f..6379b08f0 100644 --- a/config.js +++ b/config.js @@ -2,14 +2,8 @@ // application. All defaults are assumed here, validation should also be // completed here. -// 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 -// entrypoint for the entire applications configuration. -require('env-rewrite').rewrite(); - -// Apply all the configuration provided in the .env file if it isn't already -// in the environment. -require('dotenv').config(); +// Setup the environment. +require('./services/env'); const uniq = require('lodash/uniq'); const ms = require('ms'); @@ -207,13 +201,11 @@ const CONFIG = { // SMTP Server configuration //------------------------------------------------------------------------------ - SMTP_FROM_ADDRESS: process.env.TALK_SMTP_FROM_ADDRESS, SMTP_HOST: process.env.TALK_SMTP_HOST, - SMTP_PASSWORD: process.env.TALK_SMTP_PASSWORD, - SMTP_PORT: process.env.TALK_SMTP_PORT - ? parseInt(process.env.TALK_SMTP_PORT) - : undefined, SMTP_USERNAME: process.env.TALK_SMTP_USERNAME, + SMTP_PORT: process.env.TALK_SMTP_PORT, + SMTP_PASSWORD: process.env.TALK_SMTP_PASSWORD, + SMTP_FROM_ADDRESS: process.env.TALK_SMTP_FROM_ADDRESS, //------------------------------------------------------------------------------ // Flagging Config @@ -351,9 +343,7 @@ CONFIG.REDIS_CLIENT_CONFIG = JSON.parse(CONFIG.REDIS_CLIENT_CONFIG); */ CONFIG.RECAPTCHA_ENABLED = CONFIG.RECAPTCHA_SECRET && - CONFIG.RECAPTCHA_SECRET.length > 0 && - CONFIG.RECAPTCHA_PUBLIC && - CONFIG.RECAPTCHA_PUBLIC.length > 0; + CONFIG.RECAPTCHA_PUBLIC; debug( `reCAPTCHA is ${ @@ -363,26 +353,4 @@ debug( }` ); -//------------------------------------------------------------------------------ -// SMTP Server configuration -//------------------------------------------------------------------------------ - -CONFIG.EMAIL_ENABLED = - CONFIG.SMTP_FROM_ADDRESS && - CONFIG.SMTP_FROM_ADDRESS.length > 0 && - CONFIG.SMTP_USERNAME && - CONFIG.SMTP_USERNAME.length > 0 && - CONFIG.SMTP_PASSWORD && - CONFIG.SMTP_PASSWORD.length > 0 && - CONFIG.SMTP_HOST && - CONFIG.SMTP_HOST.length > 0; - -debug( - `Email is ${ - CONFIG.EMAIL_ENABLED - ? 'enabled' - : 'disabled, required config is not present' - }` -); - module.exports = CONFIG; diff --git a/docs/_docs/03-02-product-guide-commenter-features.md b/docs/_docs/03-02-product-guide-commenter-features.md index 7f56880db..144bdb634 100644 --- a/docs/_docs/03-02-product-guide-commenter-features.md +++ b/docs/_docs/03-02-product-guide-commenter-features.md @@ -197,4 +197,4 @@ will see a message at the top of their streams stating this. ### Ban When a commenter has been banned, they will see a message at the top of their -streams staging this. +streams stating this. diff --git a/errors.js b/errors.js index a87d234e8..9078dc866 100644 --- a/errors.js +++ b/errors.js @@ -59,6 +59,11 @@ const ErrUsernameTaken = new APIError('Username already in use', { status: 400 }); +const ErrSameUsernameProvided = new APIError('Username provided for change is the same as current', { + translation_key: 'SAME_USERNAME_PROVIDED', + status: 400 +}); + const ErrSpecialChars = new APIError('No special characters are allowed in a username', { translation_key: 'NO_SPECIAL_CHARACTERS', status: 400 @@ -69,9 +74,17 @@ const ErrMissingUsername = new APIError('A username is required to create a user status: 400 }); -// ErrMissingToken is returned in the event that the password reset is requested +// ErrEmailVerificationToken is returned in the event that the password reset is requested // without a token. -const ErrMissingToken = new APIError('token is required', { +const ErrEmailVerificationToken = new APIError('token is required', { + translation_key: 'EMAIL_VERIFICATION_TOKEN_INVALID', + status: 400 +}); + +// ErrPasswordResetToken is returned in the event that the password reset is requested +// without a token. +const ErrPasswordResetToken = new APIError('token is required', { + translation_key: 'PASSWORD_RESET_TOKEN_INVALID', status: 400 }); @@ -225,7 +238,8 @@ module.exports = { ErrMaxRateLimit, ErrMissingEmail, ErrMissingPassword, - ErrMissingToken, + ErrEmailVerificationToken, + ErrPasswordResetToken, ErrMissingUsername, ErrNotAuthorized, ErrNotFound, @@ -236,5 +250,6 @@ module.exports = { ErrSettingsNotInit, ErrSpecialChars, ErrUsernameTaken, + ErrSameUsernameProvided, ExtendableError, }; diff --git a/graph/context.js b/graph/context.js index e894c1534..bc4e782ca 100644 --- a/graph/context.js +++ b/graph/context.js @@ -71,6 +71,18 @@ class Context { // Bind the parent context. this.parent = parent; } + + /** + * + */ + static forSystem() { + const {models: {User}} = connectors; + + // Create the system user. + const user = new User({system: true}); + + return new Context({user}); + } } module.exports = Context; diff --git a/graph/loaders/users.js b/graph/loaders/users.js index 679d3dea5..112fe21bc 100644 --- a/graph/loaders/users.js +++ b/graph/loaders/users.js @@ -1,7 +1,6 @@ const DataLoader = require('dataloader'); const util = require('./util'); -const sc = require('snake-case'); const { SEARCH_OTHER_USERS, @@ -121,7 +120,7 @@ const getUsersByQuery = async ({user}, {limit, cursor, value = '', state, action if (action_type) { query.merge({ - [`action_counts.${sc(action_type.toLowerCase())}`]: { + [`action_counts.${action_type.toLowerCase()}`]: { $gt: 0 } }); @@ -199,7 +198,7 @@ const getCountByQuery = async ({user}, {action_type, state}) => { if (action_type) { query.merge({ - [`action_counts.${sc(action_type.toLowerCase())}`]: { + [`action_counts.${action_type.toLowerCase()}`]: { $gt: 0 } }); diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index ac64bdad7..c1e484860 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -34,13 +34,13 @@ const RootMutation = { suspendUser: async (obj, {input: {id, until, message}}, {mutators: {User}}) => { await User.setUserSuspensionStatus(id, until, message); }, - unSuspendUser: async (obj, {input: {id}}, {mutators: {User}}) => { + unsuspendUser: async (obj, {input: {id}}, {mutators: {User}}) => { await User.setUserSuspensionStatus(id); }, banUser: async (obj, {input: {id, message}}, {mutators: {User}}) => { await User.setUserBanStatus(id, true, message); }, - unBanUser: async (obj, {input: {id}}, {mutators: {User}}) => { + unbanUser: async (obj, {input: {id}}, {mutators: {User}}) => { await User.setUserBanStatus(id, false); }, ignoreUser: async (_, {id}, {mutators: {User}}) => { diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 7d39125e0..002318bc3 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -1314,7 +1314,7 @@ input BanUserInput { message: String! } -input UnBanUserInput { +input UnbanUserInput { id: ID! } @@ -1322,7 +1322,7 @@ type BanUsersResponse implements Response { errors: [UserError!] } -type UnBanUserResponse implements Response { +type UnbanUserResponse implements Response { errors: [UserError!] } @@ -1336,13 +1336,13 @@ type SuspendUserResponse implements Response { errors: [UserError!] } -input UnSuspendUserInput { +input UnsuspendUserInput { id: ID! } -# UnSuspendUserResponse is the response returned with possibly some +# UnsuspendUserResponse is the response returned with possibly some # errors relating to the suspend action attempt. -type UnSuspendUserResponse implements Response { +type UnsuspendUserResponse implements Response { # An array of errors relating to the mutation that occurred. errors: [UserError!] @@ -1396,7 +1396,7 @@ type RootMutation { # Sets the suspension status on a given user. Requires the `MODERATOR` role. # Mutation is restricted. - unSuspendUser(input: UnSuspendUserInput!): UnSuspendUserResponse + unsuspendUser(input: UnsuspendUserInput!): UnsuspendUserResponse # Sets the ban status on a given user. Requires the `MODERATOR` role. # Mutation is restricted. @@ -1404,7 +1404,7 @@ type RootMutation { # Sets the ban status on a given user. Requires the `MODERATOR` role. # Mutation is restricted. - unBanUser(input: UnBanUserInput!): UnBanUserResponse + unbanUser(input: UnbanUserInput!): UnbanUserResponse # Sets the username status on a given user to `APPROVED`. Requires the # `MODERATOR` role. Mutation is restricted. diff --git a/locales/en.yml b/locales/en.yml index 2926d140a..a4238d5c0 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -19,6 +19,15 @@ en: email_message_ban: "Dear {0},\n\nSomeone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, like or report comments. if you think this has been done in error, please contact our community team." bio_offensive: "This bio is offensive" cancel: "Cancel" + confirm_email: + click_to_confirm: "Click below to confirm your email address" + confirm: "Confirm" + password_reset: + set_new_password: "Change Your Password" + new_password: "New Password" + new_password_help: "Password must be at least 8 characters" + confirm_new_password: "Confirm New Password" + change_password: "Change Password" characters_remaining: "characters remaining" comment: anon: "Anonymous" @@ -189,6 +198,8 @@ en: embedlink: copy: "Copy to Clipboard" error: + EMAIL_VERIFICATION_TOKEN_INVALID: "Email verification token is invalid." + PASSWORD_RESET_TOKEN_INVALID: "Your password reset link is invalid." COMMENT_TOO_SHORT: "Comments should be more than one character, please revise your comment and try again." NOT_AUTHORIZED: "You are not authorized to perform this action." NO_SPECIAL_CHARACTERS: "Usernames can contain letters numbers and _ only" diff --git a/middleware/staticTemplate.js b/middleware/staticTemplate.js index 823ab6624..c94797519 100644 --- a/middleware/staticTemplate.js +++ b/middleware/staticTemplate.js @@ -1,3 +1,5 @@ +const SettingsService = require('../services/settings'); + const { BASE_URL, BASE_PATH, @@ -24,8 +26,8 @@ const TEMPLATE_LOCALS = { }, }; -// attachLocals will attach the locals to the response only. -const attachLocals = (locals) => { +// attachStaticLocals will attach the locals to the response only. +const attachStaticLocals = (locals) => { for (const key in TEMPLATE_LOCALS) { const value = TEMPLATE_LOCALS[key]; @@ -33,13 +35,23 @@ const attachLocals = (locals) => { } }; -module.exports = (req, res, next) => { +module.exports = async (req, res, next) => { + + try { + + // Attach the custom css url. + const {customCssUrl} = await SettingsService.retrieve('customCssUrl'); + res.locals.customCssUrl = customCssUrl; + } catch (err) { + console.warn(err); + } // Always attach the locals. - attachLocals(res.locals); + attachStaticLocals(res.locals); // Forward the request. next(); }; -module.exports.attachLocals = attachLocals; +module.exports.attachStaticLocals = attachStaticLocals; +module.exports.TEMPLATE_LOCALS = TEMPLATE_LOCALS; diff --git a/models/user.js b/models/user.js index f6797f94a..37edf4dde 100644 --- a/models/user.js +++ b/models/user.js @@ -5,6 +5,7 @@ const uuid = require('uuid'); const TagLinkSchema = require('./schema/tag_link'); const TokenSchema = require('./schema/token'); const can = require('../perms'); +const {get} = require('lodash'); // USER_ROLES is the array of roles that is permissible as a user role. const USER_ROLES = require('./enum/user_roles'); @@ -244,8 +245,6 @@ UserSchema.index({ background: true, }); -// TODO: Add indexes for searching the user collection. Needs product decision. - /** * returns true if a commenter is staff */ @@ -280,6 +279,30 @@ UserSchema.method('can', function(...actions) { return can(this, ...actions); }); +/** + * hasVerifiedEmail will return true if at least one of the local email accounts + * have their email verified. + */ +UserSchema.virtual('hasVerifiedEmail').get(function() { + return this.profiles + .filter(({provider}) => provider === 'local') + .some((profile) => { + const confirmedAt = get(profile, 'metadata.confirmed_at') || null; + + // If the profile doesn't have a metadata field, or it does not have a + // confirmed_at field, or that field is null, then send them back. + return confirmedAt !== null; + }); +}); + +UserSchema.virtual('system') + .get(function() { + return this._system; + }) + .set(function(system) { + this._system = system; + }); + /** * banned returns true when the user is currently banned, and sets the banned * status locally. diff --git a/package.json b/package.json index b1889158b..7c8a0f9a0 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "immutability-helper": "^2.2.0", "imports-loader": "^0.7.1", "inquirer": "^3.2.2", + "inquirer-autocomplete-prompt": "^0.12.1", "ioredis": "3.1.4", "joi": "^10.6.0", "json-loader": "^0.5.7", @@ -172,6 +173,7 @@ "snake-case": "2.1.0", "style-loader": "^0.16.0", "subscriptions-transport-ws": "^0.7.2", + "supports-color": "^4", "timeago.js": "^2.0.3", "timekeeper": "^1.0.0", "tlds": "^1.196.0", diff --git a/perms/reducers/index.js b/perms/reducers/index.js index 70761990d..933be8329 100644 --- a/perms/reducers/index.js +++ b/perms/reducers/index.js @@ -11,6 +11,13 @@ module.exports = [ return false; } }, + (user) => { + + // System users can do everything! + if (user.system === true) { + return true; + } + }, query, mutation, subscription, diff --git a/plugins/talk-plugin-featured-comments/index.js b/plugins/talk-plugin-featured-comments/index.js index c81afa3ea..2d68bac50 100644 --- a/plugins/talk-plugin-featured-comments/index.js +++ b/plugins/talk-plugin-featured-comments/index.js @@ -57,7 +57,7 @@ module.exports = { hooks: { RootMutation: { addTag: { - async post(obj, {tag: {name, id, item_type}}, {user, mutators: {Comment}, pubsub}, _info) { + async post(obj, {tag: {name, id, item_type}}, {user, mutators: {Comment}, pubsub}) { if (name === 'FEATURED' && item_type === 'COMMENTS') { const comment = await Comment.setStatus({id: id, status: 'ACCEPTED'}); if (comment) { @@ -67,7 +67,7 @@ module.exports = { }, }, removeTag: { - async post(obj, {tag: {name, id, item_type}}, {user, loaders: {Comments}, pubsub}, _info) { + async post(obj, {tag: {name, id, item_type}}, {user, loaders: {Comments}, pubsub}) { if (name === 'FEATURED' && item_type === 'COMMENTS') { const comment = await Comments.get.load(id); if (comment) { diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 000000000..50e3109c5 --- /dev/null +++ b/public/css/admin.css @@ -0,0 +1,71 @@ +body, #root { + width: 100%; + height: 100%; + margin: 0; + background: #fff; +} + +.container { + max-width: 300px; + margin: 50px auto; +} + +#root form { + display: none; + padding: 15px; +} + +.legend { + text-align: center; + width: 100%; + font-weight: bold; +} + +label { + display: block; + margin-top: 10px; + margin-bottom: 3px; + padding-right: 30px; +} + +small { + color: #888; +} + +input { + border-radius: 4px; + margin-top: 3px; + border: 1px solid lightgrey; + font-size: 16px; + width: 100%; + padding: 14px; + height: 100%; + display: inline-block; +} + +button[type="submit"] { + border-radius: 4px; + border: none; + display: block; + background-color: #333; + color: white; + text-align: center; + width: 100%; + padding: 10px; + margin-top: 10px; + cursor: pointer; +} + +.error-console { + display: none; + margin-top: 10px; + border-radius: 4px; + background-color: pink; + color: red; + border: 1px solid red; + padding: 10px; +} + +.error-console.active { + display: block; +} \ No newline at end of file diff --git a/public/javascripts/admin.js b/public/javascripts/admin.js new file mode 100644 index 000000000..2c8b7c42a --- /dev/null +++ b/public/javascripts/admin.js @@ -0,0 +1,8 @@ +function showError(error) { + try { + let err = JSON.parse(error); + $('.error-console').text(err.message).addClass('active'); + } catch (err) { + $('.error-console').text(error).addClass('active'); + } +} diff --git a/routes/admin/index.js b/routes/admin/index.js index d6cb481de..66f9c123d 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -1,17 +1,11 @@ const express = require('express'); const router = express.Router(); -// Get /email-confirmation expects a signed JWT in the hash router.get('/confirm-email', (req, res) => { res.render('admin/confirm-email'); }); -// Get /password-reset expects a signed token (JWT) in the hash. -// Links to this endpoint are generated by /views/password-reset-email.ejs. router.get('/password-reset', (req, res) => { - - // TODO: store the redirect uri in the token or something fancy. - // admins and regular users should probably be redirected to different places. res.render('admin/password-reset'); }); diff --git a/routes/api/account/index.js b/routes/api/account/index.js index de3555725..f134ec0e5 100644 --- a/routes/api/account/index.js +++ b/routes/api/account/index.js @@ -13,31 +13,56 @@ router.get('/', authorization.needed(), (req, res, next) => { res.json(req.user); }); +/** + * verifyTokenOnCheck will verify that the request contains a token, and if + * being checked, will return the check status to the user. + * + * @param {Function} verifier the function used to verify the token, will throw on error + * @param {Object} error the error object to send back in the event an error is found + */ +const tokenCheck = (verifier, error) => async (req, res, next) => { + const {token = null, check = false} = req.body; + + if (check) { + + // This request is checking to see if the token is valid. + try { + + // Verify the token. + await verifier(token); + } catch (err) { + + // Log out the error, slurp it and send out the predefined error to the + // error handler. + console.error(err); + return next(error); + } + + res.status(204).end(); + + // Don't continue to pass it onto the next middleware, as we've only been + // asked to verify the token. + return; + } + + next(); +}; + // POST /email/confirm takes the password confirmation token available as a // payload parameter and if it verifies, it updates the confirmed_at date on the // local profile. -router.post('/email/verify', async (req, res, next) => { - - const { - token - } = req.body; - - if (!token) { - return next(errors.ErrMissingToken); - } +router.post('/email/verify', tokenCheck(UsersService.verifyEmailConfirmationToken, errors.ErrEmailVerificationToken), async (req, res, next) => { + const {token} = req.body; try { let {referer} = await UsersService.verifyEmailConfirmation(token); - res.json({redirectUri: referer}); - } catch (e) { - return next(e); + return res.json({redirectUri: referer}); + } catch (err) { + console.error(err); + return next(errors.ErrEmailVerificationToken); } }); -/** - * this endpoint takes an email (username) and checks if it belongs to a User account - * if it does, create a JWT and send an email - */ router.post('/password/reset', async (req, res, next) => { const {email, loc} = req.body; @@ -48,7 +73,7 @@ router.post('/password/reset', async (req, res, next) => { try { let token = await UsersService.createPasswordResetToken(email, loc); if (token) { - await mailer.sendSimple({ + await mailer.send({ template: 'password-reset', locals: { token, @@ -64,34 +89,20 @@ router.post('/password/reset', async (req, res, next) => { } }); -/** - * expects 2 fields in the body of the request - * 1) the token that was in the url of the email link {String} - * 2) the new password {String} - */ -router.put('/password/reset', async (req, res, next) => { - const {check} = req.query; +router.put('/password/reset', tokenCheck(UsersService.verifyPasswordResetToken, errors.ErrPasswordResetToken), async (req, res, next) => { const {token, password} = req.body; - if (!token) { - return next(errors.ErrMissingToken); - } - - if (check !== 'true' && (!password || password.length < 8)) { + if (!password || password.length < 8) { return next(errors.ErrPasswordTooShort); } try { - let [user, loc] = await UsersService.verifyPasswordResetToken(token); - if (check === 'true') { - res.status(204).end(); - return; - } + let [user, redirect] = await UsersService.verifyPasswordResetToken(token); // Change the users' password. await UsersService.changePassword(user.id, password); - res.json({redirect: loc}); + res.json({redirect}); } catch (e) { console.error(e); return next(errors.ErrNotAuthorized); diff --git a/routes/embed/index.js b/routes/embed/index.js index 852d9afa7..695ffe609 100644 --- a/routes/embed/index.js +++ b/routes/embed/index.js @@ -1,16 +1,8 @@ const express = require('express'); const router = express.Router(); -const SettingsService = require('../../services/settings'); -router.use('/:embed', async (req, res, next) => { - switch (req.params.embed) { - case 'stream': { - const {customCssUrl} = await SettingsService.retrieve('customCssUrl'); - return res.render('embed/stream', {customCssUrl}); - } - } - - return next(); +router.use('/stream', (req, res) => { + res.render('embed/stream'); }); module.exports = router; diff --git a/routes/index.js b/routes/index.js index 506ed3cfb..3f7041654 100644 --- a/routes/index.js +++ b/routes/index.js @@ -174,7 +174,7 @@ router.use('/api', (err, req, res, next) => { if (err instanceof errors.APIError) { res.status(err.status).json({ - message: err.message, + message: res.locals.t(`error.${err.translation_key}`), error: err }); } else { @@ -190,7 +190,7 @@ router.use('/', (err, req, res, next) => { if (err instanceof errors.APIError) { res.status(err.status); res.render('error', { - message: err.message, + message: res.locals.t(`error.${err.translation_key}`), error: process.env.NODE_ENV === 'development' ? err : {} }); } else { diff --git a/services/actions.js b/services/actions.js index adffdd134..cc6f9afa9 100644 --- a/services/actions.js +++ b/services/actions.js @@ -1,7 +1,6 @@ const ActionModel = require('../models/action'); const CommentModel = require('../models/comment'); const UserModel = require('../models/user'); -const sc = require('snake-case'); const _ = require('lodash'); const errors = require('../errors'); const events = require('./events'); @@ -253,14 +252,14 @@ module.exports = class ActionsService { }; const incrActionCounts = async (action, value) => { - const ACTION_TYPE = sc(action.action_type.toLowerCase()); + const ACTION_TYPE = action.action_type.toLowerCase(); const update = { [`action_counts.${ACTION_TYPE}`]: value, }; if (action.group_id && action.group_id.length > 0) { - const GROUP_ID = sc(action.group_id.toLowerCase()); + const GROUP_ID = action.group_id.toLowerCase(); update[`action_counts.${ACTION_TYPE}_${GROUP_ID}`] = value; } diff --git a/services/email/email-confirm.txt.ejs b/services/email/email-confirm.txt.ejs index d327220a7..b3cf28a01 100644 --- a/services/email/email-confirm.txt.ejs +++ b/services/email/email-confirm.txt.ejs @@ -4,6 +4,6 @@ <%= t('email.confirm.to_confirm') %> - <%= BASE_URL %>confirm/endpoint#<%= token %> + <%= BASE_URL %>admin/confirm-email#<%= token %> <%= t('email.confirm.if_you_did_not') %> diff --git a/services/env.js b/services/env.js new file mode 100644 index 000000000..a9feb67a8 --- /dev/null +++ b/services/env.js @@ -0,0 +1,11 @@ +// 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 +// entrypoint for the entire applications configuration. +require('env-rewrite').rewrite(); + +if (process.env.NODE_ENV !== 'test') { + + // Apply all the configuration provided in the .env file if it isn't already + // in the environment. + require('dotenv').config(); +} diff --git a/services/mailer.js b/services/mailer.js index 4433b9ef5..f66fd8240 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -1,12 +1,11 @@ const debug = require('debug')('talk:services:mailer'); const nodemailer = require('nodemailer'); const kue = require('./kue'); -const path = require('path'); -const fs = require('fs'); -const _ = require('lodash'); -const {attachLocals} = require('../middleware/staticTemplate'); - const i18n = require('./i18n'); +const path = require('path'); +const fs = require('fs-extra'); +const _ = require('lodash'); +const {TEMPLATE_LOCALS} = require('../middleware/staticTemplate'); const { SMTP_HOST, @@ -23,133 +22,129 @@ const templates = { }; // load the templates per request during development -templates.render = (name, format = 'txt', context) => new Promise((resolve, reject) => { +templates.render = async (name, format = 'txt', context) => { - // If we are in production mode, check the view cache. if (process.env.NODE_ENV === 'production') { - if (name in templates.data && format in templates.data[name]) { - let view = templates.data[name][format]; - return resolve(view(context)); + // If we are in production mode, check the view cache. + const view = _.get(templates.data, [name, format], null); + if (view !== null) { + return view(context); } } const filename = path.join(__dirname, 'email', [name, format, 'ejs'].join('.')); + const file = await fs.readFile(filename, 'utf8'); + const view = _.template(file); - fs.readFile(filename, (err, file) => { - if (err) { - return reject(err); - } - - let view = _.template(file); + if (process.env.NODE_ENV === 'production') { // If we are in production mode, fill the view cache. - if (process.env.NODE_ENV === 'production') { - if (!(name in templates.data)) { - templates.data[name] = {}; - } - - templates.data[name][format] = view; - } - - return resolve(view(context)); - }); -}); // ends templates.render - -const options = { - host: SMTP_HOST, - auth: { - user: SMTP_USERNAME, - pass: SMTP_PASSWORD + _.set(templates.data, [name, format], view); } + + return view(context); }; -if (SMTP_PORT) { - try { - options.port = parseInt(SMTP_PORT); - } catch (e) { - throw new Error('TALK_SMTP_PORT is not an integer'); +const mailer = {}; + +// enabled is true when the required configuration is available. When testing +// is enabled, we will be simulating that emails are being sent, because in a +// production system, emails should and would be sent. +mailer.enabled = Boolean( + SMTP_HOST && + SMTP_USERNAME && + SMTP_PASSWORD && + SMTP_FROM_ADDRESS +) || process.env.NODE_ENV === 'test'; + +if (mailer.enabled) { + const options = { + host: SMTP_HOST, + auth: { + user: SMTP_USERNAME, + pass: SMTP_PASSWORD + } + }; + + if (SMTP_PORT) { + try { + options.port = parseInt(SMTP_PORT); + } catch (e) { + throw new Error('TALK_SMTP_PORT is not an integer'); + } + } else { + options.port = 25; } -} else { - options.port = 25; + + mailer.transport = nodemailer.createTransport(options); } -const defaultTransporter = nodemailer.createTransport(options); +/** + * Create the new Task kue. + */ +mailer.task = new kue.Task({ + name: 'mailer' +}); -const mailer = module.exports = { - - /** - * Create the new Task kue. - */ - task: new kue.Task({ - name: 'mailer' - }), - - sendSimple({template, locals, to, subject}) { - - if (!to) { - return Promise.reject('sendSimple requires a comma-separated list of "to" addresses'); - } - - if (!subject) { - return Promise.reject('sendSimple requires a subject for the email'); - } - - // Prefix the subject with `[Talk]`. - subject = `${EMAIL_SUBJECT_PREFIX} ${subject}`; - - attachLocals(locals); - - // Attach the translation function. - locals.t = i18n.t; - - return Promise.all([ - - // Render the HTML version of the email. - templates.render(template, 'html', locals), - - // Render the TEXT version of the email. - templates.render(template, 'txt', locals) - ]) - .then(([html, text]) => { - - // Create the job. - return mailer.task.create({ - title: 'Mail', - message: { - to, - subject, - text, - html - } - }); - }); - }, - - /** - * Start the queue processor for the mailer job. - */ - process() { - - debug(`Now processing ${mailer.task.name} jobs`); - - return mailer.task.process(({id, data}, done) => { - debug(`Starting to send mail for Job[${id}]`); - - // Set the `from` field. - data.message.from = SMTP_FROM_ADDRESS; - - // Actually send the email. - defaultTransporter.sendMail(data.message, (err) => { - if (err) { - debug(`Failed to send mail for Job[${id}]:`, err); - return done(err); - } - - debug(`Finished sending mail for Job[${id}]`); - return done(); - }); - }); +/** + * send will create a new message and send it. + */ +mailer.send = async (options) => { + if (!mailer.enabled) { + const err = new Error('sending email is not enabled because required configuration is not available'); + console.warn(err); + return; } + // Create the new locals object and attach the static locals and the i18n + // framework. + const locals = _.merge({}, options.locals, TEMPLATE_LOCALS, {t: i18n.t}); + + // Render the templates. + const [ + html, + text, + ] = await Promise.all(['html', 'txt'].map((fmt) => { + return templates.render(options.template, fmt, locals); + })); + + // Create the job to send the email later. + return mailer.task.create({ + title: 'Mail', + message: { + to: options.to, + subject: `${EMAIL_SUBJECT_PREFIX} ${options.subject}`, + text, + html + } + }); }; + +/** + * Start the queue processor for the mailer job. + */ +mailer.process = () => { + + debug(`Now processing ${mailer.task.name} jobs`); + + return mailer.task.process(({id, data}, done) => { + debug(`Starting to send mail for Job[${id}]`); + + // Set the `from` field. + data.message.from = SMTP_FROM_ADDRESS; + + // Actually send the email. + mailer.transport.sendMail(data.message, (err) => { + if (err) { + debug(`Failed to send mail for Job[${id}]:`, err); + return done(err); + } + + debug(`Finished sending mail for Job[${id}]`); + return done(); + }); + }); +}; + +module.exports = mailer; diff --git a/services/mongoose.js b/services/mongoose.js index 747df2d66..6f0759908 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -1,14 +1,14 @@ -const mongoose = require('mongoose'); -const debug = require('debug')('talk:db'); -const enabled = require('debug').enabled; -const queryDebugger = require('debug')('talk:db:query'); - const { MONGO_URL, WEBPACK, CREATE_MONGO_INDEXES, } = require('../config'); +const mongoose = require('mongoose'); +const debug = require('debug')('talk:db'); +const enabled = require('debug').enabled; +const queryDebugger = require('debug')('talk:db:query'); + // Loading the formatter from Mongoose: // // https://github.com/Automattic/mongoose/blob/1a93d1f4d12e441e17ddf451e96fbc5f6e8f54b8/lib/drivers/node-mongodb-native/collection.js#L182 diff --git a/services/users.js b/services/users.js index c0fc47d9b..33c6047fa 100644 --- a/services/users.js +++ b/services/users.js @@ -24,7 +24,7 @@ const RECAPTCHA_WINDOW = '10m'; // 10 minutes. const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha will be required. const ActionsService = require('./actions'); -const MailerService = require('./mailer'); +const mailer = require('./mailer'); const i18n = require('./i18n'); const Wordlist = require('./wordlist'); const DomainList = require('./domain_list'); @@ -140,7 +140,7 @@ class UsersService { let user = await UserModel.findOneAndUpdate( { id, - status: { + 'status.banned.status': { $ne: status, }, }, @@ -189,7 +189,7 @@ class UsersService { let user = await UserModel.findOneAndUpdate( { id, - status: { + 'status.username.status': { $ne: status, }, }, @@ -281,7 +281,7 @@ class UsersService { } if (!resetAllowed && user.username === username) { - throw errors.ErrUsernameTaken; + throw errors.ErrSameUsernameProvided; } throw new Error('edit username failed for an unexpected reason'); @@ -359,35 +359,6 @@ class UsersService { ); } - /** - * Merges two users together by taking all the profiles on a given user and - * pushing them into the source user followed by deleting the destination user's - * user account. This will - * not merge the roles associated with the source user. - * @param {String} dstUserID id of the user to which is the target of the merge - * @param {String} srcUserID id of the user to which is the source of the merge - * @return {Promise} resolves when the users are merged - */ - static mergeUsers(dstUserID, srcUserID) { - let srcUser, dstUser; - - return Promise.all([ - UserModel.findOne({id: dstUserID}).exec(), - UserModel.findOne({id: srcUserID}).exec(), - ]) - .then((users) => { - dstUser = users[0]; - srcUser = users[1]; - - srcUser.profiles.forEach((profile) => { - dstUser.profiles.push(profile); - }); - - return srcUser.remove(); - }) - .then(() => dstUser.save()); - } - static castUsername(username) { return username.replace(/ /g, '_').replace(/[^a-zA-Z_]/g, ''); } @@ -452,7 +423,7 @@ class UsersService { redirectURI ); - return MailerService.sendSimple({ + return mailer.send({ template: 'email-confirm', locals: { token, @@ -478,7 +449,7 @@ class UsersService { to, }); - return MailerService.sendSimple(options); + return mailer.send(options); } static async changePassword(id, password) { @@ -741,10 +712,16 @@ class UsersService { } /** - * Verifies a jwt and returns the associated user. + * Verifies a jwt and returns the associated user. Throws an error when the + * token isn't valid. + * * @param {String} token the JSON Web Token to verify */ static async verifyPasswordResetToken(token) { + if (!token) { + throw new Error('cannot verify an empty token'); + } + const {userId, loc, version} = await UsersService.verifyToken(token, { subject: PASSWORD_RESET_JWT_SUBJECT, }); @@ -851,6 +828,46 @@ class UsersService { ); } + /** + * verifyEmailConfirmationToken checks the validity of a given token without + * actually confirming the user's email address. + * + * @param {String} token the token to verify + */ + static async verifyEmailConfirmationToken(token) { + if (!token) { + throw new Error('cannot verify an empty token'); + } + + const decoded = await UsersService.verifyToken(token, { + subject: EMAIL_CONFIRM_JWT_SUBJECT + }); + + const user = await UserModel.findOne({ + id: decoded.userID, + profiles: { + $elemMatch: { + id: decoded.email, + provider: 'local', + }, + }, + }); + if (!user) { + throw errors.ErrNotFound; + } + + const profile = user.profiles.find(({id}) => id === decoded.email); + if (!profile) { + throw errors.ErrNotFound; + } + + if (profile.metadata && profile.metadata.confirmed_at !== null) { + throw errors.ErrEmailVerificationToken; + } + + return decoded; + } + /** * This verifies that a given token was for the email confirmation and updates * that user's profile with a 'confirmed_at' parameter with the current date. @@ -859,9 +876,7 @@ class UsersService { * @return {Promise} */ static async verifyEmailConfirmation(token) { - let {userID, email, referer} = await UsersService.verifyToken(token, { - subject: EMAIL_CONFIRM_JWT_SUBJECT, - }); + let {userID, email, referer} = await UsersService.verifyEmailConfirmationToken(token); await UsersService.confirmEmail(userID, email); diff --git a/test/server/graph/mutations/setUserBanStatus.js b/test/server/graph/mutations/setUserBanStatus.js index 6dc1c3167..3b62ba327 100644 --- a/test/server/graph/mutations/setUserBanStatus.js +++ b/test/server/graph/mutations/setUserBanStatus.js @@ -5,7 +5,7 @@ const Context = require('../../../../graph/context'); const SettingsService = require('../../../../services/settings'); const UserModel = require('../../../../models/user'); const UsersService = require('../../../../services/users'); -const MailerService = require('../../../../services/mailer'); +const mailer = require('../../../../services/mailer'); const sinon = require('sinon'); const chai = require('chai'); @@ -22,7 +22,7 @@ describe('graph.mutations.banUser', () => { let spy; before(() => { - spy = sinon.spy(MailerService, 'sendSimple'); + spy = sinon.spy(mailer, 'send'); }); afterEach(() => { @@ -46,7 +46,7 @@ describe('graph.mutations.banUser', () => { } mutation UnBanUser($user_id: ID!) { - unBanUser(input: { + unbanUser(input: { id: $user_id }) { errors { @@ -112,7 +112,7 @@ describe('graph.mutations.banUser', () => { console.error(res.errors); } expect(res.errors).to.be.undefined; - expect(res.data.unBanUser).to.be.null; + expect(res.data.unbanUser).to.be.null; user = await UserModel.findOne({id: user.id}); diff --git a/test/server/graph/mutations/setUserSuspensionStatus.js b/test/server/graph/mutations/setUserSuspensionStatus.js index 79604d7c4..3a18ad00a 100644 --- a/test/server/graph/mutations/setUserSuspensionStatus.js +++ b/test/server/graph/mutations/setUserSuspensionStatus.js @@ -6,7 +6,7 @@ const Context = require('../../../../graph/context'); const SettingsService = require('../../../../services/settings'); const UserModel = require('../../../../models/user'); const UsersService = require('../../../../services/users'); -const MailerService = require('../../../../services/mailer'); +const mailer = require('../../../../services/mailer'); const sinon = require('sinon'); const chai = require('chai'); @@ -24,7 +24,7 @@ describe('graph.mutations.suspendUser', () => { let spy; before(() => { - spy = sinon.spy(MailerService, 'sendSimple'); + spy = sinon.spy(mailer, 'send'); }); afterEach(() => { @@ -49,7 +49,7 @@ describe('graph.mutations.suspendUser', () => { } mutation UnSuspendUser($user_id: ID!) { - unSuspendUser(input: { + unsuspendUser(input: { id: $user_id, }) { errors { @@ -124,7 +124,7 @@ describe('graph.mutations.suspendUser', () => { console.error(res.errors); } expect(res.errors).to.be.undefined; - expect(res.data.unSuspendUser).to.be.null; + expect(res.data.unsuspendUser).to.be.null; user = await UserModel.findOne({id: user.id}); diff --git a/test/server/routes/api/auth/index.js b/test/server/routes/api/auth/index.js index 463e27711..8e3061938 100644 --- a/test/server/routes/api/auth/index.js +++ b/test/server/routes/api/auth/index.js @@ -54,7 +54,7 @@ describe('/api/v1/auth/local', () => { .catch((err) => { expect(err).to.not.be.null; expect(err.response).to.have.status(401); - expect(err.response.body).to.have.property('message', 'not authorized'); + expect(err.response.body).to.have.property('message', 'You are not authorized to perform this action.'); }); }); diff --git a/test/server/services/users.js b/test/server/services/users.js index dffbc0ee6..837ce9fa2 100644 --- a/test/server/services/users.js +++ b/test/server/services/users.js @@ -1,6 +1,6 @@ const UsersService = require('../../../services/users'); const SettingsService = require('../../../services/settings'); -const MailerService = require('../../../services/mailer'); +const mailer = require('../../../services/mailer'); const chai = require('chai'); chai.use(require('chai-as-promised')); @@ -29,11 +29,11 @@ describe('services.UsersService', () => { password: '3Coral!3' }]); - sinon.spy(MailerService, 'sendSimple'); + sinon.spy(mailer, 'send'); }); afterEach(() => { - MailerService.sendSimple.restore(); + mailer.send.restore(); }); describe('#findById()', () => { @@ -238,7 +238,7 @@ describe('services.UsersService', () => { await UsersService[func](user.id, user.username); throw new Error('edit was processed successfully'); } catch (err) { - expect(err).have.property('translation_key', 'USERNAME_IN_USE'); + expect(err).have.property('translation_key', 'SAME_USERNAME_PROVIDED'); } } else { await UsersService[func](user.id, user.username); diff --git a/views/admin.ejs b/views/admin.ejs index 9bbee7e32..da689f07f 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -34,12 +34,15 @@ height: 100%; } + <%_ if (locals.customCssUrl) { _%> + + <%_ } _%> <% if (data != null) { %> - + <% } %> - +
diff --git a/views/admin/confirm-email.ejs b/views/admin/confirm-email.ejs index ed23d3dc8..0bc54990b 100644 --- a/views/admin/confirm-email.ejs +++ b/views/admin/confirm-email.ejs @@ -6,68 +6,25 @@ Email Verification - - + + <%_ if (locals.customCssUrl) { _%> + + <%_ } _%> - +
-
-
-

Verify Email Address

-
-
- Click the button below to verify the email on your new user account. -
- -
- +
+
+ <%= t('confirm_email.click_to_confirm') %> + +
- - + diff --git a/views/admin/docs.ejs b/views/admin/docs.ejs index cf1dc0b3c..7c6ad9251 100644 --- a/views/admin/docs.ejs +++ b/views/admin/docs.ejs @@ -25,8 +25,11 @@ font-weight: bold; } + <%_ if (locals.customCssUrl) { _%> + + <%_ } _%> - +
diff --git a/views/admin/password-reset.ejs b/views/admin/password-reset.ejs index 6725409d1..d5163f2fd 100644 --- a/views/admin/password-reset.ejs +++ b/views/admin/password-reset.ejs @@ -6,111 +6,33 @@ Password Reset - - + + <%_ if (locals.customCssUrl) { _%> + + <%_ } _%> - +
- Set new password + <%= t('password_reset.set_new_password') %> - +
+ diff --git a/yarn.lock b/yarn.lock index 03e59c6a1..cbdddb034 100644 --- a/yarn.lock +++ b/yarn.lock @@ -183,6 +183,10 @@ ansi-escapes@^1.1.0: version "1.4.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" +ansi-escapes@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-2.0.0.tgz#5bae52be424878dd9783e8910e3fc2922e83c81b" + ansi-escapes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92" @@ -4237,6 +4241,16 @@ ini@^1.3.4, ini@~1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" +inquirer-autocomplete-prompt@^0.12.1: + version "0.12.1" + resolved "https://registry.yarnpkg.com/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-0.12.1.tgz#17b20145fcd656634555ad5645727bd0fe816c57" + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.0" + figures "^2.0.0" + inquirer "3.2.0" + run-async "^2.3.0" + inquirer-confirm@0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/inquirer-confirm/-/inquirer-confirm-0.2.2.tgz#6f406d037bf9d9e455ef0f953929f357fe9a8848" @@ -4275,6 +4289,25 @@ inquirer@0.8.2: rx "^2.4.3" through "^2.3.6" +inquirer@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.2.0.tgz#45b44c2160c729d7578c54060b3eed94487bb42b" + dependencies: + ansi-escapes "^2.0.0" + chalk "^2.0.0" + cli-cursor "^2.1.0" + cli-width "^2.0.0" + external-editor "^2.0.4" + figures "^2.0.0" + lodash "^4.3.0" + mute-stream "0.0.7" + run-async "^2.2.0" + rx-lite "^4.0.8" + rx-lite-aggregates "^4.0.8" + string-width "^2.1.0" + strip-ansi "^4.0.0" + through "^2.3.6" + inquirer@3.3.0, inquirer@^3.0.6, inquirer@^3.2.2: version "3.3.0" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.3.0.tgz#9dd2f2ad765dcab1ff0443b491442a20ba227dc9" @@ -8281,7 +8314,7 @@ run-async@^0.1.0: dependencies: once "^1.3.0" -run-async@^2.2.0: +run-async@^2.2.0, run-async@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" dependencies: @@ -8964,7 +8997,7 @@ supports-color@^3.1.0, supports-color@^3.1.2, supports-color@^3.2.3: dependencies: has-flag "^1.0.0" -supports-color@^4.0.0, supports-color@^4.4.0: +supports-color@^4, supports-color@^4.0.0, supports-color@^4.4.0: version "4.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b" dependencies: