feat(email-settings): Add email settings

This commit is contained in:
Bouncey
2018-09-20 10:22:45 +01:00
committed by Stuart Taylor
parent 147e61e4f7
commit 2de2143457
11 changed files with 420 additions and 139 deletions

View File

@ -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');
});
@ -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',
{
const createToken = observeQuery(AccessToken, 'create', {
userId: user.id,
created: new Date(),
ttl: user.constructor.settings.ttl
}
);
const updateUser = user.update$({
});
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,
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',
{
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);
}
};
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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) {
<Spacer />
<Privacy />
<Spacer />
{/* <EmailSettings />
<Email
email={email}
isEmailVerified={isEmailVerified}
sendQuincyEmail={sendQuincyEmail}
updateQuincyEmail={updateQuincyEmail}
/>
<Spacer />
<InternetSettings />
{/* <InternetSettings />
<Spacer />
<PortfolioSettings />
<Spacer />

View File

@ -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 (
<Link style={{ textDecoration: 'none' }} to='/update-email'>
<Button block={true} bsSize='lg' bsStyle='primary'>
Edit
</Button>
</Link>
);
}
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 (
<div>
<FullWidthRow>
<p className='large-p text-center'>
You do not have an email associated with this account.
</p>
</FullWidthRow>
<FullWidthRow>
<UpdateEmailButton />
</FullWidthRow>
</div>
);
}
return (
<div className='email-settings'>
<SectionHeader>Email Settings</SectionHeader>
{isEmailVerified ? null : (
<FullWidthRow>
<HelpBlock>
<Alert bsStyle='info' className='text-center'>
Your email has not been verified.
<br />
Please check your email, or{' '}
<Link to='/update-email'>
request a new verification email here
</Link>
.
</Alert>
</HelpBlock>
</FullWidthRow>
)}
<FullWidthRow>
<form id='form-update-email' onSubmit={this.handleSubmit}>
<FormGroup controlId='current-email'>
<ControlLabel>Current Email</ControlLabel>
<FormControl.Static>{currentEmail}</FormControl.Static>
</FormGroup>
<FormGroup
controlId='new-email'
validationState={newEmailValidation}
>
<ControlLabel>New Email</ControlLabel>
<FormControl
onChange={this.createHandleEmailFormChange('newEmail')}
type='email'
value={newEmail}
/>
{newEmailValidationMessage ? (
<HelpBlock>{newEmailValidationMessage}</HelpBlock>
) : null}
</FormGroup>
<FormGroup
controlId='confirm-email'
validationState={confirmEmailValidation}
>
<ControlLabel>Confirm New Email</ControlLabel>
<FormControl
onChange={this.createHandleEmailFormChange('confirmNewEmail')}
type='email'
value={confirmNewEmail}
/>
{confirmEmailValidationMessage ? (
<HelpBlock>{confirmEmailValidationMessage}</HelpBlock>
) : null}
</FormGroup>
<BlockSaveButton
disabled={
newEmailValidation !== 'success' ||
confirmEmailValidation !== 'success' ||
isPristine
}
/>
</form>
</FullWidthRow>
<Spacer />
<FullWidthRow>
<form id='form-quincy-email' onSubmit={this.handleSubmit}>
<ToggleSetting
action="Send me Quincy's weekly email"
flag={sendQuincyEmail}
flagName='sendQuincyEmail'
offLabel='No thanks'
onLabel='Yes please'
toggleFlag={() => updateQuincyEmail(!sendQuincyEmail)}
/>
</form>
</FullWidthRow>
</div>
);
}
}
EmailSettings.displayName = 'EmailSettings';
EmailSettings.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(EmailSettings);

View File

@ -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 => {
handleSubmit = e => {
e.preventDefault();
return fn(this.state.emailValue);
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 (
<Layout>
<Helmet>
@ -90,10 +89,7 @@ class UpdateEmail extends Component {
<Row>
<Col sm={6} smOffset={3}>
<Row>
<Form
horizontal={true}
onSubmit={this.createSubmitHandler(updateMyEmail)}
>
<Form horizontal={true} onSubmit={this.handleSubmit}>
<FormGroup
controlId='emailInput'
validationState={this.getEmailValidationState()}

View File

@ -6,7 +6,6 @@ import { createAcceptTermsSaga } from './accept-terms-saga';
import { createAppMountSaga } from './app-mount-saga';
import { createReportUserSaga } from './report-user-saga';
import { createShowCertSaga } from './show-cert-saga';
import { createUpdateMyEmailSaga } from './update-email-saga';
import { createNightModeSaga } from './night-mode-saga';
import { types as settingsTypes } from './settings';
@ -38,7 +37,6 @@ const types = createTypes(
...createAsyncTypes('fetchUser'),
...createAsyncTypes('acceptTerms'),
...createAsyncTypes('showCert'),
...createAsyncTypes('updateMyEmail'),
...createAsyncTypes('reportUser')
],
ns
@ -48,7 +46,6 @@ export const sagas = [
...createAcceptTermsSaga(types),
...createAppMountSaga(types),
...createFetchUserSaga(types),
...createUpdateMyEmailSaga(types),
...createShowCertSaga(types),
...createReportUserSaga(types),
...createNightModeSaga({ ...types, ...settingsTypes })
@ -72,10 +69,6 @@ export const showCert = createAction(types.showCert);
export const showCertComplete = createAction(types.showCertComplete);
export const showCertError = createAction(types.showCertError);
export const updateMyEmail = createAction(types.updateMyEmail);
export const updateMyEmailComplete = createAction(types.updateMyEmailComplete);
export const updateMyEmailError = createAction(types.updateMyEmailError);
export const isSignedInSelector = state => !!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
);

View File

@ -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,

View File

@ -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)];
}

View File

@ -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)];
}

View File

@ -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/;