feat(settings): Expand Settings page functionality (#16664)

* fix(layout): Fix Settings layout in firefox

* chore(availableForHire): Remove available for hire setting

* feat(helpers): Use helper components for Settings layout

* fix(map): Fix undefined lang requested

* feat(settings): Expand Settings page functionality

* chore(pledge): Remove pledge from Settings

* fix(about): Adjust AboutSettings layout

* fix(portfolio): Improve PortfolioSettings layout

* fix(email): Improve EmailSettings layout

* fix(settings): Align save buttons with form fields

* fix(AHP): Format AHP

* fix(DangerZone): Adjust DangerZone layout

* fix(projectSettings): Change Button Copy

* fix(CertSettings): Fix certificate claim logic

* chore(lint): Lint
This commit is contained in:
Stuart Taylor
2018-02-16 23:18:53 +00:00
committed by Quincy Larson
parent 9f034f4f79
commit 24ef69cf7a
78 changed files with 4395 additions and 1724 deletions

View File

@ -6,17 +6,16 @@ import debugFactory from 'debug';
import { isEmail } from 'validator';
import path from 'path';
import loopback from 'loopback';
import _ from 'lodash';
import { themes } from '../utils/themes';
import { dasherize } from '../../server/utils';
import { saveUser, observeMethod } from '../../server/utils/rx.js';
import { blacklistedUsernames } from '../../server/utils/constants.js';
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
import {
getServerFullURL,
getEmailSender,
getProtocol,
getHost,
getPort
getEmailSender
} from '../../server/utils/url-utils.js';
const debug = debugFactory('fcc:models:user');
@ -38,6 +37,61 @@ function destroyAll(id, Model) {
)({ userId: id });
}
function buildChallengeMapUpdate(challengeMap, project) {
const currentChallengeMap = { ...challengeMap };
const { nameToIdMap } = _.values(project)[0];
const incomingUpdate = _.pickBy(
_.omit(_.values(project)[0], [ 'id', 'nameToIdMap' ]),
Boolean
);
const currentCompletedProjects = _.pick(challengeMap, _.values(nameToIdMap));
const now = Date.now();
const update = Object.keys(incomingUpdate).reduce((update, current) => {
const dashedName = dasherize(current)
.replace('java-script', 'javascript')
.replace('metric-imperial', 'metricimperial');
const currentId = nameToIdMap[dashedName];
if (
currentId in currentCompletedProjects &&
currentCompletedProjects[currentId].solution !== incomingUpdate[current]
) {
return {
...update,
[currentId]: {
...currentCompletedProjects[currentId],
solution: incomingUpdate[current],
numOfAttempts: currentCompletedProjects[currentId].numOfAttempts + 1
}
};
}
if (!(currentId in currentCompletedProjects)) {
return {
...update,
[currentId]: {
id: currentId,
solution: incomingUpdate[current],
challengeType: 3,
completedDate: now,
numOfAttempts: 1
}
};
}
return update;
}, {});
const updatedExisting = {
...currentCompletedProjects,
...update
};
return {
...currentChallengeMap,
...updatedExisting
};
}
function isTheSame(val1, val2) {
return val1 === val2;
}
const renderSignUpEmail = loopback.template(path.join(
__dirname,
'..',
@ -58,6 +112,16 @@ const renderSignInEmail = loopback.template(path.join(
'user-request-sign-in.ejs'
));
const renderEmailChangeEmail = loopback.template(path.join(
__dirname,
'..',
'..',
'server',
'views',
'emails',
'user-request-update-email.ejs'
));
function getAboutProfile({
username,
githubProfile: github,
@ -285,7 +349,12 @@ module.exports = function(User) {
});
};
User.prototype.loginByRequest = function login(req, res) {
User.prototype.loginByRequest = function loginByRequest(req, res) {
const {
query: {
emailChange
}
} = req;
const createToken = this.createAccessToken$()
.do(accessToken => {
const config = {
@ -297,11 +366,19 @@ module.exports = function(User) {
res.cookie('userId', accessToken.userId, config);
}
});
const updateUser = this.update$({
let data = {
emailVerified: true,
emailAuthLinkTTL: null,
emailVerifyTTL: null
});
};
if (emailChange && this.newEmail) {
data = {
...data,
email: this.newEmail,
newEmail: null
};
}
const updateUser = this.update$(data);
return Observable.combineLatest(
createToken,
updateUser,
@ -425,7 +502,7 @@ module.exports = function(User) {
User.decodeEmail = email => Buffer(email, 'base64').toString();
User.prototype.requestAuthEmail = function requestAuthEmail(isSignUp) {
function requestAuthEmail(isSignUp, newEmail) {
return Observable.defer(() => {
const messageOrNull = getWaitMessage(this.emailAuthLinkTTL);
if (messageOrNull) {
@ -448,22 +525,26 @@ module.exports = function(User) {
renderAuthEmail = renderSignUpEmail;
subject = 'Account Created - freeCodeCamp';
}
if (newEmail) {
renderAuthEmail = renderEmailChangeEmail;
subject = 'Email Change Request - freeCodeCamp';
}
const { id: loginToken, created: emailAuthLinkTTL } = token;
const loginEmail = this.getEncodedEmail();
const loginEmail = this.getEncodedEmail(newEmail ? newEmail : null);
const host = getServerFullURL();
const mailOptions = {
type: 'email',
to: this.email,
to: newEmail ? newEmail : this.email,
from: getEmailSender(),
subject,
text: renderAuthEmail({
host,
loginEmail,
loginToken
loginToken,
emailChange: !!newEmail
})
};
return Observable.combineLatest(
return Observable.forkJoin(
User.email.send$(mailOptions),
this.update$({ emailAuthLinkTTL })
);
@ -479,17 +560,19 @@ module.exports = function(User) {
Please follow that link to sign in.
`
);
};
}
User.prototype.requestAuthEmail = requestAuthEmail;
User.prototype.requestUpdateEmail = function requestUpdateEmail(newEmail) {
const currentEmail = this.email;
return Observable.defer(() => {
const ownEmail = newEmail === this.email;
if (!isEmail('' + newEmail)) {
throw createEmailError();
}
// email is already associated and verified with this account
if (ownEmail) {
const isOwnEmail = isTheSame(newEmail, currentEmail);
const sameUpdate = isTheSame(newEmail, this.newEmail);
const messageOrNull = getWaitMessage(this.emailVerifyTTL);
if (isOwnEmail) {
if (this.emailVerified) {
// email is already associated and verified with this account
throw wrapHandledError(
new Error('email is already verified'),
{
@ -497,10 +580,8 @@ module.exports = function(User) {
message: `${newEmail} is already associated with this account.`
}
);
} else {
const messageOrNull = getWaitMessage(this.emailVerifyTTL);
// email is already associated but unverified
if (messageOrNull) {
} else if (!this.emailVerified && messageOrNull) {
// email is associated but unverified and
// email is within time limit
throw wrapHandledError(
new Error(),
@ -510,69 +591,175 @@ module.exports = function(User) {
}
);
}
}
}
// at this point email is not associated with the account
// or has not been verified but user is requesting another token
// outside of the time limit
if (sameUpdate && messageOrNull) {
// trying to update with the same newEmail and
// confirmation email is still valid
throw wrapHandledError(
new Error(),
{
type: 'info',
message: dedent`
We have already sent an email change request to ${newEmail}.
Please check your inbox`
}
);
}
if (!isEmail('' + newEmail)) {
throw createEmailError();
}
// newEmail is not associated with this user, and
// this attempt to change email is the first or
// previous attempts have expired
return Observable.if(
() => ownEmail,
() => isOwnEmail || (sameUpdate && messageOrNull),
Observable.empty(),
// defer prevents the promise from firing prematurely (before subscribe)
Observable.defer(() => User.doesExist(null, newEmail))
)
.do(exists => {
// not associated with this account, but is associated with another
if (exists) {
throw wrapHandledError(
new Error('email already in use'),
{
type: 'info',
message:
`${newEmail} is already associated with another account.`
}
);
}
})
.defaultIfEmpty();
})
.flatMap(() => {
const emailVerified = false;
const data = {
newEmail,
emailVerified,
emailVerifyTTL: new Date()
};
return this.update$(data).do(() => Object.assign(this, data));
.do(exists => {
if (exists) {
// newEmail is not associated with this account,
// but is associated with different account
throw wrapHandledError(
new Error('email already in use'),
{
type: 'info',
message:
`${newEmail} is already associated with another account.`
}
);
}
})
.flatMap(() => {
const mailOptions = {
type: 'email',
to: newEmail,
from: getEmailSender(),
subject: 'freeCodeCamp - Email Update Requested',
protocol: getProtocol(),
host: getHost(),
port: getPort(),
template: path.join(
__dirname,
'..',
'..',
'server',
'views',
'emails',
'user-request-update-email.ejs'
)
};
return this.verify(mailOptions);
const update = {
newEmail,
emailVerified: false,
emailVerifyTTL: new Date()
};
return this.update$(update)
.do(() => Object.assign(this, update))
.flatMap(() => this.requestAuthEmail(false, newEmail));
});
});
};
User.prototype.requestChallengeMap = function requestChallengeMap() {
return this.getChallengeMap$();
};
User.prototype.requestUpdateFlags = function requestUpdateFlags(values) {
const flagsToCheck = Object.keys(values);
const valuesToCheck = _.pick({ ...this }, flagsToCheck);
const valuesToUpdate = flagsToCheck
.filter(flag => !isTheSame(values[flag], valuesToCheck[flag]));
if (!valuesToUpdate.length) {
return Observable.of(dedent`
No property in
${JSON.stringify(flagsToCheck, null, 2)}
will introduce a change in this user.
`
)
.do(console.log)
.map(() => dedent`Your settings have not been changed`);
}
return Observable.from(valuesToUpdate)
.flatMap(flag => Observable.of({ flag, newValue: values[flag] }))
.toArray()
.flatMap(updates => {
return Observable.forkJoin(
Observable.from(updates)
.flatMap(({ flag, newValue }) => {
return Observable.fromPromise(User.doesExist(null, this.email))
.flatMap(() => {
return this.update$({ [flag]: newValue })
.do(() => {
this[flag] = newValue;
});
});
})
);
})
.map(() => dedent`
Please check your email.
We sent you a link that you can click to verify your email address.
We have successfully updated your account.
`);
};
User.prototype.updateMyPortfolio =
function updateMyPortfolio(portfolioItem, deleteRequest) {
const currentPortfolio = this.portfolio.slice(0);
const pIndex = _.findIndex(
currentPortfolio,
p => p.id === portfolioItem.id
);
let updatedPortfolio = [];
if (deleteRequest) {
updatedPortfolio = currentPortfolio.filter(
p => p.id !== portfolioItem.id
);
} else if (pIndex === -1) {
updatedPortfolio = currentPortfolio.concat([ portfolioItem ]);
} else {
updatedPortfolio = [ ...currentPortfolio ];
updatedPortfolio[pIndex] = { ...portfolioItem };
}
return this.update$({ portfolio: updatedPortfolio })
.do(() => {
this.portfolio = updatedPortfolio;
})
.map(() => dedent`
Your portfolio has been updated
`);
};
User.prototype.updateMyProjects = function updateMyProjects(project) {
const updateData = {};
return this.getChallengeMap$()
.flatMap(challengeMap => {
updateData.challengeMap = buildChallengeMapUpdate(
challengeMap,
project
);
return this.update$(updateData);
})
.do(() => Object.assign(this, updateData))
.map(() => dedent`
Your projects have been updated
`);
};
User.prototype.updateMyUsername = function updateMyUsername(newUsername) {
return Observable.defer(
() => {
const isOwnUsername = isTheSame(newUsername, this.username);
if (isOwnUsername) {
return Observable.of(dedent`
${newUsername} is already associated with this account
`);
}
return Observable.fromPromise(User.doesExist(newUsername));
}
)
.flatMap(boolOrMessage => {
if (typeof boolOrMessage === 'string') {
return Observable.of(boolOrMessage);
}
if (boolOrMessage) {
return Observable.of(dedent`
${newUsername} is associated with a different account
`);
}
return this.update$({ username: newUsername })
.do(() => {
this.username = newUsername;
})
.map(() => dedent`
Username updated successfully
`);
});
};
User.giveBrowniePoints =
function giveBrowniePoints(receiver, giver, data = {}, dev = false, cb) {
const findUser = observeMethod(User, 'findOne');