From 56ddc493869b850daffa88c24f886cbb4d606724 Mon Sep 17 00:00:00 2001 From: Riley Davis Date: Tue, 31 Jan 2017 12:04:19 -0700 Subject: [PATCH] resend verify email --- client/coral-framework/actions/auth.js | 31 +++- client/coral-framework/constants/auth.js | 5 + client/coral-framework/helpers/response.js | 14 +- client/coral-framework/reducers/auth.js | 4 + client/coral-framework/translations.json | 2 + .../coral-sign-in/components/SignInContent.js | 137 +++++++++++------- .../containers/SignInContainer.js | 16 ++ client/coral-sign-in/translations.js | 8 +- errors.js | 20 +++ routes/api/users/index.js | 20 +++ services/passport.js | 5 +- services/users.js | 60 ++++---- test/routes/api/auth/index.js | 3 + 13 files changed, 233 insertions(+), 92 deletions(-) diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index e71635ae1..d06abaee0 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -22,6 +22,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()); @@ -32,7 +33,18 @@ export const fetchSignIn = (formData) => (dispatch) => { dispatch(hideSignInDialog()); dispatch(addItem(user, 'users')); }) - .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 @@ -138,7 +150,20 @@ export const checkLogin = () => dispatch => { .catch(error => dispatch(checkLoginFailure(error))); }; -export const requestConfirmEmail = () => dispatch => { +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()); + 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()); + }); }; - diff --git a/client/coral-framework/constants/auth.js b/client/coral-framework/constants/auth.js index 5742adf75..1dae348df 100644 --- a/client/coral-framework/constants/auth.js +++ b/client/coral-framework/constants/auth.js @@ -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'; diff --git a/client/coral-framework/helpers/response.js b/client/coral-framework/helpers/response.js index e4e6e7c37..d95773c04 100644 --- a/client/coral-framework/helpers/response.js +++ b/client/coral-framework/helpers/response.js @@ -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(); diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index 36f8e0764..54ffa37a6 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -11,6 +11,7 @@ const initialState = Map({ error: '', passwordRequestSuccess: null, passwordRequestFailure: null, + emailConfirmationFailure: false, successSignUp: false }); @@ -33,6 +34,7 @@ export default function auth (state = initialState, action) { error: '', passwordRequestFailure: null, passwordRequestSuccess: null, + emailConfirmationFailure: false, successSignUp: false })); case actions.CHANGE_VIEW : @@ -101,6 +103,8 @@ 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); default : return state; } diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json index 5bf9b40a9..07b719f6b 100644 --- a/client/coral-framework/translations.json +++ b/client/coral-framework/translations.json @@ -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 _", diff --git a/client/coral-sign-in/components/SignInContent.js b/client/coral-sign-in/components/SignInContent.js index efd6ad5c2..d3d064c06 100644 --- a/client/coral-sign-in/components/SignInContent.js +++ b/client/coral-sign-in/components/SignInContent.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {PropTypes} from 'react'; import Alert from './Alert'; import {Button, FormField, Spinner} from 'coral-ui'; import styles from './styles.css'; @@ -6,60 +6,89 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations'; const lang = new I18n(translations); -const SignInContent = ({handleChange, formData, ...props}) => ( -
-
-

- {lang.t('signIn.signIn')} -

-
-
- -
-
-

- {lang.t('signIn.or')} -

-
- { props.auth.error && {props.auth.error} } -
- - -
- { - !props.auth.isLoading ? - - : - - } +const SignInContent = ({ + handleChange, + handleChangeEmail, + emailToBeResent, + handleResendConfirmation, + formData, + ...props +}) => { + + return ( +
+
+

+ {props.auth.emailConfirmationFailure ? lang.t('signIn.emailConfirmCTA') : lang.t('signIn.signIn')} +

+
+
+ +
+
+

+ {lang.t('signIn.or')} +

+
+ { props.auth.error && {props.auth.error} } + { + props.auth.emailConfirmationFailure + ? +

{lang.t('signIn.requestNewConfirmEmail')}

+ + + + :
+ + +
+ { + !props.auth.isLoading ? + + : + + } +
+ + } + - - -
-); + ); +}; + +SignInContent.propTypes = { + handleResendConfirmation: PropTypes.func.isRequired, + handleChangeEmail: PropTypes.func.isRequired, + emailToBeResent: PropTypes.string.isRequired +}; export default SignInContent; diff --git a/client/coral-sign-in/containers/SignInContainer.js b/client/coral-sign-in/containers/SignInContainer.js index 07f82a849..5cf07d605 100644 --- a/client/coral-sign-in/containers/SignInContainer.js +++ b/client/coral-sign-in/containers/SignInContainer.js @@ -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,6 +40,8 @@ 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); @@ -71,6 +75,17 @@ class SignInContainer extends Component { }); } + handleChangeEmail(e) { + const {value} = e.target; + console.log('handleChangeEmail', value); + this.setState({emailToBeResent: value}); + } + + handleResendConfirmation(e) { + e.preventDefault(); + this.props.requestConfirmEmail(this.state.emailToBeResent); + } + addError(name, error) { return this.setState(state => ({ errors: { @@ -159,6 +174,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()), diff --git a/client/coral-sign-in/translations.js b/client/coral-sign-in/translations.js index 3dd3171f1..137141d34 100644 --- a/client/coral-sign-in/translations.js +++ b/client/coral-sign-in/translations.js @@ -1,8 +1,8 @@ export default { en: { 'signIn': { - emailConfirmCTA: 'Please verify your email address. We sent an email to {0} for verification.', - requestNewConfirmEmail: 'Request another email', + emailConfirmCTA: 'Please verify your email address.', + requestNewConfirmEmail: 'Request another email:', notYou: 'Not you?', loggedInAs: 'Logged in as', facebookSignIn: 'Sign in with Facebook', @@ -30,8 +30,8 @@ export default { }, es: { 'signIn': { - emailConfirmCTA: 'Por favor verifique su correo electronico. Le enviamos un correo a {0} para verificar.', - requestNewConfirmEmail: 'Enviar otro correo', + emailConfirmCTA: 'Por favor verifique su correo electronico.', + requestNewConfirmEmail: 'Enviar otro correo:', notYou: 'No eres tu?', loggedInAs: 'Entraste como', facebookSignIn: 'Entrar con Facebook', diff --git a/errors.js b/errors.js index 1b26e7170..39c0cb8fc 100644 --- a/errors.js +++ b/errors.js @@ -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,6 +124,10 @@ const ErrNotAuthorized = new APIError('not authorized', { status: 401 }); +// ErrSettingsNotInit is returned when the settings object isn't available in +// the mongo collection. +const ErrSettingsNotInit = new Error('settings not initialized, run `./bin/cli settings init` to setup the settings'); + module.exports = { ExtendableError, APIError, @@ -125,5 +143,7 @@ module.exports = { ErrAssetCommentingClosed, ErrNotFound, ErrInvalidAssetURL, + ErrSettingsNotInit, + ErrAuthentication, ErrNotAuthorized }; diff --git a/routes/api/users/index.js b/routes/api/users/index.js index 563acd2a1..01b34ecd8 100644 --- a/routes/api/users/index.js +++ b/routes/api/users/index.js @@ -4,6 +4,7 @@ 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. @@ -142,6 +143,25 @@ 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; + + 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, process.env.TALK_ROOT_URL) + .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 diff --git a/services/passport.js b/services/passport.js index f24d12165..dcb6904ec 100644 --- a/services/passport.js +++ b/services/passport.js @@ -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)); } } diff --git a/services/users.js b/services/users.js index 1a2c384d6..0fc16f366 100644 --- a/services/users.js +++ b/services/users.js @@ -531,41 +531,51 @@ module.exports = class UsersService { * @param {String} email The email that we are needing to get confirmed. * @return {Promise} */ - static createEmailConfirmToken(userID, email, referer) { + static createEmailConfirmToken(userID = null, email, referer) { if (!email || typeof email !== 'string') { return Promise.reject('email is required when creating a JWT for resetting passord'); } + const tokenOptions = { + jwtid: uuid.v4(), + algorithm: 'HS256', + expiresIn: '1d', + subject: EMAIL_CONFIRM_JWT_SUBJECT + }; + email = email.toLowerCase(); + let userPromise; - return UsersService - .findById(userID) - .then((user) => { - if (!user) { - return Promise.reject(new Error('user not found')); - } + if (!userID) { - // Get the profile representing the local account. - let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local'); + // 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); + } - // 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 userPromise.then((user) => { + if (!user) { + return Promise.reject(new Error('user not found')); + } - const payload = { - email, - referer, - userID - }; + // Get the profile representing the local account. + let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local'); - return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { - jwtid: uuid.v4(), - algorithm: 'HS256', - expiresIn: '1d', - subject: EMAIL_CONFIRM_JWT_SUBJECT - }); - }); + // 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')); + } + + const payload = { + email, + referer, + userID: user.id + }; + + return jwt.sign(payload, process.env.TALK_SESSION_SECRET, tokenOptions); + }); } /** diff --git a/test/routes/api/auth/index.js b/test/routes/api/auth/index.js index 4882d859e..60efc30a2 100644 --- a/test/routes/api/auth/index.js +++ b/test/routes/api/auth/index.js @@ -75,6 +75,9 @@ describe('/api/v1/auth/local', () => { .send({email: 'maria@gmail.com', password: 'password!'}) .catch((err) => { err.response.should.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); })