diff --git a/api-server/common/models/User-Identity.js b/api-server/common/models/User-Identity.js index b3913ba3212c51..f9756885cabfec 100644 --- a/api-server/common/models/User-Identity.js +++ b/api-server/common/models/User-Identity.js @@ -9,7 +9,6 @@ import { wrapHandledError } from '../../server/utils/create-handled-error.js'; // const log = debug('fcc:models:userIdent'); export default function(UserIdent) { - UserIdent.on('dataSourceAttached', () => { UserIdent.findOne$ = observeMethod(UserIdent, 'findOne'); }); @@ -33,7 +32,7 @@ export default function(UserIdent) { // get the social provider data and the external id from auth0 profile.id = profile.id || profile.openid; const auth0IdString = '' + profile.id; - const [ provider, socialExtId ] = auth0IdString.split('|'); + const [provider, socialExtId] = auth0IdString.split('|'); const query = { where: { provider: provider, @@ -42,8 +41,10 @@ export default function(UserIdent) { include: 'user' }; // get the email from the auth0 (its expected from social providers) - const email = (profile && profile.emails && profile.emails[0]) ? - profile.emails[0].value : ''; + const email = + profile && profile.emails && profile.emails[0] + ? profile.emails[0].value + : ''; if (!isEmail('' + email)) { throw wrapHandledError( new Error('invalid or empty email recieved from auth0'), @@ -60,12 +61,11 @@ export default function(UserIdent) { } if (provider === 'email') { - return User.findOne$({ where: { email } }) .flatMap(user => { - return user ? - Observable.of(user) : - User.create$({ email }).toPromise(); + return user + ? Observable.of(user) + : User.create$({ email }).toPromise(); }) .flatMap(user => { if (!user) { @@ -81,56 +81,51 @@ export default function(UserIdent) { } ); } - const createToken = observeQuery( - AccessToken, - 'create', - { - userId: user.id, - created: new Date(), - ttl: user.constructor.settings.ttl - } - ); - const updateUser = user.update$({ - emailVerified: true, - emailAuthLinkTTL: null, - emailVerifyTTL: null + const createToken = observeQuery(AccessToken, 'create', { + userId: user.id, + created: new Date(), + ttl: user.constructor.settings.ttl }); + const updateUserPromise = new Promise((resolve, reject) => + user.updateAttributes( + { + emailVerified: true, + emailAuthLinkTTL: null, + emailVerifyTTL: null + }, + err => { + if (err) { + return reject(err); + } + return resolve(); + } + ) + ); return Observable.combineLatest( Observable.of(user), createToken, - updateUser, - (user, token) => ({user, token}) + Observable.fromPromise(updateUserPromise), + (user, token) => ({ user, token }) ); }) - .subscribe( - ({ user, token }) => cb(null, user, null, token), - cb - ); - + .subscribe(({ user, token }) => cb(null, user, null, token), cb); } else { - return UserIdent.findOne$(query) .flatMap(identity => { - return identity ? - Observable.of(identity.user()) : - User.findOne$({ where: { email } }) - .flatMap(user => { - return user ? - Observable.of(user) : - User.create$({ email }).toPromise(); + return identity + ? Observable.of(identity.user()) + : User.findOne$({ where: { email } }).flatMap(user => { + return user + ? Observable.of(user) + : User.create$({ email }).toPromise(); }); }) .flatMap(user => { - - const createToken = observeQuery( - AccessToken, - 'create', - { - userId: user.id, - created: new Date(), - ttl: user.constructor.settings.ttl - } - ); + const createToken = observeQuery(AccessToken, 'create', { + userId: user.id, + created: new Date(), + ttl: user.constructor.settings.ttl + }); const updateUser = user.update$({ email: email, emailVerified: true, @@ -144,11 +139,7 @@ export default function(UserIdent) { (user, token) => ({ user, token }) ); }) - .subscribe( - ({ user, token }) => cb(null, user, null, token), - cb - ); - + .subscribe(({ user, token }) => cb(null, user, null, token), cb); } }; } diff --git a/api-server/common/models/user.js b/api-server/common/models/user.js index adfce5de015c8c..f3230fcf823fb9 100644 --- a/api-server/common/models/user.js +++ b/api-server/common/models/user.js @@ -614,10 +614,8 @@ module.exports = function(User) { this.update$({ emailAuthLinkTTL }) ); }) - .map(() => - dedent` - Check your email and click the link we sent you to confirm you email. - ` + .map(() => 'Check your email and click the link we sent you to confirm' + + ' your new email address.' ); } @@ -691,12 +689,18 @@ module.exports = function(User) { } }) .flatMap(()=>{ + const updatePromise = new Promise((resolve, reject) => + this.updateAttributes(updateConfig, err => { + if (err) { + return reject(err); + } + return resolve(); + })); return Observable.forkJoin( - this.update$(updateConfig), + Observable.fromPromise(updatePromise), this.requestAuthEmail(false, newEmail), (_, message) => message - ) - .doOnNext(() => this.manualReload()); + ); }); } else { diff --git a/api-server/server/middlewares/email-not-verified-notice.js b/api-server/server/middlewares/email-not-verified-notice.js index ae65a65282e317..39d0309c3de6ee 100644 --- a/api-server/server/middlewares/email-not-verified-notice.js +++ b/api-server/server/middlewares/email-not-verified-notice.js @@ -2,14 +2,12 @@ import dedent from 'dedent'; const ALLOWED_METHODS = ['GET']; const EXCLUDED_PATHS = [ - '/api/flyers/findOne', '/signout', '/accept-privacy-terms', '/update-email', '/confirm-email', - '/passwordless-change', - '/external/services/user' -]; + '/passwordless-change' +].reduce((list, item) => [...list, item, `/internal${item}`], []); export default function emailNotVerifiedNotice() { return function(req, res, next) { diff --git a/client/src/client-only-routes/ShowSettings.js b/client/src/client-only-routes/ShowSettings.js index 9eeb46571f2d02..7da818380f1915 100644 --- a/client/src/client-only-routes/ShowSettings.js +++ b/client/src/client-only-routes/ShowSettings.js @@ -12,20 +12,25 @@ import { submitNewAbout, updateUserFlag } from '../redux/settings'; import Layout from '../components/Layout'; import Spacer from '../components/helpers/Spacer'; import Loader from '../components/helpers/Loader'; -import { FullWidthRow } from '../components/helpers'; +import FullWidthRow from '../components/helpers/FullWidthRow'; import About from '../components/settings/About'; import Privacy from '../components/settings/Privacy'; +import Email from '../components/settings/Email'; const propTypes = { about: PropTypes.string, + email: PropTypes.string, + isEmailVerified: PropTypes.bool, location: PropTypes.string, name: PropTypes.string, picture: PropTypes.string, points: PropTypes.number, + sendQuincyEmail: PropTypes.bool, showLoading: PropTypes.bool, submitNewAbout: PropTypes.func.isRequired, theme: PropTypes.string, toggleNightMode: PropTypes.func.isRequired, + updateQuincyEmail: PropTypes.func.isRequired, username: PropTypes.string }; @@ -34,8 +39,22 @@ const mapStateToProps = createSelector( userSelector, ( showLoading, - { username = '', about, picture, points, name, location, theme } + { + username = '', + about, + email, + sendQuincyEmail, + isEmailVerified, + picture, + points, + name, + location, + theme + } ) => ({ + email, + sendQuincyEmail, + isEmailVerified, showLoading, username, about, @@ -49,12 +68,19 @@ const mapStateToProps = createSelector( const mapDispatchToProps = dispatch => bindActionCreators( - { submitNewAbout, toggleNightMode: theme => updateUserFlag({theme}) }, + { + submitNewAbout, + toggleNightMode: theme => updateUserFlag({ theme }), + updateQuincyEmail: sendQuincyEmail => updateUserFlag({ sendQuincyEmail }) + }, dispatch ); function ShowSettings(props) { const { + email, + isEmailVerified, + sendQuincyEmail, showLoading, username, about, @@ -64,7 +90,8 @@ function ShowSettings(props) { location, name, submitNewAbout, - toggleNightMode + toggleNightMode, + updateQuincyEmail } = props; if (showLoading) { @@ -120,9 +147,14 @@ function ShowSettings(props) { - {/* + - + {/* diff --git a/client/src/components/settings/Email.js b/client/src/components/settings/Email.js new file mode 100644 index 00000000000000..c24cdd824c2488 --- /dev/null +++ b/client/src/components/settings/Email.js @@ -0,0 +1,250 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Link } from 'gatsby'; +import { + HelpBlock, + Alert, + FormGroup, + ControlLabel, + FormControl, + Button +} from '@freecodecamp/react-bootstrap'; +import isEmail from 'validator/lib/isEmail'; + +import { updateMyEmail } from '../../redux/settings'; +import { maybeEmailRE } from '../../utils'; + +import FullWidthRow from '../helpers/FullWidthRow'; +import Spacer from '../helpers/Spacer'; +import SectionHeader from './SectionHeader'; +import BlockSaveButton from '../helpers/form/BlockSaveButton'; +import ToggleSetting from './ToggleSetting'; + +const mapStateToProps = () => ({}); +const mapDispatchToProps = dispatch => + bindActionCreators({ updateMyEmail }, dispatch); + +const propTypes = { + email: PropTypes.string, + isEmailVerified: PropTypes.bool, + sendQuincyEmail: PropTypes.bool, + updateMyEmail: PropTypes.func.isRequired, + updateQuincyEmail: PropTypes.func.isRequired +}; + +export function UpdateEmailButton() { + return ( + + + + ); +} + +class EmailSettings extends Component { + constructor(props) { + super(props); + + this.state = { + emailForm: { + currentEmail: props.email, + newEmail: '', + confirmNewEmail: '', + isPristine: true + } + }; + } + + handleSubmit = e => { + e.preventDefault(); + const { + emailForm: { newEmail } + } = this.state; + const { updateMyEmail } = this.props; + return updateMyEmail(newEmail); + }; + + createHandleEmailFormChange = key => e => { + e.preventDefault(); + const userInput = e.target.value.slice(); + return this.setState(state => ({ + emailForm: { + ...state.emailForm, + [key]: userInput, + isPristine: userInput === state.emailForm.currentEmail + } + })); + }; + + getValidationForNewEmail = () => { + const { + emailForm: { newEmail, currentEmail } + } = this.state; + + if (!maybeEmailRE.test(newEmail)) { + return { + state: null, + message: '' + }; + } + if (newEmail === currentEmail) { + return { + state: 'error', + message: 'This email is the same as your current email' + }; + } + if (isEmail(newEmail)) { + return { state: 'success', message: '' }; + } else { + return { + state: 'warning', + message: + 'We could not validate your email correctly, ' + + 'please ensure it is correct' + }; + } + }; + + getValidationForConfirmEmail = () => { + const { + emailForm: { confirmNewEmail, newEmail } + } = this.state; + + if (!maybeEmailRE.test(newEmail)) { + return { + state: null, + message: '' + }; + } + const isMatch = newEmail === confirmNewEmail; + if (maybeEmailRE.test(confirmNewEmail)) { + return { + state: isMatch ? 'success' : 'error', + message: isMatch ? '' : 'Both new email addresses must be the same' + }; + } else { + return { + state: null, + message: '' + }; + } + }; + + render() { + const { + emailForm: { newEmail, confirmNewEmail, currentEmail, isPristine } + } = this.state; + const { isEmailVerified, updateQuincyEmail, sendQuincyEmail } = this.props; + + const { + state: newEmailValidation, + message: newEmailValidationMessage + } = this.getValidationForNewEmail(); + + const { + state: confirmEmailValidation, + message: confirmEmailValidationMessage + } = this.getValidationForConfirmEmail(); + if (!currentEmail) { + return ( +
+ +

