Merge branch 'next' of github.com:coralproject/talk into next

* 'next' of github.com:coralproject/talk: (26 commits)
  removed snakecase
  fix to optional required param
  refactored mailer service with lodash!
  Docs typo
  Fix live status updates
  Move ChangeUsername to the appropiate tab
  Fix change username not displaying errors
  Remove defunct refetch detection workaround
  Rename UnBan and UnSuspend
  hardened configuration
  user cli patches
  don't apply configuration from dotfiles during testing
  applied rename to graph mutation edges
  reintroduced the errors.ErrSameUsernameProvided
  fixed wrong query logic
  resolving pr concerns
  Add proptype
  Mark onClickOutside not required
  Remove unused state
  Use null instead of boolean
  ...
This commit is contained in:
okbel
2018-01-09 10:13:07 -03:00
67 changed files with 805 additions and 861 deletions
+1 -1
View File
@@ -28,5 +28,5 @@ plugins/*
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-subscriber
!plugins/talk-plugin-flag-details
public
node_modules
+1 -1
View File
@@ -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",
+25 -27
View File
@@ -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');
// }
// });
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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');
+1
View File
@@ -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');
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const program = require('commander');
const util = require('./util');
const program = require('commander');
const serve = require('../serve');
//==============================================================================
+1 -1
View File
@@ -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()]);
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+173 -283
View File
@@ -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 <userID>')
.description('delete a user')
.action(deleteUser);
program
.command('passwd <userID>')
.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 <userID>')
.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 <dstUserID> <srcUserID>')
.description('merge srcUser into the dstUser')
.action(mergeUsers);
program
.command('setrole <userID> [role]')
.option('-i, --interactive', 'Enable interactive mode')
.command('set-role <userID> <role>')
.description('sets the role on a user')
.action(setRole);
.action(setUserRole);
program
.command('verify <userID> <email>')
.description('verifies the given user\'s email address')
.action(verify);
.action(verifyUserEmail);
program.parse(process.argv);
+1 -1
View File
@@ -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.
+11
View File
@@ -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;
});
+2 -3
View File
@@ -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;
@@ -128,9 +128,9 @@ class UserDetail extends React.Component {
const banned = isBanned(user);
const suspended = isSuspended(user);
return (
<ClickOutside onClickOutside={!modal && hideUserDetail}>
<ClickOutside onClickOutside={modal ? null : hideUserDetail}>
<Drawer className="talk-admin-user-detail-drawer" onClose={hideUserDetail}>
<h3 className={cn(styles.username, 'talk-admin-user-detail-username')}>
{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;
+2 -2
View File
@@ -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});
@@ -37,19 +37,6 @@ export default withFragments({
user {
id
username
state {
status {
username {
status
}
banned {
status
}
suspension {
until
}
}
}
}
asset {
id
@@ -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,
});
@@ -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';
@@ -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
);
+10 -32
View File
@@ -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;
}
@@ -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;
}
@@ -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')});
@@ -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';
@@ -211,7 +211,8 @@ class StreamContainer extends React.Component {
return <Spinner />;
}
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 (
<Stream
@@ -387,7 +388,6 @@ const fragments = {
const mapStateToProps = (state) => ({
auth: state.auth,
refetching: state.embed.refetching,
activeReplyBox: state.stream.activeReplyBox,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
@@ -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);
}
};
+2 -2
View File
@@ -6,11 +6,11 @@ export default {
'SetUserRoleResponse',
'ChangeUsernameResponse',
'BanUsersResponse',
'UnBanUserResponse',
'UnbanUserResponse',
'SetUserSuspensionStatusResponse',
'SetCommentStatusResponse',
'SetUsernameStatusResponse',
'UnSuspendUserResponse',
'UnsuspendUserResponse',
'SuspendUserResponse',
'CreateCommentResponse',
'CreateFlagResponse',
+6 -6
View File
@@ -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
}
}
`, {
+1 -1
View File
@@ -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 {
+6 -38
View File
@@ -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;
@@ -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.
+18 -3
View File
@@ -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,
};
+12
View File
@@ -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;
+2 -3
View File
@@ -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
}
});
+2 -2
View File
@@ -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}}) => {
+7 -7
View File
@@ -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.
+11
View File
@@ -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"
+17 -5
View File
@@ -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;
+25 -2
View File
@@ -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.
+2
View File
@@ -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",
+7
View File
@@ -11,6 +11,13 @@ module.exports = [
return false;
}
},
(user) => {
// System users can do everything!
if (user.system === true) {
return true;
}
},
query,
mutation,
subscription,
@@ -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) {
+71
View File
@@ -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;
}
+8
View File
@@ -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');
}
}
-6
View File
@@ -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');
});
+46 -35
View File
@@ -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);
+2 -10
View File
@@ -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;
+2 -2
View File
@@ -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 {
+2 -3
View File
@@ -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;
}
+1 -1
View File
@@ -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') %>
+11
View File
@@ -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();
}
+110 -115
View File
@@ -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;
+5 -5
View File
@@ -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
+54 -39
View File
@@ -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);
@@ -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});
@@ -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});
+1 -1
View File
@@ -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.');
});
});
+4 -4
View File
@@ -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);
+5 -2
View File
@@ -34,12 +34,15 @@
height: 100%;
}
</style>
<%_ if (locals.customCssUrl) { _%>
<link href="<%= customCssUrl %>" rel="stylesheet" type="text/css">
<%_ } _%>
<% if (data != null) { %>
<script id="data" type="application/json"><%- JSON.stringify(data) %></script>
<script id="data" type="application/json"><%- JSON.stringify(data) %></script>
<% } %>
<base href="<%= BASE_URL %>"/>
</head>
<body>
<body class="admin-page">
<div id="root"></div>
<script src='https://www.google.com/recaptcha/api.js?render=explicit' async defer></script>
<script src="<%= STATIC_URL %>static/coral-admin/bundle.js" charset="utf-8"></script>
+26 -58
View File
@@ -6,68 +6,25 @@
<title>Email Verification</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<style media="screen">
#root {
max-width: 400px;
padding-top: 100px;
margin: 0 auto;
background: #fff;
}
.coral-card-wide > .mdl-card__title {
color: #fff;
height: 176px;
background: #F47E6B url('/path/to/logo.jpg') center / cover;
}
.coral-card-wide > .mdl-card__menu {
color: #fff;
}
.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;
}
</style>
<link rel="stylesheet" href="/public/css/admin.css">
<%_ if (locals.customCssUrl) { _%>
<link href="<%= customCssUrl %>" rel="stylesheet" type="text/css">
<%_ } _%>
</head>
<body>
<body class="confirm-email-page">
<div id="root">
<div class="coral-card-wide mdl-card mdl-shadow--2dp">
<div class="mdl-card__title">
<h2 class="mdl-card__title-text">Verify Email Address</h2>
</div>
<div class="mdl-card__supporting-text">
Click the button below to verify the email on your new user account.
</div>
<div class="mdl-card__actions mdl-card--border">
<a class="mdl-button mdl-button--colored mdl-js-button mdl-js-ripple-effect" id="verify-email">
Verify
</a>
<div style="display: none" id="p2" class="mdl-progress mdl-js-progress mdl-progress__indeterminate"></div>
</div>
</div>
<div class="error-console container"></div>
<form id="verify-email-form" class="container">
<legend class="legend"><%= t('confirm_email.click_to_confirm') %></legend>
<button type="submit"><%= t('confirm_email.confirm') %></button>
</form>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script defer src="https://code.getmdl.io/1.3.0/material.min.js"></script>
<script>
$(function () {
function showError(message) {
$('.error-console').text(message).addClass('active');
}
function handleClick (e) {
<script src="/public/javascripts/admin.js"></script>
<script type="text/javascript">
$(function() {
function handleSubmit(e) {
e.preventDefault();
$('#p2').css('display', 'block');
$('.error-console').removeClass('active');
$.ajax({
@@ -82,7 +39,18 @@
});
}
$('#verify-email').on('click', handleClick);
$.ajax({
url: '<%= BASE_PATH %>api/v1/account/email/verify',
contentType: 'application/json',
method: 'POST',
data: JSON.stringify({token: location.hash.replace('#', ''), check: true})
})
.then(function () {
$('#verify-email-form').fadeIn().on('submit', handleSubmit);
})
.catch(function (error) {
showError(error.responseText);
});
});
</script>
</body>
+4 -1
View File
@@ -25,8 +25,11 @@
font-weight: bold;
}
</style>
<%_ if (locals.customCssUrl) { _%>
<link href="<%= customCssUrl %>" rel="stylesheet" type="text/css">
<%_ } _%>
</head>
<body>
<body class="docs-page">
<div id="root"></div>
<script src="<%= STATIC_URL %>static/coral-docs/bundle.js" charset="utf-8"></script>
</body>
+20 -97
View File
@@ -6,111 +6,33 @@
<title>Password Reset</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<style media="screen">
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;
}
.submit-password-reset {
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;
}
</style>
<link rel="stylesheet" href="/public/css/admin.css">
<%_ if (locals.customCssUrl) { _%>
<link href="<%= customCssUrl %>" rel="stylesheet" type="text/css">
<%_ } _%>
</head>
<body>
<body class="password-reset-page">
<div id="root">
<div class="error-console container"></div>
<form id="reset-password-form" class="container">
<legend class="legend">Set new password</legend>
<legend class="legend"><%= t('password_reset.set_new_password') %></legend>
<label for="password">
New password
<input type="password" name="password" placeholder="new password" />
<p><small>Password must be at least 8 characters</small></p>
<%= t('password_reset.new_password') %>
<input type="password" name="password" placeholder="<%= t('password_reset.new_password') %>" />
<p><small><%= t('password_reset.new_password_help') %></small></p>
</label>
<label for="confirm-password">
Confirm password
<input type="password" name="confirm-password" placeholder="confirm password" />
<%= t('password_reset.confirm_new_password') %>
<input type="password" name="confirm-password" placeholder="<%= t('password_reset.confirm_new_password') %>" />
</label>
<button class="submit-password-reset" type="submit">Apply</button>
<button type="submit"><%= t('password_reset.change_password') %></button>
</form>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="/public/javascripts/admin.js"></script>
<script>
$(function () {
function showError(error) {
try {
var err = JSON.parse(error);
$('.error-console').text(err.message).addClass('active');
} catch (err) {
$('.error-console').text(error).addClass('active');
}
}
$(function() {
function handleSubmit (e) {
e.preventDefault();
$('.error-console').removeClass('active');
@@ -140,15 +62,16 @@
});
}
$.ajax({
url: '<%= BASE_PATH %>api/v1/account/password/reset?check=true',
url: '<%= BASE_PATH %>api/v1/account/password/reset',
contentType: 'application/json',
method: 'PUT',
data: JSON.stringify({token: location.hash.replace('#', '')})
}).then(function () {
data: JSON.stringify({token: location.hash.replace('#', ''), check: true})
})
.then(function () {
$('#reset-password-form').fadeIn().on('submit', handleSubmit);
}).catch(function (error) {
})
.catch(function (error) {
showError(error.responseText);
});
});
+1 -1
View File
@@ -14,7 +14,7 @@
<%_ } _%>
<base href="<%= BASE_URL %>"/>
</head>
<body>
<body class="embed-stream-page">
<div id="talk-embed-stream-container"></div>
<script src="<%= STATIC_URL %>static/embed/stream/bundle.js"></script>
</body>
+35 -2
View File
@@ -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: