Merge branch 'master' into design-pass-tweaks

This commit is contained in:
Belén Curcio
2017-02-02 07:55:41 -03:00
committed by GitHub
49 changed files with 928 additions and 503 deletions
+2 -34
View File
@@ -4,45 +4,13 @@
* Module dependencies.
*/
const program = require('commander');
const pkg = require('../package.json');
const dotenv = require('dotenv');
const util = require('../util');
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.version(pkg.version)
.option('-c, --config [path]', 'Specify the configuration file to load')
.option('--pid [path]', 'Specify a path to output the current PID to')
.parse(process.argv);
if (program.config) {
let r = dotenv.config({
path: program.config
});
if (r.error) {
throw r.error;
}
}
// If the `--pid` flag is used, put the current pid there.
if (program.pid) {
util.pid(program.pid);
}
// 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 application.
require('env-rewrite').rewrite();
const program = require('./commander');
program
.command('serve', 'serve the application')
.command('assets', 'interact with assets')
.command('settings', 'work with the application settings')
.command('setup', 'setup the application')
.command('jobs', 'work with the job queues')
.command('users', 'work with the application auth')
.parse(process.argv);
+1 -5
View File
@@ -4,8 +4,7 @@
* Module dependencies.
*/
const program = require('commander');
const pkg = require('../package.json');
const program = require('./commander');
const parseDuration = require('parse-duration');
const Table = require('cli-table');
const AssetModel = require('../models/asset');
@@ -86,9 +85,6 @@ function refreshAssets(ageString) {
// Setting up the program command line arguments.
//==============================================================================
program
.version(pkg.version);
program
.command('list')
.description('list all the assets in the database')
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('commander');
const program = require('./commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const util = require('../util');
+7 -19
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
const app = require('../app');
const program = require('./commander');
const debug = require('debug')('talk:server');
const http = require('http');
const init = require('../init');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const kue = require('../services/kue');
@@ -86,27 +86,15 @@ function onListening() {
* Start the app.
*/
function startApp() {
init().then(() => {
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
}
/**
* Module dependencies.
*/
const program = require('commander');
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('commander');
const mongoose = require('../services/mongoose');
const SettingsService = require('../services/settings');
const util = require('../util');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('init')
.description('initilizes the talk settings')
.action(() => {
const defaults = {
moderation: 'POST',
wordlist: {
banned: [],
suspect: []
}
};
SettingsService
.init(defaults)
.then(() => {
console.log('Created settings object.');
util.shutdown();
})
.catch((err) => {
console.error(`failed to create the settings object ${JSON.stringify(err)}`);
util.shutdown(1);
});
});
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
Executable
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('./commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingModel = require('../models/setting');
const SettingsService = require('../services/settings');
const util = require('../util');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.description('runs the setup wizard to setup the application')
.option('--defaults', 'apply defaults for config instead of prompting')
.parse(process.argv);
//==============================================================================
// Setup the application
//==============================================================================
SettingsService
.init()
.then((settings) => {
if (program.defaults) {
return settings.save();
}
console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n');
return inquirer.prompt([
{
type: 'input',
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName,
validate: (input) => {
if (input && input.length > 0) {
return true;
}
return 'Organization Name is required.';
}
},
{
type: 'list',
choices: SettingModel.MODERATION_OPTIONS,
name: 'moderation',
default: settings.moderation,
message: 'Select a moderation mode'
},
{
type: 'confirm',
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
}
])
.then((answers) => {
// Update the settings that were changed.
Object.keys(answers).forEach((key) => {
if (answers[key] !== undefined) {
settings[key] = answers[key];
}
});
return settings.save();
});
})
.then(() => {
console.log('Talk is now installed!');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
+137 -109
View File
@@ -4,105 +4,132 @@
* Module dependencies.
*/
const program = require('commander');
const pkg = require('../package.json');
const prompt = require('prompt');
const program = require('./commander');
const inquirer = require('inquirer');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const mongoose = require('../services/mongoose');
const util = require('../util');
const Table = require('cli-table');
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
if (input && input.length >= len) {
return true;
}
return msg;
};
// Regeister 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,
displayName: options.name,
roles: []
};
if (options.role && UserModel.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: 'displayName',
message: 'Display Name',
filter: (displayName) => {
return UsersService
.isValidDisplayName(displayName)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'roles',
message: 'User Role',
type: 'checkbox',
choices: UserModel.USER_ROLES
}
]);
}
/**
* Prompts for input and registers a user based on those.
*/
function createUser(options) {
return new Promise((resolve, reject) => {
if (options.flag_mode) {
return resolve({
email: options.email,
password: options.password,
displayName: options.name,
role: options.role
});
}
prompt.start();
prompt.get([
{
name: 'email',
description: 'Email',
format: 'email',
required: true
},
{
name: 'password',
description: 'Password',
hidden: true,
required: true
},
{
name: 'confirmPassword',
description: 'Confirm Password',
hidden: true,
required: true
},
{
name: 'displayName',
description: 'Display Name',
required: true
},
{
name: 'role',
description: 'User Role',
required: false
}
], (err, result) => {
if (err) {
return reject(err);
getUserCreateAnswers(options)
.then((answers) => {
if (answers.password !== answers.confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
if (result.password !== result.confirmPassword) {
return reject(new Error('Passwords do not match'));
}
return answers;
})
.then((answers) => {
return UsersService
.createLocalUser(answers.email.trim(), answers.password.trim(), answers.displayName.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
resolve(result);
});
})
.then((result) => {
return UsersService.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
let role = result.role ? result.role.trim() : null;
if (role && role.length > 0) {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
});
} else {
util.shutdown();
}
if (answers.roles.length > 0) {
return Promise.all(answers.roles.map((role) => {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the role ${role} to User ${user.id}.`);
});
}));
}
});
})
.then(() => {
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
});
});
}
/**
@@ -127,44 +154,34 @@ function deleteUser(userID) {
* Changes the password for a user.
*/
function passwd(userID) {
prompt.start();
prompt.get([
inquirer.prompt([
{
name: 'password',
description: 'Password',
hidden: true,
required: true
message: 'Password',
type: 'password',
validate: validateRequired('Password is required')
},
{
name: 'confirmPassword',
description: 'Confirm Password',
hidden: true,
required: true
message: 'Confirm Password',
type: 'password',
validate: validateRequired('Confirm Password is required')
}
], (err, result) => {
if (err) {
console.error(err);
util.shutdown();
return;
])
.then((answers) => {
if (answers.password !== answers.confirmPassword) {
return Promise.reject(new Error('Password mismatch'));
}
if (result.password !== result.confirmPassword) {
console.error(new Error('Password mismatch'));
util.shutdown(1);
return;
}
UsersService
.changePassword(userID, result.password)
.then(() => {
console.log('Password changed.');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
return UsersService.changePassword(userID, answers.password);
})
.then(() => {
console.log('Password changed.');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
@@ -273,6 +290,13 @@ function mergeUsers(dstUserID, srcUserID) {
* @param {String} role the role to add
*/
function addRole(userID, role) {
if (UserModel.USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
UsersService
.addRoleToUser(userID, role)
.then(() => {
@@ -291,6 +315,13 @@ function addRole(userID, role) {
* @param {String} role the role to remove
*/
function removeRole(userID, role) {
if (UserModel.USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
UsersService
.removeRoleFromUser(userID, role)
.then(() => {
@@ -375,9 +406,6 @@ function enableUser(userID) {
// Setting up the program command line arguments.
//==============================================================================
program
.version(pkg.version);
program
.command('create')
.option('--email [email]', 'Email to use')
+46
View File
@@ -0,0 +1,46 @@
const pkg = require('../package.json');
const dotenv = require('dotenv');
const fs = require('fs');
const program = require('commander');
const util = require('../util');
// Perform rewrites to the runtime environment variables based on the contents
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
// entrypoint for the entire application.
require('env-rewrite').rewrite();
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
const parseArgs = require('minimist')(process.argv.slice(2), {
alias: {
'c': 'config'
},
string: [
'config',
'pid'
],
default: {
'config': null,
'pid': null
}
});
if (parseArgs.config) {
let envConfig = dotenv.parse(fs.readFileSync(parseArgs.config, {encoding: 'utf8'}));
Object.keys(envConfig).forEach((k) => {
process.env[k] = envConfig[k];
});
}
// If the `--pid` flag is used, put the current pid there.
if (parseArgs.pid) {
util.pid(parseArgs.pid);
}
module.exports = program
.version(pkg.version)
.option('-c, --config [path]', 'Specify the configuration file to load')
.option('--pid [path]', 'Specify a path to output the current PID to');
+1 -1
View File
@@ -14,7 +14,7 @@ database:
post:
# Initialize the settings in the database, this will create indicies for the
# database.
- ./bin/cli settings init
- ./bin/cli setup --defaults
- sleep 2
test:
@@ -32,6 +32,10 @@ const updateModeration = (updateSettings, mod) => () => {
updateSettings({moderation});
};
const updateEmailConfirmation = (updateSettings, verify) => () => {
updateSettings({requireEmailConfirmation: !verify});
};
const updateInfoBoxEnable = (updateSettings, infoBox) => () => {
const infoBoxEnable = !infoBox;
updateSettings({infoBoxEnable});
@@ -67,106 +71,123 @@ const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError
return <Card shadow="4"><Spinner/>Loading settings...</Card>;
}
// just putting this here for shorthand below
const on = styles.enabledSetting;
const off = styles.disabledSetting;
return (
<div className={styles.commentSettingsSection}>
<h3>{title}</h3>
<Card className={`${styles.configSetting} ${settings.moderation === 'PRE' ? styles.enabledSetting : styles.disabledSetting}`}>
<div className={styles.action}>
<Checkbox
onChange={updateModeration(updateSettings, settings.moderation)}
checked={settings.moderation === 'PRE'} />
</div>
<div className={styles.content}>
<Card className={`${styles.configSetting} ${settings.moderation === 'PRE' ? on : off}`}>
<div className={styles.action}>
<Checkbox
onChange={updateModeration(updateSettings, settings.moderation)}
checked={settings.moderation === 'PRE'} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{lang.t('configure.enable-pre-moderation')}</div>
<p className={settings.moderation === 'PRE' ? '' : styles.disabledSettingText}>
{lang.t('configure.enable-pre-moderation-text')}
</p>
</div>
</Card>
<Card className={`${styles.configSetting} ${settings.charCountEnable ? styles.enabledSetting : styles.disabledSetting}`}>
<div className={styles.action}>
<Checkbox
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
checked={settings.charCountEnable} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{lang.t('configure.comment-count-header')}</div>
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
<span>{lang.t('configure.comment-count-text-pre')}</span>
<input type='text'
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
htmlFor='charCount'
onChange={updateCharCount(updateSettings, settingsError)}
value={settings.charCount}/>
<span>{lang.t('configure.comment-count-text-post')}</span>
{
errors.charCount &&
<span className={styles.settingsError}>
<br/>
<Icon name="error_outline"/>
{lang.t('configure.comment-count-error')}
</span>
}
</p>
</div>
</Card>
<Card className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? styles.enabledSetting : styles.disabledSetting}`}>
<div className={styles.action}>
<Checkbox
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
checked={settings.infoBoxEnable} />
</div>
<div className={styles.content}>
{lang.t('configure.include-comment-stream')}
<p>
{lang.t('configure.include-comment-stream-desc')}
</p>
<div className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
<div className={styles.content}>
<Textfield
onChange={updateInfoBoxContent(updateSettings)}
value={settings.infoBoxContent}
label={lang.t('configure.include-text')}
rows={3}/>
</div>
</Card>
<Card className={`${styles.configSetting} ${settings.requireEmailConfirmation ? on : off}`}>
<div className={styles.action}>
<Checkbox
onChange={updateEmailConfirmation(updateSettings, settings.requireEmailConfirmation)}
checked={settings.requireEmailConfirmation} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{lang.t('configure.require-email-verification')}</div>
<p className={settings.requireEmailConfirmation ? '' : styles.disabledSettingText}>
{lang.t('configure.require-email-verification-text')}
</p>
</div>
</Card>
<Card className={`${styles.configSetting} ${settings.charCountEnable ? on : off}`}>
<div className={styles.action}>
<Checkbox
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
checked={settings.charCountEnable} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{lang.t('configure.comment-count-header')}</div>
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
<span>{lang.t('configure.comment-count-text-pre')}</span>
<input type='text'
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
htmlFor='charCount'
onChange={updateCharCount(updateSettings, settingsError)}
value={settings.charCount}/>
<span>{lang.t('configure.comment-count-text-post')}</span>
{
errors.charCount &&
<span className={styles.settingsError}>
<br/>
<Icon name="error_outline"/>
{lang.t('configure.comment-count-error')}
</span>
}
</p>
</div>
</Card>
<Card className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? on : off}`}>
<div className={styles.action}>
<Checkbox
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
checked={settings.infoBoxEnable} />
</div>
<div className={styles.content}>
{lang.t('configure.include-comment-stream')}
<p>
{lang.t('configure.include-comment-stream-desc')}
</p>
<div className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
<div className={styles.content}>
<Textfield
onChange={updateInfoBoxContent(updateSettings)}
value={settings.infoBoxContent}
label={lang.t('configure.include-text')}
rows={3}/>
</div>
</div>
</Card>
<Card className={styles.configSettingInfoBox}>
<div className={styles.content}>
{lang.t('configure.closed-comments-desc')}
<div>
<Textfield
onChange={updateClosedMessage(updateSettings)}
value={settings.closedMessage}
label={lang.t('configure.closed-comments-label')}
rows={3}/>
</div>
</div>
</Card>
<Card className={styles.configSettingInfoBox}>
<div className={styles.content}>
{lang.t('configure.closed-comments-desc')}
<div>
<Textfield
onChange={updateClosedMessage(updateSettings)}
value={settings.closedMessage}
label={lang.t('configure.closed-comments-label')}
rows={3}/>
</div>
</Card>
<Card className={`${styles.configSettingInfoBox}`}>
<div className={styles.content}>
{lang.t('configure.close-after')}
<br />
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
value={getTimeoutAmount(settings.closedTimeout)}
label={lang.t('configure.closed-comments-label')} />
<div className={styles.configTimeoutSelect}>
<SelectField
label="comments closed time window"
value={getTimeoutMeasure(settings.closedTimeout)}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
<Option value={'hours'}>{lang.t('configure.hours')}</Option>
<Option value={'days'}>{lang.t('configure.days')}</Option>
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
</SelectField>
</div>
</div>
</Card>
<Card className={`${styles.configSettingInfoBox}`}>
<div className={styles.content}>
{lang.t('configure.close-after')}
<br />
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
value={getTimeoutAmount(settings.closedTimeout)}
label={lang.t('configure.closed-comments-label')} />
<div className={styles.configTimeoutSelect}>
<SelectField
label="comments closed time window"
value={getTimeoutMeasure(settings.closedTimeout)}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
<Option value={'hours'}>{lang.t('configure.hours')}</Option>
<Option value={'days'}>{lang.t('configure.days')}</Option>
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
</SelectField>
</div>
</Card>
</div>
</Card>
</div>
);
};
@@ -38,12 +38,13 @@ class Streams extends Component {
}
onSearchChange = (e) => {
const search = e.target.value;
this.setState((prevState) => {
prevState.search = e.target.value;
prevState.search = search;
clearTimeout(prevState.timer);
const fetchAssets = this.props.fetchAssets;
prevState.timer = setTimeout(() => {
fetchAssets(0, limit, e.target.value, this.state.sort, this.state.filter);
fetchAssets(0, limit, search, this.state.sort, this.state.filter);
}, 350);
return prevState;
});
+4
View File
@@ -50,6 +50,8 @@
"configure": {
"enable-pre-moderation": "Enable pre-moderation",
"enable-pre-moderation-text": "Moderators must approve any comment before it is published.",
"require-email-verification": "Require Email Confirmation",
"require-email-verification-text": "New Users must verify their email before commenting",
"include-comment-stream": "Include Comment Stream Description for Readers.",
"include-comment-stream-desc": "Write a message to be added to the top of your comment stream. Pose a topic, include community guidelines, etc.",
"include-text": "Include your text here.",
@@ -155,6 +157,8 @@
"configure": {
"enable-pre-moderation": "Habilitar pre-moderación",
"enable-pre-moderation-text": "Los moderadores deben aprobar cada comentario antes de que sea publicado.",
"require-email-verification": "Necesita confirmación de correo",
"require-email-verification-text": "Nuevos usuarios deben verificar sus correos antes de comentar",
"include-comment-stream": "Incluir la Descripción a un Hilo de Comentario para los y las Lectoras.",
"include-comment-stream-desc": "Escribir un mensaje que será agregado a la parte de arriba del tu hilo de comentarios. Por ejemplo, un tema, guias de comunidad, etc.",
"include-text": "Incluir tu texto aqui.",
+2 -1
View File
@@ -5,7 +5,7 @@ import isEqual from 'lodash/isEqual';
import {TabBar, Tab, TabContent, Spinner} from 'coral-ui';
const {logout, showSignInDialog} = authActions;
const {logout, showSignInDialog, requestConfirmEmail} = authActions;
const {addNotification, clearNotification} = notificationActions;
const {fetchAssetSuccess} = assetActions;
@@ -181,6 +181,7 @@ const mapStateToProps = state => ({
});
const mapDispatchToProps = dispatch => ({
requestConfirmEmail: () => dispatch(requestConfirmEmail()),
loadAsset: (asset) => dispatch(fetchAssetSuccess(asset)),
addNotification: (type, text) => {
pym.sendMessage('getPosition');
+31 -1
View File
@@ -21,6 +21,7 @@ export const cleanState = () => ({type: actions.CLEAN_STATE});
const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST});
const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin});
const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error});
const emailConfirmError = () => ({type: actions.EMAIL_CONFIRM_ERROR});
export const fetchSignIn = (formData) => (dispatch) => {
dispatch(signInRequest());
@@ -30,7 +31,18 @@ export const fetchSignIn = (formData) => (dispatch) => {
dispatch(signInSuccess(user, isAdmin));
dispatch(hideSignInDialog());
})
.catch(() => dispatch(signInFailure(lang.t('error.emailPasswordError'))));
.catch(error => {
if (error.metadata) {
// the user might not have a valid email. prompt the user user re-request the confirmation email
dispatch(signInFailure(lang.t('error.emailNotVerified', error.metadata)));
dispatch(emailConfirmError());
} else {
// invalid credentials
dispatch(signInFailure(lang.t('error.emailPasswordError')));
}
});
};
// Sign In - Facebook
@@ -137,3 +149,21 @@ export const checkLogin = () => dispatch => {
dispatch(checkLoginFailure(`${error.message}`));
});
};
const confirmEmailRequest = () => ({type: actions.CONFIRM_EMAIL_REQUEST});
const confirmEmailSuccess = () => ({type: actions.CONFIRM_EMAIL_SUCCESS});
const confirmEmailFailure = () => ({type: actions.CONFIRM_EMAIL_FAILURE});
export const requestConfirmEmail = email => dispatch => {
dispatch(confirmEmailRequest());
return coralApi('/users/resend-confirm', {method: 'POST', body: {email}})
.then(() => {
dispatch(confirmEmailSuccess());
})
.catch(err => {
console.log('failed to send email confirmation', err);
// email might have already been confirmed
dispatch(confirmEmailFailure());
});
};
+5
View File
@@ -32,3 +32,8 @@ export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS';
export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE';
export const CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN';
export const EMAIL_CONFIRM_ERROR = 'EMAIL_CONFIRM_ERROR';
export const CONFIRM_EMAIL_REQUEST = 'CONFIRM_EMAIL_REQUEST';
export const CONFIRM_EMAIL_SUCCESS = 'CONFIRM_EMAIL_SUCCESS';
export const CONFIRM_EMAIL_FAILURE = 'CONFIRM_EMAIL_FAILURE';
+10 -4
View File
@@ -34,15 +34,21 @@ const buildOptions = (inputOptions = {}) => {
};
const handleResp = res => {
if (res.status === 401) {
throw new Error('Not Authorized to make this request');
} else if (res.status > 399) {
if (res.status > 399) {
return res.json().then(err => {
let message = err.message || res.status;
const error = new Error();
if (err.error && err.error.metadata && err.error.metadata.message) {
error.metadata = err.error.metadata.message;
}
if (err.error && err.error.translation_key) {
message = err.error.translation_key;
}
throw new Error(message);
error.message = message;
throw error;
});
} else if (res.status === 204) {
return res.text();
+1 -1
View File
@@ -70,7 +70,7 @@ class i18n {
};
this.timeago = (time) => {
return this.timeagoInstance.format(time);
return this.timeagoInstance.format(new Date(time));
};
}
}
+16
View File
@@ -11,6 +11,9 @@ const initialState = Map({
error: '',
passwordRequestSuccess: null,
passwordRequestFailure: null,
emailConfirmationFailure: false,
emailConfirmationLoading: false,
emailConfirmationSuccess: false,
successSignUp: false
});
@@ -33,6 +36,9 @@ export default function auth (state = initialState, action) {
error: '',
passwordRequestFailure: null,
passwordRequestSuccess: null,
emailConfirmationFailure: false,
emailConfirmationSuccess: false,
emailConfirmationLoading: false,
successSignUp: false
}));
case actions.CHANGE_VIEW :
@@ -101,6 +107,16 @@ export default function auth (state = initialState, action) {
return state
.set('passwordRequestFailure', 'There was an error sending your password reset email. Please try again soon!')
.set('passwordRequestSuccess', null);
case actions.EMAIL_CONFIRM_ERROR:
return state
.set('emailConfirmationFailure', true)
.set('emailConfirmationLoading', false);
case actions.CONFIRM_EMAIL_REQUEST:
return state.set('emailConfirmationLoading', true);
case actions.CONFIRM_EMAIL_SUCCESS:
return state
.set('emailConfirmationSuccess', true)
.set('emailConfirmationLoading', false);
default :
return state;
}
+2
View File
@@ -5,6 +5,7 @@
"contentNotAvailable": "This content is not available",
"suspendedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Flag, or write comments. Please contact moderator@fakeurl.com for more information",
"error": {
"emailNotVerified": "Email address {0} not verified.",
"email": "Not a valid E-Mail",
"password": "Password must be at least 8 characters",
"displayName": "Display names can contain letters, numbers and _ only",
@@ -27,6 +28,7 @@
"contentNotAvailable": "El contenido no se encuentra disponible",
"suspendedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes dar Like, Marcar o escribir commentarios. Por favor, contacta moderator@fakeurl for more information",
"error": {
"emailNotVerified": "Dirección de correo electrónico {0} no verificada.",
"email": "No es un email válido",
"password": "La contraseña debe tener por lo menos 8 caracteres",
"displayName": "Los nombres pueden contener letras, números y _",
@@ -17,7 +17,12 @@ const SignDialog = ({open, view, handleClose, offset, ...props}) => (
}}>
<span className={styles.close} onClick={handleClose}>×</span>
{view === 'SIGNIN' && <SignInContent {...props} />}
{view === 'SIGNUP' && <SignUpContent {...props} />}
{
view === 'SIGNUP' && <SignUpContent
emailConfirmationLoading={props.emailConfirmationLoading}
emailConfirmationSuccess={props.emailConfirmationSuccess}
{...props} />
}
{view === 'FORGOT' && <ForgotContent {...props} />}
</Dialog>
);
@@ -1,67 +1,100 @@
import React from 'react';
import Button from 'coral-ui/components/Button';
import FormField from './FormField';
import React, {PropTypes} from 'react';
import Alert from './Alert';
import Spinner from 'coral-ui/components/Spinner';
import {Button, FormField, Spinner, Success} from 'coral-ui';
import styles from './styles.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
const SignInContent = ({handleChange, formData, ...props}) => (
<div>
<div className={styles.header}>
<h1>
{lang.t('signIn.signIn')}
</h1>
</div>
<div className={styles.socialConnections}>
<Button cStyle="facebook" onClick={props.fetchSignInFacebook} full>
{lang.t('signIn.facebookSignIn')}
</Button>
</div>
<div className={styles.separator}>
<h1>
{lang.t('signIn.or')}
</h1>
</div>
{ props.auth.error && <Alert>{props.auth.error}</Alert> }
<form onSubmit={props.handleSignIn}>
<FormField
id="email"
type="email"
label={lang.t('signIn.email')}
value={formData.email}
onChange={handleChange}
/>
<FormField
id="password"
type="password"
label={lang.t('signIn.password')}
value={formData.password}
onChange={handleChange}
/>
<div className={styles.action}>
{
!props.auth.isLoading ?
<Button id='coralLogInButton' type="submit" cStyle="black" className={styles.signInButton} full>
{lang.t('signIn.signIn')}
</Button>
:
<Spinner />
}
const SignInContent = ({
handleChange,
handleChangeEmail,
emailToBeResent,
handleResendConfirmation,
emailConfirmationLoading,
emailConfirmationSuccess,
formData,
...props
}) => {
return (
<div>
<div className={styles.header}>
<h1>
{props.auth.emailConfirmationFailure ? lang.t('signIn.emailConfirmCTA') : lang.t('signIn.signIn')}
</h1>
</div>
<div className={styles.socialConnections}>
<Button cStyle="facebook" onClick={props.fetchSignInFacebook} full>
{lang.t('signIn.facebookSignIn')}
</Button>
</div>
<div className={styles.separator}>
<h1>
{lang.t('signIn.or')}
</h1>
</div>
{ props.auth.error && <Alert>{props.auth.error}</Alert> }
{
props.auth.emailConfirmationFailure
? <form onSubmit={handleResendConfirmation}>
<p>{lang.t('signIn.requestNewConfirmEmail')}</p>
<FormField
id="confirm-email"
type="email"
label={lang.t('signIn.email')}
value={emailToBeResent}
onChange={handleChangeEmail} />
<Button id='resendConfirmEmail' type='submit' cStyle='black' full>Send Email</Button>
{emailConfirmationLoading && <Spinner />}
{emailConfirmationSuccess && <Success />}
</form>
: <form onSubmit={props.handleSignIn}>
<FormField
id="email"
type="email"
label={lang.t('signIn.email')}
value={formData.email}
onChange={handleChange}
/>
<FormField
id="password"
type="password"
label={lang.t('signIn.password')}
value={formData.password}
onChange={handleChange}
/>
<div className={styles.action}>
{
!props.auth.isLoading ?
<Button id='coralLogInButton' type="submit" cStyle="black" className={styles.signInButton} full>
{lang.t('signIn.signIn')}
</Button>
:
<Spinner />
}
</div>
</form>
}
<div className={styles.footer}>
<span><a onClick={() => props.changeView('FORGOT')}>{lang.t('signIn.forgotYourPass')}</a></span>
<span>
{lang.t('signIn.needAnAccount')}
<a onClick={() => props.changeView('SIGNUP')} id='coralRegister'>
{lang.t('signIn.register')}
</a>
</span>
</div>
</form>
<div className={styles.footer}>
<span><a onClick={() => props.changeView('FORGOT')}>{lang.t('signIn.forgotYourPass')}</a></span>
<span>
{lang.t('signIn.needAnAccount')}
<a onClick={() => props.changeView('SIGNUP')} id='coralRegister'>
{lang.t('signIn.register')}
</a>
</span>
</div>
</div>
);
);
};
SignInContent.propTypes = {
emailConfirmationLoading: PropTypes.bool.isRequired,
emailConfirmationSuccess: PropTypes.bool.isRequired,
handleResendConfirmation: PropTypes.func.isRequired,
handleChangeEmail: PropTypes.func.isRequired,
emailToBeResent: PropTypes.string.isRequired
};
export default SignInContent;
@@ -1,9 +1,6 @@
import React from 'react';
import FormField from './FormField';
import Alert from './Alert';
import Button from 'coral-ui/components/Button';
import Spinner from 'coral-ui/components/Spinner';
import Success from 'coral-ui/components/Success';
import {Button, FormField, Spinner, Success} from 'coral-ui';
import styles from './styles.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
+12 -22
View File
@@ -14,28 +14,6 @@
font-size: 1.2em;
}
.formField {
margin-top: 15px;
}
.formField label {
font-size: 1.08em;
font-weight: bold;
margin-bottom: 5px;
}
.formField input {
width: 100%;
display: block;
border: none;
outline: none;
border: 1px solid rgba(0,0,0,.12);
padding: 10px 6px;
box-sizing: border-box;
border-radius: 2px;
margin: 5px auto;
}
.footer {
margin: 20px auto 10px;
text-align: center;
@@ -153,3 +131,15 @@ input.error{
background-color: 1px solid coral;
padding: 10px;
}
.emailConfirmDialog {
margin-top: 15px;
}
.confirmLabel {
display: block;
}
.confirmSubmit {
}
@@ -16,6 +16,7 @@ import {
hideSignInDialog,
fetchSignInFacebook,
fetchForgotPassword,
requestConfirmEmail,
facebookCallback,
invalidForm,
validForm,
@@ -30,6 +31,7 @@ class SignInContainer extends Component {
password: '',
confirmPassword: ''
},
emailToBeResent: '',
errors: {},
showErrors: false
};
@@ -38,9 +40,10 @@ class SignInContainer extends Component {
super(props);
this.state = this.initialState;
this.handleChange = this.handleChange.bind(this);
this.handleChangeEmail = this.handleChangeEmail.bind(this);
this.handleResendConfirmation = this.handleResendConfirmation.bind(this);
this.handleSignUp = this.handleSignUp.bind(this);
this.handleSignIn = this.handleSignIn.bind(this);
this.handleClose = this.handleClose.bind(this);
this.addError = this.addError.bind(this);
}
@@ -71,6 +74,23 @@ class SignInContainer extends Component {
});
}
handleChangeEmail(e) {
const {value} = e.target;
this.setState({emailToBeResent: value});
}
handleResendConfirmation(e) {
e.preventDefault();
this.props.requestConfirmEmail(this.state.emailToBeResent)
.then(() => {
setTimeout(() => {
// allow success UI to be shown for a second, and then close the modal
this.props.handleClose();
}, 2500);
});
}
addError(name, error) {
return this.setState(state => ({
errors: {
@@ -124,12 +144,9 @@ class SignInContainer extends Component {
this.props.fetchSignIn(this.state.formData);
}
handleClose() {
this.props.hideSignInDialog();
}
render() {
const {auth, showSignInDialog, noButton, offset} = this.props;
const {emailConfirmationLoading, emailConfirmationSuccess} = auth;
return (
<div>
{!noButton && <Button id='coralSignInButton' onClick={showSignInDialog} full>
@@ -139,6 +156,8 @@ class SignInContainer extends Component {
open={auth.showSignInDialog}
view={auth.view}
offset={offset}
emailConfirmationLoading={emailConfirmationLoading}
emailConfirmationSuccess={emailConfirmationSuccess}
{...this}
{...this.state}
{...this.props}
@@ -159,6 +178,7 @@ const mapDispatchToProps = dispatch => ({
fetchSignIn: formData => dispatch(fetchSignIn(formData)),
fetchSignInFacebook: () => dispatch(fetchSignInFacebook()),
fetchForgotPassword: formData => dispatch(fetchForgotPassword(formData)),
requestConfirmEmail: email => dispatch(requestConfirmEmail(email)),
showSignInDialog: () => dispatch(showSignInDialog()),
changeView: view => dispatch(changeView(view)),
handleClose: () => dispatch(hideSignInDialog()),
+4
View File
@@ -1,6 +1,8 @@
export default {
en: {
'signIn': {
emailConfirmCTA: 'Please verify your email address.',
requestNewConfirmEmail: 'Request another email:',
notYou: 'Not you?',
loggedInAs: 'Logged in as',
facebookSignIn: 'Sign in with Facebook',
@@ -28,6 +30,8 @@ export default {
},
es: {
'signIn': {
emailConfirmCTA: 'Por favor verifique su correo electronico.',
requestNewConfirmEmail: 'Enviar otro correo:',
notYou: 'No eres tu?',
loggedInAs: 'Entraste como',
facebookSignIn: 'Entrar con Facebook',
+48
View File
@@ -0,0 +1,48 @@
.formField {
margin-top: 15px;
}
.formField label {
font-size: 1.08em;
font-weight: bold;
margin-bottom: 5px;
}
.formField input {
width: 100%;
display: block;
border: none;
outline: none;
border: 1px solid rgba(0,0,0,.12);
padding: 10px 6px;
box-sizing: border-box;
border-radius: 2px;
margin: 5px auto;
}
input.error{
border: solid 2px #f44336;
}
.errorMsg, .hint {
color: grey;
font-weight: 600;
padding: 3px 0 16px;
}
.attention {
display: inline-block;
width: 15px;
height: 15px;
background: #B71C1C;
color: #FFEBEE;
font-weight: bolder;
padding: 4px;
vertical-align: middle;
border-radius: 20px;
box-sizing: border-box;
font-size: 9px;
line-height: 7px;
text-align: center;
margin-right: 5px;
}
@@ -1,5 +1,5 @@
import React from 'react';
import styles from './styles.css';
import React, {PropTypes} from 'react';
import styles from './FormField.css';
const FormField = ({className, showErrors = false, errorMsg, label, ...props}) => (
<div className={`${styles.formField} ${className ? className : ''}`}>
@@ -15,4 +15,12 @@ const FormField = ({className, showErrors = false, errorMsg, label, ...props}) =
</div>
);
FormField.propTypes = {
label: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
errorMsg: PropTypes.string,
type: PropTypes.string
};
export default FormField;
+2
View File
@@ -13,5 +13,7 @@ export {default as Icon} from './components/Icon';
export {default as List} from './components/List';
export {default as Item} from './components/Item';
export {default as Card} from './components/Card';
export {default as FormField} from './components/FormField';
export {default as Success} from './components/Success';
export {default as Pager} from './components/Pager';
export {default as SnackBar} from './components/SnackBar';
+20
View File
@@ -89,6 +89,20 @@ class ErrAssetCommentingClosed extends APIError {
}
}
/**
* ErrAuthentication is returned when there is an error authenticating and the
* message is provided.
*/
class ErrAuthentication extends APIError {
constructor(message = null) {
super('authentication error occured', {
status: 401
}, {
message
});
}
}
// ErrContainsProfanity is returned in the event that the middleware detects
// profanity/wordlisted words in the payload.
const ErrContainsProfanity = new APIError('Suspected profanity. If you think this in error, please let us know!', {
@@ -110,10 +124,15 @@ const ErrNotAuthorized = new APIError('not authorized', {
status: 401
});
// ErrSettingsNotInit is returned when the settings are required but not
// initialized.
const ErrSettingsNotInit = new Error('settings not initialized, run `./bin/cli setup` to setup the application first');
module.exports = {
ExtendableError,
APIError,
ErrPasswordTooShort,
ErrSettingsNotInit,
ErrMissingEmail,
ErrMissingPassword,
ErrMissingToken,
@@ -125,5 +144,6 @@ module.exports = {
ErrAssetCommentingClosed,
ErrNotFound,
ErrInvalidAssetURL,
ErrAuthentication,
ErrNotAuthorized
};
-13
View File
@@ -1,13 +0,0 @@
const SettingsService = require('./services/settings');
module.exports = () => Promise.all([
// Upsert the settings object.
SettingsService.init({
moderation: 'POST',
wordlist: {
banned: [],
suspect: []
}
})
]);
+19 -11
View File
@@ -1,12 +1,10 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const WordlistSchema = new Schema({
banned: [String],
suspect: [String]
}, {
_id: false
});
const MODERATION_OPTIONS = [
'PRE',
'POST'
];
/**
* SettingSchema manages application settings that get used on front and backend.
@@ -19,10 +17,7 @@ const SettingSchema = new Schema({
},
moderation: {
type: String,
enum: [
'PRE',
'POST'
],
enum: MODERATION_OPTIONS,
default: 'POST'
},
infoBoxEnable: {
@@ -33,6 +28,9 @@ const SettingSchema = new Schema({
type: String,
default: ''
},
organizationName: {
type: String
},
closedTimeout: {
type: Number,
@@ -43,7 +41,16 @@ const SettingSchema = new Schema({
type: String,
default: 'Expired'
},
wordlist: WordlistSchema,
wordlist: {
banned: {
type: Array,
default: []
},
suspect: {
type: Array,
default: []
}
},
charCount: {
type: Number,
default: 5000
@@ -87,3 +94,4 @@ SettingSchema.method('merge', function(src) {
const Setting = mongoose.model('Setting', SettingSchema);
module.exports = Setting;
module.exports.MODERATION_OPTIONS = MODERATION_OPTIONS;
+1
View File
@@ -64,6 +64,7 @@ const UserSchema = new mongoose.Schema({
displayName: {
type: String,
unique: true,
lowercase: true,
required: true
},
+2 -2
View File
@@ -24,7 +24,7 @@ module.exports = {
},
'test_settings': {
'default': {
'launch_url' : 'http://localhost:3000',
'launch_url' : 'http://localhost:3011',
'selenium_port': 6666,
'selenium_host': 'localhost',
'silent': true,
@@ -48,7 +48,7 @@ module.exports = {
]
},
'integration': {
'launch_url': 'http://localhost:3000'
'launch_url': 'http://localhost:3011'
}
}
};
+3 -2
View File
@@ -12,7 +12,7 @@
"lint-fix": "eslint bin/* . --fix",
"test": "TEST_MODE=unit NODE_ENV=test mocha -R ${NPM_PACKAGE_CONFIG_MOCHA_REPORTER:-spec}",
"test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
"pree2e": "NODE_ENV=test scripts/pree2e.sh",
"pree2e": "NODE_ENV=test TALK_PORT=3011 scripts/pree2e.sh",
"e2e": "NODE_ENV=test nightwatch",
"poste2e": "NODE_ENV=test scripts/poste2e.sh",
"embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs",
@@ -68,10 +68,12 @@
"graphql-tag": "^1.2.3",
"graphql-tools": "^0.9.0",
"helmet": "^3.1.0",
"inquirer": "^3.0.1",
"jsonwebtoken": "^7.1.9",
"kue": "^0.11.5",
"lodash": "^4.16.6",
"metascraper": "^1.0.6",
"minimist": "^1.2.0",
"mongoose": "^4.6.5",
"morgan": "^1.7.0",
"natural": "^0.4.0",
@@ -80,7 +82,6 @@
"passport": "^0.3.2",
"passport-facebook": "^2.1.1",
"passport-local": "^1.0.0",
"prompt": "^1.0.0",
"react-apollo": "^0.8.1",
"redis": "^2.6.3",
"uuid": "^2.0.3"
+5
View File
@@ -1,6 +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) => {
+2 -2
View File
@@ -28,8 +28,8 @@ router.post('/email/confirm', (req, res, next) => {
UsersService
.verifyEmailConfirmation(token)
.then(() => {
res.status(204).end();
.then(({referer}) => {
res.json({redirectUri: referer});
})
.catch((err) => {
next(err);
+29 -8
View File
@@ -4,8 +4,10 @@ const UsersService = require('../../../services/users');
const SettingsService = require('../../../services/settings');
const CommentsService = require('../../../services/comments');
const mailer = require('../../../services/mailer');
const errors = require('../../../errors');
const authorization = require('../../../middleware/authorization');
// get a list of users.
router.get('/', authorization.needed('ADMIN'), (req, res, next) => {
const {
value = '',
@@ -44,6 +46,7 @@ router.post('/:user_id/role', authorization.needed('ADMIN'), (req, res, next) =>
.catch(next);
});
// update the status of a user
router.post('/:user_id/status', authorization.needed('ADMIN'), (req, res, next) => {
UsersService
.setStatus(req.params.user_id, req.body.status)
@@ -97,8 +100,8 @@ router.post('/:user_id/email', authorization.needed('admin'), (req, res, next) =
* @param {String} userID the id for the user to send the email to
* @param {String} email the email for the user to send the email to
*/
const SendEmailConfirmation = (app, userID, email) => UsersService
.createEmailConfirmToken(userID, email)
const SendEmailConfirmation = (app, userID, email, referer) => UsersService
.createEmailConfirmToken(userID, email, referer)
.then((token) => {
return mailer.sendSimple({
app, // needed to render the templates.
@@ -113,12 +116,10 @@ const SendEmailConfirmation = (app, userID, email) => UsersService
});
});
// create a local user.
router.post('/', (req, res, next) => {
const {
email,
password,
displayName
} = req.body;
const {email, password, displayName} = req.body;
const redirectUri = req.header('Referer');
UsersService
.createLocalUser(email, password, displayName)
@@ -131,7 +132,7 @@ router.post('/', (req, res, next) => {
if (requireEmailConfirmation) {
SendEmailConfirmation(req.app, user.id, email)
SendEmailConfirmation(req.app, user.id, email, redirectUri)
.then(() => {
// Then send back the user.
@@ -176,6 +177,26 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
});
});
// trigger an email confirmation re-send by a new user
router.post('/resend-confirm', (req, res, next) => {
const {email} = req.body;
const redirectUri = req.header('Referer');
if (!email) {
return next(errors.ErrMissingEmail);
}
// find user by email.
// if the local profile is verified, return an error code?
// send a 204 after the email is re-sent
SendEmailConfirmation(req.app, null, email, redirectUri)
.then(() => {
res.status(204).end();
})
.catch(next);
});
// trigger an email confirmation re-send from the admin panel
router.post('/:user_id/email/confirm', authorization.needed('ADMIN'), (req, res, next) => {
const {
user_id
+2 -2
View File
@@ -3,8 +3,8 @@
# install selenium
selenium-standalone install --config=./selenium.config.js
#Init settings
./bin/cli settings init
# Init application
./bin/cli setup --defaults
# Creating Admin Test User
./bin/cli users create --flag_mode --email "admin@test.com" --password "testtest" --name "AdminTestUser" --role "ADMIN"
+3 -2
View File
@@ -3,6 +3,7 @@ const UsersService = require('./users');
const SettingsService = require('./settings');
const LocalStrategy = require('passport-local').Strategy;
const FacebookStrategy = require('passport-facebook').Strategy;
const errors = require('../errors');
//==============================================================================
// SESSION SERIALIZATION
@@ -34,7 +35,7 @@ function ValidateUserLogin(loginProfile, user, done) {
}
if (user.disabled) {
return done(null, false, {message: 'Account disabled'});
return done(new errors.ErrAuthentication('Account disabled'));
}
// If the user isn't a local user (i.e., a social user).
@@ -61,7 +62,7 @@ function ValidateUserLogin(loginProfile, user, done) {
// 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.
if (!profile.metadata || !profile.metadata.confirmed_at || profile.metadata.confirmed_at === null) {
return done(null, false, {message: `Email address ${loginProfile.id} not verified.`});
return done(new errors.ErrAuthentication(loginProfile.id));
}
}
+17 -9
View File
@@ -1,4 +1,5 @@
const SettingModel = require('../models/setting');
const errors = require('../errors');
/**
* The selector used to uniquely identify the settings document.
@@ -15,7 +16,15 @@ module.exports = class SettingsService {
* @return {Promise} settings the whole settings record
*/
static retrieve() {
return SettingModel.findOne(selector);
return SettingModel
.findOne(selector)
.then((settings) => {
if (!settings) {
return Promise.reject(errors.ErrSettingsNotInit);
}
return settings;
});
}
/**
@@ -35,15 +44,14 @@ module.exports = class SettingsService {
/**
* This is run once when the app starts to ensure settings are populated.
* @return {Promise} null initialize the global settings object
*/
static init(defaults) {
static init(defaults = {}) {
return SettingsService
.retrieve()
.catch(() => {
let settings = new SettingModel(defaults);
// Inject the defaults on top of the passed in defaults to ensure that the new
// settings conform to the required selector.
defaults = Object.assign({}, defaults, selector);
// Actually update the settings collection.
return SettingsService.update(defaults);
return settings.save();
});
}
};
+56 -37
View File
@@ -174,7 +174,7 @@ module.exports = class UsersService {
* @return {Promise} rejected if the machine's sensibilites are offended
*/
static isValidDisplayName(displayName) {
const onlyLettersNumbersUnderscore = /^[a-z0-9_]+$/;
const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/;
if (!displayName) {
return Promise.reject(errors.ErrMissingDisplay);
@@ -189,6 +189,18 @@ module.exports = class UsersService {
return Wordlist.displayNameCheck(displayName);
}
static isValidPassword(password) {
if (!password) {
return Promise.reject(errors.ErrMissingPassword);
}
if (password.length < 8) {
return Promise.reject(errors.ErrPasswordTooShort);
}
return Promise.resolve(password);
}
/**
* Creates the local user with a given email, password, and name.
* @param {String} email email of the new user
@@ -205,15 +217,10 @@ module.exports = class UsersService {
email = email.toLowerCase().trim();
displayName = displayName.toLowerCase().trim();
if (!password) {
return Promise.reject(errors.ErrMissingPassword);
}
if (password.length < 8) {
return Promise.reject(errors.ErrPasswordTooShort);
}
return UsersService.isValidDisplayName(displayName)
return Promise.all([
UsersService.isValidDisplayName(displayName),
UsersService.isValidPassword(password)
])
.then(() => { // displayName is valid
return new Promise((resolve, reject) => {
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
@@ -531,40 +538,51 @@ module.exports = class UsersService {
* @param {String} email The email that we are needing to get confirmed.
* @return {Promise}
*/
static createEmailConfirmToken(userID, email) {
static createEmailConfirmToken(userID = null, email, referer = process.env.TALK_ROOT_URL) {
if (!email || typeof email !== 'string') {
return Promise.reject('email is required when creating a JWT for resetting passord');
}
// Conform the email to lowercase.
email = email.toLowerCase();
return UsersService
.findById(userID)
.then((user) => {
if (!user) {
return Promise.reject(new Error('user not found'));
}
const tokenOptions = {
jwtid: uuid.v4(),
algorithm: 'HS256',
expiresIn: '1d',
subject: EMAIL_CONFIRM_JWT_SUBJECT
};
// Get the profile representing the local account.
let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local');
let userPromise;
// Ensure that the user email hasn't already been verified.
if (profile && profile.metadata && profile.metadata.confirmed_at) {
return Promise.reject(new Error('email address already confirmed'));
}
if (!userID) {
const payload = {
email,
userID
};
// If there is no userID, we're coming from the endpoint where a new user
// is re-requesting a confirmation email and we don't know the userID.
userPromise = UserModel.findOne({profiles: {$elemMatch: {id: email, provider: 'local'}}});
} else {
userPromise = UsersService.findById(userID);
}
return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
jwtid: uuid.v4(),
algorithm: 'HS256',
expiresIn: '1d',
subject: EMAIL_CONFIRM_JWT_SUBJECT
});
});
return userPromise.then((user) => {
if (!user) {
return Promise.reject(errors.ErrNotFound);
}
// Get the profile representing the local account.
let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local');
// Ensure that the user email hasn't already been verified.
if (profile && profile.metadata && profile.metadata.confirmed_at) {
return Promise.reject(new Error('email address already confirmed'));
}
return jwt.sign({
email,
referer,
userID: user.id
}, process.env.TALK_SESSION_SECRET, tokenOptions);
});
}
/**
@@ -579,8 +597,7 @@ module.exports = class UsersService {
.verifyToken(token, {
subject: EMAIL_CONFIRM_JWT_SUBJECT
})
.then(({userID, email}) => {
.then(({userID, email, referer}) => {
return UserModel
.update({
id: userID,
@@ -594,8 +611,10 @@ module.exports = class UsersService {
$set: {
'profiles.$.metadata.confirmed_at': new Date()
}
});
})
.then(() => ({userID, email, referer}));
});
}
/**
+1
View File
@@ -203,6 +203,7 @@ class Wordlist {
*/
static displayNameCheck(displayName) {
const wl = new Wordlist();
return wl.load()
.then(() => {
displayName = displayName.replace(/_/g, '');
+1 -1
View File
@@ -1,6 +1,6 @@
module.exports = {
waitForConditionTimeout: 8000,
baseUrl: 'http://localhost:3000',
baseUrl: 'http://localhost:3011',
users: {
admin: {
email: 'admin@test.com',
+5 -2
View File
@@ -66,7 +66,7 @@ describe('/api/v1/auth/local', () => {
describe('email confirmation enabled', () => {
beforeEach(() => SettingsService.init({requireEmailConfirmation: true}));
beforeEach(() => SettingsService.update({requireEmailConfirmation: true}));
describe('#post', () => {
it('should not allow a login from a user that is not confirmed', () => {
@@ -74,7 +74,10 @@ describe('/api/v1/auth/local', () => {
.post('/api/v1/auth/local')
.send({email: 'maria@gmail.com', password: 'password!'})
.catch((err) => {
err.response.should.have.status(401);
expect(err).to.have.status(401);
err.response.body.should.have.property('error');
err.response.body.error.should.have.property('metadata');
err.response.body.error.metadata.should.have.property('message', 'maria@gmail.com');
return UsersService.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id);
})
+3 -1
View File
@@ -20,6 +20,8 @@ util.shutdown = (defaultCode = 0, signal = null) => {
debug(`Reached ${signal} signal`);
}
debug(`${util.toshutdown.length} jobs now being called`);
Promise
.all(util.toshutdown.map((func) => func ? func(signal) : null).filter((func) => func))
.then(() => {
@@ -41,7 +43,7 @@ util.shutdown = (defaultCode = 0, signal = null) => {
*/
util.onshutdown = (jobs) => {
debug(`${jobs.length} jobs registered`);
debug(`${jobs.length} jobs registered to be called during shutdown`);
// Add the new jobs to shutdown to the object reference.
util.toshutdown = util.toshutdown.concat(jobs);
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<title>Confirm Email</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>
</head>
<body>
<div id="root">
<div class="coral-card-wide mdl-card mdl-shadow--2dp">
<div class="mdl-card__title">
<h2 class="mdl-card__title-text">Confirm Email Address</h2>
</div>
<div class="mdl-card__supporting-text">
Click the button below to confirm 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="confirm-email">
Confirm
</a>
<div style="display: none" id="p2" class="mdl-progress mdl-js-progress mdl-progress__indeterminate"></div>
</div>
</div>
</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) {
e.preventDefault();
$('#p2').css('display', 'block');
$('.error-console').removeClass('active');
$.ajax({
url: '/api/v1/account/email/confirm',
contentType: 'application/json',
method: 'POST',
headers: {
'X-CSRF-Token': '<%= csrfToken %>'
},
data: JSON.stringify({token: location.hash.replace('#', '')})
}).then(function (success) {
location.href = success.redirectUri;
}).catch(function (error) {
showError(error.responseText);
});
}
$('#confirm-email').on('click', handleClick);
});
</script>
</body>
</html>
+1 -1
View File
@@ -12,7 +12,7 @@
main {
margin-left:auto;
margin-right:auto;
width:500px;
max-width:500px;
}
</style>
<title><%= title %></title>
+1 -1
View File
@@ -1,3 +1,3 @@
<p>A email confirmation has been requested for the following account: <b><%= email %></b>.</p>
<p>To confirm the account, please visit the following link: <a href="http://example.com/email/confirm/endpoint#<%= token %>">http://example.com/email/confirm/endpoint#<%= token %></a></p>
<p>To confirm the account, please visit the following link: <a href="<%= rootURL %>/admin/confirm-email#<%= token %>">Confirm Email</a></p>
<p>If you did not request this, you can safely ignore this email.</p>
+1 -1
View File
@@ -4,6 +4,6 @@ A email confirmation has been requested for the following account:
To confirm the account, please visit the following link:
http://example.com/email/confirm/endpoint#<%= token %>
<%= rootURL %>/confirm/endpoint#<%= token %>
If you did not request this, you can safely ignore this email.