+ You do not have an email associated with this account. +

+
+ + + +
+ ); + } + return ( +
+ Email Settings + {isEmailVerified ? null : ( + + + + Your email has not been verified. +
+ Please check your email, or{' '} + + request a new verification email here + + . +
+
+
+ )} + +
+ + Current Email + {currentEmail} + + + New Email + + {newEmailValidationMessage ? ( + {newEmailValidationMessage} + ) : null} + + + Confirm New Email + + {confirmEmailValidationMessage ? ( + {confirmEmailValidationMessage} + ) : null} + + + +
+ + +
+ updateQuincyEmail(!sendQuincyEmail)} + /> + +
+
+ ); + } +} + +EmailSettings.displayName = 'EmailSettings'; +EmailSettings.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(EmailSettings); diff --git a/client/src/pages/update-email.js b/client/src/pages/update-email.js index 8cd6735af7977b..8d965105c430d9 100644 --- a/client/src/pages/update-email.js +++ b/client/src/pages/update-email.js @@ -15,13 +15,15 @@ import { Button } from '@freecodecamp/react-bootstrap'; import Helmet from 'react-helmet'; +import isEmail from 'validator/lib/isEmail'; +import { isString } from 'lodash'; import Layout from '../components/Layout'; import { Spacer } from '../components/helpers'; import './update-email.css'; -import { userSelector, updateMyEmail } from '../redux'; -import { isString } from 'lodash'; -import isEmail from 'validator/lib/isEmail'; +import { userSelector } from '../redux'; +import { updateMyEmail } from '../redux/settings'; +import { maybeEmailRE } from '../utils'; const propTypes = { isNewEmail: PropTypes.bool, @@ -38,8 +40,6 @@ const mapStateToProps = createSelector( const mapDispatchToProps = dispatch => bindActionCreators({ updateMyEmail }, dispatch); -const maybeEmailRE = /[\w.+]*?@\w*?\.\w+?/; - class UpdateEmail extends Component { constructor(props) { super(props); @@ -48,16 +48,15 @@ class UpdateEmail extends Component { emailValue: '' }; - // this.createSubmitHandler = this.createSubmitHandler.bind(this); this.onChange = this.onChange.bind(this); } - createSubmitHandler(fn) { - return e => { - e.preventDefault(); - return fn(this.state.emailValue); - }; - } + handleSubmit = e => { + e.preventDefault(); + const { emailValue } = this.state; + const { updateMyEmail } = this.props; + return updateMyEmail(emailValue); + }; onChange(e) { const change = e.target.value; @@ -78,7 +77,7 @@ class UpdateEmail extends Component { } render() { - const { isNewEmail, updateMyEmail } = this.props; + const { isNewEmail } = this.props; return ( @@ -90,10 +89,7 @@ class UpdateEmail extends Component { -
+ !!Object.keys(state[ns].user).length; export const signInLoadingSelector = state => @@ -96,6 +89,19 @@ export const userSelector = state => { return state[ns].user[username] || {}; }; +function spreadThePayloadOnUser(state, payload) { + return { + ...state, + user: { + ...state.user, + [state.appUsername]: { + ...state.user[state.appUsername], + ...payload + } + } + }; +} + export const reducer = handleActions( { [types.fetchUser]: state => ({ @@ -164,31 +170,11 @@ export const reducer = handleActions( } : state, [settingsTypes.submitNewAboutComplete]: (state, { payload }) => - payload - ? { - ...state, - user: { - ...state.user, - [state.appUsername]: { - ...state.user[state.appUsername], - ...payload - } - } - } - : state, + payload ? spreadThePayloadOnUser(state, payload) : state, + [settingsTypes.updateMyEmailComplete]: (state, { payload }) => + payload ? spreadThePayloadOnUser(state, payload) : state, [settingsTypes.updateUserFlagComplete]: (state, { payload }) => - payload - ? { - ...state, - user: { - ...state.user, - [state.appUsername]: { - ...state.user[state.appUsername], - ...payload - } - } - } - : state + payload ? spreadThePayloadOnUser(state, payload) : state }, initialState ); diff --git a/client/src/redux/settings/index.js b/client/src/redux/settings/index.js index dacdd1bd578ce3..59deefaf4b980a 100644 --- a/client/src/redux/settings/index.js +++ b/client/src/redux/settings/index.js @@ -2,6 +2,7 @@ import { createAction, handleActions } from 'redux-actions'; import { createTypes, createAsyncTypes } from '../../utils/createTypes'; import { createSettingsSagas } from './settings-sagas'; +import { createUpdateMyEmailSaga } from './update-email-saga'; const ns = 'settings'; @@ -24,16 +25,21 @@ export const types = createTypes( ...createAsyncTypes('validateUsername'), ...createAsyncTypes('submitNewAbout'), ...createAsyncTypes('submitNewUsername'), + ...createAsyncTypes('updateMyEmail'), ...createAsyncTypes('updateUserFlag'), ...createAsyncTypes('submitProfileUI') ], ns ); + +export const sagas = [ + ...createSettingsSagas(types), + ...createUpdateMyEmailSaga(types) +]; + const checkForSuccessPayload = ({ type, payload }) => type === 'success' ? payload : null; -export const sagas = [...createSettingsSagas(types)]; - export const submitNewAbout = createAction(types.submitNewAbout); export const submitNewAboutComplete = createAction( types.submitNewAboutComplete, @@ -57,6 +63,10 @@ export const submitProfileUIComplete = createAction( ); export const submitProfileUIError = createAction(types.submitProfileUIError); +export const updateMyEmail = createAction(types.updateMyEmail); +export const updateMyEmailComplete = createAction(types.updateMyEmailComplete); +export const updateMyEmailError = createAction(types.updateMyEmailError); + export const updateUserFlag = createAction(types.updateUserFlag); export const updateUserFlagComplete = createAction( types.updateUserFlagComplete, diff --git a/client/src/redux/settings/update-email-saga.js b/client/src/redux/settings/update-email-saga.js new file mode 100644 index 00000000000000..5922b6d708897e --- /dev/null +++ b/client/src/redux/settings/update-email-saga.js @@ -0,0 +1,32 @@ +import { call, put, takeEvery } from 'redux-saga/effects'; +import isEmail from 'validator/lib/isEmail'; + +import { updateMyEmailComplete, updateMyEmailError } from './'; +import { createFlashMessage } from '../../components/Flash/redux'; + +import { putUserUpdateEmail } from '../../utils/ajax'; +import reallyWeirdErrorMessage from '../../utils/reallyWeirdErrorMessage'; + +function* updateMyEmailSaga({ payload: email = '' }) { + console.log('saga', email); + if (!email || !isEmail(email)) { + yield put(createFlashMessage(reallyWeirdErrorMessage)); + return; + } + try { + const { data: response } = yield call(putUserUpdateEmail, email); + yield put( + updateMyEmailComplete({ + ...response, + payload: { email, isEmailVerified: false } + }) + ); + yield put(createFlashMessage(response)); + } catch (e) { + yield put(updateMyEmailError(e)); + } +} + +export function createUpdateMyEmailSaga(types) { + return [takeEvery(types.updateMyEmail, updateMyEmailSaga)]; +} diff --git a/client/src/redux/update-email-saga.js b/client/src/redux/update-email-saga.js deleted file mode 100644 index 7c56bd1e35799e..00000000000000 --- a/client/src/redux/update-email-saga.js +++ /dev/null @@ -1,21 +0,0 @@ -import { call, put, takeEvery } from 'redux-saga/effects'; - -import { updateMyEmailComplete, updateMyEmailError } from './'; -import { createFlashMessage } from '../components/Flash/redux'; - -import { putUserUpdateEmail } from '../utils/ajax'; - -function* updateMyEmailSaga({ payload: newEmail }) { - try { - const { data: response } = yield call(putUserUpdateEmail, newEmail); - - yield put(updateMyEmailComplete()); - yield put(createFlashMessage(response)); - } catch (e) { - yield put(updateMyEmailError(e)); - } -} - -export function createUpdateMyEmailSaga(types) { - return [takeEvery(types.updateMyEmail, updateMyEmailSaga)]; -} diff --git a/client/src/utils/index.js b/client/src/utils/index.js new file mode 100644 index 00000000000000..f46b749d05a2b0 --- /dev/null +++ b/client/src/utils/index.js @@ -0,0 +1,3 @@ +// This regex is not for validation, it is purely to see +// if we are looking at something like an email before we try to validate +export const maybeEmailRE = /.*@.*\.\w\w/;